Added tooling

This commit is contained in:
2026-09-05 18:24:21 +02:00
parent 415aa30cf7
commit 81c111c3c3
20 changed files with 181 additions and 59 deletions
@@ -18,7 +18,7 @@ public class GetOrganisationEndpoint : IEndpoint
IGovernanceService service, CancellationToken cancellationToken = default) => IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.GetOrganisationAsync(organisationId, cancellationToken) switch 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<IdDecodeError>() => Results.Forbid(), var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(), var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
@@ -14,7 +14,7 @@ public sealed class GetPortfolioProgrammesEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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) => CancellationToken cancellationToken = default) =>
await service.GetPortfolioProgrammesAsync(portfolioId, cancellationToken) switch await service.GetPortfolioProgrammesAsync(portfolioId, cancellationToken) switch
{ {
@@ -14,7 +14,7 @@ public class GetPortfoliosEndpoint : IEndpoint
{ {
public void Map(IEndpointRouteBuilder builder) 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) => IGovernanceService service, CancellationToken cancellationToken = default) =>
await service.GetPorfoliosAsync(organisationId, cancellationToken) switch await service.GetPorfoliosAsync(organisationId, cancellationToken) switch
{ {
@@ -21,8 +21,7 @@ public sealed class UpdatePortfolioEndpoint : IEndpoint
{ IsSuccess: true } => Results.Ok(), { IsSuccess: true } => Results.Ok(),
var r when r.HasError<NotFoundError>() => Results.NotFound(), var r when r.HasError<NotFoundError>() => Results.NotFound(),
var r when r.HasError<IdDecodeError>() => Results.Forbid(), var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<UpdateError>() => Results.BadRequest(), var r when r.HasError<UpdateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r when r.HasError<ValidationError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems()) var r => Results.InternalServerError(r.Errors.ToProblems())
}) })
.WithDescription("Update an existing organization portfolio") .WithDescription("Update an existing organization portfolio")
@@ -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<ValidationError>() => 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<string>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Tools)
.CacheOutput();
}
}
+3
View File
@@ -42,6 +42,9 @@ builder.Services.AddHealthChecks()
IEnumerable<string> identityScopes = ["openid", "profile", "roles", "email", "address"]; IEnumerable<string> identityScopes = ["openid", "profile", "roles", "email", "address"];
builder.Services.AddValidation()
.AddEndpointValidation();
builder.Services.AddOpenApi(options => builder.Services.AddOpenApi(options =>
{ {
options.AddDocumentTransformer((document, context, cancellationToken) => options.AddDocumentTransformer((document, context, cancellationToken) =>
@@ -0,0 +1,6 @@
namespace PostFundManagement.Application.Abstractions.Services;
public interface IToolService
{
ValueTask<Result<string>> EncodeLong(long id, CancellationToken cancellationToken);
}
@@ -6,6 +6,8 @@ namespace PostFundManagement.Application.Extensions;
public static class Api public static class Api
{ {
public static IServiceCollection AddEndpointValidation(this IServiceCollection services) => services.AddValidation();
public static IServiceCollection AddEndpointAndServiceCaching(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddEndpointAndServiceCaching(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddStackExchangeRedisOutputCache(options => services.AddStackExchangeRedisOutputCache(options =>
@@ -2,6 +2,8 @@
public static class General public static class General
{ {
public static string Clean(this string input) => Patterns.SpaceRegex().Replace(input, string.Empty);
public static string ToJson<TValue>(this TValue obj) where TValue : class => JsonSerializer.Serialize(obj); public static string ToJson<TValue>(this TValue obj) where TValue : class => JsonSerializer.Serialize(obj);
public static TValue FromJson<TValue>(this string json) where TValue : class => JsonSerializer.Deserialize<TValue>(json)!; public static TValue FromJson<TValue>(this string json) where TValue : class => JsonSerializer.Deserialize<TValue>(json)!;
@@ -32,6 +34,6 @@ public static class General
{ {
Detail = string.Join(',', errors.Select(e => e.Message)), Detail = string.Join(',', errors.Select(e => e.Message)),
Status = StatusCodes.Status400BadRequest, Status = StatusCodes.Status400BadRequest,
Title = errors[0]?.Message, Title = "Error",
}; };
} }
@@ -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; }
}
@@ -7,6 +7,7 @@ using PostFundManagement.Application.Services.Governance;
using PostFundManagement.Application.Services.Grants; using PostFundManagement.Application.Services.Grants;
using PostFundManagement.Application.Services.Projects; using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Application.Services.Shared; using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Application.Services.Tools;
using PostFundManagement.Application.Services.Users; using PostFundManagement.Application.Services.Users;
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
@@ -40,6 +41,9 @@ public static class Services
services.AddScoped<IProjectService, ProjectService>(); services.AddScoped<IProjectService, ProjectService>();
services.Decorate<IProjectService, ProjectCacheService>(); services.Decorate<IProjectService, ProjectCacheService>();
services.AddScoped<IToolService, ToolService>();
services.Decorate<IToolService, ToolCacheService>();
services.AddStackExchangeRedisCache(options => services.AddStackExchangeRedisCache(options =>
{ {
options.Configuration = configuration.GetConnectionString("Redis"); options.Configuration = configuration.GetConnectionString("Redis");
@@ -25,6 +25,7 @@
<!-- API Documentation --> <!-- API Documentation -->
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Validation" Version="10.0.11" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.2" /> <PackageReference Include="Scalar.AspNetCore" Version="2.17.2" />
<Using Include="Scalar.AspNetCore" /> <Using Include="Scalar.AspNetCore" />
@@ -34,6 +35,8 @@
<!-- Shared Usings --> <!-- Shared Usings -->
<ItemGroup> <ItemGroup>
<Using Include="Humanizer" />
<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" />
<Using Include="System.IdentityModel.Tokens.Jwt" /> <Using Include="System.IdentityModel.Tokens.Jwt" />
@@ -5,6 +5,7 @@ using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models; using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database; using PostFundManagement.Infrastructure.Database;
using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Application.Services.Governance; namespace PostFundManagement.Application.Services.Governance;
@@ -131,14 +132,16 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); 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")); return Result.Fail(CreateError.Create("Organisation with the same registration number already exists"));
context.Organisations.Add(new Domain.Entities.Organisation context.Organisations.Add(new Domain.Entities.Organisation
{ {
Name = request.Name, Name = request.Name.Humanize(LetterCasing.Title).Trim(),
RegistrationNo = request.RegistrationNo, RegistrationNo = registrationNo,
Email = request.Email, Email = request.Email.ToLower(CultureInfo.InvariantCulture).Clean(),
Type = request.Type, Type = request.Type,
Status = ActivityStatus.Active, Status = ActivityStatus.Active,
CreatedBy = userId, CreatedBy = userId,
@@ -156,9 +159,9 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
var changedTotal = await context.Organisations var changedTotal = await context.Organisations
.Where(f => f.Id == request.OrganisationId) .Where(f => f.Id == request.OrganisationId)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(u => u.Name, request.Name) .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.RegistrationNo, request.RegistrationNo) .SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
.SetProperty(u => u.Email, request.Email) .SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
.SetProperty(u => u.Type, request.Type), cancellationToken); .SetProperty(u => u.Type, request.Type), cancellationToken);
return Result.FailIf(changedTotal <= 0, UpdateError.New); return Result.FailIf(changedTotal <= 0, UpdateError.New);
@@ -175,16 +178,19 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
if (!await context.Organisations.AnyAsync(u => u.Id == request.OrganisationId, cancellationToken)) if (!await context.Organisations.AnyAsync(u => u.Id == request.OrganisationId, cancellationToken))
return Result.Fail(NotFoundError.Create("Organisation not found")); 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")); return Result.Fail(ValidationError.Create("A portfolio with the same name exists in this organisation"));
context.Portfolios.Add(new Domain.Entities.Portfolio context.Portfolios.Add(new Domain.Entities.Portfolio
{ {
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedBy = userId, CreatedBy = userId,
OrganisationId = request.OrganisationId,
OwnedBy = request.OwnerId, OwnedBy = request.OwnerId,
Name = request.Name, Name = name,
Description = request.Description Description = request.Description.Humanize(LetterCasing.Sentence).Trim()
}); });
return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New); return Result.FailIf(await context.SaveChangesAsync(cancellationToken) <= 0, CreateError.New);
@@ -201,11 +207,11 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
var changedTotal = await context.Portfolios var changedTotal = await context.Portfolios
.Where(p => p.Id == request.PortfolioId) .Where(p => p.Id == request.PortfolioId)
.ExecuteUpdateAsync(f => f .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.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<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) => public async ValueTask<Result<Programme[]>> GetPortfolioProgrammesAsync(string portfolioIdHash, CancellationToken cancellationToken = default) =>
@@ -1,13 +1,32 @@
using PostFundManagement.Domain; using PostFundManagement.Domain;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Services.Governance; 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);
@@ -15,17 +15,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); 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 var changedTotal = await context.Projects
.Where(p => p.Id != request.ProjectId) .Where(p => p.Id != request.ProjectId)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
@@ -91,17 +80,21 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
{ {
using var context = await contextFactory.CreateDbContextAsync(cancellationToken); 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")); 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)) if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
return Result.Fail(NotFoundError.New); return Result.Fail(NotFoundError.New);
var description = request.Description.Humanize(LetterCasing.Sentence).Trim();
context.Projects.Add(new Domain.Entities.Project context.Projects.Add(new Domain.Entities.Project
{ {
ProgrammeId = request.ProgrammeId, ProgrammeId = request.ProgrammeId,
Name = request.Name, Name = name,
Description = request.Description, Description = description,
Sector = request.Sector, Sector = request.Sector,
ProjectType = request.ProjectType, ProjectType = request.ProjectType,
Status = ApprovalStatus.Pending, Status = ApprovalStatus.Pending,
@@ -130,7 +123,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
MilestoneId = request.MilestoneId, MilestoneId = request.MilestoneId,
Category = request.Category, Category = request.Category,
Amount = request.Amount, Amount = request.Amount,
Title = request.Title, Title = request.Title.Humanize(LetterCasing.Title).Trim(),
Description = request.Description, Description = request.Description,
PerformedAt = request.PerformedAt, PerformedAt = request.PerformedAt,
Status = ApprovalStatus.Pending, Status = ApprovalStatus.Pending,
@@ -218,7 +211,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
var changedTotal = await context.Milestones.Where(m => m.Id == request.MilestoneId) var changedTotal = await context.Milestones.Where(m => m.Id == request.MilestoneId)
.ExecuteUpdateAsync(f => f .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.DueAt, request.DueAt)
.SetProperty(u => u.Priority, request.Priority) .SetProperty(u => u.Priority, request.Priority)
.SetProperty(u => u.Status, request.Status) .SetProperty(u => u.Status, request.Status)
@@ -235,8 +228,8 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
var changedTotal = await context.Projects.Where(p => p.Id == request.ProjectId) var changedTotal = await context.Projects.Where(p => p.Id == request.ProjectId)
.ExecuteUpdateAsync(f => f .ExecuteUpdateAsync(f => f
.SetProperty(u => u.Name, request.Name) .SetProperty(u => u.Name, request.Name.Humanize(LetterCasing.Title).Trim())
.SetProperty(u => u.Description, request.Description) .SetProperty(u => u.Description, request.Description.Humanize(LetterCasing.Sentence).Trim())
.SetProperty(u => u.StartedAt, request.StartedAt) .SetProperty(u => u.StartedAt, request.StartedAt)
.SetProperty(u => u.EndedAt, request.EndedAt) .SetProperty(u => u.EndedAt, request.EndedAt)
.SetProperty(u => u.UpdatedBy, userId) .SetProperty(u => u.UpdatedBy, userId)
@@ -290,15 +283,15 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
{ {
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken)) var name = request.Name.Humanize(LetterCasing.Title).Trim();
return Result.Fail(NotFoundError.New);
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")); return Result.Fail(ValidationError.Create("Programme of the same name exists"));
context.Programmes.Add(new Domain.Entities.Programme context.Programmes.Add(new Domain.Entities.Programme
{ {
OwnedBy = request.OwnerId, OwnedBy = request.OwnerId,
Name = name,
CreatedBy = userId, CreatedBy = userId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
Status = ApprovalStatus.Pending Status = ApprovalStatus.Pending
@@ -317,7 +310,7 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
var changedTotal = await context.Programmes.Where(p => p.Id == request.ProgrammeId) var changedTotal = await context.Programmes.Where(p => p.Id == request.ProgrammeId)
.ExecuteUpdateAsync(f => f .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.OwnedBy, request.OwnerId)
.SetProperty(u => u.Status, request.Status) .SetProperty(u => u.Status, request.Status)
.SetProperty(u => u.UpdatedBy, userId) .SetProperty(u => u.UpdatedBy, userId)
@@ -1,24 +1,16 @@
using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Errors;
using PostFundManagement.Application.Extensions;
namespace PostFundManagement.Application.Services.Shared; namespace PostFundManagement.Application.Services.Shared;
public sealed partial class HashService(IHashids hasher) : IService 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) => 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) => 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) => public static string? StringToSha256Hash(string? input) =>
string.IsNullOrEmpty(input) ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(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()); return Result.Ok(Convert.ToHexString(bytes).ToLowerInvariant());
} }
public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !HexHashRegex.IsMatch(input) public Result<string> HashEncodeHex(string input) => string.IsNullOrWhiteSpace(input) || !Patterns.HexHashRegex.IsMatch(input)
? Result.Fail<string>(ValidationError.Create("Input must be a valid hexadecimal string.")) ? Result.Fail<string>(ValidationError.Create("Input must be a valid hexadecimal string."))
: Result.Ok(hasher.EncodeHex(input)); : Result.Ok(hasher.EncodeHex(input));
@@ -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<Result<string>> EncodeLong(long id, CancellationToken cancellationToken) =>
await Result.Try((Func<ValueTask<Result<string>>>)(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);
}));
}
@@ -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<Result<string>> EncodeLong(long id, CancellationToken cancellationToken = default) =>
ValueTask.FromResult(service.HashEncodeLongId(id));
}
@@ -15,4 +15,6 @@ public static class EndpointTags
public const string Communications = nameof(Communications); public const string Communications = nameof(Communications);
public const string Users = nameof(Users); public const string Users = nameof(Users);
public const string Tools = nameof(Tools);
} }
@@ -228,7 +228,8 @@ public static class Mappers
CreatedBy = entity.CreatedBy, CreatedBy = entity.CreatedBy,
Description = entity.Description, Description = entity.Description,
Name = entity.Name, Name = entity.Name,
OwnedBy = entity.OwnedBy OwnedBy = entity.OwnedBy,
OrganisationId = entity.OrganisationId
}; };
public static Notification Map(this Entities.Notification entity) => new() public static Notification Map(this Entities.Notification entity) => new()