using PostFundManagement.Application.Abstractions.Services; using PostFundManagement.Application.Services.Shared; using PostFundManagement.Domain; using PostFundManagement.Domain.Abstractions; using PostFundManagement.Domain.Errors; using PostFundManagement.Domain.Extensions; using PostFundManagement.Domain.Models; using PostFundManagement.Infrastructure.Database; namespace PostFundManagement.Application.Services.Grants; public sealed class GrantsService(IDbContextFactory contextFactory, HashService hashService, [FromKeyedServices(Constants.GeneralBucketName)] IS3Service s3Service, ITenantContextService tenantContext) : IGrantsService { public async Task> GetContractsAsync(Pagination pagination, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { await 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() ?? []); })); public async Task> GetContractAsync(string contractIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { var decodeResult = hashService.DecodeLongIdHash(contractIdHash); if (decodeResult.IsFailed) return Result.Fail(IdDecodeError.Create() .CausedBy(decodeResult.Errors)); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var contract = await context.Contracts.AsNoTracking() .FirstOrDefaultAsync(c => c.Id == decodeResult.Value, cancellationToken); return Result.FailIf(contract is null, NotFoundError.Create("Contract not found")) .ToResult(contract?.Map()!); })); public async Task> GetBudgetsAsync(Pagination pagination, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { await 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() ?? []); })); public async Task> GetBudgetAsync(string budgetIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { var decodeResult = hashService.DecodeLongIdHash(budgetIdHash); if (decodeResult.IsFailed) return Result.Fail(IdDecodeError.Create() .CausedBy(decodeResult.Errors)); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var budget = await context.Budgets.AsNoTracking() .FirstOrDefaultAsync(b => b.Id == decodeResult.Value, cancellationToken); return Result.FailIf(budget is null, NotFoundError.Create("Budget not found")) .ToResult(budget?.Map()!); })); public async Task> GetAwardsAsync(Pagination pagination, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { await 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() ?? []); })); public async Task> GetAwardAsync(string awardIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { var decodeResult = hashService.DecodeLongIdHash(awardIdHash); if (decodeResult.IsFailed) return Result.Fail(IdDecodeError.Create() .CausedBy(decodeResult.Errors)); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var award = await context.Awards.AsNoTracking().FirstOrDefaultAsync(a => a.Id == decodeResult.Value, cancellationToken); return Result.FailIf(award is null, NotFoundError.Create("Award not found")) .ToResult(award?.Map()!); })); public async Task ExecuteContractAsync(ExecuteContractRequest request, Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var changedTotal = await context.Contracts .Where(c => c.Id == request.ContractId && 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 Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create("Failed to update contract")); })); public async Task CreateContractAsync(CreateContractRequest request, Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { await 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(NotFoundError.New); 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 Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create contract")); })); public async Task CreateBudgetAsync(CreateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { if (request.Amount <= 0) return Result.Fail(ValidationError.Create("Budget amount must be greater than zero.")); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken)) return Result.Fail(NotFoundError.New); var hasApprovedAward = await context.Awards .AnyAsync(a => a.ProjectId == request.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken); if (!hasApprovedAward) return Result.Fail(ValidationError.Create("Cannot create budget: The project's linked award is not approved or does not exist.")); var budget = new Domain.Entities.Budget { ProjectId = request.ProjectId, Name = request.Name, Amount = request.Amount, CreatedBy = userId, CreatedAt = DateTime.UtcNow, Status = ApprovalStatus.Pending }; context.Budgets.Add(budget); return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create budget")); })); public async Task CreateAwardAsync(CreateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { if (request.Amount <= 0) return Result.Fail(ValidationError.Create("Approved amount must be greater than zero.")); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken)) return Result.Fail(NotFoundError.New); if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) return Result.Fail(NotFoundError.New); var award = new Domain.Entities.Award { OrganisationId = request.OrganisationId, ProgrammeId = request.ProgrammeId, Amount = request.Amount, Status = ApprovalStatus.Pending, CreatedBy = userId, CreatedAt = DateTime.UtcNow, TenantId = tenantContext.TenantId }; context.Awards.Add(award); return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, CreateError.Create("Failed to create award")); })); public async Task UpdateAwardAsync(UpdateAwardRequest request, Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { if (request.Amount <= 0) return Result.Fail(ValidationError.Create("Approved amount must be greater than zero.")); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var existingAward = await context.Awards.FirstOrDefaultAsync(a => a.Id == request.AwardId, cancellationToken); if (existingAward is null) return Result.Fail(NotFoundError.Create("Award not found")); if (existingAward.Status == ApprovalStatus.Approved) return Result.Fail(ValidationError.Create("Approved awards are immutable and cannot be updated.")); if (!await context.Organisations.AnyAsync(o => o.Id == request.OrganisationId, cancellationToken)) return Result.Fail(NotFoundError.Create("Organisation not found")); if (!await context.Programmes.AnyAsync(p => p.Id == request.ProgrammeId, cancellationToken)) return Result.Fail(NotFoundError.Create("Programme not found")); existingAward.OrganisationId = request.OrganisationId; existingAward.ProgrammeId = request.ProgrammeId; existingAward.Amount = request.Amount; if (request.Status.HasValue) { existingAward.Status = request.Status.Value; if (request.Status.Value == ApprovalStatus.Approved) { existingAward.ApprovedBy = userId; } } existingAward.UpdatedBy = userId; existingAward.UpdatedAt = DateTime.UtcNow; return Result.OkIf(await context.SaveChangesAsync(cancellationToken) > 0, UpdateError.Create("Failed to update award")); })); public async Task> GetContractUrlAsync(string contractIdHash, CancellationToken cancellationToken = default) => await Result.Try((Func>>)(async () => { var decodeResult = hashService.DecodeLongIdHash(contractIdHash); if (decodeResult.IsFailed) return Result.Fail(IdDecodeError.Create() .CausedBy(decodeResult.Errors)); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var contract = await context.Contracts.AsNoTracking().FirstOrDefaultAsync(e => e.Id == decodeResult.Value, cancellationToken); if (contract is null) return Result.Fail(NotFoundError.New); if (string.IsNullOrWhiteSpace(contract.SignedDocumentUrl)) return Result.Fail(ValidationError.Create("Evidence is invalid, has no saved documentUrl")); var secureDownloadUrlResult = await s3Service.GeneratePresignedUrlAsync(contract.SignedDocumentUrl, TimeSpan.FromMinutes(15), null, cancellationToken); return Result.OkIf(secureDownloadUrlResult.IsSuccess && !string.IsNullOrWhiteSpace(secureDownloadUrlResult.Value), NotFoundError.Create("Failed to generate secure file url")) .ToResult(secureDownloadUrlResult.Value); })); public async Task UploadContractAsync(string contractIdHash, Guid userId, IFormFile file, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { var decodeResult = hashService.DecodeLongIdHash(contractIdHash); if (decodeResult.IsFailed) return Result.Fail(IdDecodeError.Create() .CausedBy(decodeResult.Errors)); await using var stream = file.OpenReadStream(); var uploadResult = await s3Service.UploadFileAsync(file.FileName, "contracts", stream, file.ContentType, cancellationToken); if (uploadResult.IsFailed) return Result.Fail(uploadResult.Errors); await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var changedTotal = await context.Contracts.Where(c => c.Id == decodeResult.Value) .ExecuteUpdateAsync(f => f .SetProperty(c => c.SignedDocumentUrl, uploadResult.Value), cancellationToken); var secureDownloadUrlResult = await s3Service.GeneratePresignedUrlAsync(uploadResult.Value, TimeSpan.FromMinutes(15), null, cancellationToken); return Result.OkIf(changedTotal > 0 && secureDownloadUrlResult.IsSuccess && !string.IsNullOrWhiteSpace(secureDownloadUrlResult.Value), CreateError.Create("Failed to upload contract file")); })); public async Task UpdateContractAsync(UpdateContractRequest request, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var changedTotal = await context.Contracts.Where(c => c.Id == request.ContractId) .ExecuteUpdateAsync(f => f .SetProperty(u => u.Status, request.Status), cancellationToken); return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update the contract.")); })); public async Task UpdateBudgetAsync(UpdateBudgetRequest request, Guid userId, CancellationToken cancellationToken = default) => await Result.Try((Func>)(async () => { await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); var changedTotal = await context.Budgets .Where(f => f.Id == request.BudgetId) .ExecuteUpdateAsync(f => f .SetProperty(u => u.ApprovedBy, userId) .SetProperty(u => u.UpdatedAt, DateTime.UtcNow) .SetProperty(u => u.Status, request.Status), cancellationToken); return Result.OkIf(changedTotal > 0, UpdateError.Create("Failed to update budget")); })); }