Skip to content
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,28 @@ Notes:
- `/api/users/debug/me` returns sanitized post-auth claims, groups, and role resolution data only when `DEV_ENABLE_DEBUG_ENDPOINTS=true` and the API is running in `Development` or `Test`.
- `TAG` applies to API, Web, and Data Sync worker images. Use `TAG=test` for the test host, `TAG=stable` for production, and `TAG=sha-<shortsha>` for rollback or pinning a specific build.

Web post-login landing config:

- `src/F1.Web/wwwroot/appsettings.json` contains the `PostLoginRouting` section used by the web client after authentication.
- `AdminLandingPath` defaults to `/admin/migration-runs`.
- `AuthenticatedUserLandingPath` defaults to `/results`.
- `FallbackPath` is used when the user session is missing or a role-specific landing path is blank, and defaults to `/results`.
- Environment-specific overrides can be added in `src/F1.Web/wwwroot/appsettings.Development.json`, `src/F1.Web/wwwroot/appsettings.Test.json`, or `src/F1.Web/wwwroot/appsettings.Production.json`.

Web competition context config:

- `src/F1.Web/wwwroot/appsettings.json` also contains the `SelectionContext` section used by the race-selection workspace selector.
- Each entry defines `CompetitionSlug`, `CompetitionLabel`, `Season`, and `DefaultRound`.
- Visiting `/selection` restores the last-used competition and season from browser storage when the saved context is still configured; otherwise the client falls back to the default configured context.
- Removing a context from configuration automatically causes stored stale selections to fall back to a valid configured context on the next visit.

Competition leaderboard config:

- `src/F1.Api/appsettings.json` contains the `CompetitionLeaderboard` section used by the standings endpoint.
- Each context can be backed by a completed migration run or marked unavailable until a leaderboard source is approved.
- For migration-backed contexts, `MigrationSourcePathContains` selects the latest completed run used for leaderboard totals.
- Official leaderboard totals currently use imported legacy scores for approved migrated contexts; admins can request recalculated comparison mode from the API/UI.

#### B. Data Sync Worker (`src/F1.DataSyncWorker/appsettings*.json`)
The worker reads `ConnectionStrings:Postgres` and the `DataSyncWorker` section. Default config includes the three baseline competitions for this epic:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,28 @@ Test notes:
- Add automated accessibility checks for key pages and components.
- Add keyboard-navigation E2E tests for primary review workflow paths.

### Story E8: Separate migration storage from main domain data
As an engineer, I want migration-prefixed tables isolated from the main application data model so admin migration workflows do not leak into product read paths and the final data is persisted in the correct canonical tables.

Acceptance criteria:
- Any table or entity whose name starts with `Migration` or `MigrationImport` is only used by migration/admin workflows, import staging, reconciliation, or audit views.
- Core product features read from canonical domain tables only and do not depend on migration-prefixed tables for runtime behavior.
- The migration pipeline explicitly moves or materializes the needed data into the proper canonical tables before the app or UI consumes it.
- If the implementation chooses a separate schema or separate database, the boundary is documented and enforced so migration storage and canonical storage cannot be mixed accidentally.
- The storage model includes an explicit mapping from migration tables to their canonical target tables for every persisted data type that needs to survive import.
- Idempotent re-run behavior is preserved so imports can be repeated without duplicating canonical data or leaving orphaned migration records.

Test notes:
- Add architecture-level tests or static checks that fail if non-admin code paths reference `Migration*` tables directly.
- Add import/integration tests proving the required data lands in canonical tables after migration completes.
- Add rerun/idempotency tests for the migration-to-canonical handoff.

## Delivery Plan
1. Finalize role-based post-login destinations and context contract
2. Implement competition-season selection and remembered context
3. Deliver leaderboard and participant drilldown with score-source clarity
4. Add deep-linking, admin migration visibility, and accessibility coverage
5. Define and enforce the migration-storage boundary between admin/import tables and canonical application data

## Risks and Mitigations
- Risk: Users lose context when navigating between leaderboard and participant detail.
Expand All @@ -121,8 +138,12 @@ Test notes:
- Risk: Score-source labels are present but still misunderstood.
- Mitigation: Use consistent labels, helper copy, and link to scoring truth contract.

- Risk: Migration-prefixed tables become accidental runtime dependencies.
- Mitigation: Keep migration data isolated to admin/import workflows, enforce a canonical target mapping, and consider a dedicated schema or database boundary if table-level separation is not enough.

## Definition of Done
- Post-login flow is role-based and deterministic.
- Competition-season context is selectable, persisted, and deep-linkable.
- Leaderboard and participant drilldowns show clear score-source semantics.
- Accessibility and keyboard flow coverage exists for primary review tasks.
- Migration-prefixed tables are isolated from main product reads and only feed canonical domain tables through explicit import steps.
25 changes: 25 additions & 0 deletions src/F1.Api/Configuration/CompetitionLeaderboardOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace F1.Api.Configuration;

