Introduce typed API responses and align workflow outputs

This commit is contained in:
2026-02-07 01:19:51 +01:00
parent 35d842d6ee
commit 79dc8f899f
7 changed files with 99 additions and 77 deletions

56
Contracts/Responses.cs Normal file
View File

@@ -0,0 +1,56 @@
using GameList.Domain;
namespace GameList.Contracts;
public record SuggestionCreatedResponse(int Id);
public record SuggestionUpdatedResponse(
int Id,
string Name,
string? Genre,
string? Description,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
int? MinPlayers,
int? MaxPlayers
);
public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score);
public record VoteFinalizeResponse(bool VotesFinal);
public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset UpdatedAt);
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
public record AdminUnlinkSuggestionsResponse(IReadOnlyList<int> UnlinkedSuggestionIds, int UnfinalizedPlayers);
public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOffset UpdatedAt);
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
public record ResultItemDto(
int Id,
string Name,
string? Author,
int? MinPlayers,
int? MaxPlayers,
int Total,
int Count,
double Average,
IReadOnlyList<int> Votes,
int? MyVote,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
string? Description,
string? Genre,
int? ParentSuggestionId,
IReadOnlyList<int> LinkedIds,
IReadOnlyList<string> LinkedTitles
);

View File

@@ -27,11 +27,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
var currentState = await db.AppState.AsNoTracking().FirstAsync();
return Results.Ok(new
{
currentState.ResultsOpen,
currentState.UpdatedAt
});
return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
}
public async Task<IResult> GetVoteStatusAsync()
@@ -45,12 +41,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0;
return Results.Ok(new
{
voters,
ready,
waiting
});
return Results.Ok(new VoteStatusResponse(voters, ready, waiting));
}
public async Task<IResult> GrantJokerAsync(GrantJokerRequest request)
@@ -67,11 +58,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
player.VotesFinal = false;
await db.SaveChangesAsync();
return Results.Ok(new
{
player.Id,
player.HasJoker
});
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
}
public async Task<IResult> DeletePlayerAsync(Guid playerId)
@@ -98,7 +85,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new { DeletedPlayerId = playerId });
return Results.Ok(new AdminDeletePlayerResponse(playerId));
}
public async Task<IResult> LinkSuggestionsAsync(Player adminPlayer, LinkSuggestionsRequest request)
@@ -153,12 +140,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
return Results.Ok(new
{
RootId = targetRoot,
LinkedSuggestionIds = affectedIds,
UnfinalizedPlayers = await db.Players.CountAsync()
});
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
}
public async Task<IResult> UnlinkSuggestionsAsync(Player adminPlayer, UnlinkSuggestionsRequest request)
@@ -170,19 +152,11 @@ internal sealed class AdminWorkflowService(AppDbContext db)
var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
if (target is null)
return Results.Ok(new
{
UnlinkedSuggestionIds = Array.Empty<int>(),
UnfinalizedPlayers = 0
});
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId))
return Results.Ok(new
{
UnlinkedSuggestionIds = Array.Empty<int>(),
UnfinalizedPlayers = 0
});
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
@@ -201,11 +175,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
return Results.Ok(new
{
UnlinkedSuggestionIds = groupIds,
UnfinalizedPlayers = await db.Players.CountAsync()
});
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
}
public async Task<IResult> ResetAsync()
@@ -222,12 +192,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new
{
Phase = Phase.Suggest,
state.ResultsOpen,
state.UpdatedAt
});
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
}
public async Task<IResult> FactoryResetAsync()
@@ -245,11 +210,6 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync();
return Results.Ok(new
{
Phase = Phase.Suggest,
fresh.ResultsOpen,
fresh.UpdatedAt
});
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
}
}

View File

@@ -1,3 +1,4 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.EntityFrameworkCore;
@@ -54,8 +55,12 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
.Where(id => id != r.Id)
.ToList();
return new
{
var linkedTitles = linkedIds
.Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id])
.ToList();
return new ResultItemDto(
r.Id,
r.Name,
r.Author,
@@ -72,12 +77,9 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
r.Description,
r.Genre,
r.ParentSuggestionId,
LinkedIds = linkedIds,
LinkedTitles = linkedIds
.Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id])
.ToList()
};
linkedIds,
linkedTitles
);
});
return Results.Ok(shaped);

View File

@@ -80,7 +80,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
}
public async Task<IResult> DeleteAsync(Player player, bool isAdmin, int suggestionId)
@@ -150,8 +150,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
await db.SaveChangesAsync();
return Results.Ok(new
{
return Results.Ok(new SuggestionUpdatedResponse(
suggestion.Id,
suggestion.Name,
suggestion.Genre,
@@ -161,7 +160,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
suggestion.GameUrl,
suggestion.MinPlayers,
suggestion.MaxPlayers
});
));
}
public async Task<IResult> GetAllAsync(Player player)

View File

