From 81c111c3c3e029cf8b2917e877a7df57101fd2ac Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 5 Sep 2026 18:24:21 +0200 Subject: [PATCH] Added tooling --- .../Governance/GetOrganisationEndpoint.cs | 2 +- .../GetPortfolioProgrammesEndpoint.cs | 2 +- .../Governance/GetPortfoliosEndpoint.cs | 2 +- .../Governance/UpdatePortfolioEndpoint.cs | 3 +- .../Endpoints/Tools/EncodeIdEndpoint.cs | 35 ++++++++++++++++++ PostFundManagement.Api/Program.cs | 3 ++ .../Abstractions/Services/IToolService.cs | 6 +++ .../Extensions/Api.cs | 2 + .../Extensions/General.cs | 4 +- .../Extensions/Patterns.cs | 16 ++++++++ .../Extensions/Services.cs | 4 ++ .../PostFundManagement.Application.csproj | 3 ++ .../Services/Governance/GovernanceService.cs | 32 +++++++++------- .../Services/Governance/Records.cs | 29 ++++++++++++--- .../Services/Projects/ProjectService.cs | 37 ++++++++----------- .../Services/Shared/HashService.cs | 16 ++------ .../Services/Tools/ToolCacheService.cs | 29 +++++++++++++++ .../Services/Tools/ToolService.cs | 10 +++++ .../Extensions/EndpointTags.cs | 2 + .../Extensions/Mappers.cs | 3 +- 20 files changed, 181 insertions(+), 59 deletions(-) create mode 100644 PostFundManagement.Api/Endpoints/Tools/EncodeIdEndpoint.cs create mode 100644 PostFundManagement.Application/Abstractions/Services/IToolService.cs create mode 100644 PostFundManagement.Application/Extensions/Patterns.cs create mode 100644 PostFundManagement.Application/Services/Tools/ToolCacheService.cs create mode 100644 PostFundManagement.Application/Services/Tools/ToolService.cs diff --git a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationEndpoint.cs index 6937726..d6f0e98 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetOrganisationEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetOrganisationEndpoint.cs @@ -18,7 +18,7 @@ public class GetOrganisationEndpoint : IEndpoint IGovernanceService service, CancellationToken cancellationToken = default) => await service.GetOrganisationAsync(organisationId, cancellationToken) switch { - { IsSuccess: true, Value: var organisation } => Results.Ok(organisationId), + { IsSuccess: true, Value: var organisation } => Results.Ok(organisation), var r when r.HasError() => Results.Forbid(), var r when r.HasError() => Results.NotFound(), var r => Results.InternalServerError(r.Errors.ToProblems()) diff --git a/PostFundManagement.Api/Endpoints/Governance/GetPortfolioProgrammesEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetPortfolioProgrammesEndpoint.cs index 30c193c..2559c20 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetPortfolioProgrammesEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetPortfolioProgrammesEndpoint.cs @@ -14,7 +14,7 @@ public sealed class GetPortfolioProgrammesEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/portfolios/programmes/{portfolioId}", async (string portfolioId, IGovernanceService service, + builder.MapGet("api/governance/portfolios/{portfolioId}/programmes", async (string portfolioId, IGovernanceService service, CancellationToken cancellationToken = default) => await service.GetPortfolioProgrammesAsync(portfolioId, cancellationToken) switch { diff --git a/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs index d90b04b..1127ef3 100644 --- a/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/GetPortfoliosEndpoint.cs @@ -14,7 +14,7 @@ public class GetPortfoliosEndpoint : IEndpoint { public void Map(IEndpointRouteBuilder builder) { - builder.MapGet("api/governance/portfolios/{organisationId}", async (string organisationId, + builder.MapGet("api/governance/organisations/{organisationId}/portfolio", async (string organisationId, IGovernanceService service, CancellationToken cancellationToken = default) => await service.GetPorfoliosAsync(organisationId, cancellationToken) switch { diff --git a/PostFundManagement.Api/Endpoints/Governance/UpdatePortfolioEndpoint.cs b/PostFundManagement.Api/Endpoints/Governance/UpdatePortfolioEndpoint.cs index e041f1b..71035f4 100644 --- a/PostFundManagement.Api/Endpoints/Governance/UpdatePortfolioEndpoint.cs +++ b/PostFundManagement.Api/Endpoints/Governance/UpdatePortfolioEndpoint.cs @@ -21,8 +21,7 @@ public sealed class UpdatePortfolioEndpoint : IEndpoint { IsSuccess: true } => Results.Ok(), var r when r.HasError() => Results.NotFound(), var r when r.HasError() => Results.Forbid(), - var r when r.HasError() => Results.BadRequest(), - 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("Update an existing organization portfolio") diff --git a/PostFundManagement.Api/Endpoints/Tools/EncodeIdEndpoint.cs b/PostFundManagement.Api/Endpoints/Tools/EncodeIdEndpoint.cs new file mode 100644 index 0000000..f1b444d --- /dev/null +++ b/PostFundManagement.Api/Endpoints/Tools/EncodeIdEndpoint.cs @@ -0,0 +1,35 @@ +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.Tools; + +[ApiVersionTarget(1)] +public sealed class EncodeIdEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder builder) + { + builder.MapGet("api/tools/hashid/{id64:long}", async (long id64, IToolService service, + CancellationToken cancellationToken) => + await service.EncodeLong(id64, cancellationToken) switch + { + { IsSuccess: true, Value: var hash } => Results.Ok(hash), + var r when r.HasError() => Results.BadRequest(r.Errors.ToProblems()), + var r => Results.InternalServerError(r.Errors.ToProblems()) + }) + .WithDescription("Hashes an int64 identifier") + .WithName(typeof(EncodeIdEndpoint).ToEndpointName()) + .MapToApiVersion(new ApiVersion(1)) + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithTags(EndpointTags.Tools) + .CacheOutput(); + } +} diff --git a/PostFundManagement.Api/Program.cs b/PostFundManagement.Api/Program.cs index 697c834..38018f1 100644 --- a/PostFundManagement.Api/Program.cs +++ b/PostFundManagement.Api/Program.cs @@ -42,6 +42,9 @@ builder.Services.AddHealthChecks() IEnumerable identityScopes = ["openid", "profile", "roles", "email", "address"]; +builder.Services.AddValidation() + .AddEndpointValidation(); + builder.Services.AddOpenApi(options => { options.AddDocumentTransformer((document, context, cancellationToken) => diff --git a/PostFundManagement.Application/Abstractions/Services/IToolService.cs b/PostFundManagement.Application/Abstractions/Services/IToolService.cs new file mode 100644 index 0000000..6fdfb0d --- /dev/null +++ b/PostFundManagement.Application/Abstractions/Services/IToolService.cs @@ -0,0 +1,6 @@ +namespace PostFundManagement.Application.Abstractions.Services; + +public interface IToolService +{ + ValueTask> EncodeLong(long id, CancellationToken cancellationToken); +} diff --git a/PostFundManagement.Application/Extensions/Api.cs b/PostFundManagement.Application/Extensions/Api.cs index 50af457..4617f93 100644 --- a/PostFundManagement.Application/Extensions/Api.cs +++ b/PostFundManagement.Application/Extensions/Api.cs @@ -6,6 +6,8 @@ namespace PostFundManagement.Application.Extensions; public static class Api { + public static IServiceCollection AddEndpointValidation(this IServiceCollection services) => services.AddValidation(); + public static IServiceCollection AddEndpointAndServiceCaching(this IServiceCollection services, IConfiguration configuration) { services.AddStackExchangeRedisOutputCache(options => diff --git a/PostFundManagement.Application/Extensions/General.cs b/PostFundManagement.Application/Extensions/General.cs index 282a08e..55f7874 100644 --- a/PostFundManagement.Application/Extensions/General.cs +++ b/PostFundManagement.Application/Extensions/General.cs @@ -2,6 +2,8 @@ public static class General { + public static string Clean(this string input) => Patterns.SpaceRegex().Replace(input, string.Empty); + public static string ToJson(this TValue obj) where TValue : class => JsonSerializer.Serialize(obj); public static TValue FromJson(this string json) where TValue : class => JsonSerializer.Deserialize(json)!; @@ -32,6 +34,6 @@ public static class General { Detail = string.Join(',', errors.Select(e => e.Message)), Status = StatusCodes.Status400BadRequest, - Title = errors[0]?.Message, + Title = "Error", }; } diff --git a/PostFundManagement.Application/Extensions/Patterns.cs b/PostFundManagement.Application/Extensions/Patterns.cs new file mode 100644 index 0000000..a2e718a --- /dev/null +++ b/PostFundManagement.Application/Extensions/Patterns.cs @@ -0,0 +1,16 @@ +namespace PostFundManagement.Application.Extensions; + +public partial class Patterns +{ + [GeneratedRegex(@"\s+")] + public static partial Regex SpaceRegex(); + + [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + public static partial Regex HexHashRegex { get; } + + [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + public static partial Regex Md5Regex { get; } + + [GeneratedRegex(@"\A[0-9a-fA-F]{64}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] + public static partial Regex Sha256Regex { get; } +} diff --git a/PostFundManagement.Application/Extensions/Services.cs b/PostFundManagement.Application/Extensions/Services.cs index d93fd2e..c571328 100644 --- a/PostFundManagement.Application/Extensions/Services.cs +++ b/PostFundManagement.Application/Extensions/Services.cs @@ -7,6 +7,7 @@ using PostFundManagement.Application.Services.Governance; using PostFundManagement.Application.Services.Grants; using PostFundManagement.Application.Services.Projects; using PostFundManagement.Application.Services.Shared; +using PostFundManagement.Application.Services.Tools; using PostFundManagement.Application.Services.Users; using PostFundManagement.Domain.Abstractions; @@ -39,6 +40,9 @@ public static class Services services.AddScoped(); services.Decorate(); + + services.AddScoped(); + services.Decorate(); services.AddStackExchangeRedisCache(options => { diff --git a/PostFundManagement.Application/PostFundManagement.Application.csproj b/PostFundManagement.Application/PostFundManagement.Application.csproj index f76e2aa..3b25ffa 100644 --- a/PostFundManagement.Application/PostFundManagement.Application.csproj +++ b/PostFundManagement.Application/PostFundManagement.Application.csproj @@ -25,6 +25,7 @@ + @@ -34,6 +35,8 @@ + + diff --git a/PostFundManagement.Application/Services/Governance/GovernanceService.cs b/PostFundManagement.Application/Services/Governance/GovernanceService.cs index 05bc14b..bbf8d6a 100644 --- a/PostFundManagement.Application/Services/Governance/GovernanceService.cs +++ b/PostFundManagement.Application/Services/Governance/GovernanceService.cs @@ -5,6 +5,7 @@ using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Application.Services.Governance; @@ -131,14 +132,16 @@ public sealed class GovernanceService(IDbContextFactory co { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - if (await context.Organisations.AnyAsync(o => o.RegistrationNo == request.RegistrationNo, cancellationToken)) + var registrationNo = request.RegistrationNo.Clean(); + + if (await context.Organisations.AnyAsync(o => o.RegistrationNo == registrationNo, cancellationToken)) return Result.Fail(CreateError.Create("Organisation with the same registration number already exists")); context.Organisations.Add(new Domain.Entities.Organisation { - Name = request.Name, - RegistrationNo = request.RegistrationNo, - Email = request.Email, + Name = request.Name.Humanize(LetterCasing.Title).Trim(), + RegistrationNo = registrationNo, + Email = request.Email.ToLower(CultureInfo.InvariantCulture).Clean(), Type = request.Type, Status = ActivityStatus.Active, CreatedBy = userId, @@ -156,9 +159,9 @@ public sealed class GovernanceService(IDbContextFactory co var changedTotal = await context.Organisations .Where(f => f.Id == request.OrganisationId) .ExecuteUpdateAsync(f => f - .SetProperty(u => u.Name, request.Name) - .SetProperty(u => u.RegistrationNo, request.RegistrationNo) - .SetProperty(u => u.Email, request.Email) + .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()) + .SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean()) + .SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean()) .SetProperty(u => u.Type, request.Type), cancellationToken); return Result.FailIf(changedTotal <= 0, UpdateError.New); @@ -175,16 +178,19 @@ public sealed class GovernanceService(IDbContextFactory co if (!await context.Organisations.AnyAsync(u => u.Id == request.OrganisationId, cancellationToken)) return Result.Fail(NotFoundError.Create("Organisation not found")); - if (await context.Portfolios.AnyAsync(p => p.Name == request.Name && p.OrganisationId == request.OrganisationId, cancellationToken)) + var name = request.Name.Humanize(LetterCasing.Title).Trim(); + + if (await context.Portfolios.AnyAsync(p => p.Name == name && p.OrganisationId == request.OrganisationId, cancellationToken)) return Result.Fail(ValidationError.Create("A portfolio with the same name exists in this organisation")); context.Portfolios.Add(new Domain.Entities.Portfolio { CreatedAt = DateTime.UtcNow, CreatedBy = userId, + OrganisationId = request.OrganisationId, OwnedBy = request.OwnerId, - Name = request.Name, - Description = request.Description + Name = name, + Description = request.Description.Humanize(LetterCasing.Sentence).Trim() }); return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New); @@ -201,11 +207,11 @@ public sealed class GovernanceService(IDbContextFactory co var changedTotal = await context.Portfolios .Where(p => p.Id == request.PortfolioId) .ExecuteUpdateAsync(f => f - .SetProperty(u => u.Name, request.Name) + .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()) .SetProperty(u => u.OwnedBy, request.OwnerId) - .SetProperty(u => u.Description, request.Description), cancellationToken); + .SetProperty(u => u.Description, request.Description.Humanize(LetterCasing.Sentence).Trim()), cancellationToken); - return Result.FailIf(changedTotal <= 0, UpdateError.New); + return Result.FailIf(changedTotal <= 0, UpdateError.Create("Nothing was saved, no change detected.")); })); public async ValueTask> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => diff --git a/PostFundManagement.Application/Services/Governance/Records.cs b/PostFundManagement.Application/Services/Governance/Records.cs index b6c55a1..8d10e5f 100644 --- a/PostFundManagement.Application/Services/Governance/Records.cs +++ b/PostFundManagement.Application/Services/Governance/Records.cs @@ -1,13 +1,32 @@ using PostFundManagement.Domain; +using static PostFundManagement.Domain.Extensions.Constants; namespace PostFundManagement.Application.Services.Governance; -public record UpdatePortfolioRequest(long PortfolioId, string Name, string Description, Guid OwnerId); +public record UpdatePortfolioRequest([Required] long PortfolioId, + [Required][MaxLength(LabelLength)] string Name, + [Required][MaxLength(TextLength)] string Description, + [Required] Guid OwnerId); -public record CreatePortfolioRequest(string Name, string Description, long OrganisationId, Guid OwnerId); +public record CreatePortfolioRequest([Required][MaxLength(LabelLength)] string Name, + [Required][MaxLength(TextLength)] string Description, + [Required] long OrganisationId, + [Required] Guid OwnerId); -public record UpdateOrganisationRequest(long OrganisationId, string Name, string RegistrationNo, string Email, OrganisationType Type); +public 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(string Name, string RegistrationNo, string Email, OrganisationType Type); +public 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(decimal PortfolioValue, int ActiveAwards, decimal Disbursed, double OnTrackPercentage, int AtRiskCount, int BeneficiariesReached); +public record Dashboard([Required] decimal PortfolioValue, + [Required] int ActiveAwards, + [Required] decimal Disbursed, + [Required] double OnTrackPercentage, + [Required] int AtRiskCount, + [Required] int BeneficiariesReached); diff --git a/PostFundManagement.Application/Services/Projects/ProjectService.cs b/PostFundManagement.Application/Services/Projects/ProjectService.cs index 7165ce5..a3b822f 100644 --- a/PostFundManagement.Application/Services/Projects/ProjectService.cs +++ b/PostFundManagement.Application/Services/Projects/ProjectService.cs @@ -15,17 +15,6 @@ public sealed class ProjectService(IDbContextFactory conte { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - var project = await context.Projects.FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken); - - if (project == null) - return Result.Fail(NotFoundError.New); - - project.Status = request.Status; - project.UpdatedBy = userId; - project.UpdatedAt = DateTime.UtcNow; - - context.Projects.Update(project); - var changedTotal = await context.Projects .Where(p => p.Id != request.ProjectId) .ExecuteUpdateAsync(f => f @@ -91,17 +80,21 @@ public sealed class ProjectService(IDbContextFactory conte { using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - if (await context.Projects.AnyAsync(p => p.Name == request.Name && p.Sector == request.Sector && p.ProjectType == request.ProjectType, cancellationToken)) + var name = request.Name.Humanize(LetterCasing.Title).Trim(); + + if (await context.Projects.AnyAsync(p => p.Name == name && p.Sector == request.Sector && p.ProjectType == request.ProjectType, cancellationToken)) return Result.Fail(ValidationError.Create("A similarly named project exists of the same project type and sector")); if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) return Result.Fail(NotFoundError.New); + var description = request.Description.Humanize(LetterCasing.Sentence).Trim(); + context.Projects.Add(new Domain.Entities.Project { ProgrammeId = request.ProgrammeId, - Name = request.Name, - Description = request.Description, + Name = name, + Description = description, Sector = request.Sector, ProjectType = request.ProjectType, Status = ApprovalStatus.Pending, @@ -130,7 +123,7 @@ public sealed class ProjectService(IDbContextFactory conte MilestoneId = request.MilestoneId, Category = request.Category, Amount = request.Amount, - Title = request.Title, + Title = request.Title.Humanize(LetterCasing.Title).Trim(), Description = request.Description, PerformedAt = request.PerformedAt, Status = ApprovalStatus.Pending, @@ -218,7 +211,7 @@ public sealed class ProjectService(IDbContextFactory conte var changedTotal = await context.Milestones.Where(m => m.Id == request.MilestoneId) .ExecuteUpdateAsync(f => f - .SetProperty(u => u.Name, request.Name) + .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()) .SetProperty(u => u.DueAt, request.DueAt) .SetProperty(u => u.Priority, request.Priority) .SetProperty(u => u.Status, request.Status) @@ -235,8 +228,8 @@ public sealed class ProjectService(IDbContextFactory conte var changedTotal = await context.Projects.Where(p => p.Id == request.ProjectId) .ExecuteUpdateAsync(f => f - .SetProperty(u => u.Name, request.Name) - .SetProperty(u => u.Description, request.Description) + .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()) + .SetProperty(u => u.Description, request.Description.Humanize(LetterCasing.Sentence).Trim()) .SetProperty(u => u.StartedAt, request.StartedAt) .SetProperty(u => u.EndedAt, request.EndedAt) .SetProperty(u => u.UpdatedBy, userId) @@ -290,15 +283,15 @@ public sealed class ProjectService(IDbContextFactory conte { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); - if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken)) - return Result.Fail(NotFoundError.New); + var name = request.Name.Humanize(LetterCasing.Title).Trim(); - if (await context.Programmes.AnyAsync(p => p.Name == request.Name, cancellationToken)) + if (await context.Programmes.AnyAsync(p => p.Name == name, cancellationToken)) return Result.Fail(ValidationError.Create("Programme of the same name exists")); context.Programmes.Add(new Domain.Entities.Programme { OwnedBy = request.OwnerId, + Name = name, CreatedBy = userId, CreatedAt = DateTime.UtcNow, Status = ApprovalStatus.Pending @@ -317,7 +310,7 @@ public sealed class ProjectService(IDbContextFactory conte var changedTotal = await context.Programmes.Where(p => p.Id == request.ProgrammeId) .ExecuteUpdateAsync(f => f - .SetProperty(u => u.Name, request.Name) + .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim()) .SetProperty(u => u.OwnedBy, request.OwnerId) .SetProperty(u => u.Status, request.Status) .SetProperty(u => u.UpdatedBy, userId) diff --git a/PostFundManagement.Application/Services/Shared/HashService.cs b/PostFundManagement.Application/Services/Shared/HashService.cs index 6d0e3df..7a25365 100644 --- a/PostFundManagement.Application/Services/Shared/HashService.cs +++ b/PostFundManagement.Application/Services/Shared/HashService.cs @@ -1,24 +1,16 @@ using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Errors; +using PostFundManagement.Application.Extensions; namespace PostFundManagement.Application.Services.Shared; public sealed partial class HashService(IHashids hasher) : IService { - [GeneratedRegex(@"\A\b[0-9a-fA-F]+\b\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] - private static partial Regex HexHashRegex { get; } - - [GeneratedRegex(@"\A[0-9a-fA-F]{32}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] - private static partial Regex Md5Regex { get; } - - [GeneratedRegex(@"\A[0-9a-fA-F]{64}\Z", RegexOptions.None, matchTimeoutMilliseconds: 100)] - private static partial Regex Sha256Regex { get; } - public static bool IsMd5Hash(string? value) => - !string.IsNullOrWhiteSpace(value) && Md5Regex.IsMatch(value); + !string.IsNullOrWhiteSpace(value) && Patterns.Md5Regex.IsMatch(value); public static bool IsSha256Hash(string? value) => - !string.IsNullOrWhiteSpace(value) && Sha256Regex.IsMatch(value); + !string.IsNullOrWhiteSpace(value) && Patterns.Sha256Regex.IsMatch(value); public static string? StringToSha256Hash(string? input) => string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input))); @@ -39,7 +31,7 @@ public sealed partial class HashService(IHashids hasher) : IService return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant()); } - public Result HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input) + public Result HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !Patterns.HexHashRegex.IsMatch(input) ? Result.Fail(ValidationError.Create("Input must be a valid hexadecimal string.")) : Result.Ok(hasher.EncodeHex(input)); diff --git a/PostFundManagement.Application/Services/Tools/ToolCacheService.cs b/PostFundManagement.Application/Services/Tools/ToolCacheService.cs new file mode 100644 index 0000000..fce4397 --- /dev/null +++ b/PostFundManagement.Application/Services/Tools/ToolCacheService.cs @@ -0,0 +1,29 @@ +using PostFundManagement.Application.Abstractions; +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Services.Shared; + +namespace PostFundManagement.Application.Services.Tools; + +public sealed class ToolCacheService(ToolService service, IDistributedCache cache) : ServiceCacheBase, IToolService +{ + public async ValueTask> EncodeLong(long id, CancellationToken cancellationToken) => + await Result.Try((Func>>)(async () => + { + var cacheKey = HashService.ToMd5Hash($"encodelonghash-{id}").Value; + + var cached = await cache.GetStringAsync(cacheKey, cancellationToken); + + if(string.IsNullOrWhiteSpace(cached)) + { + var result = (await service.EncodeLong(id).ConfigureAwait(false)); + + if (result.IsFailed) return result; + + await cache.SetStringAsync(cacheKey, result.Value, cacheOptions, cancellationToken); + + return result.Value; + } + + return Result.Ok(cached); + })); +} diff --git a/PostFundManagement.Application/Services/Tools/ToolService.cs b/PostFundManagement.Application/Services/Tools/ToolService.cs new file mode 100644 index 0000000..4b3aacb --- /dev/null +++ b/PostFundManagement.Application/Services/Tools/ToolService.cs @@ -0,0 +1,10 @@ +using PostFundManagement.Application.Abstractions.Services; +using PostFundManagement.Application.Services.Shared; + +namespace PostFundManagement.Application.Services.Tools; + +public sealed class ToolService(HashService service) : IToolService +{ + public ValueTask> EncodeLong(long id, CancellationToken cancellationToken = default) => + ValueTask.FromResult(service.HashEncodeLongId(id)); +} diff --git a/PostFundManagement.Domain/Extensions/EndpointTags.cs b/PostFundManagement.Domain/Extensions/EndpointTags.cs index 8329ecf..57f1ccf 100644 --- a/PostFundManagement.Domain/Extensions/EndpointTags.cs +++ b/PostFundManagement.Domain/Extensions/EndpointTags.cs @@ -15,4 +15,6 @@ public static class EndpointTags public const string Communications = nameof(Communications); public const string Users = nameof(Users); + + public const string Tools = nameof(Tools); } \ No newline at end of file diff --git a/PostFundManagement.Domain/Extensions/Mappers.cs b/PostFundManagement.Domain/Extensions/Mappers.cs index 104defa..1e547ff 100644 --- a/PostFundManagement.Domain/Extensions/Mappers.cs +++ b/PostFundManagement.Domain/Extensions/Mappers.cs @@ -228,7 +228,8 @@ public static class Mappers CreatedBy = entity.CreatedBy, Description = entity.Description, Name = entity.Name, - OwnedBy = entity.OwnedBy + OwnedBy = entity.OwnedBy, + OrganisationId = entity.OrganisationId }; public static Notification Map(this Entities.Notification entity) => new() -- 2.47.3