Merge pull request 'Fixed beneficiary outcomes misconfiguration' (#153) from finishline into main

Reviewed-on: #153
This commit was merged in pull request #153.
This commit is contained in:
2026-10-22 17:08:35 +02:00
10 changed files with 955 additions and 314 deletions
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Application.Services.Projects;
using PostFundManagement.Domain.Abstractions;
@@ -12,14 +12,7 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/beneficiaris/outcomes", async (CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
.WithDescription("Add a new beneficiary outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
@@ -29,6 +22,25 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
.WithDescription("Add a new beneficiary outcome (legacy route)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
@@ -12,15 +12,7 @@ public sealed class GetOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/outcomes/{outcomeId}", async (string outcomeId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
builder.MapGet("api/projects/outcome/{outcomeId}", HandleGetAsync)
.WithDescription("Gets a specific project outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName())
@@ -32,5 +24,28 @@ public sealed class GetOutcomeEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
builder.MapGet("api/projects/outcomes/single/{outcomeId}", HandleGetAsync)
.WithDescription("Gets a specific project outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomeEndpoint).ToEndpointName("Single"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
private static async Task<IResult> HandleGetAsync(string outcomeId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomeAsync(outcomeId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcome } => Results.Ok(outcome),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
}
@@ -1,4 +1,4 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
@@ -12,15 +12,7 @@ public sealed class GetOutcomesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/outcomes/{beneficiatyId}", async (string beneficiatyId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomesAsync(beneficiatyId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
builder.MapGet("api/projects/beneficiaries/{beneficiaryId}/outcomes", HandleGetAsync)
.WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName())
@@ -32,5 +24,41 @@ public sealed class GetOutcomesEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
builder.MapGet("api/projects/beneficiaris/{beneficiaryId}/outcomes", HandleGetAsync)
.WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("TypoAlias"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
builder.MapGet("api/projects/outcomes/{beneficiaryId}", HandleGetAsync)
.WithDescription("Gets project beneficiary outcomes")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetOutcomesEndpoint).ToEndpointName("Legacy"))
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
private static async Task<IResult> HandleGetAsync(string beneficiaryId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetOutcomesAsync(beneficiaryId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
}
@@ -0,0 +1,37 @@
using PostFundManagement.Application.Abstractions.Services;
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Errors;
using PostFundManagement.Domain.Extensions;
using PostFundManagement.Domain.Models;
namespace PostFundManagement.Api.Endpoints.Projects;
[ApiVersionTarget(1)]
public sealed class GetProjectOutcomesEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/projects/{projectId}/outcomes", async (string projectId, IProjectService service,
CancellationToken cancellationToken = default) =>
await service.GetProjectOutcomesAsync(projectId, cancellationToken) switch
{
{ IsSuccess: true, Value: var outcomes } => Results.Ok(outcomes),
var r when r.HasError<IdDecodeError>() => Results.Forbid(),
var r when r.HasError<NotFoundError>() => Results.NotFound(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Gets project outcomes for all beneficiaries in a project")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(GetProjectOutcomesEndpoint).ToEndpointName())
.MapToApiVersion(new ApiVersion(1))
.Produces<Outcome[]>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status401Unauthorized)
.Produces<Problems>(StatusCodes.Status404NotFound)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.WithTags(EndpointTags.Projects)
.CacheOutput();
}
}
@@ -142,6 +142,9 @@ public interface IProjectService
[Log]
Task<Result<Outcome[]>> GetOutcomesAsync(string beneficiaryIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default);
[Log]
Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default);
@@ -375,6 +375,30 @@ public sealed class ProjectCacheService(ProjectService service, IDistributedCach
return Result.Ok(cached.FromJson<Outcome[]>());
}));
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
{
if (!await featureManager.IsEnabledAsync(ServiceCachingFeature))
return await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
var cacheKey = HashService.ToMd5Hash($"getprojectoutcomes-{projectIdHash}").Value;
var cached = await cache.GetStringAsync(cacheKey, cancellationToken);
if (string.IsNullOrWhiteSpace(cached))
{
var result = await service.GetProjectOutcomesAsync(projectIdHash, cancellationToken);
if (result.IsFailed) return result;
await cache.SetStringAsync(cacheKey, result.Value.ToJson(), cacheOptions, cancellationToken);
return result.Value;
}
return Result.Ok(cached.FromJson<Outcome[]>());
}));
public async Task<Result<Programme>> GetProgrammeAsync(string programmeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Programme>>>)(async () =>
{
@@ -834,6 +834,25 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
}));
public async Task<Result<Outcome[]>> GetProjectOutcomesAsync(string projectIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome[]>>>)(async () =>
{
var decodeResult = hashService.DecodeLongIdHash(projectIdHash);
if (decodeResult.IsFailed)
return Result.Fail<Outcome[]>(IdDecodeError.Create()
.CausedBy(decodeResult.Errors));
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
var outcomes = await context.Outcomes.AsNoTracking()
.Where(o => o.Beneficiary!.ProjectId == decodeResult.Value)
.OrderByDescending(o => o.Id)
.ToListAsync(cancellationToken);
return Result.Ok(outcomes?.Select(o => o.Map()).ToArray() ?? []);
}));
public async Task<Result<Outcome>> GetOutcomeAsync(string outcomeIdHash, CancellationToken cancellationToken = default) =>
await Result.Try((Func<Task<Result<Outcome>>>)(async () =>
{
File diff suppressed because it is too large Load Diff
@@ -1743,14 +1743,41 @@ export const updateBeneficiary = async (payload: UpdateBeneficiaryRequest): Prom
export const getOutcome = async (outcomeId: string | number): Promise<Outcome> => {
const hashedOutcomeId = await toolsService.encodeId(outcomeId);
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/${hashedOutcomeId}`);
return res.data;
try {
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcome/${hashedOutcomeId}`);
return res.data;
} catch {
const res = await apiClient.get<Outcome>(`/v1/api/projects/outcomes/single/${hashedOutcomeId}`);
return res.data;
}
};
export const getBeneficiaryOutcomes = async (beneficiaryId: string | number): Promise<Outcome[]> => {
const hashedBeneficiaryId = await toolsService.encodeId(beneficiaryId);
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`);
return res.data || [];
try {
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/beneficiaries/${hashedBeneficiaryId}/outcomes`);
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
} catch (err: any) {
if (err?.response?.status === 404) {
try {
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/outcomes/${hashedBeneficiaryId}`);
return Array.isArray(res.data) ? res.data : (res.data ? [res.data] : []);
} catch {
return [];
}
}
return [];
}
};
export const getProjectOutcomes = async (projectId: string | number): Promise<Outcome[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
try {
const res = await apiClient.get<Outcome[]>(`/v1/api/projects/${hashedProjectId}/outcomes`);
return Array.isArray(res.data) ? res.data : [];
} catch {
return [];
}
};
export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void> => {
@@ -1761,10 +1788,10 @@ export const createOutcome = async (payload: CreateOutcomeRequest): Promise<void
status: payload.status !== undefined ? Number(payload.status) : 1,
};
try {
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
} catch (err: any) {
if (err?.response?.status === 404) {
await apiClient.post('/v1/api/projects/beneficiaries/outcomes', body);
await apiClient.post('/v1/api/projects/beneficiaris/outcomes', body);
} else {
throw err;
}
@@ -78,10 +78,10 @@ export const projectsApi = {
apiClient.put<Models.Outcome>('/v1/api/projects/outcomes', payload),
getOutcome: (outcomeId: string | number) =>
apiClient.get<Models.Outcome>(`/v1/api/projects/outcomes/${hashParam(outcomeId)}`),
apiClient.get<Models.Outcome>(`/v1/api/projects/outcome/${hashParam(outcomeId)}`),
getBeneficiaryOutcomes: (beneficiaryId: string | number) =>
apiClient.get<Models.Outcome[]>(`/v1/api/projects/outcomes/${hashParam(beneficiaryId)}`),
apiClient.get<Models.Outcome[]>(`/v1/api/projects/beneficiaries/${hashParam(beneficiaryId)}/outcomes`),
// Change Requests
createChangeRequest: (payload: Models.CreateChangeRequest) =>