public sealed class CompetitionLeaderboardOptions
{
public const string SectionName = "CompetitionLeaderboard";

public List<CompetitionLeaderboardContextOption> Contexts { get; set; } = [];
}

public sealed class CompetitionLeaderboardContextOption
{
public string CompetitionSlug { get; set; } = string.Empty;

public int Season { get; set; }

public string DisplayName { get; set; } = string.Empty;

public string SourceType { get; set; } = "Unavailable";

public string ActiveScoreSource { get; set; } = "ImportedLegacy";

public string? MigrationSourcePathContains { get; set; }

public string? UnavailableMessage { get; set; }
}
46 changes: 46 additions & 0 deletions src/F1.Api/Dtos/CompetitionLeaderboardDtos.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
namespace F1.Api.Dtos;

public sealed record CompetitionLeaderboardResponseDto(
string CompetitionSlug,
int Season,
string DisplayName,
string ActiveScoreSource,
string ScoreView,
string ScoreSourceLabel,
string ScoreSourceHelperText,
bool IsComparisonAvailable,
bool IsDataAvailable,
string? EmptyStateMessage,
Guid? SourceRunId,
IReadOnlyList<CompetitionLeaderboardEntryDto> Items);

public sealed record CompetitionLeaderboardEntryDto(
int Position,
string ParticipantName,
int DisplayPoints,
int ImportedPoints,
int RecalculatedPoints);

public sealed record CompetitionParticipantDetailResponseDto(
string CompetitionSlug,
int Season,
string DisplayName,
string ParticipantName,
CompetitionParticipantSectionSummaryDto RacePicks,
CompetitionParticipantSectionSummaryDto Preseason,
CompetitionParticipantSectionSummaryDto H2h);

public sealed record CompetitionParticipantSectionSummaryDto(
string Title,
int ImportedTotalPoints,
int RecalculatedTotalPoints,
IReadOnlyList<CompetitionParticipantDetailItemDto> Items);

public sealed record CompetitionParticipantDetailItemDto(
string Label,
string Description,
int? ImportedPoints,
int CalculatedPoints,
int DeltaPoints,
string? ReasonCode,
string? Explanation);
44 changes: 41 additions & 3 deletions src/F1.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using F1.Api.Middleware;
using F1.Api.Infrastructure;
using F1.Api.Configuration;
using Serilog;
using Serilog.Formatting.Compact;
using F1.Api.Services;
using F1.Core.Interfaces;
using F1.Infrastructure.Data;
using F1.Infrastructure.Repositories;
using F1.Services;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;

Expand Down Expand Up @@ -45,6 +47,8 @@
builder.Services.AddControllers(); // Add this line to register controller services
builder.Services.AddSwaggerGen();
builder.Services.AddScoped<IRaceService, RaceService>();
builder.Services.Configure<CompetitionLeaderboardOptions>(builder.Configuration.GetSection(CompetitionLeaderboardOptions.SectionName));
builder.Services.AddScoped<ICompetitionLeaderboardService, CompetitionLeaderboardService>();
builder.Services.AddScoped<IRaceMetadataService, RaceMetadataService>();
builder.Services.AddScoped<IRaceContextResolver, RaceContextResolver>();
builder.Services.AddSingleton<ICompetitionRuleCatalog, CompetitionRuleCatalog>();
Expand Down Expand Up @@ -137,10 +141,44 @@

app.MapControllers(); // This line is crucial for mapping your controllers

app.MapGet("/races/results", (IRaceService raceService) =>
app.MapGet("/races/results", async (
string competition,
int season,
string? view,
ClaimsPrincipal user,
ICompetitionLeaderboardService leaderboardService,
CancellationToken cancellationToken) =>
{
var results = raceService.GetMockResults();
return Results.Ok(results);
var normalizedView = string.IsNullOrWhiteSpace(view) ? "active" : view.Trim().ToLowerInvariant();
if ((normalizedView == "recalculated" || normalizedView == "imported") && !user.IsInRole("Admin"))
{
return Results.Forbid();
}

var leaderboard = await leaderboardService.GetLeaderboardAsync(
competition,
season,
normalizedView,
user.IsInRole("Admin"),
cancellationToken);

return Results.Ok(leaderboard);
}).RequireAuthorization();

app.MapGet("/races/results/participants/{participantName}", async (
string competition,
int season,
string participantName,
ICompetitionLeaderboardService leaderboardService,
CancellationToken cancellationToken) =>
{
var detail = await leaderboardService.GetParticipantDetailAsync(
competition,
season,
participantName,
cancellationToken);

return Results.Ok(detail);
}).RequireAuthorization();

app.Run();
Expand Down
Loading
Loading