Refactored endpoints to use services

This commit is contained in:
2026-08-30 14:24:13 +02:00
parent b288450cbb
commit f51b1feb4b
78 changed files with 1494 additions and 870 deletions
@@ -0,0 +1,12 @@
using static PostFundManagement.Domain.Extensions.Timezones;
namespace PostFundManagement.Application.Abstractions;
public abstract class EventBase
{
public Guid Id { get; set; } = Guid.CreateVersion7();
public DateTimeOffset EnqueueAt { get; set; } = (DateTimeOffset)SouthAfricanTimeZone.UtcNow();
public string CorrelationId { get; set; } = Guid.CreateVersion7().ToString();
}
@@ -0,0 +1,3 @@
namespace PostFundManagement.Application.Abstractions;
public interface IApplication;
@@ -0,0 +1,83 @@
using PostFundManagement.Application.Shared;
namespace PostFundManagement.Application.Abstractions;
public abstract class S3ServiceBase(IAmazonS3 amazonS3)
{
protected readonly IAmazonS3 Client = amazonS3;
protected abstract string BucketName { get; }
protected abstract string CdnBaseUrl { get; }
public virtual async Task<Result<string>> UploadFileAsync(string fileName, Stream fileStream, string contentType, CancellationToken cancellationToken = default)
{
try
{
if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail<string>("Bucket name is not configured.");
if (string.IsNullOrWhiteSpace(CdnBaseUrl))
return Result.Fail<string>("CDN base URL is not configured.");
using var stream = new MemoryStream();
await fileStream.CopyToAsync(stream, cancellationToken);
await fileStream.DisposeAsync();
stream.Seek(0, SeekOrigin.Begin);
var fileHash = HashService.StreamToSha256Hash(stream);
if(string.IsNullOrWhiteSpace(fileHash))
return Result.Fail<string>("Failed to compute file hash.");
var fileKey = $"{fileHash.ToLower(CultureInfo.InvariantCulture)}{Path.GetExtension(fileName)}";
var putRequest = new PutObjectRequest
{
BucketName = BucketName,
Key = fileKey,
InputStream = stream,
ContentType = contentType,
UseChunkEncoding = false,
};
stream.Seek(0, SeekOrigin.Begin);
var response = await Client.PutObjectAsync(putRequest, cancellationToken);
return response.HttpStatusCode != System.Net.HttpStatusCode.OK
? Result.Fail<string>($"Failed to upload {fileName} to S3.")
: Result.Ok($"{CdnBaseUrl}/{fileKey}");
}
catch (Exception ex)
{
return Result.Fail<string>(new Error($"Error uploading {fileName} to S3: {ex.Message}").CausedBy(ex));
}
}
public virtual async Task<Result> DeleteFileAsync(string fileKey, CancellationToken cancellationToken = default)
{
try
{
if (string.IsNullOrWhiteSpace(BucketName))
return Result.Fail("Bucket name is not configured.");
var deleteRequest = new DeleteObjectRequest
{
BucketName = BucketName,
Key = fileKey
};
var response = await Client.DeleteObjectAsync(deleteRequest, cancellationToken);
return response.HttpStatusCode != System.Net.HttpStatusCode.NoContent
? Result.Fail($"Failed to delete {fileKey} from S3.")
: Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error($"Error deleting {fileKey} from S3: {ex.Message}").CausedBy(ex));
}
}
}
@@ -0,0 +1,34 @@
using PostFundManagement.Application.Shared;
using static PostFundManagement.Domain.Extensions.General;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Api.Filters;
public sealed class ActiveUserFilter(IdentityService identityService) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var principal = context.HttpContext.User;
if (principal.Identity is null || !principal.Identity.IsAuthenticated)
return Results.Unauthorized();
var (decoded, userId) = principal.GetUserId();
var (emailDecoded, email) = principal.GetUserEmail();
if (!decoded)
return Results.Json(new { message = "Invalid user identifier in token." }, statusCode: StatusCodes.Status400BadRequest);
if (!emailDecoded)
return Results.Json(new { message = "Invalid/missing user email in token." }, statusCode: StatusCodes.Status400BadRequest);
if ((await identityService.UserIsAuthorisedAsync(userId, context.HttpContext.RequestAborted)).IsFailed)
return (object?)Results.Json(new { message = "Account approval is pending or inactive." }, statusCode: StatusCodes.Status403Forbidden);
context.HttpContext.Items[UserIdItemKey] = userId;
context.HttpContext.Items[EmaiItemKey] = email;
return await next(context);
}
}
@@ -8,15 +8,10 @@ namespace PostFundManagement.Application.Communications;
public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService
{
public async ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default)
public async ValueTask<Result<Invite>> SendInviteAsync(InviteRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
if (!Guid.TryParse(sidClaim, out var currentUserId))
return Result.Fail("Missing or invalid 'sid' claim.");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var invite = new Domain.Entities.Invite
@@ -26,7 +21,7 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
AwardId = request.AwardId,
ContractId = request.ContractId,
Recipient = request.Recipient,
CreatedBy = currentUserId,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
};
@@ -59,13 +54,13 @@ public sealed class CommunicationsService(IDbContextFactory<ApplicationDbContext
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var envites = await context.Invites.AsNoTracking()
var invites = await context.Invites.AsNoTracking()
.OrderByDescending(i => i.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(envites.Select(i => i.Map()).ToArray());
return Result.Ok(invites?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
@@ -0,0 +1,9 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Application.Communications.Events;
public class SyncUserEvent : EventBase, IEvent
{
public string Name { get; set; } = nameof(SyncUserEvent);
}
@@ -1,6 +1,4 @@
using PostFundManagement.Domain.Events.Communications;
namespace PostFundManagement.Application.Communications.Events;
namespace PostFundManagement.Application.Communications.Events;
public class SyncUserEventHandler(ILogger<SyncUserEvent> logger) : INotificationHandler<SyncUserEvent>
{
@@ -8,7 +8,7 @@ using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Evidence;
public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService,
[FromKeyedServices(Constants.EvidenceS3SettingsSection)] IS3Service s3Service) : IService
[FromKeyedServices(Constants.EvidenceBucketName)] IS3Service s3Service) : IService
{
public async ValueTask<Result> UploadEvidenceFileAsync(Guid userId, IFormCollection form, IFormFile file, CancellationToken cancellationToken = default)
{
@@ -57,24 +57,18 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
}
}
public async ValueTask<Result> RecordIndicatorActualAmountAsync(string hash, decimal amount, ClaimsPrincipal principal, CancellationToken cancellationToken = default)
public async ValueTask<Result> RecordIndicatorActualAmountAsync(string indicatorIdHash, decimal amount, Guid userId, CancellationToken cancellationToken = default)
{
try
{
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
{
var decodeResult = hashService.DecodeLongIdHash(indicatorIdHash);
if (!Guid.TryParse(sidClaim, out var userId))
return Result.Fail("Missing or invalid 'sid' claim.");
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var hashResult = hashService.DecodeLongIdHash(hash);
long indicatorId = 0;
if (hashResult.IsFailed)
return Result.Fail(hashResult.Errors);
var totalUpdated = await context.Indicators.Where(i => i.Id == indicatorId)
var totalUpdated = await context.Indicators.Where(i => i.Id == decodeResult.Value)
.ExecuteUpdateAsync(f => f
.SetProperty(f => f.ActualAmount, amount)
.SetProperty(f => f.CreatedBy, userId), cancellationToken);
@@ -89,18 +83,23 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
}
}
public async ValueTask<Result<Milestone[]>> GetMilestonesByProjectIdAsync(long projectId, CancellationToken cancellationToken = default)
public async ValueTask<Result<Milestone[]>> GetMilestonesByProjectIdAsync(string projectIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var milestones = await context.Milestones.AsNoTracking()
.Where(m => m.ProjectId == projectId)
.Where(m => m.ProjectId == decodeResult.Value)
.OrderBy(m => m.DueAt)
.ToListAsync(cancellationToken);
return Result.Ok(milestones.Select(m => m.Map()).ToArray());
return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
@@ -120,9 +119,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return milestones?.Count > 0
? Result.Ok(milestones.Select(i => i.Map()).ToArray())
: Result.Ok<Milestone[]>([]);
return Result.Ok(milestones?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
@@ -142,9 +139,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return indicators?.Count > 0
? Result.Ok(indicators.Select(i => i.Map()).ToArray())
: Result.Ok<Indicator[]>([]);
return Result.Ok(indicators?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
@@ -164,9 +159,7 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return evidence?.Count > 0
? Result.Ok(evidence.Select(e => e.Map()).ToArray())
: Result.Ok<Domain.Models.Evidence[]>([]);
return Result.Ok(evidence?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
@@ -174,21 +167,16 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
}
}
public async ValueTask<Result<(string Id, Milestone Milestone)>> CreateMilestoneAsync(CreateMilestoneRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default)
public async ValueTask<Result> CreateMilestoneAsync(CreateMilestoneRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
if (!Guid.TryParse(sidClaim, out var userId))
return Result.Fail("Missing or invalid 'sid' claim.");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist.");
var milestone = new Domain.Entities.Milestone
context.Milestones.Add(new Domain.Entities.Milestone
{
ProjectId = request.ProjectId,
DueAt = request.DueAt,
@@ -197,12 +185,10 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
};
context.Milestones.Add(milestone);
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok((hashService.HashEncodeLongId(milestone.Id).Value, milestone.Map()))
? Result.Ok()
: Result.Fail("Failed to create the milestone");
}
catch (Exception ex)
@@ -211,21 +197,19 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
}
}
public async ValueTask<Result<(string Id, Indicator Indicator)>> CreateIndicatorAsync(CreateIndicatorRequest request, ClaimsPrincipal principal, CancellationToken cancellationToken = default)
public async ValueTask<Result> CreateIndicatorAsync(CreateIndicatorRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
var sidClaim = principal.FindFirst("sid")?.Value ?? principal.FindFirst(ClaimTypes.Sid)?.Value;
if (!Guid.TryParse(sidClaim, out var userId))
return Result.Fail("Missing or invalid 'sid' claim.");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Users.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken))
return Result.Fail("Invalid user");
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist.");
var indicator = new Domain.Entities.Indicator
context.Indicators.Add(new Domain.Entities.Indicator
{
ProjectId = request.ProjectId,
Name = request.Name,
@@ -235,12 +219,10 @@ public sealed class EvidenceService(IDbContextFactory<ApplicationDbContext> cont
ActualAmount = request.BaselineAmount,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
};
context.Indicators.Add(indicator);
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok((hashService.HashEncodeLongId(indicator.Id).Value, indicator.Map()))
? Result.Ok()
: Result.Fail("Failed to create the invite");
}
catch (Exception ex)
@@ -0,0 +1,161 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Execution;
public sealed class ExecutionService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async Task<Result<Risk[]>> GetRisksAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var risks = await context.Risks.AsNoTracking()
.OrderByDescending(r => r.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(risks?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Risk[]>> GetProjectRisksAsync(string projectIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var risks = await context.Risks.AsNoTracking()
.Where(r => r.ProjectId == decodeResult.Value)
.OrderByDescending(r => r.CreatedAt)
.ToListAsync(cancellationToken);
return Result.Ok(risks?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Disbursement[]>> GetDisbursementsAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var disbursements = await context.Disbursements.AsNoTracking()
.OrderByDescending(d => d.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(disbursements?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateRiskAsync(CreateRiskRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist.");
context.Risks.Add(new Domain.Entities.Risk
{
ProjectId = request.ProjectId,
Likelihood = request.Likelihood,
Impact = request.Impact,
Name = request.Name,
Description = request.Description,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create project risk");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateDisbursementAsync(CreateDisbursementRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist.");
context.Disbursements.Add(new Domain.Entities.Disbursement
{
ProjectId = request.ProjectId,
MilestoneId = request.MilestoneId,
Amount = request.Amount,
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create disbursement");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> ApproveDisbursementAsync(string disbursementIdHash, Guid userId, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(disbursementIdHash);
if (decodeResult.IsFailed)
return Result.Fail("Invalid disbursementId");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var totalSaved = await context.Disbursements.Where(d => d.Id == decodeResult.Value)
.ExecuteUpdateAsync(f =>
f.SetProperty(f => f.Status, ApprovalStatus.Approved)
.SetProperty(f => f.UpdatedBy, userId)
.SetProperty(f => f.UpdatedAt, DateTime.UtcNow), cancellationToken);
return totalSaved > 0
? Result.Ok()
: Result.Fail("Failed to approve payment");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,7 @@
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Execution;
public record CreateDisbursementRequest(long ProjectId, long? MilestoneId, decimal Amount);
public record CreateRiskRequest(long ProjectId, RiskLikelihood Likelihood, RiskImpact Impact, string Name, string Description);
@@ -0,0 +1,178 @@
using Asp.Versioning;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api;
using PostFundManagement.Domain.Api.Configuration;
namespace PostFundManagement.Application.Extensions;
public static class Api
{
public static void ConfigureCookieOidcSameSiteSupport(this IServiceCollection services) =>
services.Configure<CookiePolicyOptions>(options =>
{
options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
});
public static void CheckSameSite(HttpContext httpContext, CookieOptions options)
{
if (options.SameSite == SameSiteMode.None)
{
bool isSecure = httpContext.Request.IsHttps;
if (!isSecure && httpContext.Request.Headers.TryGetValue("X-Forwarded-Proto", out var proto))
isSecure = string.Equals(proto, "https", StringComparison.OrdinalIgnoreCase);
if (!isSecure && httpContext.Request.Headers.TryGetValue("Forwarded", out var forwarded))
isSecure = forwarded.ToString().Contains("proto=https", StringComparison.OrdinalIgnoreCase);
if (!isSecure) options.SameSite = SameSiteMode.Unspecified;
}
}
public static IServiceCollection AddLiteCharmsApiSecurity(this IServiceCollection services, IConfiguration configuration)
{
var configSection = configuration.GetSection(nameof(SecuritySettings));
var authOptions = new SecuritySettings();
configSection.Bind(authOptions);
services.Configure<SecuritySettings>(configSection);
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = authOptions.Authority;
options.Audience = authOptions.Audience;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = authOptions.Authority,
ValidateAudience = true,
ValidateIssuer = true,
};
});
services.AddAuthorization();
return services;
}
public static WebApplication AddSecurityEndpoints(this WebApplication app)
{
app.MapGet("/login", async (HttpContext context, string redirectUri = "/") =>
{
await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties
{
RedirectUri = redirectUri,
});
});
app.MapGet("/logout", async (HttpContext context, string? redirectUri = null) =>
{
var idToken = await context.GetTokenAsync("id_token");
if (string.IsNullOrWhiteSpace(redirectUri))
{
var host = context.Request.Host.ToUriComponent();
redirectUri = $"https://{host}/";
}
var authProperties = new AuthenticationProperties { RedirectUri = redirectUri, };
if (!string.IsNullOrEmpty(idToken))
authProperties.Parameters.Add("id_token_hint", idToken);
await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
});
return app;
}
public static IServiceCollection AddApiServices(this IServiceCollection services, IConfiguration configuration)
{
services.AddHttpClient();
services.AddApiVersioning(options =>
{
options.ReportApiVersions = true;
options.AssumeDefaultVersionWhenUnspecified = true;
options.ApiVersionReader = ApiVersionReader.Combine(new UrlSegmentApiVersionReader(),
new QueryStringApiVersionReader("version"),
new QueryStringApiVersionReader("version"),
new MediaTypeApiVersionReader("version"));
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var urls = configuration["ASPNETCORE_URLS"] ?? configuration["Urls"];
var healthUrl = "http://localhost:8080/health";
if (!string.IsNullOrWhiteSpace(urls))
{
string firstUrl = urls.Split(';').FirstOrDefault(s => s.Contains("http://", StringComparison.InvariantCultureIgnoreCase))!
.Replace("0.0.0.0", "localhost")
.Replace("*", "localhost")
.Replace("+", "localhost");
healthUrl = $"{firstUrl.TrimEnd('/')}/health";
}
services.AddHealthChecksUI(setup =>
{
setup.SetNotifyUnHealthyOneTimeUntilChange();
setup.AddHealthCheckEndpoint("primary, heal", healthUrl);
setup.SetHeaderText("Midrand Books");
})
.AddInMemoryStorage();
services.AddOutputCache(options =>
{
options.AddBasePolicy(builder => builder.Cache());
options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(10);
});
services.AddOpenApi(options => options.AddDocumentTransformer<OpenApiBearerSecuritySchemeTransformer>());
return services;
}
public static IApplicationBuilder MapEndpoints(this WebApplication app, IDictionary<int, RouteGroupBuilder> versionGroups)
{
var endpoints = app.Services.GetRequiredService<IEnumerable<IEndpoint>>();
foreach (var endpoint in endpoints)
{
var versionAttributes = endpoint.GetType().GetCustomAttributes<ApiVersionTargetAttribute>().ToList();
if (versionAttributes.Count != 0)
{
foreach (var attr in versionAttributes)
if (versionGroups.TryGetValue(attr.MajorVersion, out var targetGroup))
endpoint.Map(targetGroup);
}
else
endpoint.Map(app);
}
return app;
}
public static IServiceCollection AddEndpoints(this IServiceCollection services, Assembly assembly)
{
ServiceDescriptor[] discriptors = [.. assembly.DefinedTypes
.Where(t => t is { IsInterface: false, IsAbstract: false })
.Where(t => t.IsAssignableTo(typeof(IEndpoint)))
.Select(t => ServiceDescriptor.Transient(typeof(IEndpoint), t))];
services.TryAddEnumerable(discriptors);
return services;
}
public static string ToEndpointName(this Type target, string? annotation = "") =>
$"{target.Name.Replace("Endpoint", string.Empty)}{annotation}".ToLower(CultureInfo.CurrentCulture);
}
@@ -0,0 +1,23 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Configuration.Email;
namespace PostFundManagement.Application.Extensions;
public static class Email
{
public const string EmailFromName = "PFM Team";
public const string EmailFromAddress = "info@pfm.co.za"; // TODO: replace with real address
public static IServiceCollection AddEmailServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<SmtpSettings>(configuration.GetSection("Email"));
services.AddSingleton<EmailService>();
services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("Pfm.EmailService"))
.WithMetrics(metrics => metrics.AddMeter("Pfm.EmailService"));
return services;
}
}
@@ -0,0 +1,19 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Application.Extensions;
public static class General
{
public static IServiceCollection AddRegisterServices(this IServiceCollection services)
{
var serviceType = typeof(IService);
var sharedImplementations = typeof(IApplication).Assembly.GetTypes()
.Where(t => serviceType.IsAssignableFrom(t) && t.IsClass && !t.IsAbstract);
foreach (var sharedImplementation in sharedImplementations) services.AddScoped(sharedImplementation);
return services;
}
}
@@ -0,0 +1,23 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Configuration.Hash;
namespace PostFundManagement.Application.Extensions;
public static class Hash
{
public const string HasherConfigSectionName = "HasherSettings";
public static IServiceCollection AddHashServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<HasherSettings>(configuration.GetSection(HasherConfigSectionName));
var settings = configuration.GetSection(HasherConfigSectionName).Get<HasherSettings>();
services.AddSingleton<IHashids>(_ =>
new Hashids(settings!.Salt, minHashLength: settings.MinHashLength));
services.AddSingleton<HashService>();
return services;
}
}
@@ -0,0 +1,64 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants;
namespace PostFundManagement.Application.Extensions;
public static class S3
{
public static IServiceCollection AddGarageS3(this IServiceCollection services, IConfiguration configuration)
{
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{GeneralS3SettingsSection}:ServiceUrl").Value))
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(GeneralBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{GeneralS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{GeneralS3SettingsSection}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{GeneralS3SettingsSection}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{GeneralS3SettingsSection}:Region").Value,
ForcePathStyle = true,
EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{GeneralS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
}));
services.AddKeyedScoped<IS3Service, GeneralS3Service>(GeneralBucketName);
}
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value))
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(EvidenceBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{EvidenceS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{EvidenceS3SettingsSection}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{EvidenceS3SettingsSection}:Region").Value,
ForcePathStyle = true,
EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{EvidenceS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
}));
services.AddKeyedScoped<IS3Service, EvidenceS3Service>(EvidenceBucketName);
}
if (!string.IsNullOrWhiteSpace(configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value))
{
services.AddKeyedSingleton<IAmazonS3, AmazonS3Client>(ContractBucketName, (provider, client) =>
new AmazonS3Client(new BasicAWSCredentials(configuration.GetSection($"{ContractS3SettingsSection}:AccessKey").Value,
configuration.GetSection($"{ContractS3SettingsSection}:SecretKey").Value),
new AmazonS3Config
{
ServiceURL = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value,
AuthenticationRegion = configuration.GetSection($"{ContractS3SettingsSection}:Region").Value,
ForcePathStyle = true,
EndpointDiscoveryEnabled = true,
UseHttp = configuration.GetSection($"{ContractS3SettingsSection}:ServiceUrl").Value!.Contains("http://", StringComparison.InvariantCultureIgnoreCase),
}));
services.AddKeyedScoped<IS3Service, ContractS3Service>(ContractBucketName);
}
return services;
}
}
@@ -0,0 +1,165 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Governance;
public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async ValueTask<Result<Programme[]>> GetProgrammesAsync(CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var programmes = await context.Programmes.AsNoTracking().ToListAsync(cancellationToken);
return Result.Ok(programmes?.Select(p => p.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Portfolio[]>> GetPorfoliosAsync(CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var portfolios = await context.Portfolios.AsNoTracking().ToListAsync(cancellationToken);
return Result.Ok(portfolios?.Select(p => p.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Organisation[]>> GetOrganisationsAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var organisations = await context.Organisations.AsNoTracking()
.OrderBy(o => o.Name)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(organisations?.Select(o => o.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Organisation>> GetOrganisationByIdAsync(string organisationIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(organisationIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var organisation = await context.Organisations.AsNoTracking()
.FirstOrDefaultAsync(o => o.Id == decodeResult.Value, cancellationToken);
return organisation is not null
? Result.Ok(organisation.Map())
: Result.Fail("Failed to find specific organisation");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Instrument[]>> GetInstrumentsAsync(CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var instruments = await context.Instruments.AsNoTracking().ToListAsync(cancellationToken);
return Result.Ok(instruments?.Select(i => i.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Dashboard>> GetDashboardAsync(CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var totalPortfolioValue = await context.Awards.SumAsync(a => a.Amount, cancellationToken);
var activeAwardsCount = await context.Awards.CountAsync(a => a.Status == ApprovalStatus.Approved, cancellationToken);
var disbursedAmount = await context.Disbursements.Where(d => d.Status == ApprovalStatus.Approved)
.SumAsync(d => d.Amount, cancellationToken);
var totalProjects = await context.Projects.CountAsync(cancellationToken);
var onTrackProjects = await context.Projects.CountAsync(p => p.Status == ApprovalStatus.Approved, cancellationToken);
var onTrackPercentage = totalProjects > 0
? Math.Round((double)onTrackProjects / totalProjects * 100, 1)
: 100.0;
var atRiskCount = await context.Risks.CountAsync(r => r.Impact == RiskImpact.Critical || r.Impact == RiskImpact.Major, cancellationToken);
var beneficiariesReached = await context.Beneficiaries.SumAsync(b => b.ReachedCount, cancellationToken);
return Result.Ok(new Dashboard(totalPortfolioValue, activeAwardsCount,
disbursedAmount, onTrackPercentage, atRiskCount, beneficiariesReached));
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateOrganisationAsync(CreateOrganisationRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (await context.Organisations.AnyAsync(o => o.RegistrationNo == request.RegistrationNo, cancellationToken))
return Result.Fail("Organisation with the same registration number already exists");
context.Organisations.Add(new Domain.Entities.Organisation
{
Name = request.Name,
RegistrationNo = request.RegistrationNo,
Email = request.Email,
Type = request.Type,
Status = ActivityStatus.Active,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create organisation");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,7 @@
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Governance;
public record CreateOrganisationRequest(string Name, string RegistrationNo, string Email, OrganisationType Type);
public record Dashboard(decimal PortfolioValue, int ActiveAwards, decimal Disbursed, double OnTrackPercentage, int AtRiskCount, int BeneficiariesReached);
@@ -0,0 +1,269 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Grants;
public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async ValueTask<Result<Contract[]>> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var contracts = await context.Contracts.AsNoTracking()
.OrderByDescending(c => c.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(contracts?.Select(c => c.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Contract>> GetContractByIdAsync(string contractIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(contractIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(c => c.Id == decodeResult.Value, cancellationToken);
return contract is not null
? Result.Ok(contract.Map())
: Result.Fail("Failed to find specific contract");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Budget[]>> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var budgets = await context.Budgets.AsNoTracking()
.OrderByDescending(b => b.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(budgets?.Select(b => b.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Budget>> GetBudgetByIdAsync(string budgetIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(budgetIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var budget = await context.Budgets.AsNoTracking().FirstOrDefaultAsync(b => b.Id == decodeResult.Value, cancellationToken);
return budget is not null
? Result.Ok(budget.Map())
: Result.Fail("Failed to get specific budget.");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Award[]>> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var awards = await context.Awards.AsNoTracking()
.OrderByDescending(a => a.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(awards?.Select(a => a.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Award>> GetAwardByIdAsync(string awardIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(awardIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var award = await context.Awards.AsNoTracking().FirstOrDefaultAsync(a => a.Id == decodeResult.Value, cancellationToken);
return award is not null
? Result.Ok(award.Map())
: Result.Fail("Award not found");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var changedTotal = await context.Contracts
.Where(c => c.Id == request.ContractId && c.Status != ContractStatus.Active)
.ExecuteUpdateAsync(f => f
.SetProperty(u => u.ExternalSignatureId, request.ExternalSignatureId)
.SetProperty(u => u.SignedDocumentUrl, request.SignedDocumentUrl)
.SetProperty(u => u.CreatedBy, userId)
.SetProperty(u => u.Status, ContractStatus.Active), cancellationToken);
return changedTotal > 0
? Result.Ok()
: Result.Fail("Contract activation failed");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var award = await context.Awards.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken);
if (award == null)
return Result.Fail($"Award with ID {request.AwardId} does not exist.");
var contract = new Domain.Entities.Contract
{
AwardId = request.AwardId,
TotalValue = request.TotalValue,
EffectiveAt = request.EffectiveAt,
ExpiresAt = request.ExpiresAt,
Status = ContractStatus.Draft,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
};
context.Contracts.Add(contract);
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create contract");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
if (request.Amount <= 0)
return Result.Fail("Budget amount must be greater than zero.");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project with ID {request.ProjectId} does not exist.");
var budget = new Domain.Entities.Budget
{
ProjectId = request.ProjectId,
Name = request.Name,
Amount = request.Amount,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
};
context.Budgets.Add(budget);
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create budget");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
if (request.Amount <= 0)
return Result.Fail("Approved amount must be greater than zero.");
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken))
return Result.Fail($"Organisation with ID {request.OrganisationId} does not exist.");
if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken))
return Result.Fail($"Programme with ID {request.ProgrammeId} does not exist.");
var award = new Domain.Entities.Award
{
OrganisationId = request.OrganisationId,
ProgrammeId = request.ProgrammeId,
Amount = request.Amount,
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
};
context.Awards.Add(award);
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create award.");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,9 @@
namespace PostFundManagement.Application.Grants;
public record CreateAwardRequest(long OrganisationId, long ProgrammeId, decimal Amount);
public record CreateBudgetRequest(long ProjectId, string Name, decimal Amount);
public record CreateContractRequest(long AwardId, decimal TotalValue, DateTime EffectiveAt, DateTime ExpiresAt);
public record ExecuteContractRequest(long ContractId, string ExternalSignatureId, string SignedDocumentUrl);
@@ -14,8 +14,19 @@
<ProjectReference Include="..\PostFundManagement.Infrastructure\PostFundManagement.Infrastructure.csproj" />
</ItemGroup>
<!-- API Documentation -->
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
<PackageReference Include="Scalar.AspNetCore" Version="2.17.1" />
<Using Include="Scalar.AspNetCore" />
<Using Include="Microsoft.OpenApi" />
<Using Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<!-- Shared Usings -->
<ItemGroup>
<Using Include="Asp.Versioning" />
<Using Include="System.Security.Claims" />
<Using Include="System.Diagnostics" />
<Using Include="MimeKit" />
@@ -0,0 +1,182 @@
using PostFundManagement.Application.Shared;
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Projects;
public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> contextFactory, HashService hashService) : IService
{
public async ValueTask<Result> UpdateProjectStatusAsync(UpdateProjectStatusRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
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("Project does not exist.");
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
.SetProperty(p => p.Status, request.Status)
.SetProperty(p => p.UpdatedBy, userId)
.SetProperty(p => p.UpdatedAt, DateTime.UtcNow), cancellationToken);
return changedTotal > 0
? Result.Ok()
: Result.Fail("Failed to update project");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Project[]>> GetProjectsAsync(Pagination pagination, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var projects = await context.Projects.AsNoTracking().OrderByDescending(p => p.CreatedAt)
.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize)
.ToListAsync(cancellationToken);
return Result.Ok(projects?.Select(p => p.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Project>> GetProjectByIdAsync(string projectIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var project = await context.Projects.AsNoTracking().FirstOrDefaultAsync(p => p.Id == decodeResult.Value, cancellationToken);
return project is not null
? Result.Ok(project.Map())
: Result.Fail("Failed to get specific project.");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<Domain.Models.Activity[]>> GetActivitiesAsync(string projectIdHash, CancellationToken cancellationToken = default)
{
try
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail(decodeResult.Errors);
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var activities = await context.Activities.AsNoTracking()
.Where(a => a.ProjectId == decodeResult.Value)
.OrderByDescending(a => a.CreatedAt)
.ToListAsync(cancellationToken);
return Result.Ok(activities?.Select(a => a.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateProjectAsync(CreateProjectRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
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))
return Result.Fail("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($"Programme does not exist.");
context.Projects.Add(new Domain.Entities.Project
{
ProgrammeId = request.ProgrammeId,
Name = request.Name,
Description = request.Description,
Sector = request.Sector,
ProjectType = request.ProjectType,
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create project");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result> CreateActivityAsync(CreateActivityRequest request, Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail($"Project does not exist.");
if (request.MilestoneId.HasValue)
if (!await context.Milestones.AnyAsync(m => m.Id == request.MilestoneId.Value, cancellationToken))
return Result.Fail($"Milestone does not exist.");
context.Activities.Add(new Domain.Entities.Activity
{
ProjectId = request.ProjectId,
MilestoneId = request.MilestoneId,
Category = request.Category,
Amount = request.Amount,
Title = request.Title,
Description = request.Description,
PerformedAt = request.PerformedAt,
Status = ApprovalStatus.Pending,
CreatedBy = userId,
CreatedAt = DateTime.UtcNow
});
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to create activity.");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -0,0 +1,9 @@
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Projects;
public record UpdateProjectStatusRequest(long ProjectId, ApprovalStatus Status);
public record CreateProjectRequest(long ProgrammeId, string Name, string Description, IndustrySector Sector, ProjectType ProjectType);
public record CreateActivityRequest(long ProjectId, long? MilestoneId, string Category, decimal Amount, string Title, string Description, DateTime? PerformedAt);
@@ -1,3 +1,4 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants;
@@ -1,3 +1,4 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants;
@@ -1,3 +1,4 @@
using PostFundManagement.Application.Abstractions;
using PostFundManagement.Domain.Abstractions;
using static PostFundManagement.Domain.Extensions.Constants;
@@ -0,0 +1,24 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Shared;
public sealed class IdentityService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService
{
public async ValueTask<Result> UserIsAuthorisedAsync(Guid userId, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
return !await context.Users.AnyAsync(u => u.Id == userId && u.Status == ActivityStatus.Active, cancellationToken)
? Result.Fail("Invalid user")
: Result.Ok();
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}
@@ -1,3 +1,4 @@
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Api.Configuration;
using PostFundManagement.Domain.Api.Models;
using PostFundManagement.Domain.Sdk;
@@ -0,0 +1,5 @@
using PostFundManagement.Domain;
namespace PostFundManagement.Application.Users;
public record UserFilterRequest(ActivityStatus? Status = null, string? Email = null, string? SortBy = null, bool SortDescending = false);
@@ -0,0 +1,82 @@
using PostFundManagement.Domain;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
using PostFundManagement.Infrastructure.Database;
namespace PostFundManagement.Application.Users;
public sealed class UserService(IDbContextFactory<ApplicationDbContext> contextFactory) : IService
{
public async ValueTask<Result> SyncUserAsync(Guid userId, string email, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken);
if (user is null)
context.Users.Add(new Domain.Entities.User
{
Id = userId,
Email = email,
Status = ActivityStatus.Active,
LastLoginAt = DateTime.UtcNow
});
else
{
user!.Email = email;
user.LastLoginAt = DateTime.UtcNow;
context.Users.Update(user);
}
return await context.SaveChangesAsync(cancellationToken) > 0
? Result.Ok()
: Result.Fail("Failed to sync user");
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
public async ValueTask<Result<User[]>> GetUsersAsync(Pagination pagination, UserFilterRequest request, CancellationToken cancellationToken = default)
{
try
{
using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var query = context.Users.AsNoTracking();
if (request.Status.HasValue)
query = query.Where(u => u.Status == request.Status.Value);
if (!string.IsNullOrWhiteSpace(request.Email))
query = query.Where(u => u.Email != null && u.Email.Contains(request.Email));
if (!string.IsNullOrWhiteSpace(request.SortBy))
query = request.SortBy.ToLowerInvariant() switch
{
"email" => request.SortDescending ? query.OrderByDescending(u => u.Email) : query.OrderBy(u => u.Email),
"lastloginat" => request.SortDescending ? query.OrderByDescending(u => u.LastLoginAt) : query.OrderBy(u => u.LastLoginAt),
"status" => request.SortDescending ? query.OrderByDescending(u => u.Status) : query.OrderBy(u => u.Status),
_ => request.SortDescending ? query.OrderByDescending(u => u.Id) : query.OrderBy(u => u.Id)
};
else
query = query.OrderBy(u => u.Id);
var totalCount = await query.CountAsync(cancellationToken);
var users = await query.Skip((pagination.Page - 1) * pagination.PageSize)
.Take(pagination.PageSize).ToListAsync(cancellationToken);
return Result.Ok(users?.Select(u => u.Map()).ToArray() ?? []);
}
catch (Exception ex)
{
return Result.Fail(new Error(ex.Message).CausedBy(ex));
}
}
}