From 1c1e2b9038ae2240addf29aa6e2ea58065ea4db1 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 12 Sep 2026 11:26:04 +0200 Subject: [PATCH] Implemented missing file handling endpoints Added contract update endpoint --- .../Evidence/GetEvidenceUrlEndpoint.cs | 36 +++++++++ .../Grants/GetContractUrlEndpoint.cs | 36 +++++++++ .../Grants/UpdateContractEndpoint.cs | 38 ++++++++++ .../Grants/UploadContractEndpoint.cs | 46 ++++++++++++ PostFundManagement.Api/appsettings.json | 18 +---- .../Abstractions/S3ServiceBase.cs | 53 ++++++++++++-- .../Abstractions/Services/IEvidenceService.cs | 2 + .../Abstractions/Services/IGrantsService.cs | 6 ++ .../Extensions/S3.cs | 34 --------- .../Services/Communications/Records.cs | 2 +- .../Services/Evidence/EvidenceCacheService.cs | 21 ++++++ .../Services/Evidence/EvidenceService.cs | 36 +++++++-- .../Services/Evidence/Records.cs | 8 +- .../Services/Execution/Records.cs | 8 +- .../Services/Governance/Records.cs | 10 +-- .../Services/Grants/GrantsCacheService.cs | 27 +++++++ .../Services/Grants/GrantsService.cs | 73 ++++++++++++++++++- .../Services/Grants/Records.cs | 13 ++-- .../Services/Projects/Records.cs | 14 ++-- .../Services/Shared/ContractS3Service.cs | 12 --- .../Services/Shared/EvidenceS3Service.cs | 12 --- .../Services/Users/Records.cs | 2 +- .../Abstractions/IS3Service.cs | 4 +- .../Extensions/Constants.cs | 6 +- docker-compose.yml | 20 +---- 25 files changed, 402 insertions(+), 135 deletions(-) create mode 100644 PostFundManagement.Api/Endpoints/Evidence/GetEvidenceUrlEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/GetContractUrlEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/UpdateContractEndpoint.cs create mode 100644 PostFundManagement.Api/Endpoints/Grants/UploadContractEndpoint.cs delete mode 100644 PostFundManagement.Application/Services/Shared/ContractS3Service.cs delete mode 100644 PostFundManagement.Application/Services/Shared/EvidenceS3Service.cs diff --git a/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceUrlEndpoint.cs b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceUrlEndpoint.cs new file mode 100644 index 0000000..4b5c73c --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Evidence/GetEvidenceUrlEndpoint.cs @@ -0,0 +1,36 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Security; +using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails; + +namespace PostFundManagement.Api.Endpoints.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() => Results.Forbid(), + var r when r.HasError() => Results.NotFound(), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Gets a secure download URL of the evidence file, URL expires (default: 15min)") + .WithName(typeof(GetEvidenceUrlEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Grants) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/GetContractUrlEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/GetContractUrlEndpoint.cs new file mode 100644 index 0000000..b2ab796 --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/GetContractUrlEndpoint.cs @@ -0,0 +1,36 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Extensions; +using PostFundManagement.Domain.Abstractions; +using PostFundManagement.Domain.Errors; +using PostFundManagement.Domain.Extensions; +using PostFundManagement.Domain.Security; +using Problems = Microsoft.AspNetCore.Mvc.ProblemDetails; + +namespace PostFundManagement.Api.Endpoints.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() => Results.Forbid(), + var r when r.HasError() => Results.NotFound(), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Gets a secure download URL, has expiry period (default 15min)") + .WithName(typeof(GetContractUrlEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Grants) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/UpdateContractEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/UpdateContractEndpoint.cs new file mode 100644 index 0000000..e483d3b --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/UpdateContractEndpoint.cs @@ -0,0 +1,38 @@ +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() => Results.NotFound(), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Updates a specific contract") + .WithName(typeof(UpdateContractEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Grants) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/Endpoints/Grants/UploadContractEndpoint.cs b/PostFundManagement.Api/Endpoints/Grants/UploadContractEndpoint.cs new file mode 100644 index 0000000..d3028bf --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Grants/UploadContractEndpoint.cs @@ -0,0 +1,46 @@ +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() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }; + }) + .WithDescription("Uploads a contract file securely") + .WithName(typeof(UploadContractEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Grants) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/appsettings.json b/PostFundManagement.Api/appsettings.json index 5e805f2..9add939 100644 --- a/PostFundManagement.Api/appsettings.json +++ b/PostFundManagement.Api/appsettings.json @@ -1,21 +1,9 @@ { "GeneralS3Settings": { - "ServiceUrl": "http://192.168.1.177:30900", + "ServiceUrl": "https://s3.garage.khongisa.co.za", "Region": "garage", - "BucketName": "pfm.general", - "CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" - }, - "ContractS3Settings": { - "ServiceUrl": "http://192.168.1.177:30900", - "Region": "garage", - "BucketName": "pfm.general", - "CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" - }, - "EvidenceS3Settings": { - "ServiceUrl": "http://192.168.1.177:30900", - "Region": "garage", - "BucketName": "pfm.general", - "CdnBaseUrl": "https://pfm.general.cdn.khongisa.co.za" + "BucketName": "pfm", + "CdnBaseUrl": "https://pfm.cdn.khongisa.co.za" }, "Monitoring": { "Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889", diff --git a/PostFundManagement.Application/Abstractions/S3ServiceBase.cs b/PostFundManagement.Application/Abstractions/S3ServiceBase.cs index 1cb715a..6063984 100644 --- a/PostFundManagement.Application/Abstractions/S3ServiceBase.cs +++ b/PostFundManagement.Application/Abstractions/S3ServiceBase.cs @@ -10,14 +10,55 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) protected abstract string BucketName { get; } protected abstract string CdnBaseUrl { get; } - public virtual async ValueTask> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default) => + public string GenerateCdnUrl(string fileKey) => $"{CdnBaseUrl}/{fileKey}"; + + public virtual async ValueTask> GeneratePresignedUrlAsync(string fileKey, TimeSpan duration, string? contentType = null, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { if (string.IsNullOrWhiteSpace(BucketName)) - return Result.Fail(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> UploadFileAsync(string fileName, string subPath, Stream fileStream, string contentType, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + if (string.IsNullOrWhiteSpace(BucketName)) + return Result.Fail(ValidationError.Create("Bucket name is not configured.")); if (string.IsNullOrWhiteSpace(CdnBaseUrl)) - return Result.Fail(Domain.Errors.ValidationError.Create("CDN base URL is not configured.")); + return Result.Fail(ValidationError.Create("CDN base URL is not configured.")); await using var stream = new MemoryStream(); @@ -31,7 +72,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) if (string.IsNullOrWhiteSpace(fileHash)) return Result.Fail(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 { @@ -48,7 +89,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) return response.HttpStatusCode != HttpStatusCode.OK ? Result.Fail(CreateError.Create($"Failed to upload {fileName} to S3.")) - : Result.Ok($"{CdnBaseUrl}/{fileKey}"); + : Result.Ok(fileKey); })); public virtual async ValueTask DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default) => @@ -63,7 +104,7 @@ public abstract class S3ServiceBase(IAmazonS3 amazonS3) Key = fileKey }, cancellationToken); - return Result.FailIf(response.HttpStatusCode == HttpStatusCode.NoContent, + return Result.FailIf(response.HttpStatusCode == HttpStatusCode.NoContent, UpdateError.Create($"Failed to delete {fileKey} from S3.")); })); } \ No newline at end of file diff --git a/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs b/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs index e0f4a2f..57550a0 100644 --- a/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IEvidenceService.cs @@ -27,4 +27,6 @@ public interface IEvidenceService ValueTask> GetProjectEvidencesAsync(string projectIdHash, CancellationToken cancellationToken = default); ValueTask UpdateEvidenceAsync(UpdateEvidenceRequest request, Guid userId, CancellationToken cancellationToken = default); + + ValueTask> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default); } diff --git a/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs b/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs index dda3e2d..ca9bf30 100644 --- a/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs +++ b/PostFundManagement.Application/Abstractions/Services/IGrantsService.cs @@ -25,4 +25,10 @@ public interface IGrantsService ValueTask CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default); ValueTask CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default); + + ValueTask> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default); + + ValueTask UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default); + + ValueTask UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default); } diff --git a/PostFundManagement.Application/Extensions/S3.cs b/PostFundManagement.Application/Extensions/S3.cs index 6d45848..89d39d8 100644 --- a/PostFundManagement.Application/Extensions/S3.cs +++ b/PostFundManagement.Application/Extensions/S3.cs @@ -25,40 +25,6 @@ public static class S3 services.AddKeyedScoped(GeneralBucketName); } - if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value)) - { - services.AddKeyedSingleton(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(EvidenceBucketName); - } - - if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value)) - { - services.AddKeyedSingleton(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(ContractBucketName); - } - return services; } } \ No newline at end of file diff --git a/PostFundManagement.Application/Services/Communications/Records.cs b/PostFundManagement.Application/Services/Communications/Records.cs index 67b36b2..3d7ca5d 100644 --- a/PostFundManagement.Application/Services/Communications/Records.cs +++ b/PostFundManagement.Application/Services/Communications/Records.cs @@ -3,7 +3,7 @@ using static PostFundManagement.Domain.Extensions.Constants; namespace PostFundManagement.Application.Services.Communications; -public record InviteRequest([Required] long OrganisationId, +public sealed record InviteRequest([Required] long OrganisationId, [Required] long InvitedOrganisationId, [Required] long AwardId, [Required] long ContractId, diff --git a/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs b/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs index 126b601..51d58c8 100644 --- a/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs +++ b/PostFundManagement.Application/Services/Evidence/EvidenceCacheService.cs @@ -60,6 +60,27 @@ public sealed class EvidenceCacheService(EvidenceService service, IDistributedCa return Result.Ok(cached.FromJson()); })); + public async ValueTask> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + 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> GetIndicatorAsync(string indicatorId, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { diff --git a/PostFundManagement.Application/Services/Evidence/EvidenceService.cs b/PostFundManagement.Application/Services/Evidence/EvidenceService.cs index de6712c..851275b 100644 --- a/PostFundManagement.Application/Services/Evidence/EvidenceService.cs +++ b/PostFundManagement.Application/Services/Evidence/EvidenceService.cs @@ -10,7 +10,7 @@ using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Application.Services.Evidence; public sealed class EvidenceService(IDbContextFactory contextFactory, HashService hashService, - [FromKeyedServices(Constants.EvidenceBucketName)] IS3Service s3Service) : IEvidenceService + [FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service) : IEvidenceService { public async ValueTask CreateEvidenceAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => @@ -22,13 +22,12 @@ public sealed class EvidenceService(IDbContextFactory cont 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) return Result.Fail(uploadResult.Errors); - var documentUrl = uploadResult.Value; - await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var evidence = new Domain.Entities.Evidence @@ -41,7 +40,7 @@ public sealed class EvidenceService(IDbContextFactory cont CreatedAt = DateTime.UtcNow, Version = 1, Type = type, - DocumentUrl = documentUrl, + DocumentUrl = uploadResult.Value, Status = ApprovalStatus.Pending }; @@ -238,4 +237,31 @@ public sealed class EvidenceService(IDbContextFactory cont return Result.OkIf(changedTotal > 0, UpdateError.New); })); + + public async ValueTask> GetEvidenceUrlAsync(string evidenceIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(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(NotFoundError.New); + + if (string.IsNullOrWhiteSpace(evidence.DocumentUrl)) + return Result.Fail(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); + })); } diff --git a/PostFundManagement.Application/Services/Evidence/Records.cs b/PostFundManagement.Application/Services/Evidence/Records.cs index ef82c23..7bfc003 100644 --- a/PostFundManagement.Application/Services/Evidence/Records.cs +++ b/PostFundManagement.Application/Services/Evidence/Records.cs @@ -3,19 +3,19 @@ using static PostFundManagement.Domain.Extensions.Constants; namespace PostFundManagement.Application.Services.Evidence; -public record UpdateEvidenceRequest([Required] long EvidenceId, +public sealed record UpdateEvidenceRequest([Required] long EvidenceId, [Required] int Version, [Required] EvidenceType EvidenceType, [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][MaxLength(LabelLength)] string Name, [Required] Priority Priority); -public record CreateIndicatorRequest([Required] long ProjectId, +public sealed record CreateIndicatorRequest([Required] long ProjectId, [Required][MaxLength(LabelLength)] string Name, [Required] UnitOfMeasure UnitOfMeasure, [Required] decimal BaselineAmount, diff --git a/PostFundManagement.Application/Services/Execution/Records.cs b/PostFundManagement.Application/Services/Execution/Records.cs index 0f737cc..d4d7a94 100644 --- a/PostFundManagement.Application/Services/Execution/Records.cs +++ b/PostFundManagement.Application/Services/Execution/Records.cs @@ -3,17 +3,17 @@ using static PostFundManagement.Domain.Extensions.Constants; 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] RiskImpact Impact, [Required][MaxLength(LabelLength)] string Name, [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] RiskImpact Impact, [Required][MaxLength(LabelLength)] string Name, diff --git a/PostFundManagement.Application/Services/Governance/Records.cs b/PostFundManagement.Application/Services/Governance/Records.cs index 8d10e5f..5a70f0b 100644 --- a/PostFundManagement.Application/Services/Governance/Records.cs +++ b/PostFundManagement.Application/Services/Governance/Records.cs @@ -3,28 +3,28 @@ using static PostFundManagement.Domain.Extensions.Constants; 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(TextLength)] string Description, [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] long OrganisationId, [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 RegistrationNo, [Required][EmailAddress][MaxLength(LabelLength)] string Email, [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][EmailAddress][MaxLength(LabelLength)] string Email, [Required] OrganisationType Type = OrganisationType.PrivateLimitedCompany); -public record Dashboard([Required] decimal PortfolioValue, +public sealed record Dashboard([Required] decimal PortfolioValue, [Required] int ActiveAwards, [Required] decimal Disbursed, [Required] double OnTrackPercentage, diff --git a/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs b/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs index 979c1e0..53c091f 100644 --- a/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs +++ b/PostFundManagement.Application/Services/Grants/GrantsCacheService.cs @@ -146,4 +146,31 @@ public sealed class GrantsCacheService(GrantsService service, IDistributedCache return Result.Ok(cached.FromJson()); })); + + public async ValueTask> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(async () => + { + 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 UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) => + await service.UpdateContractAsync(request, cancellationToken); + + public async ValueTask UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) => + await service.UploadContractAsync(contractIdHash, userId, file, cancellationToken); } diff --git a/PostFundManagement.Application/Services/Grants/GrantsService.cs b/PostFundManagement.Application/Services/Grants/GrantsService.cs index a99a0f1..ac31e32 100644 --- a/PostFundManagement.Application/Services/Grants/GrantsService.cs +++ b/PostFundManagement.Application/Services/Grants/GrantsService.cs @@ -1,6 +1,7 @@ using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; +using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; @@ -8,7 +9,8 @@ using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Application.Services.Grants; -public sealed class GrantsService(IDbContextFactory contextFactory, HashService hashService) : IGrantsService +public sealed class GrantsService(IDbContextFactory contextFactory, HashService hashService, + [FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service) : IGrantsService { public async ValueTask> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => @@ -201,4 +203,73 @@ public sealed class GrantsService(IDbContextFactory contex return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.New); })); + + public async ValueTask> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => + await Result.Try((Func>>)(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(NotFoundError.New); + + if (string.IsNullOrWhiteSpace(contract.SignedDocumentUrl)) + return Result.Fail(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 UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) => + await Result.Try((Func>)(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 UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) => + await Result.Try((Func>)(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.")); + })); } diff --git a/PostFundManagement.Application/Services/Grants/Records.cs b/PostFundManagement.Application/Services/Grants/Records.cs index 6e82d28..627fcda 100644 --- a/PostFundManagement.Application/Services/Grants/Records.cs +++ b/PostFundManagement.Application/Services/Grants/Records.cs @@ -1,18 +1,21 @@ -using static PostFundManagement.Domain.Extensions.Constants; +using PostFundManagement.Domain; +using static PostFundManagement.Domain.Extensions.Constants; 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] decimal Amount); -public record CreateContractRequest([Required] long AwardId, +public sealed record CreateContractRequest([Required] long AwardId, [Required] decimal TotalValue, [Required] DateTime EffectiveAt, [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 SignedDocumentUrl); diff --git a/PostFundManagement.Application/Services/Projects/Records.cs b/PostFundManagement.Application/Services/Projects/Records.cs index 8f8e8a3..28562a2 100644 --- a/PostFundManagement.Application/Services/Projects/Records.cs +++ b/PostFundManagement.Application/Services/Projects/Records.cs @@ -3,35 +3,35 @@ using static PostFundManagement.Domain.Extensions.Constants; namespace PostFundManagement.Application.Services.Projects; -public record UpdateProgrammeRequest([Required] long ProgrammeId, +public sealed record UpdateProgrammeRequest([Required] long ProgrammeId, [Required][MaxLength(LabelLength)] string Name, [Required] Guid OwnerId, [Required] ApprovalStatus Status); -public record CreateProgrammeRequest(long PortfolioId, string Name, Guid OwnerId); +public sealed record CreateProgrammeRequest(long PortfolioId, string Name, Guid OwnerId); -public record UpdateProjectRequest([Required] long ProjectId, +public sealed record UpdateProjectRequest([Required] long ProjectId, [Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(TextLength)] string Description, DateTime? StartedAt, DateTime? EndedAt, [Required] IndustrySector Sector, [Required] ProjectType ProjectType); -public record UpdateMilestoneRequest([Required] long MilestoneId, +public sealed record UpdateMilestoneRequest([Required] long MilestoneId, [Required] DateTime DueAt, [Required][MaxLength(LabelLength)] string Name, [Required] Priority Priority, [Required] ApprovalStatus Status); -public record UpdateProjectStatusRequest([Required] long ProjectId, [Required] ApprovalStatus Status); +public sealed record UpdateProjectStatusRequest([Required] long ProjectId, [Required] ApprovalStatus Status); -public record CreateProjectRequest([Required] long ProgrammeId, +public sealed record CreateProjectRequest([Required] long ProgrammeId, [Required][MaxLength(LabelLength)] string Name, [Required][MaxLength(TextLength)] string Description, [Required] IndustrySector Sector, [Required] ProjectType ProjectType); -public record CreateActivityRequest([Required] long ProjectId, +public sealed record CreateActivityRequest([Required] long ProjectId, [Required] long? MilestoneId, [Required][MaxLength(LabelLength)] string Category, [Required] decimal Amount, diff --git a/PostFundManagement.Application/Services/Shared/ContractS3Service.cs b/PostFundManagement.Application/Services/Shared/ContractS3Service.cs deleted file mode 100644 index 33b5965..0000000 --- a/PostFundManagement.Application/Services/Shared/ContractS3Service.cs +++ /dev/null @@ -1,12 +0,0 @@ -using PostFundManagement.Application.Abstractions; -using PostFundManagement.Domain.Abstractions; -using static PostFundManagement.Domain.Extensions.Constants; - -namespace PostFundManagement.Application.Services.Shared; - -public sealed class ContractS3Service(IConfiguration configuration, [FromKeyedServices(ContractBucketName)] IAmazonS3 amazonS3) : - S3ServiceBase(amazonS3), IS3Service -{ - protected override string BucketName => configuration.GetSection($"{ContractS3SettingsSection}:BucketName").Value ?? string.Empty; - protected override string CdnBaseUrl => configuration.GetSection($"{ContractS3SettingsSection}:CdnBaseUrl").Value ?? string.Empty; -} \ No newline at end of file diff --git a/PostFundManagement.Application/Services/Shared/EvidenceS3Service.cs b/PostFundManagement.Application/Services/Shared/EvidenceS3Service.cs deleted file mode 100644 index 2116867..0000000 --- a/PostFundManagement.Application/Services/Shared/EvidenceS3Service.cs +++ /dev/null @@ -1,12 +0,0 @@ -using PostFundManagement.Application.Abstractions; -using PostFundManagement.Domain.Abstractions; -using static PostFundManagement.Domain.Extensions.Constants; - -namespace PostFundManagement.Application.Services.Shared; - -public sealed class EvidenceS3Service(IConfiguration configuration, [FromKeyedServices(EvidenceBucketName)] IAmazonS3 amazonS3) : - S3ServiceBase(amazonS3), IS3Service -{ - protected override string BucketName => configuration.GetSection($"{EvidenceS3SettingsSection}:BucketName").Value ?? string.Empty; - protected override string CdnBaseUrl => configuration.GetSection($"{EvidenceS3SettingsSection}:CdnBaseUrl").Value ?? string.Empty; -} \ No newline at end of file diff --git a/PostFundManagement.Application/Services/Users/Records.cs b/PostFundManagement.Application/Services/Users/Records.cs index b8ba6a8..be2efb9 100644 --- a/PostFundManagement.Application/Services/Users/Records.cs +++ b/PostFundManagement.Application/Services/Users/Records.cs @@ -2,4 +2,4 @@ namespace PostFundManagement.Application.Services.Users; -public record UserFilterRequest(ActivityStatus? Status = null, string? Email = null, string? SortBy = null, bool SortDescending = false); +public sealed record UserFilterRequest(ActivityStatus? Status = null, string? Email = null, string? SortBy = null, bool SortDescending = false); diff --git a/PostFundManagement.Domain/Abstractions/IS3Service.cs b/PostFundManagement.Domain/Abstractions/IS3Service.cs index a1d6a3e..89d00ba 100644 --- a/PostFundManagement.Domain/Abstractions/IS3Service.cs +++ b/PostFundManagement.Domain/Abstractions/IS3Service.cs @@ -2,6 +2,8 @@ namespace PostFundManagement.Domain.Abstractions; public interface IS3Service { - ValueTask> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default); + string GenerateCdnUrl(string fileKey); + ValueTask> GeneratePresignedUrlAsync(string fileKey, TimeSpan duration, string? contentType = null, CancellationToken cancellationToken = default); + ValueTask> UploadFileAsync(string fileName, string subPath, Stream fileStream, string contentType, CancellationToken cancellationToken = default); ValueTask DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/Constants.cs b/PostFundManagement.Domain/Extensions/Constants.cs index 4a26e15..337ef79 100644 --- a/PostFundManagement.Domain/Extensions/Constants.cs +++ b/PostFundManagement.Domain/Extensions/Constants.cs @@ -16,11 +16,7 @@ public static class Constants public const string ContractS3SettingsSection = "ContractS3Settings"; - public const string GeneralBucketName = "pfm.general"; - - public const string EvidenceBucketName = "pfm.evidence"; - - public const string ContractBucketName = "pfm.contract"; + public const string GeneralBucketName = "pfm"; public const string DatabaseConfigName = "PfmDatabase"; diff --git a/docker-compose.yml b/docker-compose.yml index 0103b94..2557112 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,26 +18,12 @@ services: - ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD}; # Garage S3 Settings - - GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-http://192.168.1.177:30900} + - GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za} - GeneralS3Settings__Region=${GENERAL_S3_REGION:-garage} - - GeneralS3Settings__BucketName=${GENERAL_S3_BUCKET_NAME:-pfm.general} - - GeneralS3Settings__CdnBaseUrl=${GENERAL_S3_CDN_BASE_URL:-https://pfm.general.cdn.khongisa.co.za} + - GeneralS3Settings__BucketName=${GENERAL_S3_BUCKET_NAME:-pfm} + - GeneralS3Settings__CdnBaseUrl=${GENERAL_S3_CDN_BASE_URL:-https://pfm.cdn.khongisa.co.za} - GeneralS3Settings__AccessKey=${GENERAL_S3_ACCESS_KEY} - GeneralS3Settings__SecretKey=${GENERAL_S3_SECRET_KEY} - - - ContractS3Settings__ServiceUrl=${CONTRACT_S3_SERVICE_URL:-http://192.168.1.177:30900} - - ContractS3Settings__Region=${CONTRACT_S3_REGION:-garage} - - ContractS3Settings__BucketName=${CONTRACT_S3_BUCKET_NAME:-pfm.contract} - - ContractS3Settings__CdnBaseUrl=${CONTRACT_S3_CDN_BASE_URL:-https://pfm.contract.cdn.khongisa.co.za} - - ContractS3Settings__AccessKey=${CONTRACT_S3_ACCESS_KEY} - - ContractS3Settings__SecretKey=${CONTRACT_S3_SECRET_KEY} - - - EvidenceS3Settings__ServiceUrl=${EVIDENCE_S3_SERVICE_URL:-http://192.168.1.177:30900} - - EvidenceS3Settings__Region=${EVIDENCE_S3_REGION:-garage} - - EvidenceS3Settings__BucketName=${EVIDENCE_S3_BUCKET_NAME:-pfm.evidence} - - EvidenceS3Settings__CdnBaseUrl=${EVIDENCE_S3_CDN_BASE_URL:-https://pfm.evidence.cdn.khongisa.co.za } - - EvidenceS3Settings__AccessKey=${EVIDENCE_S3_ACCESS_KEY} - - EvidenceS3Settings__SecretKey=${EVIDENCE_S3_SECRET_KEY} # HashIds overrides - HasherSettings__MinHashLength=${HASHER_MIN_HASH_LENGTH:-11} -- 2.47.3