@@ -80,11 +80,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
}
await db.SaveChangesAsync();
return Results.Ok(new
{
SuggestionIds = linkedIds,
request.Score
});
return Results.Ok(new VoteUpsertResponse(linkedIds, request.Score));
}
public async Task<IResult> SetFinalizeAsync(Player player, VoteFinalizeRequest request)
@@ -95,6 +91,6 @@ internal sealed class VoteWorkflowService(AppDbContext db)
player.VotesFinal = request.Final;
await db.SaveChangesAsync();
return Results.Ok(new { player.VotesFinal });
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
}
}

View File

@@ -10,6 +10,7 @@ Progress update (as of February 7, 2026):
- Completed: build/test guardrails added (`.github/workflows/ci.yml`) and root ownership/setup docs added (`README.md:1`).
- Completed: backend validators centralized for suggestions and auth (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`).
- Completed: request safety hardened for redirects and forwarded headers (`Program.cs:44`, `Program.cs:108`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`).
- In progress: API/response contract standardization started with typed response DTOs and client parsing compatibility (`Contracts/Responses.cs:5`, `Contracts/Responses.cs:35`, `wwwroot/js/api.js:25`).
Top 5 maintainability risks (priority order):
@@ -19,7 +20,7 @@ Top 5 maintainability risks (priority order):
- Impact: hard-to-debug regressions and fragile refactors in UI workflows.
2. Rule duplication still present between backend/frontend validations (High)
- Suggestion validation is centralized on the backend (`Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:109`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`).
- Suggestion validation is centralized on the backend (`Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:115`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`).
- Auth validation is centralized on the backend (`Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`) while frontend length checks remain duplicated (`wwwroot/app.js:92`, `wwwroot/app.js:121`).
- Impact: inconsistent behavior and repeated fixes across server/client.
@@ -29,8 +30,8 @@ Top 5 maintainability risks (priority order):
- Hidden module coupling through globals: `wwwroot/js/data.js:131`-`wwwroot/js/data.js:134`, plus `window` callbacks consumed in `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:696`, `wwwroot/js/ui.js:1009`.
- Impact: every UI change risks regressions outside its feature area.
4. Endpoint contract consistency and response shaping are still uneven (High)
- Service-layer extraction is now in place for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:7`), but response shapes are still mostly anonymous objects and ad-hoc error payloads.
4. Endpoint contract consistency and error shaping are still uneven (High)
- Service-layer extraction is now in place for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:8`), and typed response DTO adoption has started (`Contracts/Responses.cs:5`, `Contracts/Responses.cs:35`), but many endpoints still emit ad-hoc error payloads.
- Impact: API evolution and client compatibility changes are still high-friction.
5. Static-analysis and frontend lint guardrails remain incomplete (Medium)
@@ -77,7 +78,7 @@ Worst coupling points:
[P0][Done] Make phase reads side-effect free and move reconciliation to explicit writes
- Problem: Severity `Critical`, Category `Architecture`. Read endpoints/filters previously relied on mutating phase reads. Impact: unsafe refactors and non-deterministic behavior.
- Evidence: `Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:20`, `Infrastructure/PhaseRequirementFilter.cs:17`, `Endpoints/ResultsWorkflowService.cs:9`, `GameList.Tests/StateTests.cs:236`, `GameList.Tests/FiltersTests.cs:55`.
- Evidence: `Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:20`, `Infrastructure/PhaseRequirementFilter.cs:17`, `Endpoints/ResultsWorkflowService.cs:10`, `GameList.Tests/StateTests.cs:236`, `GameList.Tests/FiltersTests.cs:55`.
- Recommendation: Split into `GetCurrentPhaseAsync` (pure read) and explicit `ReconcilePhaseAsync` (write command). Run reconciliation only on intentional transition points (admin toggle, phase change commands, migration job), not on GET paths.
- Acceptance criteria (testable): GET `/api/state` and GET `/api/me` never call `SaveChangesAsync`; integration tests verify no phase mutations occur during read-only requests; filters perform one phase check path without side effects.
- Effort / Risk: `M / Med`.
@@ -101,7 +102,7 @@ Worst coupling points:
[P0][Partial] Centralize validation rules to stop backend/frontend drift
- Problem: Severity `High`, Category `Complexity/Duplication`. Validation rules are duplicated in multiple backend endpoints and frontend forms. Impact: inconsistent behavior and repeated fixes.
- Evidence: backend centralized in `Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:109`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`.
- Evidence: backend centralized in `Endpoints/SuggestionWorkflowService.cs:39`, `Endpoints/SuggestionWorkflowService.cs:115`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`.
- Recommendation: Extract backend validators (e.g., `SuggestionValidator`, `AuthValidator`) and reuse in create/update paths; simplify frontend to UX-only prechecks and rely on server responses for source-of-truth.
- Acceptance criteria (testable): create/update share one backend validator path; tests cover validator once and both endpoints; frontend no longer re-implements server-only security rules.
- Effort / Risk: `M / Med`.
@@ -117,7 +118,7 @@ Worst coupling points:
[P1][Done] Extract service-layer workflows from endpoint lambdas
- Problem: Severity `High`, Category `Architecture`. Endpoint files contain business orchestration, persistence, and policy logic inline; large lambdas are hard to reason about and reuse.
- Evidence: extraction completed for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:7`, `Program.cs:37`, `Program.cs:38`, `Program.cs:39`, `Program.cs:40`).
- Evidence: extraction completed for suggestions, votes, admin, and results (`Endpoints/SuggestionWorkflowService.cs:8`, `Endpoints/VoteWorkflowService.cs:8`, `Endpoints/AdminWorkflowService.cs:8`, `Endpoints/ResultsWorkflowService.cs:8`, `Program.cs:37`, `Program.cs:38`, `Program.cs:39`, `Program.cs:40`).
- Recommendation: Introduce focused application services (`SuggestionService`, `VoteService`, `AdminWorkflowService`) and keep endpoints as transport adapters.
- Acceptance criteria (testable): endpoint handlers reduced to routing + DTO mapping + service calls; domain rule tests target service methods directly; endpoint tests remain green.
- Effort / Risk: `L / Med`.
@@ -149,7 +150,7 @@ Worst coupling points:
[P1] Make write workflows transaction-consistent and explicit
- Problem: Severity `Medium`, Category `Correctness/Architecture`. Several multi-step state changes rely on multiple DB commands without explicit transaction grouping.
- Evidence: `Endpoints/SuggestionWorkflowService.cs:71`, `Endpoints/SuggestionWorkflowService.cs:75`, `Endpoints/AdminWorkflowService.cs:74`, `Endpoints/AdminWorkflowService.cs:208`, `Endpoints/AdminWorkflowService.cs:227`.
- Evidence: `Endpoints/SuggestionWorkflowService.cs:72`, `Endpoints/SuggestionWorkflowService.cs:77`, `Endpoints/AdminWorkflowService.cs:17`, `Endpoints/AdminWorkflowService.cs:181`, `Endpoints/AdminWorkflowService.cs:198`.
- Recommendation: Wrap multi-entity updates in explicit transactions where consistency matters, or refactor into idempotent command handlers with compensating behavior.
- Acceptance criteria (testable): fault-injection tests prove no partial state after exceptions; transaction boundaries documented per workflow.
- Effort / Risk: `M / Med`.
@@ -157,12 +158,20 @@ Worst coupling points:
[P1] Strengthen test quality for flaky/time-sensitive cases and security edges
- Problem: Severity `Medium`, Category `Testing`. Some tests depend on sleeps and do not cover realistic redirect behavior or overlapping refresh flows.
- Evidence: `GameList.Tests/SuggestionTests.cs:379`, `GameList.Tests/SuggestionTests.cs:575`, `GameList.Tests/HelperTests.cs:121`.
- Evidence: redirect and forwarded-header cases are covered (`GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`), and delay-based ordering checks were removed from `GameList.Tests/SuggestionTests.cs` and `GameList.Tests/AdminTests.cs`.
- Recommendation: replace `Task.Delay` ordering checks with deterministic seeded timestamps where feasible; add explicit redirect-follow tests and concurrency-path tests.
- Acceptance criteria (testable): no timing sleeps in endpoint tests for ordering; new tests cover redirect-chain rejection and race-sensitive refresh logic.
- Effort / Risk: `M / Low`.
- Dependencies (if any): P0 redirect-hardening task.
[P1][In Progress] Standardize API response contracts and error envelopes
- Problem: Severity `Medium`, Category `API/Contracts`. Success payloads are being typed, but error payloads are still inconsistent (`{ error }` objects, plain status codes, and mixed shapes).
- Evidence: typed response DTOs added in `Contracts/Responses.cs:5` and used in service workflows (`Endpoints/SuggestionWorkflowService.cs:83`, `Endpoints/VoteWorkflowService.cs:83`, `Endpoints/AdminWorkflowService.cs:44`, `Endpoints/ResultsWorkflowService.cs:63`); frontend parser now supports `error`, `detail`, and `title` in `wwwroot/js/api.js:25`.
- Recommendation: migrate endpoint error responses to `ProblemDetails` consistently while keeping backward-compatible `error` fields during transition; continue replacing anonymous success payloads with DTOs.
- Acceptance criteria (testable): all API endpoints return typed success DTOs; all non-2xx responses include `ProblemDetails` fields and remain consumable by current frontend.
- Effort / Risk: `M / Med`.
- Dependencies (if any): none.
[P2] Externalize i18n/FAQ content from executable JS modules
- Problem: Severity `Low`, Category `Complexity/Documentation`. Translation and FAQ payloads are embedded in code, making review and localization hard.
- Evidence: `wwwroot/js/i18n.js:1`-`wwwroot/js/i18n.js:799`.

View File

@@ -22,7 +22,7 @@ async function request(path, { method = "GET", body } = {}) {
let msg = `${res.status}`;
try {
const data = await res.json();
msg = data.error || JSON.stringify(data);
msg = data.error || data.detail || data.title || JSON.stringify(data);
} catch { /* ignore */ }
const err = new Error(msg);
err.status = res.status;