diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index e4f7e3d..76c3479 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -87,6 +87,15 @@ jobs: dotnet build tests/F1.Api.Tests/F1.Api.Tests.csproj --configuration Release --no-restore /warnaserror dotnet build tests/F1.Web.Tests/F1.Web.Tests.csproj --configuration Release --no-restore /warnaserror + - name: Verify EF Migrations Are Up To Date + run: | + dotnet tool install --global dotnet-ef --version 9.* || dotnet tool update --global dotnet-ef --version 9.* + dotnet ef migrations has-pending-model-changes \ + --project src/F1.Infrastructure/F1.Infrastructure.csproj \ + --startup-project src/F1.Api/F1.Api.csproj \ + --configuration Release \ + --no-build + unit-tests: runs-on: ubuntu-latest needs: build diff --git a/build.sh b/build.sh index 484105d..36b87ae 100755 --- a/build.sh +++ b/build.sh @@ -16,6 +16,7 @@ DATA_SYNC_PROJECT="src/F1.DataSyncWorker/F1.DataSyncWorker.csproj" API_TEST_PROJECT="tests/F1.Api.Tests/F1.Api.Tests.csproj" WEB_TEST_PROJECT="tests/F1.Web.Tests/F1.Web.Tests.csproj" INFRA_TEST_PROJECT="tests/F1.Infrastructure.Tests/F1.Infrastructure.Tests.csproj" +INFRA_DATA_PROJECT="src/F1.Infrastructure/F1.Infrastructure.csproj" FORMAT_INCLUDE_PATHS=( "src/F1.Api" @@ -70,6 +71,12 @@ run_quality_gate() { if ! CI=true dotnet build "$WEB_TEST_PROJECT" --configuration Release --no-restore; then return 1; fi if ! CI=true dotnet build "$INFRA_TEST_PROJECT" --configuration Release --no-restore; then return 1; fi + if ! dotnet tool update --global dotnet-ef --version 9.* && ! dotnet tool install --global dotnet-ef --version 9.*; then return 1; fi + if ! dotnet ef migrations has-pending-model-changes --project "$INFRA_DATA_PROJECT" --startup-project "$API_PROJECT" --no-build; then + printf "\033[0;31m❌ EF model has pending changes. Add a migration before continuing.\033[0m\n" + return 1 + fi + echo "✅ Quality gate passed." } diff --git a/docs/epics/gh-284-migration-write-correctness-and-non-empty-db/epic-migration-write-correctness-and-non-empty-db.md b/docs/epics/gh-284-migration-write-correctness-and-non-empty-db/epic-migration-write-correctness-and-non-empty-db.md index c7d4b76..d49289a 100644 --- a/docs/epics/gh-284-migration-write-correctness-and-non-empty-db/epic-migration-write-correctness-and-non-empty-db.md +++ b/docs/epics/gh-284-migration-write-correctness-and-non-empty-db/epic-migration-write-correctness-and-non-empty-db.md @@ -46,6 +46,7 @@ Acceptance criteria: Test notes: - Add a characterization test that reproduces current failed/no-op write behavior before fix. - Add traceability artifact reference in docs/runbook so future regressions can be triaged quickly. + - Artifact: `docs/runbooks/migration-write-pipeline-trace.md` ### Story 2: Implement transactional write path to canonical tables As an operator, I want write runs to persist all intended entities atomically so partial writes cannot corrupt state. diff --git a/docs/runbooks/migration-write-pipeline-trace.md b/docs/runbooks/migration-write-pipeline-trace.md new file mode 100644 index 0000000..2c5861f --- /dev/null +++ b/docs/runbooks/migration-write-pipeline-trace.md @@ -0,0 +1,68 @@ +# Migration Write Pipeline Trace and Gap Report + +## Scope +This trace documents the current migration import execution path from kickoff to persistence, including dry-run mapping/enrichment and write-mode canonical materialization. + +## End-to-End Pipeline Map +1. Run kickoff +- Entry points: worker orchestrator `MigrationImportOrchestrator.RunOnceAsync` and queued mode `RunNextQueuedAsync`. +- Run metadata row is created/claimed in `MigrationImportRunService`. + +2. Raw row staging +- CSV rows are classified and persisted to `MigrationImportRawRows`. + +3. Parsing and normalization +- Race picks are parsed into `MigrationImportRaceSelections`. +- Generic/preseason question inputs are parsed into: + - `MigrationImportPreseasonAnswers` + - `QuestionAnswers` + - `QuestionActuals` + +4. Mapping and enrichment (dry-run and write mode) +- Race sequence mapping persists into: + - `MigrationImportJolpicaRaceSnapshots` + - `MigrationImportRaceRoundMappings` +- Race codes in staged selections are rewritten to mapped circuit ids. + +5. Scoring +- Race pick scoring persists to `MigrationImportCalculatedScores`. +- Imported legacy totals persist to `MigrationImportLegacyPickScores` and related totals tables. +- Generic question scoring persists to `QuestionScores`. + +6. Reconciliation +- Diff and summary outputs persist to migration reconciliation tables: + - `MigrationImportPickDiffs` + - `MigrationImportRaceDiffs` + - `MigrationImportParticipantDeltaSummaries` + - `MigrationImportReasonCategorySummaries` + - preseason diff/summary companion tables + +7. Completion +- Run status and metadata are written to `MigrationImportRuns`. + +8. Canonical race-domain writes (write mode only) +- Canonical persistence runs after reconciliation in write mode via `MigrationCanonicalWriteService`. +- Races must be pre-seeded for the target season; the migration writer looks them up by circuit id or round and does not create new Race rows. +- Entities created/updated where applicable: + - `Drivers` (created when missing) + - `Selections` (created or reused per conflict policy) + - `SelectionPositions` (replaced per selection) + +## Intended Canonical Targets (Epic Contract) +For write mode, canonical race-domain entities are created or updated where applicable: +- `Drivers` (created when not already present) +- `Races` (pre-seeded required; looked up by circuit id or round — not created by migration writer) +- `Selections` (created or reused per conflict policy) +- `SelectionPositions` (replaced per selection) + +Question-domain tables already receive run-scoped writes via parser/scoring (`QuestionAnswers`, `QuestionActuals`, `QuestionScores`). + +## Current Notes (Observed) +1. Mapping/enrichment executes for both dry-run and write runs. +2. Canonical race-domain persistence is write-mode only and is now implemented. +3. Rollback paths and conflict diagnostics are available for canonical-write operations. + +## Follow-On Implementation Work +- Continue hardening rollback scope and non-empty DB safeguards. +- Expand operational runbooks for conflict policies and post-write verification. +- Keep migration and schema drift checks enforced in CI for rollout safety. diff --git a/scripts/clear-canonical-tables.sh b/scripts/clear-canonical-tables.sh new file mode 100755 index 0000000..a87d10c --- /dev/null +++ b/scripts/clear-canonical-tables.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Clears canonical domain tables used by the migration write path. +# +# Usage: +# scripts/clear-canonical-tables.sh +# scripts/clear-canonical-tables.sh --yes +# +# Connection resolution (first match wins): +# 1) DATABASE_URL +# 2) ConnectionStrings__Postgres +# 3) PGHOST/PGPORT/PGDATABASE/PGUSER/PGPASSWORD via psql defaults + +force='false' +include_competitions='false' + +while [[ $# -gt 0 ]]; do + case "$1" in + --yes|-y) + force='true' + ;; + --include-competitions) + include_competitions='true' + ;; + --help|-h) + cat <<'USAGE' +Clear canonical tables in Postgres. + +Options: + --yes, -y Skip confirmation prompt + --include-competitions Also delete rows from public."Competitions" + --help, -h Show this help text +USAGE + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + exit 2 + ;; + esac + shift +done + +if [[ "$force" != 'true' ]]; then + cat <<'WARN' +This will permanently delete data from canonical tables: + - public."QuestionScores" + - public."QuestionAnswers" + - public."QuestionActuals" + - public."QuestionTemplates" + - public."SelectionPositions" + - public."Selections" + - public."RaceMetadata" + - public."Drivers" +WARN + + if [[ "$include_competitions" == 'true' ]]; then + echo ' - public."Competitions"' + fi + + read -r -p 'Type CLEAR to continue: ' confirm + if [[ "$confirm" != 'CLEAR' ]]; then + echo 'Aborted.' + exit 1 + fi +fi + +conn='' +if [[ -n "${DATABASE_URL:-}" ]]; then + conn="$DATABASE_URL" +elif [[ -n "${ConnectionStrings__Postgres:-}" ]]; then + conn="$ConnectionStrings__Postgres" +fi + +sql=$(cat <<'SQL' +BEGIN; +DELETE FROM public."QuestionScores"; +DELETE FROM public."QuestionAnswers"; +DELETE FROM public."QuestionActuals"; +DELETE FROM public."QuestionTemplates"; +DELETE FROM public."SelectionPositions"; +DELETE FROM public."Selections"; +SQL +) + +if [[ "$include_competitions" == 'true' ]]; then + sql+=$'\nDELETE FROM public."Competitions";' +fi + +sql+=$'\nCOMMIT;\n' + +sql_file=$(mktemp) +cleanup() { + rm -f "$sql_file" +} +trap cleanup EXIT + +printf '%s' "$sql" > "$sql_file" + +if command -v psql >/dev/null 2>&1; then + if [[ -n "$conn" ]]; then + psql "$conn" -v ON_ERROR_STOP=1 -f "$sql_file" + else + psql -v ON_ERROR_STOP=1 -f "$sql_file" + fi +else + if [[ -n "$conn" ]]; then + DATABASE_URL="$conn" docker compose exec -T postgres sh -lc 'psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f /dev/stdin' < "$sql_file" + else + PGPASSWORD="${POSTGRES_PASSWORD:-f1}" docker compose exec -T postgres psql -U "${POSTGRES_USER:-f1}" -d "${POSTGRES_DB:-f1competition}" -v ON_ERROR_STOP=1 -f /dev/stdin < "$sql_file" + fi +fi + +echo 'Canonical table clear-down complete.' diff --git a/src/F1.Api/Controllers/MigrationRunsController.cs b/src/F1.Api/Controllers/MigrationRunsController.cs index 6c919dc..1354c5b 100644 --- a/src/F1.Api/Controllers/MigrationRunsController.cs +++ b/src/F1.Api/Controllers/MigrationRunsController.cs @@ -206,7 +206,8 @@ public async Task KickoffRun( new MigrationRunKickoffCommand( request.SourceFilePath, request.Mode, - ResolveActor()), + ResolveActor(), + request.ConfirmNonEmptyStrategy), cancellationToken); if (!result.Success) @@ -283,7 +284,8 @@ public async Task KickoffRunFromUpload( new MigrationRunKickoffCommand( persistedPath, request.Mode, - ResolveActor()), + ResolveActor(), + request.ConfirmNonEmptyStrategy), cancellationToken); if (!result.Success) @@ -308,6 +310,37 @@ public async Task KickoffRunFromUpload( return CreatedAtAction(nameof(GetRunDetail), new { runId = result.Run!.RunId }, result.Run); } + [HttpPost("{runId:guid}/rollback")] + public async Task RollbackRun( + Guid runId, + [FromBody] AdminMigrationRollbackRequestDto request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(request.Reason)) + { + return BadRequest(new + { + message = "Rollback reason is required.", + code = "rollback_invalid_request" + }); + } + + var result = await _migrationRunAdminService.RollbackRunAsync( + new MigrationRunRollbackCommand(runId, ResolveActor(), request.Reason.Trim()), + cancellationToken); + + if (!result.Success) + { + return BadRequest(new + { + message = result.Error ?? "Unable to rollback migration run.", + code = "rollback_failed" + }); + } + + return Ok(result.Rollback); + } + private static string ResolveWritableUploadRoot() { var primaryRoot = Path.GetFullPath(UploadDirectory, Directory.GetCurrentDirectory()); diff --git a/src/F1.Api/Dtos/AdminMigrationRunDtos.cs b/src/F1.Api/Dtos/AdminMigrationRunDtos.cs index 88b681c..74d1b2e 100644 --- a/src/F1.Api/Dtos/AdminMigrationRunDtos.cs +++ b/src/F1.Api/Dtos/AdminMigrationRunDtos.cs @@ -46,7 +46,40 @@ public sealed record AdminMigrationRunDetailResponseDto( IReadOnlyList PreseasonQuestionDiffs, IReadOnlyList PreseasonReasonCategorySummaries, IReadOnlyList RaceDiffs, - IReadOnlyList PickDiffs); + IReadOnlyList PickDiffs, + IReadOnlyList? ConflictDiagnostics = null, + IReadOnlyList? RollbackAudits = null); + +public sealed record AdminMigrationRollbackRequestDto( + string Reason); + +public sealed record AdminMigrationRollbackResponseDto( + Guid RunId, + string Status, + DateTime RequestedAtUtc, + string RequestedBy, + string Outcome, + int AffectedRaceCount, + int AffectedSelectionCount, + int AffectedSelectionPositionCount); + +public sealed record AdminMigrationRollbackAuditDto( + DateTime RequestedAtUtc, + string Actor, + string Reason, + string Outcome, + int AffectedRaceCount, + int AffectedSelectionCount, + int AffectedSelectionPositionCount); + +public sealed record AdminMigrationConflictDiagnosticDto( + string EntityType, + string ConflictType, + string KeyFields, + string SourceReference, + string PolicyOutcome, + string RecommendedAction, + DateTime CreatedAtUtc); public sealed record AdminMigrationUnresolvedTokenSummaryDto( string RawToken, @@ -119,11 +152,13 @@ public sealed record AdminMigrationPickDiffDto( public sealed record AdminMigrationRunKickoffRequestDto( string? SourceFilePath, - string Mode); + string Mode, + bool ConfirmNonEmptyStrategy = false); public sealed record AdminMigrationRunKickoffUploadRequestDto( IFormFile? SourceFile, - string Mode); + string Mode, + bool ConfirmNonEmptyStrategy = false); public sealed record AdminMigrationRunKickoffResponseDto( Guid RunId, @@ -133,7 +168,15 @@ public sealed record AdminMigrationRunKickoffResponseDto( string SourceFilePath, string SourceFileChecksum, DateTime TriggeredAtUtc, - string RequestedBy); + string RequestedBy, + string NonEmptyDbStrategy = "merge_upsert_active_records", + bool CanonicalDataPresent = false, + int ExistingDriverCount = 0, + int ExistingRaceCount = 0, + int ExistingSelectionCount = 0, + int EstimatedAffectedRaceCount = 0, + int EstimatedAffectedParticipantCount = 0, + int EstimatedAffectedSelectionCount = 0); public sealed record AdminMigrationQuestionDiffListResponseDto( int Page, @@ -148,8 +191,7 @@ public sealed record AdminMigrationQuestionDiffDto( string Participant, int? ImportedPoints, int CalculatedPoints, - int DeltaPoints, - string ReasonCode); + int DeltaPoints); public sealed record AdminMigrationQuestionDiffSummaryResponseDto( int TotalCount, diff --git a/src/F1.Api/Services/IMigrationRunAdminService.cs b/src/F1.Api/Services/IMigrationRunAdminService.cs index afc821b..027c7de 100644 --- a/src/F1.Api/Services/IMigrationRunAdminService.cs +++ b/src/F1.Api/Services/IMigrationRunAdminService.cs @@ -19,7 +19,8 @@ public sealed record MigrationRunDiffExportResponse( public sealed record MigrationRunKickoffCommand( string? SourceFilePath, string RequestedMode, - string RequestedBy); + string RequestedBy, + bool ConfirmNonEmptyStrategy = false); public sealed record MigrationRunKickoffResult( bool Success, @@ -28,6 +29,16 @@ public sealed record MigrationRunKickoffResult( Guid? ExistingRunId, AdminMigrationRunKickoffResponseDto? Run); +public sealed record MigrationRunRollbackCommand( + Guid RunId, + string RequestedBy, + string Reason); + +public sealed record MigrationRunRollbackResult( + bool Success, + string? Error, + AdminMigrationRollbackResponseDto? Rollback); + public interface IMigrationRunAdminService { Task GetRunsAsync(MigrationRunListQuery query, CancellationToken cancellationToken); @@ -72,4 +83,8 @@ public interface IMigrationRunAdminService Task KickoffRunAsync( MigrationRunKickoffCommand command, CancellationToken cancellationToken); + + Task RollbackRunAsync( + MigrationRunRollbackCommand command, + CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/F1.Api/Services/MigrationRunAdminService.cs b/src/F1.Api/Services/MigrationRunAdminService.cs index 7e4ff26..2b6e9ab 100644 --- a/src/F1.Api/Services/MigrationRunAdminService.cs +++ b/src/F1.Api/Services/MigrationRunAdminService.cs @@ -1,5 +1,6 @@ using F1.Api.Dtos; using F1.Infrastructure.Data; +using F1.Infrastructure.Data.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Npgsql; @@ -13,6 +14,7 @@ namespace F1.Api.Services; public sealed class MigrationRunAdminService : IMigrationRunAdminService { + private const string NonEmptyDbStrategy = "merge_upsert_active_records"; private const string DefaultSourceFilePath = "data/imports/phil-2025/PhilMigratedSelectionsAndScores.csv"; private const string AllowedImportRootPath = "data/imports"; private const string AllowedTempImportRootPath = "f1-imports"; @@ -77,6 +79,13 @@ public async Task GetRunsAsync(MigrationRunLis var runIds = pagedRuns.Select(run => run.Id).ToArray(); + var rawRowFallbackCounts = await _dbContext.MigrationImportRawRows + .AsNoTracking() + .Where(x => runIds.Contains(x.ImportRunId)) + .GroupBy(x => x.ImportRunId) + .Select(group => new { RunId = group.Key, Count = group.Count() }) + .ToDictionaryAsync(x => x.RunId, x => x.Count, cancellationToken); + var unresolvedCounts = await _dbContext.MigrationImportUnresolvedTokens .AsNoTracking() .Where(x => runIds.Contains(x.ImportRunId)) @@ -128,7 +137,7 @@ public async Task GetRunsAsync(MigrationRunLis run.SourceFileChecksum, run.StartedAtUtc, run.FinishedAtUtc, - run.RawRowCount, + run.RawRowCount > 0 ? run.RawRowCount : rawRowFallbackCounts.GetValueOrDefault(run.Id, 0), unresolvedCounts.GetValueOrDefault(run.Id, 0), pickDiffCounts.GetValueOrDefault(run.Id, 0), raceDiffCounts.GetValueOrDefault(run.Id, 0), @@ -198,6 +207,26 @@ public async Task KickoffRunAsync(MigrationRunKickoff var now = DateTime.UtcNow; var isDryRun = normalizedMode == "dry-run"; var runId = Guid.NewGuid(); + + var existingDriverCount = await _dbContext.Drivers.CountAsync(cancellationToken); + var existingRaceCount = await _dbContext.Races.CountAsync(cancellationToken); + var existingSelectionCount = await _dbContext.Selections.CountAsync(cancellationToken); + var canonicalDataPresent = existingDriverCount > 0 || existingRaceCount > 0 || existingSelectionCount > 0; + + var estimatedAffectedRaceCount = await EstimateAffectedRaceCountAsync(sourceFilePath, cancellationToken); + var estimatedAffectedParticipantCount = await EstimateAffectedParticipantCountAsync(sourceFilePath, cancellationToken); + var estimatedAffectedSelectionCount = estimatedAffectedRaceCount * estimatedAffectedParticipantCount; + + if (!isDryRun && canonicalDataPresent && !command.ConfirmNonEmptyStrategy) + { + return new MigrationRunKickoffResult( + Success: false, + Conflict: false, + Error: "Write mode requires non-empty DB strategy confirmation. Re-submit with confirmNonEmptyStrategy=true.", + ExistingRunId: null, + Run: null); + } + await using var transaction = await _dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken); try { @@ -284,7 +313,211 @@ public async Task KickoffRunAsync(MigrationRunKickoff SourceFilePath: sourceFilePath, SourceFileChecksum: checksum, TriggeredAtUtc: now, - RequestedBy: command.RequestedBy)); + RequestedBy: command.RequestedBy, + NonEmptyDbStrategy: NonEmptyDbStrategy, + CanonicalDataPresent: canonicalDataPresent, + ExistingDriverCount: existingDriverCount, + ExistingRaceCount: existingRaceCount, + ExistingSelectionCount: existingSelectionCount, + EstimatedAffectedRaceCount: estimatedAffectedRaceCount, + EstimatedAffectedParticipantCount: estimatedAffectedParticipantCount, + EstimatedAffectedSelectionCount: estimatedAffectedSelectionCount)); + } + + public async Task RollbackRunAsync(MigrationRunRollbackCommand command, CancellationToken cancellationToken) + { + var run = await _dbContext.MigrationImportRuns + .FirstOrDefaultAsync(x => x.Id == command.RunId, cancellationToken); + + if (run is null) + { + return new MigrationRunRollbackResult(false, "Migration run was not found.", null); + } + + if (!string.Equals(run.Status, "Completed", StringComparison.OrdinalIgnoreCase) && + !string.Equals(run.Status, "Failed", StringComparison.OrdinalIgnoreCase)) + { + return new MigrationRunRollbackResult(false, "Only completed or failed runs can be rolled back.", null); + } + + var raceCodes = await _dbContext.MigrationImportRaceSelections + .AsNoTracking() + .Where(x => x.ImportRunId == command.RunId && !x.IsActualOutcome) + .Select(x => x.RaceCode) + .Distinct() + .ToArrayAsync(cancellationToken); + + var rollbackSeasons = await _dbContext.MigrationImportRaceRoundMappings + .AsNoTracking() + .Where(x => x.ImportRunId == command.RunId && x.Season.HasValue) + .Select(x => x.Season!.Value) + .Distinct() + .ToArrayAsync(cancellationToken); + + if (rollbackSeasons.Length == 0) + { + return new MigrationRunRollbackResult( + false, + "Unable to determine migration season for rollback scope.", + null); + } + + if (rollbackSeasons.Length > 1) + { + return new MigrationRunRollbackResult( + false, + "Rollback scope is ambiguous because the run contains multiple seasons.", + null); + } + + var raceIds = await _dbContext.Races + .AsNoTracking() + .Where(x => x.Season == rollbackSeasons[0] && raceCodes.Contains(x.CircuitName)) + .Select(x => x.Id) + .Distinct() + .ToArrayAsync(cancellationToken); + + var selectionIds = await _dbContext.Selections + .AsNoTracking() + .Where(x => raceIds.Contains(x.RaceId)) + .Select(x => x.Id) + .ToArrayAsync(cancellationToken); + + await using var transaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); + try + { + var selectionPositions = await _dbContext.SelectionPositions + .Where(x => selectionIds.Contains(x.SelectionId)) + .ToListAsync(cancellationToken); + var selections = await _dbContext.Selections + .Where(x => selectionIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + var races = await _dbContext.Races + .Where(x => raceIds.Contains(x.Id)) + .ToListAsync(cancellationToken); + + var affectedSelectionPositionCount = selectionPositions.Count; + var affectedSelectionCount = selections.Count; + var affectedRaceCount = races.Count; + + _dbContext.SelectionPositions.RemoveRange(selectionPositions); + _dbContext.Selections.RemoveRange(selections); + _dbContext.Races.RemoveRange(races); + + var requestedAtUtc = DateTime.UtcNow; + _dbContext.MigrationImportRollbackAudits.Add(new MigrationImportRollbackAuditEntity + { + ImportRunId = command.RunId, + Actor = command.RequestedBy, + Reason = command.Reason, + RequestedAtUtc = requestedAtUtc, + AffectedRaceCount = affectedRaceCount, + AffectedSelectionCount = affectedSelectionCount, + AffectedSelectionPositionCount = affectedSelectionPositionCount, + Outcome = "Completed" + }); + + run.Status = "RolledBack"; + await _dbContext.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + + return new MigrationRunRollbackResult( + true, + null, + new AdminMigrationRollbackResponseDto( + command.RunId, + run.Status, + requestedAtUtc, + command.RequestedBy, + "Completed", + affectedRaceCount, + affectedSelectionCount, + affectedSelectionPositionCount)); + } + catch (Exception ex) + { + await transaction.RollbackAsync(cancellationToken); + return new MigrationRunRollbackResult(false, ex.Message, null); + } + } + + private static async Task EstimateAffectedRaceCountAsync(string sourceFilePath, CancellationToken cancellationToken) + { + var raceCodes = new HashSet(StringComparer.OrdinalIgnoreCase); + using var stream = File.OpenRead(sourceFilePath); + using var reader = new StreamReader(stream); + + while (!reader.EndOfStream) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var columns = line.Split(','); + if (columns.Length == 0) + { + continue; + } + + var label = columns[0].Trim(); + if (label.Length < 4) + { + continue; + } + + var dashIndex = label.IndexOf('-'); + if (dashIndex <= 0) + { + continue; + } + + var suffix = label[(dashIndex + 1)..].Trim(); + if (!suffix.Equals("1", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + raceCodes.Add(label[..dashIndex]); + } + + return raceCodes.Count; + } + + private static async Task EstimateAffectedParticipantCountAsync(string sourceFilePath, CancellationToken cancellationToken) + { + using var stream = File.OpenRead(sourceFilePath); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream) + { + cancellationToken.ThrowIfCancellationRequested(); + var line = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var columns = line.Split(','); + if (columns.Length < 2) + { + continue; + } + + if (!string.Equals(columns[0].Trim(), "Question", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var participants = columns + .Skip(1) + .TakeWhile(x => !string.IsNullOrWhiteSpace(x)) + .Count(); + return participants; + } + + return 0; } public async Task GetRunDetailAsync( @@ -311,6 +544,12 @@ public async Task KickoffRunAsync(MigrationRunKickoff requestedBy, DateTime.UtcNow); + var rawRowFallbackCount = run.RawRowCount > 0 + ? run.RawRowCount + : await _dbContext.MigrationImportRawRows + .AsNoTracking() + .CountAsync(x => x.ImportRunId == runId, cancellationToken); + var unresolvedTokenSummaryRows = await _dbContext.MigrationImportUnresolvedTokens .AsNoTracking() .Where(x => x.ImportRunId == runId) @@ -439,6 +678,35 @@ public async Task KickoffRunAsync(MigrationRunKickoff x.TotalDeltaPoints)) .ToArrayAsync(cancellationToken); + var conflictDiagnostics = await _dbContext.MigrationImportConflictDiagnostics + .AsNoTracking() + .Where(x => x.ImportRunId == runId) + .OrderByDescending(x => x.CreatedAtUtc) + .ThenBy(x => x.EntityType) + .Select(x => new AdminMigrationConflictDiagnosticDto( + x.EntityType, + x.ConflictType, + x.KeyFields, + x.SourceReference, + x.PolicyOutcome, + x.RecommendedAction, + x.CreatedAtUtc)) + .ToArrayAsync(cancellationToken); + + var rollbackAudits = await _dbContext.MigrationImportRollbackAudits + .AsNoTracking() + .Where(x => x.ImportRunId == runId) + .OrderByDescending(x => x.RequestedAtUtc) + .Select(x => new AdminMigrationRollbackAuditDto( + x.RequestedAtUtc, + x.Actor, + x.Reason, + x.Outcome, + x.AffectedRaceCount, + x.AffectedSelectionCount, + x.AffectedSelectionPositionCount)) + .ToArrayAsync(cancellationToken); + var preseasonSummary = new AdminMigrationPreseasonSummaryDto( QuestionDiffCount: preseasonQuestionDiffs.Length, ParticipantDeltaCount: preseasonParticipantDeltas.Length, @@ -456,7 +724,7 @@ public async Task KickoffRunAsync(MigrationRunKickoff SourceFileChecksum: run.SourceFileChecksum, StartedAtUtc: run.StartedAtUtc, FinishedAtUtc: run.FinishedAtUtc, - RawRowCount: run.RawRowCount, + RawRowCount: rawRowFallbackCount, ErrorMessage: run.ErrorMessage, UnresolvedTokenCount: unresolvedTokenSummary.Sum(x => x.OccurrenceCount), PickDiffCount: pickDiffs.Length, @@ -469,8 +737,10 @@ public async Task KickoffRunAsync(MigrationRunKickoff PreseasonParticipantDeltas: preseasonParticipantDeltas, PreseasonQuestionDiffs: preseasonQuestionDiffs, PreseasonReasonCategorySummaries: preseasonReasonCategorySummaries, + ConflictDiagnostics: conflictDiagnostics, RaceDiffs: raceDiffs, - PickDiffs: pickDiffs); + PickDiffs: pickDiffs, + RollbackAudits: rollbackAudits); } catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.UndefinedTable) { @@ -673,7 +943,7 @@ await BuildQuestionDiffRowsAsync(runId, cancellationToken), } var csv = new StringBuilder(); - csv.AppendLine("category,questionId,questionText,participant,importedPoints,calculatedPoints,deltaPoints,reasonCode"); + csv.AppendLine("category,questionId,questionText,participant,importedPoints,calculatedPoints,deltaPoints"); foreach (var row in rows) { csv.Append(EscapeCsv(row.Category)).Append(',') @@ -682,8 +952,7 @@ await BuildQuestionDiffRowsAsync(runId, cancellationToken), .Append(EscapeCsv(row.Participant)).Append(',') .Append(row.ImportedPoints?.ToString(CultureInfo.InvariantCulture) ?? string.Empty).Append(',') .Append(row.CalculatedPoints.ToString(CultureInfo.InvariantCulture)).Append(',') - .Append(row.DeltaPoints.ToString(CultureInfo.InvariantCulture)).Append(',') - .Append(EscapeCsv(row.ReasonCode)) + .Append(row.DeltaPoints.ToString(CultureInfo.InvariantCulture)) .AppendLine(); } @@ -697,11 +966,22 @@ await BuildQuestionDiffRowsAsync(runId, cancellationToken), private async Task BuildQuestionDiffRowsAsync(Guid runId, CancellationToken cancellationToken) { + var season = await _dbContext.MigrationImportRaceRoundMappings + .AsNoTracking() + .Where(x => x.ImportRunId == runId && x.Season.HasValue) + .Select(x => x.Season!.Value) + .FirstOrDefaultAsync(cancellationToken); + + var templateQuery = _dbContext.QuestionTemplates.AsNoTracking(); + if (season != 0) + { + templateQuery = templateQuery.Where(t => t.Season == season); + } + var rows = await _dbContext.QuestionScores .AsNoTracking() - .Where(x => x.ImportRunId == runId) .Join( - _dbContext.QuestionTemplates.AsNoTracking(), + templateQuery, score => score.QuestionTemplateId, template => template.Id, (score, template) => new @@ -712,13 +992,11 @@ private async Task BuildQuestionDiffRowsAsync(G score.ParticipantId, score.ImportedPoints, score.CalculatedPoints, - score.DeltaPoints, - score.ReasonCode + score.DeltaPoints }) .OrderBy(x => x.Category) .ThenBy(x => x.QuestionId) .ThenBy(x => x.ParticipantId) - .ThenBy(x => x.ReasonCode) .ToArrayAsync(cancellationToken); return rows @@ -729,8 +1007,7 @@ private async Task BuildQuestionDiffRowsAsync(G x.ParticipantId, x.ImportedPoints, x.CalculatedPoints, - x.DeltaPoints, - x.ReasonCode)) + x.DeltaPoints)) .ToArray(); } @@ -1160,7 +1437,8 @@ private static string EscapeCsv(string? value) var importRoot = Path.GetFullPath(AllowedImportRootPath, Directory.GetCurrentDirectory()); var tempImportRoot = Path.GetFullPath(AllowedTempImportRootPath, Path.GetTempPath()); - if (IsPathWithinRoot(candidatePath, importRoot) || IsPathWithinRoot(candidatePath, tempImportRoot)) + if (IsPathWithinRoot(candidatePath, importRoot) || + IsPathWithinRoot(candidatePath, tempImportRoot)) { return candidatePath; } diff --git a/src/F1.Core/Models/QuestionActual.cs b/src/F1.Core/Models/QuestionActual.cs index 71d9c8b..0bf788d 100644 --- a/src/F1.Core/Models/QuestionActual.cs +++ b/src/F1.Core/Models/QuestionActual.cs @@ -4,19 +4,12 @@ public sealed class QuestionActual { public long Id { get; set; } - public Guid ImportRunId { get; set; } - public long QuestionTemplateId { get; set; } - public string? ActualAnswer { get; set; } - - public string? NormalizedAnswer { get; set; } - - public int SourceRow { get; set; } - - public int SourceColumn { get; set; } + public string? ImportedAnswer { get; set; } - public string? NormalizationDiagnosticsJson { get; set; } + public string? OverrideAnswer { get; set; } public DateTime RecordedAtUtc { get; set; } -} \ No newline at end of file + +} diff --git a/src/F1.Core/Models/QuestionAnswer.cs b/src/F1.Core/Models/QuestionAnswer.cs index 1eb95b5..a0b6c75 100644 --- a/src/F1.Core/Models/QuestionAnswer.cs +++ b/src/F1.Core/Models/QuestionAnswer.cs @@ -4,19 +4,13 @@ public sealed class QuestionAnswer { public long Id { get; set; } - public Guid ImportRunId { get; set; } - public long QuestionTemplateId { get; set; } public string ParticipantId { get; set; } = string.Empty; public string? ImportedAnswer { get; set; } - public string? NormalizedAnswer { get; set; } - - public int SourceRow { get; set; } - - public int SourceColumn { get; set; } + public string? OverrideAnswer { get; set; } // Persisted answer rows become immutable once the owning run completes. public DateTime RecordedAtUtc { get; set; } diff --git a/src/F1.Core/Models/QuestionScore.cs b/src/F1.Core/Models/QuestionScore.cs index 5165737..5f9f1a2 100644 --- a/src/F1.Core/Models/QuestionScore.cs +++ b/src/F1.Core/Models/QuestionScore.cs @@ -4,8 +4,6 @@ public sealed class QuestionScore { public long Id { get; set; } - public Guid ImportRunId { get; set; } - public long QuestionTemplateId { get; set; } public string ParticipantId { get; set; } = string.Empty; @@ -16,7 +14,5 @@ public sealed class QuestionScore public int DeltaPoints { get; set; } - public string ReasonCode { get; set; } = string.Empty; - public DateTime RecordedAtUtc { get; set; } } \ No newline at end of file diff --git a/src/F1.DataSyncWorker/DependencyInjection/DataSyncWorkerServiceCollectionExtensions.cs b/src/F1.DataSyncWorker/DependencyInjection/DataSyncWorkerServiceCollectionExtensions.cs new file mode 100644 index 0000000..e551d0c --- /dev/null +++ b/src/F1.DataSyncWorker/DependencyInjection/DataSyncWorkerServiceCollectionExtensions.cs @@ -0,0 +1,72 @@ +using F1.DataSyncWorker.Clients; +using F1.DataSyncWorker.Options; +using F1.DataSyncWorker.Services; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace F1.DataSyncWorker.DependencyInjection; + +public static class DataSyncWorkerServiceCollectionExtensions +{ + public static IServiceCollection AddDataSyncWorker(this IServiceCollection services, IConfiguration configuration) + { + services + .AddOptions() + .Bind(configuration.GetSection(DataSyncOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services + .AddOptions() + .Bind(configuration.GetSection(MigrationImportOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + services + .AddOptions() + .Bind(configuration.GetSection(MigrationExpectedVarianceOptions.SectionName)) + .ValidateDataAnnotations() + .ValidateOnStart(); + + var postgresConnectionString = configuration.GetConnectionString("Postgres"); + if (string.IsNullOrWhiteSpace(postgresConnectionString)) + { + throw new InvalidOperationException("ConnectionStrings:Postgres must be configured."); + } + + services.AddDbContextFactory(options => options.UseNpgsql(postgresConnectionString)); + + services + .AddHttpClient("Jolpica", (sp, client) => + { + var options = sp.GetRequiredService>().Value; + client.BaseAddress = new Uri(options.JolpicaBaseUrl, UriKind.Absolute); + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); + services.AddSingleton(sp => + sp.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} \ No newline at end of file diff --git a/src/F1.DataSyncWorker/Models/MigrationImportModels.cs b/src/F1.DataSyncWorker/Models/MigrationImportModels.cs index c5b830d..684fb04 100644 --- a/src/F1.DataSyncWorker/Models/MigrationImportModels.cs +++ b/src/F1.DataSyncWorker/Models/MigrationImportModels.cs @@ -48,4 +48,10 @@ public sealed record MigrationImportRunCompletionMetadata( int PreseasonScoredQuestionCount = 0, int PreseasonQuestionDiffCount = 0, int PreseasonTotalDeltaPoints = 0, - bool PreseasonIsolationGuardPassed = true); \ No newline at end of file + bool PreseasonIsolationGuardPassed = true, + string? ParitySnapshotChecksum = null, + string ParityStatus = "NotCompared", + string? ParityComparedChecksum = null, + Guid? ParityComparedRunId = null, + string? IdempotencyScopeKey = null, + string IdempotencyOutcome = "Unknown"); \ No newline at end of file diff --git a/src/F1.DataSyncWorker/Options/MigrationImportOptions.cs b/src/F1.DataSyncWorker/Options/MigrationImportOptions.cs index 0a98508..2461670 100644 --- a/src/F1.DataSyncWorker/Options/MigrationImportOptions.cs +++ b/src/F1.DataSyncWorker/Options/MigrationImportOptions.cs @@ -22,4 +22,8 @@ public sealed class MigrationImportOptions public bool FailOnPreseasonPolicyParseError { get; set; } = false; public bool FailOnPreseasonTallyParseError { get; set; } = false; + + public string? CanonicalWriteFailureInjectionStage { get; set; } + + public string CanonicalConflictPolicy { get; set; } = "override"; } \ No newline at end of file diff --git a/src/F1.DataSyncWorker/Program.cs b/src/F1.DataSyncWorker/Program.cs index 1db3a9d..8efd74c 100644 --- a/src/F1.DataSyncWorker/Program.cs +++ b/src/F1.DataSyncWorker/Program.cs @@ -1,10 +1,7 @@ -using F1.Infrastructure.Data; using F1.DataSyncWorker; -using F1.DataSyncWorker.Clients; +using F1.DataSyncWorker.DependencyInjection; using F1.DataSyncWorker.Options; -using F1.DataSyncWorker.Services; using Microsoft.Extensions.Configuration; -using Microsoft.EntityFrameworkCore; var migrationCliOverrides = MigrationImportCliParser.ParseToConfiguration(args); var builder = Host.CreateApplicationBuilder(args); @@ -15,56 +12,8 @@ } builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(DataSyncOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); + .AddDataSyncWorker(builder.Configuration); -builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(MigrationImportOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); - -builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(MigrationExpectedVarianceOptions.SectionName)) - .ValidateDataAnnotations() - .ValidateOnStart(); - -var postgresConnectionString = builder.Configuration.GetConnectionString("Postgres"); -if (string.IsNullOrWhiteSpace(postgresConnectionString)) -{ - throw new InvalidOperationException("ConnectionStrings:Postgres must be configured."); -} - -builder.Services.AddDbContextFactory(options => options.UseNpgsql(postgresConnectionString)); - -builder.Services - .AddHttpClient("Jolpica", (sp, client) => - { - var options = sp.GetRequiredService>().Value; - client.BaseAddress = new Uri(options.JolpicaBaseUrl, UriKind.Absolute); - }); - -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(sp => - sp.GetRequiredService()); -builder.Services.AddSingleton(sp => - sp.GetRequiredService()); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); builder.Services.AddHostedService(); var host = builder.Build(); diff --git a/src/F1.DataSyncWorker/Services/IDataSyncOrchestrator.cs b/src/F1.DataSyncWorker/Services/Abstractions/IDataSyncOrchestrator.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IDataSyncOrchestrator.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IDataSyncOrchestrator.cs diff --git a/src/F1.DataSyncWorker/Services/Abstractions/IMigrationCanonicalWriteService.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationCanonicalWriteService.cs new file mode 100644 index 0000000..7e16de1 --- /dev/null +++ b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationCanonicalWriteService.cs @@ -0,0 +1,6 @@ +namespace F1.DataSyncWorker.Services; + +public interface IMigrationCanonicalWriteService +{ + Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken cancellationToken); +} diff --git a/src/F1.DataSyncWorker/Services/IMigrationExpectedVarianceRuleCatalog.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationExpectedVarianceRuleCatalog.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationExpectedVarianceRuleCatalog.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationExpectedVarianceRuleCatalog.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationExpectedVarianceRuleSetMetadataProvider.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationExpectedVarianceRuleSetMetadataProvider.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationExpectedVarianceRuleSetMetadataProvider.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationExpectedVarianceRuleSetMetadataProvider.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationImportOrchestrator.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationImportOrchestrator.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationImportOrchestrator.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationImportOrchestrator.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationImportRowClassifier.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationImportRowClassifier.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationImportRowClassifier.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationImportRowClassifier.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationImportRunService.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationImportRunService.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationImportRunService.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationImportRunService.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationLegacyScoreImporter.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationLegacyScoreImporter.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationLegacyScoreImporter.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationLegacyScoreImporter.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationRaceRoundMapper.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationRaceRoundMapper.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationRaceRoundMapper.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationRaceRoundMapper.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationRaceSelectionParser.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationRaceSelectionParser.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationRaceSelectionParser.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationRaceSelectionParser.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationReconciliationService.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationReconciliationService.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationReconciliationService.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationReconciliationService.cs diff --git a/src/F1.DataSyncWorker/Services/IMigrationScoreRecalculator.cs b/src/F1.DataSyncWorker/Services/Abstractions/IMigrationScoreRecalculator.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IMigrationScoreRecalculator.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IMigrationScoreRecalculator.cs diff --git a/src/F1.DataSyncWorker/Services/IQuestionScoringStrategy.cs b/src/F1.DataSyncWorker/Services/Abstractions/IQuestionScoringStrategy.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IQuestionScoringStrategy.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IQuestionScoringStrategy.cs diff --git a/src/F1.DataSyncWorker/Services/IQuestionScoringStrategyRegistry.cs b/src/F1.DataSyncWorker/Services/Abstractions/IQuestionScoringStrategyRegistry.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/IQuestionScoringStrategyRegistry.cs rename to src/F1.DataSyncWorker/Services/Abstractions/IQuestionScoringStrategyRegistry.cs diff --git a/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs b/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs new file mode 100644 index 0000000..93b7f6c --- /dev/null +++ b/src/F1.DataSyncWorker/Services/Canonical/MigrationCanonicalWriteService.cs @@ -0,0 +1,461 @@ +using System.Text.RegularExpressions; +using F1.Core.Models; +using F1.DataSyncWorker.Options; +using F1.Infrastructure.Data; +using F1.Infrastructure.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace F1.DataSyncWorker.Services; + +public sealed partial class MigrationCanonicalWriteService : IMigrationCanonicalWriteService +{ + private const string ActualSubject = "ACTUAL"; + private static readonly Dictionary JolpicaDriverIdByCode = new(StringComparer.OrdinalIgnoreCase) + { + ["ALB"] = "albon", + ["ALO"] = "alonso", + ["ANT"] = "antonelli", + ["BEA"] = "bearman", + ["BOR"] = "bortoleto", + ["BOT"] = "bottas", + ["COL"] = "colapinto", + ["DOO"] = "doohan", + ["GAS"] = "gasly", + ["HAD"] = "hadjar", + ["HAM"] = "hamilton", + ["HUL"] = "hulkenberg", + ["LAW"] = "lawson", + ["LEC"] = "leclerc", + ["LIN"] = "lindblad", + ["MAG"] = "magnussen", + ["NOR"] = "norris", + ["OCO"] = "ocon", + ["PER"] = "perez", + ["PIA"] = "piastri", + ["RIC"] = "ricciardo", + ["RUS"] = "russell", + ["SAI"] = "sainz", + ["STR"] = "stroll", + ["TSU"] = "tsunoda", + ["VER"] = "max_verstappen", + ["ZHO"] = "zhou" + }; + private readonly IDbContextFactory _dbContextFactory; + private readonly MigrationImportOptions _importOptions; + + public MigrationCanonicalWriteService( + IDbContextFactory dbContextFactory, + IOptions importOptions) + { + _dbContextFactory = dbContextFactory; + _importOptions = importOptions.Value; + } + + public async Task PersistCanonicalEntitiesAsync(Guid runId, CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var run = await dbContext.MigrationImportRuns + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Id == runId, cancellationToken) + ?? throw new InvalidOperationException($"Migration import run {runId} not found."); + + if (run.IsDryRun) + { + return; + } + + var selections = await dbContext.MigrationImportRaceSelections + .Where(x => x.ImportRunId == runId && !x.IsActualOutcome && x.Subject != ActualSubject) + .OrderBy(x => x.RowNumber) + .AsNoTracking() + .ToListAsync(cancellationToken); + + if (selections.Count == 0) + { + return; + } + + await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); + + try + { + var normalizedConflictPolicy = NormalizeConflictPolicy(_importOptions.CanonicalConflictPolicy); + var competition = await dbContext.Competitions + .Where(x => x.Year == _importOptions.Season) + .OrderBy(x => x.Name == "Philip 2025" ? 0 : 1) + .ThenBy(x => x.Name.Contains("Philip") ? 0 : 1) + .ThenBy(x => x.Name.StartsWith("Migration Import") ? 1 : 0) + .ThenBy(x => x.Id) + .FirstOrDefaultAsync(cancellationToken); + + if (competition is null) + { + competition = new Competition + { + Name = $"Migration Import {_importOptions.Season}", + Year = _importOptions.Season, + Description = "Auto-created by migration canonical writer" + }; + + dbContext.Competitions.Add(competition); + await dbContext.SaveChangesAsync(cancellationToken); + } + + var raceCodes = selections + .Select(x => x.RaceCode) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var existingRaces = await dbContext.Races + .Where(x => x.CompetitionId == competition.Id && x.Season == _importOptions.Season) + .ToListAsync(cancellationToken); + + var existingRaceByRound = existingRaces + .GroupBy(x => x.Round) + .ToDictionary(x => x.Key, x => x.First()); + + var existingRaceByCircuitCode = existingRaces + .SelectMany(race => new[] + { + new { Key = RaceCodeNormalizer.NormalizeRaceCode(race.CircuitName), Race = race }, + new { Key = RaceCodeNormalizer.NormalizeRaceCode(race.RaceName), Race = race } + }) + .Where(x => !string.IsNullOrWhiteSpace(x.Key)) + .GroupBy(x => x.Key, StringComparer.OrdinalIgnoreCase) + .ToDictionary(x => x.Key, x => x.First().Race, StringComparer.OrdinalIgnoreCase); + + var mappedRoundByRaceCode = await dbContext.MigrationImportRaceRoundMappings + .AsNoTracking() + .Where(x => + x.ImportRunId == runId && + x.Round.HasValue && + !string.IsNullOrWhiteSpace(x.MappedCircuitId)) + .GroupBy(x => x.MappedCircuitId!) + .Select(group => new + { + RaceCode = group.Key, + Round = group.Min(item => item.Round!.Value) + }) + .ToDictionaryAsync(x => x.RaceCode, x => x.Round, StringComparer.OrdinalIgnoreCase, cancellationToken); + + var raceIdByCode = new Dictionary(StringComparer.OrdinalIgnoreCase); + var unresolvedRaceCodes = new List(); + + foreach (var raceCode in raceCodes) + { + if (mappedRoundByRaceCode.TryGetValue(raceCode, out var mappedRound) && + existingRaceByRound.TryGetValue(mappedRound, out var existingRaceByMappedRound)) + { + raceIdByCode[raceCode] = existingRaceByMappedRound.Id; + continue; + } + + if (existingRaceByCircuitCode.TryGetValue(raceCode, out var existingRaceByCircuit)) + { + raceIdByCode[raceCode] = existingRaceByCircuit.Id; + continue; + } + + unresolvedRaceCodes.Add(raceCode); + } + + if (unresolvedRaceCodes.Count > 0) + { + var unresolved = string.Join(", ", unresolvedRaceCodes.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)); + throw new InvalidOperationException( + $"Canonical write requires pre-seeded races and could not resolve race ids for: {unresolved}."); + } + + var existingDrivers = await dbContext.Drivers + .AsNoTracking() + .Where(x => !string.IsNullOrWhiteSpace(x.DriverId)) + .Select(x => new { DriverId = x.DriverId!, x.Code }) + .ToListAsync(cancellationToken); + + var driverIdByCode = existingDrivers + .Where(x => !string.IsNullOrWhiteSpace(x.Code)) + .ToDictionary( + x => x.Code!.Trim().ToUpperInvariant(), + x => x.DriverId.Trim().ToLowerInvariant(), + StringComparer.OrdinalIgnoreCase); + + var driverIds = selections + .SelectMany(x => ExtractDriverIds(x.NormalizedValue, driverIdByCode)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase) + .ToList(); + + var existingDriverIdSet = existingDrivers + .Select(x => x.DriverId) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var driverId in driverIds) + { + if (existingDriverIdSet.Contains(driverId)) + { + continue; + } + + dbContext.Drivers.Add(new Driver + { + DriverId = driverId, + FullName = driverId, + Code = ResolveDriverCode(driverId) + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + + if (string.Equals(_importOptions.CanonicalWriteFailureInjectionStage, "after_drivers", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("Injected canonical write failure after driver/race stage."); + } + + var groupedSelections = selections + .GroupBy(x => new { x.Subject, x.RaceCode }) + .ToList(); + + var incomingScopes = groupedSelections + .Select(group => new IncomingSelectionScope( + group.Key.Subject, + group.Key.RaceCode, + raceIdByCode.TryGetValue(group.Key.RaceCode, out var raceId) ? raceId : string.Empty, + group.Min(x => x.RowNumber))) + .Where(x => !string.IsNullOrWhiteSpace(x.RaceId)) + .ToList(); + + var incomingRaceIds = incomingScopes.Select(x => x.RaceId).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + var incomingSubjects = incomingScopes.Select(x => x.Subject).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + + var existingSelections = await dbContext.Selections + .Where(x => incomingRaceIds.Contains(x.RaceId) && incomingSubjects.Contains(x.UserId)) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var existingSelectionKeys = existingSelections + .Select(x => BuildSelectionKey(x.RaceId, x.UserId)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var conflictDiagnostics = incomingScopes + .Where(scope => existingSelectionKeys.Contains(BuildSelectionKey(scope.RaceId, scope.Subject))) + .Select(scope => new MigrationImportConflictDiagnosticEntity + { + ImportRunId = runId, + EntityType = "Selection", + ConflictType = "existing_active_selection", + KeyFields = BuildSelectionKey(scope.RaceId, scope.Subject), + SourceReference = $"row:{scope.SourceRowNumber}|race:{scope.RaceCode}|subject:{scope.Subject}", + PolicyOutcome = ResolvePolicyOutcome(normalizedConflictPolicy), + RecommendedAction = ResolveRecommendedAction(normalizedConflictPolicy), + CreatedAtUtc = DateTime.UtcNow + }) + .ToList(); + + if (conflictDiagnostics.Count > 0) + { + if (normalizedConflictPolicy == "fail") + { + await PersistConflictDiagnosticsAsync(conflictDiagnostics, cancellationToken); + throw new InvalidOperationException( + $"Canonical write conflict policy blocked commit. ConflictCount={conflictDiagnostics.Count}, Policy={normalizedConflictPolicy}."); + } + + await dbContext.MigrationImportConflictDiagnostics.AddRangeAsync(conflictDiagnostics, cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); + } + + var skippedSelectionKeys = conflictDiagnostics + .Where(x => string.Equals(x.PolicyOutcome, "Skipped", StringComparison.OrdinalIgnoreCase)) + .Select(x => x.KeyFields) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var group in groupedSelections) + { + if (!raceIdByCode.TryGetValue(group.Key.RaceCode, out var raceId)) + { + continue; + } + + var selectionKey = BuildSelectionKey(raceId, group.Key.Subject); + if (skippedSelectionKeys.Contains(selectionKey)) + { + continue; + } + + var existingSelection = await dbContext.Selections + .FirstOrDefaultAsync( + x => x.RaceId == raceId && x.UserId == group.Key.Subject, + cancellationToken); + + if (existingSelection is null) + { + existingSelection = new Selection + { + Id = Guid.NewGuid(), + UserId = group.Key.Subject, + RaceId = raceId, + BetType = BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }; + dbContext.Selections.Add(existingSelection); + await dbContext.SaveChangesAsync(cancellationToken); + } + + await dbContext.SelectionPositions + .Where(x => x.SelectionId == existingSelection.Id) + .ExecuteDeleteAsync(cancellationToken); + + var ordered = group + .Where(x => int.TryParse(x.PickType, out _)) + .Select(x => new + { + Pick = int.Parse(x.PickType), + Driver = ExtractDriverIds(x.NormalizedValue, driverIdByCode).FirstOrDefault() + }) + .Where(x => x.Pick > 0 && x.Driver is not null) + .OrderBy(x => x.Pick) + .ToList(); + + foreach (var item in ordered) + { + dbContext.SelectionPositions.Add(new SelectionPositionEntity + { + SelectionId = existingSelection.Id, + Position = item.Pick, + DriverId = item.Driver! + }); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + } + catch + { + await transaction.RollbackAsync(cancellationToken); + throw; + } + } + + private static IEnumerable ExtractDriverIds(string? normalizedValue, IReadOnlyDictionary driverIdByCode) + { + if (string.IsNullOrWhiteSpace(normalizedValue)) + { + return []; + } + + return DriverTokenSplitRegex().Split(normalizedValue) + .Select(x => x.Trim()) + .Where(x => x.Length > 0 && x.Length <= 64) + .Select(x => ResolveDriverIdToken(x, driverIdByCode)) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + + private static string ResolveDriverIdToken(string token, IReadOnlyDictionary driverIdByCode) + { + if (token.Length != 3) + { + return token.Trim().ToLowerInvariant(); + } + + var code = token.ToUpperInvariant(); + if (driverIdByCode.TryGetValue(code, out var mappedDriverId)) + { + return mappedDriverId; + } + + return JolpicaDriverIdByCode.TryGetValue(code, out var fallbackDriverId) + ? fallbackDriverId + : token; + } + + private static string? ResolveDriverCode(string driverId) + { + var match = JolpicaDriverIdByCode.FirstOrDefault(x => string.Equals(x.Value, driverId, StringComparison.OrdinalIgnoreCase)); + if (!string.IsNullOrWhiteSpace(match.Key)) + { + return match.Key; + } + + return driverId.Length <= 8 + ? driverId.ToUpperInvariant() + : null; + } + + [GeneratedRegex("[\\s,;/|]+", RegexOptions.Compiled)] + private static partial Regex DriverTokenSplitRegex(); + + private static string NormalizeConflictPolicy(string? policy) + { + if (string.IsNullOrWhiteSpace(policy)) + { + return "override"; + } + + var normalized = policy.Trim().ToLowerInvariant(); + return normalized is "fail" or "skip" or "override" ? normalized : "override"; + } + + private static string ResolvePolicyOutcome(string policy) + { + return policy switch + { + "fail" => "Failed", + "skip" => "Skipped", + _ => "Overridden" + }; + } + + private static string ResolveRecommendedAction(string policy) + { + return policy switch + { + "fail" => "Review conflicting canonical rows and rerun with approved policy.", + "skip" => "Review skipped entities and run targeted reconciliation.", + _ => "Verify overridden rows in reconciliation report." + }; + } + + private static string BuildSelectionKey(string raceId, string subject) + { + return $"raceId:{raceId}|subject:{subject}"; + } + + private async Task PersistConflictDiagnosticsAsync( + IReadOnlyCollection diagnostics, + CancellationToken cancellationToken) + { + if (diagnostics.Count == 0) + { + return; + } + + await using var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var detachedDiagnostics = diagnostics.Select(item => new MigrationImportConflictDiagnosticEntity + { + ImportRunId = item.ImportRunId, + EntityType = item.EntityType, + ConflictType = item.ConflictType, + KeyFields = item.KeyFields, + SourceReference = item.SourceReference, + PolicyOutcome = item.PolicyOutcome, + RecommendedAction = item.RecommendedAction, + CreatedAtUtc = item.CreatedAtUtc + }); + + await context.MigrationImportConflictDiagnostics.AddRangeAsync(detachedDiagnostics, cancellationToken); + await context.SaveChangesAsync(cancellationToken); + } + + private readonly record struct IncomingSelectionScope( + string Subject, + string RaceCode, + string RaceId, + int SourceRowNumber); +} diff --git a/src/F1.DataSyncWorker/Services/FileBackedMigrationExpectedVarianceRuleCatalog.cs b/src/F1.DataSyncWorker/Services/ExpectedVariance/FileBackedMigrationExpectedVarianceRuleCatalog.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/FileBackedMigrationExpectedVarianceRuleCatalog.cs rename to src/F1.DataSyncWorker/Services/ExpectedVariance/FileBackedMigrationExpectedVarianceRuleCatalog.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationExpectedVarianceClassifier.cs b/src/F1.DataSyncWorker/Services/ExpectedVariance/MigrationExpectedVarianceClassifier.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationExpectedVarianceClassifier.cs rename to src/F1.DataSyncWorker/Services/ExpectedVariance/MigrationExpectedVarianceClassifier.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationExpectedVarianceRule.cs b/src/F1.DataSyncWorker/Services/ExpectedVariance/MigrationExpectedVarianceRule.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationExpectedVarianceRule.cs rename to src/F1.DataSyncWorker/Services/ExpectedVariance/MigrationExpectedVarianceRule.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationExpectedVarianceRuleCatalog.cs b/src/F1.DataSyncWorker/Services/ExpectedVariance/MigrationExpectedVarianceRuleCatalog.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationExpectedVarianceRuleCatalog.cs rename to src/F1.DataSyncWorker/Services/ExpectedVariance/MigrationExpectedVarianceRuleCatalog.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationImportRowClassifier.cs b/src/F1.DataSyncWorker/Services/Import/MigrationImportRowClassifier.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationImportRowClassifier.cs rename to src/F1.DataSyncWorker/Services/Import/MigrationImportRowClassifier.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationImportRunService.cs b/src/F1.DataSyncWorker/Services/Import/MigrationImportRunService.cs similarity index 91% rename from src/F1.DataSyncWorker/Services/MigrationImportRunService.cs rename to src/F1.DataSyncWorker/Services/Import/MigrationImportRunService.cs index 1f6433b..b571036 100644 --- a/src/F1.DataSyncWorker/Services/MigrationImportRunService.cs +++ b/src/F1.DataSyncWorker/Services/Import/MigrationImportRunService.cs @@ -68,7 +68,9 @@ public async Task StartRunAsync(string sourceFilePath StartedAtUtc = DateTime.UtcNow, PreseasonParseStatus = "NotDetected", PreseasonScoringStatus = "NotDetected", - PreseasonIsolationGuardPassed = true + PreseasonIsolationGuardPassed = true, + ParityStatus = "NotCompared", + IdempotencyOutcome = "Unknown" }); await dbContext.SaveChangesAsync(cancellationToken); @@ -157,6 +159,12 @@ private async Task UpdateRunAsync( run.PreseasonQuestionDiffCount = metadata.PreseasonQuestionDiffCount; run.PreseasonTotalDeltaPoints = metadata.PreseasonTotalDeltaPoints; run.PreseasonIsolationGuardPassed = metadata.PreseasonIsolationGuardPassed; + run.ParitySnapshotChecksum = Truncate(metadata.ParitySnapshotChecksum, 128); + run.ParityStatus = Truncate(metadata.ParityStatus, 32) ?? "NotCompared"; + run.ParityComparedChecksum = Truncate(metadata.ParityComparedChecksum, 128); + run.ParityComparedRunId = metadata.ParityComparedRunId; + run.IdempotencyScopeKey = Truncate(metadata.IdempotencyScopeKey, 256); + run.IdempotencyOutcome = Truncate(metadata.IdempotencyOutcome, 32) ?? "Unknown"; } await dbContext.SaveChangesAsync(cancellationToken); diff --git a/src/F1.DataSyncWorker/Services/MigrationLegacyScoreImporter.cs b/src/F1.DataSyncWorker/Services/Import/MigrationLegacyScoreImporter.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationLegacyScoreImporter.cs rename to src/F1.DataSyncWorker/Services/Import/MigrationLegacyScoreImporter.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationReconciliationService.cs b/src/F1.DataSyncWorker/Services/Import/MigrationReconciliationService.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationReconciliationService.cs rename to src/F1.DataSyncWorker/Services/Import/MigrationReconciliationService.cs diff --git a/src/F1.DataSyncWorker/Services/DataSyncOrchestrator.cs b/src/F1.DataSyncWorker/Services/Orchestration/DataSyncOrchestrator.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/DataSyncOrchestrator.cs rename to src/F1.DataSyncWorker/Services/Orchestration/DataSyncOrchestrator.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationImportOrchestrator.cs b/src/F1.DataSyncWorker/Services/Orchestration/MigrationImportOrchestrator.cs similarity index 71% rename from src/F1.DataSyncWorker/Services/MigrationImportOrchestrator.cs rename to src/F1.DataSyncWorker/Services/Orchestration/MigrationImportOrchestrator.cs index c2282bb..c282ff5 100644 --- a/src/F1.DataSyncWorker/Services/MigrationImportOrchestrator.cs +++ b/src/F1.DataSyncWorker/Services/Orchestration/MigrationImportOrchestrator.cs @@ -3,6 +3,8 @@ using F1.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; namespace F1.DataSyncWorker.Services; @@ -17,6 +19,7 @@ public sealed class MigrationImportOrchestrator : IMigrationImportOrchestrator private readonly IMigrationScoreRecalculator _scoreRecalculator; private readonly IMigrationLegacyScoreImporter _legacyScoreImporter; private readonly IMigrationReconciliationService _reconciliationService; + private readonly IMigrationCanonicalWriteService _canonicalWriteService; private readonly IDbContextFactory _dbContextFactory; private readonly DataSyncOptions _dataSyncOptions; private readonly MigrationImportOptions _importOptions; @@ -38,6 +41,37 @@ public MigrationImportOrchestrator( IOptions dataSyncOptions, IOptions importOptions, IMigrationExpectedVarianceRuleSetMetadataProvider ruleSetMetadataProvider) + : this( + logger, + runService, + rowClassifier, + raceSelectionParser, + raceRoundMapper, + scoreRecalculator, + legacyScoreImporter, + reconciliationService, + new MigrationCanonicalWriteService(dbContextFactory, importOptions), + dbContextFactory, + dataSyncOptions, + importOptions, + ruleSetMetadataProvider) + { + } + + public MigrationImportOrchestrator( + ILogger logger, + IMigrationImportRunService runService, + IMigrationImportRowClassifier rowClassifier, + IMigrationRaceSelectionParser raceSelectionParser, + IMigrationRaceRoundMapper raceRoundMapper, + IMigrationScoreRecalculator scoreRecalculator, + IMigrationLegacyScoreImporter legacyScoreImporter, + IMigrationReconciliationService reconciliationService, + IMigrationCanonicalWriteService canonicalWriteService, + IDbContextFactory dbContextFactory, + IOptions dataSyncOptions, + IOptions importOptions, + IMigrationExpectedVarianceRuleSetMetadataProvider ruleSetMetadataProvider) { _logger = logger; _runService = runService; @@ -47,6 +81,7 @@ public MigrationImportOrchestrator( _scoreRecalculator = scoreRecalculator; _legacyScoreImporter = legacyScoreImporter; _reconciliationService = reconciliationService; + _canonicalWriteService = canonicalWriteService; _dbContextFactory = dbContextFactory; _dataSyncOptions = dataSyncOptions.Value; _importOptions = importOptions.Value; @@ -125,25 +160,22 @@ private async Task ExecuteRunAsync(MigrationImportRunContext run, CancellationTo _importOptions.UnresolvedTokenFailThreshold); } - var mappingResult = (SnapshotCount: 0, MappingCount: 0, WarningCount: 0); - var selectionRaceCodesRewritten = 0; - if (!run.IsDryRun) - { - mappingResult = await _raceRoundMapper.MapAndPersistAsync(run.RunId, cancellationToken); - selectionRaceCodesRewritten = await RewriteSelectionRaceCodesToMappedCircuitIdsAsync(run.RunId, cancellationToken); - } - else - { - _logger.LogInformation( - "Migration import run in dry-run mode; skipping race-round mapping and Jolpica fetch. RunId={RunId}", - run.RunId); - } + var mappingResult = await _raceRoundMapper.MapAndPersistAsync(run.RunId, cancellationToken); + var selectionRaceCodesRewritten = await RewriteSelectionRaceCodesToMappedCircuitIdsAsync(run.RunId, cancellationToken); // Import legacy/preseason source tallies first so scoring can read preseason policy values (M2). var legacyResult = await _legacyScoreImporter.ImportAndPersistAsync(run.RunId, cancellationToken); var scoreResult = await _scoreRecalculator.RecalculateAndPersistAsync(run.RunId, cancellationToken); await EnsurePreseasonRaceIsolationAsync(run.RunId, cancellationToken); var reconciliationResult = await _reconciliationService.ReconcileAndPersistAsync(run.RunId, cancellationToken); + if (!run.IsDryRun) + { + await _canonicalWriteService.PersistCanonicalEntitiesAsync(run.RunId, cancellationToken); + } + + var paritySnapshotChecksum = await BuildParitySnapshotChecksumAsync(run.RunId, cancellationToken); + var parityComparison = await BuildParityComparisonAsync(run, paritySnapshotChecksum, cancellationToken); + var idempotency = await BuildIdempotencyMetadataAsync(run, cancellationToken); var runMetadata = await BuildRunCompletionMetadataAsync( run.RunId, @@ -151,6 +183,9 @@ private async Task ExecuteRunAsync(MigrationImportRunContext run, CancellationTo scoreResult, reconciliationResult, mappingResult.WarningCount, + paritySnapshotChecksum, + parityComparison, + idempotency, cancellationToken); await _runService.CompleteRunAsync( @@ -203,6 +238,20 @@ await _runService.CompleteRunAsync( reconciliationResult.ReasonSummaryCount, reconciliationResult.TotalDelta, run.SourceFileChecksum); + + _logger.LogInformation( + "Migration import parity report. RunId={RunId}, SnapshotChecksum={SnapshotChecksum}, ParityStatus={ParityStatus}, ComparedRunId={ComparedRunId}, ComparedChecksum={ComparedChecksum}", + run.RunId, + paritySnapshotChecksum, + parityComparison.ParityStatus, + parityComparison.ComparedRunId, + parityComparison.ComparedChecksum); + + _logger.LogInformation( + "Migration import idempotency report. RunId={RunId}, ScopeKey={ScopeKey}, Outcome={Outcome}", + run.RunId, + idempotency.ScopeKey, + idempotency.Outcome); } catch (Exception ex) { @@ -270,6 +319,9 @@ private async Task BuildRunCompletionMetad MigrationScoreRecalculationResult scoreResult, MigrationReconciliationResult reconciliationResult, int mappingWarningCount, + string paritySnapshotChecksum, + (string ParityStatus, Guid? ComparedRunId, string? ComparedChecksum) parityComparison, + (string ScopeKey, string Outcome) idempotency, CancellationToken cancellationToken) { await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); @@ -308,7 +360,121 @@ private async Task BuildRunCompletionMetad PreseasonScoredQuestionCount: scoreResult.PreseasonScoredQuestionCount, PreseasonQuestionDiffCount: reconciliationResult.PreseasonQuestionDiffCount, PreseasonTotalDeltaPoints: reconciliationResult.PreseasonTotalDelta, - PreseasonIsolationGuardPassed: true); + PreseasonIsolationGuardPassed: true, + ParitySnapshotChecksum: paritySnapshotChecksum, + ParityStatus: parityComparison.ParityStatus, + ParityComparedRunId: parityComparison.ComparedRunId, + ParityComparedChecksum: parityComparison.ComparedChecksum, + IdempotencyScopeKey: idempotency.ScopeKey, + IdempotencyOutcome: idempotency.Outcome); + } + + private async Task<(string ScopeKey, string Outcome)> BuildIdempotencyMetadataAsync( + MigrationImportRunContext run, + CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + var scopeKey = $"season:{_importOptions.Season}|checksum:{run.SourceFileChecksum}"; + + var hasPriorCompletedInScope = await dbContext.MigrationImportRuns + .AsNoTracking() + .AnyAsync( + x => + x.Id != run.RunId && + x.Status == "Completed" && + x.SourceFileChecksum == run.SourceFileChecksum && + x.IdempotencyScopeKey == scopeKey, + cancellationToken); + + return (scopeKey, hasPriorCompletedInScope ? "Replayed" : "FirstWrite"); + } + + private async Task BuildParitySnapshotChecksumAsync(Guid runId, CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var pickDiffs = await dbContext.MigrationImportPickDiffs + .Where(x => x.ImportRunId == runId) + .OrderBy(x => x.RaceCode) + .ThenBy(x => x.Subject) + .ThenBy(x => x.PickType) + .Select(x => new { x.RaceCode, x.Subject, x.PickType, x.ImportedPoints, x.CalculatedPoints, x.DeltaPoints, x.ReasonCode }) + .ToListAsync(cancellationToken); + + var raceDiffs = await dbContext.MigrationImportRaceDiffs + .Where(x => x.ImportRunId == runId) + .OrderBy(x => x.RaceCode) + .ThenBy(x => x.Subject) + .Select(x => new { x.RaceCode, x.Subject, x.ImportedPoints, x.CalculatedPoints, x.DeltaPoints, x.ReasonCode }) + .ToListAsync(cancellationToken); + + var questionDiffs = await dbContext.MigrationImportPreseasonQuestionDiffs + .Where(x => x.ImportRunId == runId) + .OrderBy(x => x.QuestionKey) + .ThenBy(x => x.Subject) + .Select(x => new { x.QuestionKey, x.Subject, x.ImportedPoints, x.CalculatedPoints, x.DeltaPoints, x.ReasonCode }) + .ToListAsync(cancellationToken); + + var canonical = new StringBuilder(4096); + foreach (var diff in pickDiffs) + { + canonical.Append("P|").Append(diff.RaceCode).Append('|').Append(diff.Subject).Append('|').Append(diff.PickType) + .Append('|').Append(diff.ImportedPoints).Append('|').Append(diff.CalculatedPoints).Append('|').Append(diff.DeltaPoints) + .Append('|').Append(diff.ReasonCode).Append('\n'); + } + + foreach (var diff in raceDiffs) + { + canonical.Append("R|").Append(diff.RaceCode).Append('|').Append(diff.Subject) + .Append('|').Append(diff.ImportedPoints).Append('|').Append(diff.CalculatedPoints).Append('|').Append(diff.DeltaPoints) + .Append('|').Append(diff.ReasonCode).Append('\n'); + } + + foreach (var diff in questionDiffs) + { + canonical.Append("Q|").Append(diff.QuestionKey).Append('|').Append(diff.Subject) + .Append('|').Append(diff.ImportedPoints).Append('|').Append(diff.CalculatedPoints).Append('|').Append(diff.DeltaPoints) + .Append('|').Append(diff.ReasonCode).Append('\n'); + } + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(canonical.ToString())); + var hash = new StringBuilder(bytes.Length * 2); + foreach (var value in bytes) + { + hash.Append(value.ToString("x2")); + } + + return hash.ToString(); + } + + private async Task<(string ParityStatus, Guid? ComparedRunId, string? ComparedChecksum)> BuildParityComparisonAsync( + MigrationImportRunContext run, + string snapshotChecksum, + CancellationToken cancellationToken) + { + await using var dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var counterpart = await dbContext.MigrationImportRuns + .AsNoTracking() + .Where(x => + x.Id != run.RunId && + x.Status == "Completed" && + x.SourceFileChecksum == run.SourceFileChecksum && + x.IsDryRun != run.IsDryRun && + x.ParitySnapshotChecksum != null) + .OrderByDescending(x => x.FinishedAtUtc) + .FirstOrDefaultAsync(cancellationToken); + + if (counterpart is null) + { + return ("NotCompared", null, null); + } + + var status = string.Equals(counterpart.ParitySnapshotChecksum, snapshotChecksum, StringComparison.Ordinal) + ? "Matched" + : "Mismatched"; + + return (status, counterpart.Id, counterpart.ParitySnapshotChecksum); } private async Task StageRawRowsAsync(Guid runId, string sourceFilePath, CancellationToken cancellationToken) diff --git a/src/F1.DataSyncWorker/Services/CsvLineParser.cs b/src/F1.DataSyncWorker/Services/Parsing/CsvLineParser.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/CsvLineParser.cs rename to src/F1.DataSyncWorker/Services/Parsing/CsvLineParser.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationPhil2025CsvContractPolicy.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationPhil2025CsvContractPolicy.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationPhil2025CsvContractPolicy.cs rename to src/F1.DataSyncWorker/Services/Parsing/MigrationPhil2025CsvContractPolicy.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationPhil2025RaceSequenceMapper.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationPhil2025RaceSequenceMapper.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationPhil2025RaceSequenceMapper.cs rename to src/F1.DataSyncWorker/Services/Parsing/MigrationPhil2025RaceSequenceMapper.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationRaceRoundMapper.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceRoundMapper.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/MigrationRaceRoundMapper.cs rename to src/F1.DataSyncWorker/Services/Parsing/MigrationRaceRoundMapper.cs diff --git a/src/F1.DataSyncWorker/Services/MigrationRaceSelectionParser.cs b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs similarity index 66% rename from src/F1.DataSyncWorker/Services/MigrationRaceSelectionParser.cs rename to src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs index 1075421..cd0e91b 100644 --- a/src/F1.DataSyncWorker/Services/MigrationRaceSelectionParser.cs +++ b/src/F1.DataSyncWorker/Services/Parsing/MigrationRaceSelectionParser.cs @@ -12,6 +12,7 @@ namespace F1.DataSyncWorker.Services; public sealed partial class MigrationRaceSelectionParser : IMigrationRaceSelectionParser { + private const string Philip2025CompetitionName = "Philip 2025"; private const string SectionTypeRacePick = "RacePick"; private const string SectionTypeSeasonQuestionPrediction = "SeasonQuestionPrediction"; private const string SectionTypeHeader = "Header"; @@ -29,6 +30,130 @@ public sealed partial class MigrationRaceSelectionParser : IMigrationRaceSelecti ["NOT"] = null }; + private static readonly Dictionary QuestionTokenAliasDictionary = new(StringComparer.OrdinalIgnoreCase) + { + ["YES"] = "YES", + ["Y"] = "YES", + ["TRUE"] = "YES", + ["T"] = "YES", + ["1"] = "YES", + ["NO"] = "NO", + ["N"] = "NO", + ["FALSE"] = "NO", + ["F"] = "NO", + ["0"] = "NO", + ["MAX VERSTAPPEN"] = "VER", + ["VERSTAPPEN"] = "VER", + ["LEWIS HAMILTON"] = "HAM", + ["HAMILTON"] = "HAM", + ["CHARLES LECLERC"] = "LEC", + ["LECLERC"] = "LEC", + ["LANDO NORRIS"] = "NOR", + ["NORRIS"] = "NOR", + ["GEORGE RUSSELL"] = "RUS", + ["RUSSELL"] = "RUS", + ["OSCAR PIASTRI"] = "PIA", + ["PIASTRI"] = "PIA", + ["CARLOS SAINZ"] = "SAI", + ["SAINZ"] = "SAI", + ["FERNANDO ALONSO"] = "ALO", + ["ALONSO"] = "ALO", + ["LANCE STROLL"] = "STR", + ["STROLL"] = "STR", + ["PIERRE GASLY"] = "GAS", + ["GASLY"] = "GAS", + ["ESTEBAN OCON"] = "OCO", + ["OCON"] = "OCO", + ["ALEX ALBON"] = "ALB", + ["ALBON"] = "ALB", + ["YUKI TSUNODA"] = "TSU", + ["TSUNODA"] = "TSU", + ["NICO HULKENBERG"] = "HUL", + ["HULKENBERG"] = "HUL", + ["DANIEL RICCIARDO"] = "RIC", + ["RICCIARDO"] = "RIC", + ["VALTTERI BOTTAS"] = "BOT", + ["BOTTAS"] = "BOT", + ["ZHOU GUANYU"] = "ZHO", + ["GUANYU ZHOU"] = "ZHO", + ["ZHOU"] = "ZHO", + ["KEVIN MAGNUSSEN"] = "MAG", + ["MAGNUSSEN"] = "MAG", + ["OLIVER BEARMAN"] = "BEA", + ["BEARMAN"] = "BEA", + ["SERGIO PEREZ"] = "PER", + ["PEREZ"] = "PER", + ["FRANCO COLAPINTO"] = "COL", + ["COLAPINTO"] = "COL", + ["JACK DOOHAN"] = "DOO", + ["DOOHAN"] = "DOO", + ["GABRIEL BORTOLETO"] = "BOR", + ["BORTOLETO"] = "BOR", + ["ISACK HADJAR"] = "HAD", + ["HADJAR"] = "HAD", + ["LIAM LAWSON"] = "LAW", + ["LAWSON"] = "LAW", + ["KIMI ANTONELLI"] = "ANT", + ["ANTONELLI"] = "ANT", + ["NONE"] = null, + ["NOT"] = null + }; + + private static readonly Dictionary JolpicaDriverIdByCode = new(StringComparer.OrdinalIgnoreCase) + { + ["ALB"] = "albon", + ["ALO"] = "alonso", + ["ANT"] = "antonelli", + ["BEA"] = "bearman", + ["BOR"] = "bortoleto", + ["BOT"] = "bottas", + ["COL"] = "colapinto", + ["DOO"] = "doohan", + ["GAS"] = "gasly", + ["HAD"] = "hadjar", + ["HAM"] = "hamilton", + ["HUL"] = "hulkenberg", + ["LAW"] = "lawson", + ["LEC"] = "leclerc", + ["LIN"] = "lindblad", + ["MAG"] = "magnussen", + ["NOR"] = "norris", + ["OCO"] = "ocon", + ["PER"] = "perez", + ["PIA"] = "piastri", + ["RIC"] = "ricciardo", + ["RUS"] = "russell", + ["SAI"] = "sainz", + ["STR"] = "stroll", + ["TSU"] = "tsunoda", + ["VER"] = "max_verstappen", + ["ZHO"] = "zhou" + }; + + private static readonly Dictionary JolpicaConstructorIdByName = new(StringComparer.OrdinalIgnoreCase) + { + ["ALPINE"] = "alpine", + ["ALPINE F1 TEAM"] = "alpine", + ["AMR"] = "aston_martin", + ["ASTON MARTIN"] = "aston_martin", + ["ASTON MARTIN F1 TEAM"] = "aston_martin", + ["FER"] = "ferrari", + ["FERRARI"] = "ferrari", + ["HAAS"] = "haas", + ["HAAS F1 TEAM"] = "haas", + ["MCL"] = "mclaren", + ["MCLAREN"] = "mclaren", + ["MERCEDES"] = "mercedes", + ["RB"] = "rb", + ["RBPT"] = "red_bull", + ["RB F1 TEAM"] = "rb", + ["RACING BULLS"] = "rb", + ["RED BULL"] = "red_bull", + ["RED BULL RACING"] = "red_bull", + ["SAUBER"] = "sauber", + ["WILLIAMS"] = "williams" + }; + private readonly IDbContextFactory _dbContextFactory; private readonly MigrationImportOptions _importOptions; @@ -62,19 +187,27 @@ public async Task ParseAndPersistAsync(Guid r .AsNoTracking() .ToListAsync(cancellationToken); + var driverIdByCode = await dbContext.Drivers + .Where(x => !string.IsNullOrWhiteSpace(x.Code) && !string.IsNullOrWhiteSpace(x.DriverId)) + .ToDictionaryAsync( + x => x.Code!.Trim().ToUpperInvariant(), + x => x.DriverId!.Trim().ToLowerInvariant(), + cancellationToken); + var headerRow = stagedRows.FirstOrDefault(x => string.Equals(x.SectionType, SectionTypeHeader, StringComparison.Ordinal)); var participants = ResolveParticipants(headerRow?.RawPayload); var preseasonParticipants = usePhil2025SequenceMapping ? MigrationPhil2025CsvContractPolicy.ParticipantColumns.ToList() : participants; - var preseasonAnswers = ParsePreseasonQuestionAnswers(runId, stagedRows, preseasonParticipants, usePhil2025SequenceMapping); + var preseasonAnswers = ParsePreseasonQuestionAnswers(runId, stagedRows, preseasonParticipants, usePhil2025SequenceMapping, driverIdByCode); var genericQuestions = await BuildGenericQuestionDataAsync( dbContext, runId, stagedRows, preseasonParticipants, usePhil2025SequenceMapping, + driverIdByCode, cancellationToken); if (participants.Count == 0) @@ -83,20 +216,19 @@ public async Task ParseAndPersistAsync(Guid r { dbContext.MigrationImportPreseasonAnswers.RemoveRange( dbContext.MigrationImportPreseasonAnswers.Where(x => x.ImportRunId == runId)); - if (genericQuestions is not null) - { - dbContext.QuestionAnswers.RemoveRange(dbContext.QuestionAnswers.Where(x => x.ImportRunId == runId)); - dbContext.QuestionActuals.RemoveRange(dbContext.QuestionActuals.Where(x => x.ImportRunId == runId)); - } await dbContext.SaveChangesAsync(cancellationToken); await dbContext.MigrationImportPreseasonAnswers.AddRangeAsync(preseasonAnswers, cancellationToken); if (genericQuestions is not null) { var templateIds = await UpsertQuestionTemplatesAsync(dbContext, genericQuestions.Templates, cancellationToken); - ApplyTemplateIds(genericQuestions, templateIds); - await dbContext.QuestionAnswers.AddRangeAsync(genericQuestions.Answers, cancellationToken); - await dbContext.QuestionActuals.AddRangeAsync(genericQuestions.Actuals, cancellationToken); + var materialized = ApplyTemplateIds(genericQuestions, templateIds); + var templateIdSet = templateIds.Values.Distinct().ToArray(); + dbContext.QuestionAnswers.RemoveRange(dbContext.QuestionAnswers.Where(x => templateIdSet.Contains(x.QuestionTemplateId))); + dbContext.QuestionActuals.RemoveRange(dbContext.QuestionActuals.Where(x => templateIdSet.Contains(x.QuestionTemplateId))); + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.QuestionAnswers.AddRangeAsync(materialized.Answers, cancellationToken); + await dbContext.QuestionActuals.AddRangeAsync(materialized.Actuals, cancellationToken); } await dbContext.SaveChangesAsync(cancellationToken); } @@ -146,6 +278,7 @@ public async Task ParseAndPersistAsync(Guid r { var rawValue = index < participantValues.Length ? participantValues[index] : string.Empty; var normalization = NormalizeSelection(rawValue, pickType, usePhil2025SequenceMapping); + var mappedSelectionValue = MapSelectionNormalizedValueToDriverIds(normalization.NormalizedValue, pickType, driverIdByCode); selections.Add(new MigrationImportRaceSelectionEntity { ImportRunId = runId, @@ -154,7 +287,7 @@ public async Task ParseAndPersistAsync(Guid r PickType = pickType, Subject = participants[index], RawValue = string.IsNullOrWhiteSpace(rawValue) ? null : rawValue.Trim(), - NormalizedValue = normalization.NormalizedValue, + NormalizedValue = mappedSelectionValue, IsActualOutcome = false }); @@ -175,6 +308,7 @@ public async Task ParseAndPersistAsync(Guid r var actualRaw = columns.Skip(1 + participants.Count).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); var actualNormalization = NormalizeSelection(actualRaw, pickType, usePhil2025SequenceMapping); + var mappedActualSelectionValue = MapSelectionNormalizedValueToDriverIds(actualNormalization.NormalizedValue, pickType, driverIdByCode); selections.Add(new MigrationImportRaceSelectionEntity { ImportRunId = runId, @@ -183,7 +317,7 @@ public async Task ParseAndPersistAsync(Guid r PickType = pickType, Subject = ActualSubject, RawValue = string.IsNullOrWhiteSpace(actualRaw) ? null : actualRaw.Trim(), - NormalizedValue = actualNormalization.NormalizedValue, + NormalizedValue = mappedActualSelectionValue, IsActualOutcome = true }); @@ -214,10 +348,6 @@ public async Task ParseAndPersistAsync(Guid r dbContext.MigrationImportRaceSelections.Where(x => x.ImportRunId == runId)); dbContext.MigrationImportPreseasonAnswers.RemoveRange( dbContext.MigrationImportPreseasonAnswers.Where(x => x.ImportRunId == runId)); - dbContext.QuestionAnswers.RemoveRange( - dbContext.QuestionAnswers.Where(x => x.ImportRunId == runId)); - dbContext.QuestionActuals.RemoveRange( - dbContext.QuestionActuals.Where(x => x.ImportRunId == runId)); dbContext.MigrationImportUnresolvedTokens.RemoveRange( dbContext.MigrationImportUnresolvedTokens.Where(x => x.ImportRunId == runId)); await dbContext.SaveChangesAsync(cancellationToken); @@ -231,9 +361,13 @@ public async Task ParseAndPersistAsync(Guid r if (genericQuestions is not null) { var templateIds = await UpsertQuestionTemplatesAsync(dbContext, genericQuestions.Templates, cancellationToken); - ApplyTemplateIds(genericQuestions, templateIds); - await dbContext.QuestionAnswers.AddRangeAsync(genericQuestions.Answers, cancellationToken); - await dbContext.QuestionActuals.AddRangeAsync(genericQuestions.Actuals, cancellationToken); + var materialized = ApplyTemplateIds(genericQuestions, templateIds); + var templateIdSet = templateIds.Values.Distinct().ToArray(); + dbContext.QuestionAnswers.RemoveRange(dbContext.QuestionAnswers.Where(x => templateIdSet.Contains(x.QuestionTemplateId))); + dbContext.QuestionActuals.RemoveRange(dbContext.QuestionActuals.Where(x => templateIdSet.Contains(x.QuestionTemplateId))); + await dbContext.SaveChangesAsync(cancellationToken); + await dbContext.QuestionAnswers.AddRangeAsync(materialized.Answers, cancellationToken); + await dbContext.QuestionActuals.AddRangeAsync(materialized.Actuals, cancellationToken); } if (unresolvedTokens.Count > 0) { @@ -253,6 +387,7 @@ public async Task ParseAndPersistAsync(Guid r IReadOnlyCollection stagedRows, IReadOnlyList participants, bool usePhil2025Contract, + IReadOnlyDictionary driverIdByCode, CancellationToken cancellationToken) { var questionRows = stagedRows @@ -265,29 +400,26 @@ public async Task ParseAndPersistAsync(Guid r return null; } - var competitionIds = await dbContext.Competitions - .Where(x => x.Year == _importOptions.Season) - .OrderBy(x => x.Id) - .Select(x => x.Id) - .ToListAsync(cancellationToken); + var competitionId = await ResolveTargetCompetitionIdAsync( + dbContext, + participants, + usePhil2025Contract, + cancellationToken); - if (competitionIds.Count != 1) + if (!competitionId.HasValue) { return null; } - var competitionId = competitionIds[0]; var templateKeys = questionRows.Select(row => ResolveQuestionId(row.RowNumber, row.RawPayload)).ToArray(); var existingTemplateIds = await dbContext.QuestionTemplates - .Where(x => x.CompetitionId == competitionId && x.Season == _importOptions.Season && templateKeys.Contains(x.QuestionId)) + .Where(x => x.CompetitionId == competitionId.Value && x.Season == _importOptions.Season && templateKeys.Contains(x.QuestionId)) .ToDictionaryAsync(x => x.QuestionId, x => x.Id, StringComparer.OrdinalIgnoreCase, cancellationToken); var now = DateTime.UtcNow; var templates = new List(); - var answers = new List(); - var actuals = new List(); - - var questionIdBySourceRow = new Dictionary(); + var answers = new List(); + var actuals = new List(); foreach (var row in questionRows) { @@ -304,16 +436,15 @@ public async Task ParseAndPersistAsync(Guid r } var questionId = ResolveQuestionId(row.RowNumber, row.RawPayload); - questionIdBySourceRow[row.RowNumber] = questionId; var category = ResolveQuestionCategory(row.RawPayload); var optionsJson = category == QuestionCategory.H2H - ? BuildH2hOptionsJson(questionText, columns, participants, usePhil2025Contract) + ? BuildH2hOptionsJson(questionText, columns, participants, usePhil2025Contract, driverIdByCode) : null; templates.Add(new QuestionTemplateEntity { Id = existingTemplateIds.TryGetValue(questionId, out var existingTemplateId) ? existingTemplateId : 0, - CompetitionId = competitionId, + CompetitionId = competitionId.Value, Season = _importOptions.Season, QuestionId = questionId, Category = category, @@ -333,18 +464,13 @@ public async Task ParseAndPersistAsync(Guid r { var columnIndex = participantStartIndex + index; var raw = columnIndex < columns.Count ? columns[columnIndex] : null; - var normalization = NormalizeQuestionAnswer(raw, isActualOutcome: false, category); - answers.Add(new QuestionAnswerEntity - { - ImportRunId = runId, - QuestionTemplateId = existingTemplateIds.TryGetValue(questionId, out var templateId) ? templateId : 0, - ParticipantId = participants[index], - ImportedAnswer = string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(), - NormalizedAnswer = normalization.NormalizedValue, - SourceRow = row.RowNumber, - SourceColumn = columnIndex + 1, - RecordedAtUtc = now - }); + var normalization = NormalizeQuestionAnswer(raw, isActualOutcome: false, category, driverIdByCode); + answers.Add(new PendingQuestionAnswer( + QuestionId: questionId, + ParticipantId: participants[index], + ImportedAnswer: normalization.NormalizedValue, + OverrideAnswer: null, + RecordedAtUtc: now)); } string? actualRaw; @@ -369,25 +495,71 @@ public async Task ParseAndPersistAsync(Guid r actualRaw = actualColumnIndex >= 0 ? columns[actualColumnIndex] : null; } - var actualNormalization = NormalizeQuestionAnswer(actualRaw, isActualOutcome: true, category); - actuals.Add(new QuestionActualEntity - { - ImportRunId = runId, - QuestionTemplateId = existingTemplateIds.TryGetValue(questionId, out var actualTemplateId) ? actualTemplateId : 0, - ActualAnswer = string.IsNullOrWhiteSpace(actualRaw) ? null : actualRaw.Trim(), - NormalizedAnswer = actualNormalization.NormalizedValue, - SourceRow = row.RowNumber, - SourceColumn = actualColumnIndex >= 0 ? actualColumnIndex + 1 : 0, - NormalizationDiagnosticsJson = actualNormalization.Diagnostics.Count == 0 - ? null - : JsonSerializer.Serialize(actualNormalization.Diagnostics), - RecordedAtUtc = now - }); + var actualNormalization = NormalizeQuestionAnswer(actualRaw, isActualOutcome: true, category, driverIdByCode); + actuals.Add(new PendingQuestionActual( + QuestionId: questionId, + ImportedAnswer: actualNormalization.NormalizedValue, + OverrideAnswer: null, + RecordedAtUtc: now)); } return templates.Count == 0 ? null - : new GenericQuestionData(templates, answers, actuals, questionIdBySourceRow); + : new GenericQuestionData(templates, answers, actuals); + } + + private async Task ResolveTargetCompetitionIdAsync( + F1DbContext dbContext, + IReadOnlyList participants, + bool usePhil2025Contract, + CancellationToken cancellationToken) + { + var competitions = await dbContext.Competitions + .Where(x => x.Year == _importOptions.Season) + .OrderBy(x => x.Id) + .Select(x => new { x.Id, x.Name, x.Description }) + .ToListAsync(cancellationToken); + + if (competitions.Count == 0) + { + return null; + } + + if (competitions.Count == 1) + { + return competitions[0].Id; + } + + var shouldPreferPhilipCompetition = usePhil2025Contract || + participants.Any(x => string.Equals(x, "Philip", StringComparison.OrdinalIgnoreCase)); + + if (shouldPreferPhilipCompetition) + { + var exactPhilipCompetition = competitions.FirstOrDefault(x => + string.Equals(x.Name, Philip2025CompetitionName, StringComparison.OrdinalIgnoreCase)); + + if (exactPhilipCompetition is not null) + { + return exactPhilipCompetition.Id; + } + + var philipCompetition = competitions.FirstOrDefault(x => + x.Name.Contains("Philip", StringComparison.OrdinalIgnoreCase) || + x.Name.Contains("Phil", StringComparison.OrdinalIgnoreCase) || + (!string.IsNullOrWhiteSpace(x.Description) && + (x.Description.Contains("Philip", StringComparison.OrdinalIgnoreCase) || + x.Description.Contains("Phil", StringComparison.OrdinalIgnoreCase)))); + + if (philipCompetition is not null) + { + return philipCompetition.Id; + } + } + + var mainCompetition = competitions.FirstOrDefault(x => + x.Name.Contains("Main", StringComparison.OrdinalIgnoreCase)); + + return (mainCompetition ?? competitions[0]).Id; } private static async Task> UpsertQuestionTemplatesAsync( @@ -450,24 +622,38 @@ private static async Task> UpsertQuestionTemplatesAsync return persistedIds; } - private static void ApplyTemplateIds(GenericQuestionData genericQuestions, IReadOnlyDictionary templateIds) + private static MaterializedGenericQuestionData ApplyTemplateIds(GenericQuestionData genericQuestions, IReadOnlyDictionary templateIds) { - foreach (var answer in genericQuestions.Answers) - { - answer.QuestionTemplateId = templateIds[genericQuestions.QuestionIdBySourceRow[answer.SourceRow]]; - } + var answers = genericQuestions.Answers + .Select(answer => new QuestionAnswerEntity + { + QuestionTemplateId = templateIds[answer.QuestionId], + ParticipantId = answer.ParticipantId, + ImportedAnswer = answer.ImportedAnswer, + OverrideAnswer = answer.OverrideAnswer, + RecordedAtUtc = answer.RecordedAtUtc + }) + .ToList(); - foreach (var actual in genericQuestions.Actuals) - { - actual.QuestionTemplateId = templateIds[genericQuestions.QuestionIdBySourceRow[actual.SourceRow]]; - } + var actuals = genericQuestions.Actuals + .Select(actual => new QuestionActualEntity + { + QuestionTemplateId = templateIds[actual.QuestionId], + ImportedAnswer = actual.ImportedAnswer, + OverrideAnswer = actual.OverrideAnswer, + RecordedAtUtc = actual.RecordedAtUtc + }) + .ToList(); + + return new MaterializedGenericQuestionData(answers, actuals); } private static List ParsePreseasonQuestionAnswers( Guid runId, IReadOnlyCollection stagedRows, IReadOnlyList participants, - bool usePhil2025Contract) + bool usePhil2025Contract, + IReadOnlyDictionary driverIdByCode) { if (participants.Count == 0) { @@ -510,6 +696,7 @@ private static List ParsePreseasonQuestion { var columnIndex = participantStartIndex + index; var raw = columnIndex < columns.Count ? columns[columnIndex] : null; + var normalized = NormalizePreseasonAnswer(raw, isActualOutcome: false, driverIdByCode); parsed.Add(new MigrationImportPreseasonAnswerEntity { ImportRunId = runId, @@ -518,7 +705,8 @@ private static List ParsePreseasonQuestion QuestionText = questionText, Subject = participants[index], RawAnswer = string.IsNullOrWhiteSpace(raw) ? null : raw.Trim(), - NormalizedAnswer = NormalizePreseasonAnswer(raw, isActualOutcome: false).NormalizedValue, + NormalizedAnswer = normalized.NormalizedValue, + NormalizedAnswerBoolean = ToNullableBoolean(normalized.NormalizedValue), IsActualOutcome = false }); } @@ -535,6 +723,7 @@ private static List ParsePreseasonQuestion actualRaw = columns.Skip(1 + participants.Count).FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)); } + var normalizedActual = NormalizePreseasonAnswer(actualRaw, isActualOutcome: true, driverIdByCode); parsed.Add(new MigrationImportPreseasonAnswerEntity { ImportRunId = runId, @@ -543,7 +732,8 @@ private static List ParsePreseasonQuestion QuestionText = questionText, Subject = ActualSubject, RawAnswer = string.IsNullOrWhiteSpace(actualRaw) ? null : actualRaw.Trim(), - NormalizedAnswer = NormalizePreseasonAnswer(actualRaw, isActualOutcome: true).NormalizedValue, + NormalizedAnswer = normalizedActual.NormalizedValue, + NormalizedAnswerBoolean = ToNullableBoolean(normalizedActual.NormalizedValue), IsActualOutcome = true }); } @@ -718,14 +908,18 @@ private static string NormalizeTokenLookup(string rawValue) return MultiWhitespaceRegex().Replace(rawValue.Trim().ToUpperInvariant(), " "); } - private static PreseasonNormalizationResult NormalizeQuestionAnswer(string? rawAnswer, bool isActualOutcome, QuestionCategory category) + private static PreseasonNormalizationResult NormalizeQuestionAnswer( + string? rawAnswer, + bool isActualOutcome, + QuestionCategory category, + IReadOnlyDictionary driverIdByCode) { return category == QuestionCategory.H2H - ? NormalizeH2hAnswer(rawAnswer) - : NormalizePreseasonAnswer(rawAnswer, isActualOutcome); + ? NormalizeH2hAnswer(rawAnswer, driverIdByCode) + : NormalizePreseasonAnswer(rawAnswer, isActualOutcome, driverIdByCode); } - private static PreseasonNormalizationResult NormalizeH2hAnswer(string? rawAnswer) + private static PreseasonNormalizationResult NormalizeH2hAnswer(string? rawAnswer, IReadOnlyDictionary driverIdByCode) { if (string.IsNullOrWhiteSpace(rawAnswer)) { @@ -733,14 +927,19 @@ private static PreseasonNormalizationResult NormalizeH2hAnswer(string? rawAnswer } var lookupToken = NormalizeTokenLookup(rawAnswer); + if (QuestionTokenAliasDictionary.TryGetValue(lookupToken, out var mappedQuestionToken)) + { + return new PreseasonNormalizationResult(MapDriverCodeToId(mappedQuestionToken, driverIdByCode), []); + } + if (TokenAliasDictionary.TryGetValue(lookupToken, out var mappedToken)) { - return new PreseasonNormalizationResult(mappedToken, []); + return new PreseasonNormalizationResult(MapDriverCodeToId(mappedToken, driverIdByCode), []); } if (CanonicalTokenRegex().IsMatch(lookupToken)) { - return new PreseasonNormalizationResult(lookupToken, []); + return new PreseasonNormalizationResult(MapDriverCodeToId(lookupToken, driverIdByCode), []); } var normalized = MultiWhitespaceRegex().Replace(rawAnswer.Trim(), " "); @@ -766,6 +965,11 @@ private static QuestionCategory ResolveQuestionCategory(string rawPayload) return QuestionCategory.H2H; } + if (RaceBonusPromptRegex().IsMatch(prompt)) + { + return QuestionCategory.RaceBonus; + } + return QuestionCategory.Preseason; } @@ -783,9 +987,10 @@ private static string ResolveQuestionId(int rowNumber, string rawPayload) string questionText, IReadOnlyList columns, IReadOnlyList participants, - bool usePhil2025Contract) + bool usePhil2025Contract, + IReadOnlyDictionary driverIdByCode) { - var driverCandidates = ExtractH2hCandidatesFromPrompt(questionText); + var driverCandidates = ExtractH2hCandidatesFromPrompt(questionText, driverIdByCode); if (driverCandidates.Count < 2) { var participantStartIndex = usePhil2025Contract @@ -797,8 +1002,8 @@ private static string ResolveQuestionId(int rowNumber, string rawPayload) { var columnIndex = participantStartIndex + index; var rawAnswer = columnIndex < columns.Count ? columns[columnIndex] : null; - var normalized = NormalizeH2hAnswer(rawAnswer).NormalizedValue; - if (!string.IsNullOrWhiteSpace(normalized) && CanonicalTokenRegex().IsMatch(normalized)) + var normalized = NormalizeH2hAnswer(rawAnswer, driverIdByCode).NormalizedValue; + if (!string.IsNullOrWhiteSpace(normalized)) { fallbackCandidates.Add(normalized); } @@ -810,8 +1015,8 @@ private static string ResolveQuestionId(int rowNumber, string rawPayload) var actualRaw = actualColumnIndex >= 0 && actualColumnIndex < columns.Count ? columns[actualColumnIndex] : null; - var actualNormalized = NormalizeH2hAnswer(actualRaw).NormalizedValue; - if (!string.IsNullOrWhiteSpace(actualNormalized) && CanonicalTokenRegex().IsMatch(actualNormalized)) + var actualNormalized = NormalizeH2hAnswer(actualRaw, driverIdByCode).NormalizedValue; + if (!string.IsNullOrWhiteSpace(actualNormalized)) { fallbackCandidates.Add(actualNormalized); } @@ -845,14 +1050,14 @@ private static string ResolveQuestionId(int rowNumber, string rawPayload) return JsonSerializer.Serialize(options); } - private static List ExtractH2hCandidatesFromPrompt(string questionText) + private static List ExtractH2hCandidatesFromPrompt(string questionText, IReadOnlyDictionary driverIdByCode) { var candidates = new List(); foreach (Match match in H2hDriverTokenRegex().Matches(questionText)) { var token = match.Value; - var normalized = NormalizeH2hAnswer(token).NormalizedValue; - if (string.IsNullOrWhiteSpace(normalized) || !CanonicalTokenRegex().IsMatch(normalized)) + var normalized = NormalizeH2hAnswer(token, driverIdByCode).NormalizedValue; + if (string.IsNullOrWhiteSpace(normalized)) { continue; } @@ -871,7 +1076,10 @@ private static List ExtractH2hCandidatesFromPrompt(string questionText) return candidates; } - private static PreseasonNormalizationResult NormalizePreseasonAnswer(string? rawAnswer, bool isActualOutcome) + private static PreseasonNormalizationResult NormalizePreseasonAnswer( + string? rawAnswer, + bool isActualOutcome, + IReadOnlyDictionary driverIdByCode) { if (string.IsNullOrWhiteSpace(rawAnswer)) { @@ -879,6 +1087,20 @@ private static PreseasonNormalizationResult NormalizePreseasonAnswer(string? raw } var normalized = MultiWhitespaceRegex().Replace(rawAnswer.Trim(), " "); + var mappedAtomic = NormalizeQuestionToken(normalized, driverIdByCode); + if (mappedAtomic is null) + { + var lookupToken = NormalizeTokenLookup(normalized); + if (QuestionTokenAliasDictionary.ContainsKey(lookupToken) || TokenAliasDictionary.ContainsKey(lookupToken)) + { + return new PreseasonNormalizationResult(null, ["NULL_EQUIVALENT_TOKEN"]); + } + } + else if (!string.IsNullOrWhiteSpace(mappedAtomic)) + { + normalized = mappedAtomic; + } + if (string.Equals(normalized, "NONE", StringComparison.OrdinalIgnoreCase) || string.Equals(normalized, "NOT", StringComparison.OrdinalIgnoreCase)) { @@ -896,6 +1118,7 @@ private static PreseasonNormalizationResult NormalizePreseasonAnswer(string? raw var tokens = PreseasonDelimitedAnswerRegex() .Split(normalized) .Select(token => MultiWhitespaceRegex().Replace(token.Trim(), " ")) + .Select(token => NormalizeQuestionToken(token, driverIdByCode) ?? string.Empty) .Where(token => !string.IsNullOrWhiteSpace(token)) .Where(token => !string.Equals(token, "NONE", StringComparison.OrdinalIgnoreCase) && @@ -939,6 +1162,9 @@ private static bool HasUnsupportedAnswerShape(string normalized) [GeneratedRegex("(head\\s*[- ]?to\\s*[- ]?head|h2h)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] private static partial Regex H2hPromptRegex(); + [GeneratedRegex("(dnf|fastest\\s*lap|bonus)", RegexOptions.IgnoreCase | RegexOptions.Compiled)] + private static partial Regex RaceBonusPromptRegex(); + [GeneratedRegex("\\b[A-Za-z]{3}\\b", RegexOptions.Compiled)] private static partial Regex H2hDriverTokenRegex(); @@ -953,9 +1179,125 @@ private static bool HasUnsupportedAnswerShape(string normalized) private readonly record struct PreseasonNormalizationResult(string? NormalizedValue, IReadOnlyList Diagnostics); + private static string? NormalizeQuestionToken(string? token, IReadOnlyDictionary driverIdByCode) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + var lookupToken = NormalizeTokenLookup(token); + if (JolpicaConstructorIdByName.TryGetValue(lookupToken, out var mappedConstructorId)) + { + return mappedConstructorId; + } + + if (QuestionTokenAliasDictionary.TryGetValue(lookupToken, out var mappedToken)) + { + return MapDriverCodeToId(mappedToken, driverIdByCode); + } + + if (TokenAliasDictionary.TryGetValue(lookupToken, out var mappedSelectionToken)) + { + return MapDriverCodeToId(mappedSelectionToken, driverIdByCode); + } + + if (CanonicalTokenRegex().IsMatch(lookupToken)) + { + return MapDriverCodeToId(lookupToken, driverIdByCode); + } + + return MultiWhitespaceRegex().Replace(token.Trim(), " "); + } + + private static string? MapDriverCodeToId(string? value, IReadOnlyDictionary driverIdByCode) + { + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + var token = value.Trim(); + if (token.Length != 3) + { + return token; + } + + var code = token.ToUpperInvariant(); + if (driverIdByCode.TryGetValue(code, out var mappedDriverIdFromDb)) + { + return mappedDriverIdFromDb; + } + + return JolpicaDriverIdByCode.TryGetValue(code, out var mappedDriverId) + ? mappedDriverId + : token; + } + + private static string? MapSelectionNormalizedValueToDriverIds( + string? normalizedValue, + string pickType, + IReadOnlyDictionary driverIdByCode) + { + if (string.IsNullOrWhiteSpace(normalizedValue)) + { + return normalizedValue; + } + + var trimmed = normalizedValue.Trim(); + if (!string.Equals(pickType, "DNF", StringComparison.OrdinalIgnoreCase)) + { + return MapDriverCodeToId(trimmed, driverIdByCode); + } + + var mappedTokens = trimmed + .Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(token => MapDriverCodeToId(token, driverIdByCode) ?? token) + .ToArray(); + + return mappedTokens.Length == 0 ? null : string.Join(" ", mappedTokens); + } + + private static bool? ToNullableBoolean(string? normalizedValue) + { + if (string.IsNullOrWhiteSpace(normalizedValue)) + { + return null; + } + + var token = normalizedValue.Trim().ToUpperInvariant(); + if (token is "YES" or "TRUE") + { + return true; + } + + if (token is "NO" or "FALSE") + { + return false; + } + + return null; + } + private sealed record GenericQuestionData( IReadOnlyList Templates, + IReadOnlyList Answers, + IReadOnlyList Actuals); + + private sealed record PendingQuestionAnswer( + string QuestionId, + string ParticipantId, + string? ImportedAnswer, + string? OverrideAnswer, + DateTime RecordedAtUtc); + + private sealed record PendingQuestionActual( + string QuestionId, + string? ImportedAnswer, + string? OverrideAnswer, + DateTime RecordedAtUtc); + + private sealed record MaterializedGenericQuestionData( IReadOnlyList Answers, - IReadOnlyList Actuals, - IReadOnlyDictionary QuestionIdBySourceRow); + IReadOnlyList Actuals); } \ No newline at end of file diff --git a/src/F1.DataSyncWorker/Services/RaceCodeNormalizer.cs b/src/F1.DataSyncWorker/Services/Parsing/RaceCodeNormalizer.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/RaceCodeNormalizer.cs rename to src/F1.DataSyncWorker/Services/Parsing/RaceCodeNormalizer.cs diff --git a/src/F1.DataSyncWorker/Services/H2hQuestionScoringStrategy.cs b/src/F1.DataSyncWorker/Services/Scoring/H2hQuestionScoringStrategy.cs similarity index 85% rename from src/F1.DataSyncWorker/Services/H2hQuestionScoringStrategy.cs rename to src/F1.DataSyncWorker/Services/Scoring/H2hQuestionScoringStrategy.cs index f9c6e67..0e85ff7 100644 --- a/src/F1.DataSyncWorker/Services/H2hQuestionScoringStrategy.cs +++ b/src/F1.DataSyncWorker/Services/Scoring/H2hQuestionScoringStrategy.cs @@ -1,5 +1,6 @@ using System.Text.Json; using F1.Core.Models; +using F1.Infrastructure.Data.Entities; namespace F1.DataSyncWorker.Services; @@ -15,7 +16,7 @@ public IReadOnlyList Score(QuestionScoringContext cont { var options = DeserializeOptions(template.OptionsJson); var actual = context.Actuals.SingleOrDefault(x => x.QuestionTemplateId == template.Id); - var actualAnswer = NormalizeDriver(actual?.NormalizedAnswer); + var actualAnswer = NormalizeDriver(ResolveEffectiveAnswer(actual)); var answers = context.Answers .Where(x => x.QuestionTemplateId == template.Id) .OrderBy(x => x.ParticipantId, StringComparer.OrdinalIgnoreCase) @@ -23,7 +24,7 @@ public IReadOnlyList Score(QuestionScoringContext cont foreach (var answer in answers) { - var predicted = NormalizeDriver(answer.NormalizedAnswer); + var predicted = NormalizeDriver(ResolveEffectiveAnswer(answer)); var (points, reasonCode) = ScorePick(predicted, actualAnswer, options); computed.Add(new QuestionScoreComputation( QuestionTemplateId: template.Id, @@ -106,4 +107,14 @@ private static (int Points, string ReasonCode) ScorePick(string? predicted, stri { return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToUpperInvariant(); } + + private static string? ResolveEffectiveAnswer(QuestionAnswerEntity? answer) + { + return string.IsNullOrWhiteSpace(answer?.OverrideAnswer) ? answer?.ImportedAnswer : answer.OverrideAnswer; + } + + private static string? ResolveEffectiveAnswer(QuestionActualEntity? actual) + { + return string.IsNullOrWhiteSpace(actual?.OverrideAnswer) ? actual?.ImportedAnswer : actual.OverrideAnswer; + } } \ No newline at end of file diff --git a/src/F1.DataSyncWorker/Services/MigrationScoreRecalculator.cs b/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs similarity index 84% rename from src/F1.DataSyncWorker/Services/MigrationScoreRecalculator.cs rename to src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs index b8c2de1..3af935e 100644 --- a/src/F1.DataSyncWorker/Services/MigrationScoreRecalculator.cs +++ b/src/F1.DataSyncWorker/Services/Scoring/MigrationScoreRecalculator.cs @@ -33,7 +33,8 @@ public MigrationScoreRecalculator(IDbContextFactory dbContextFactor dbContextFactory, new QuestionScoringStrategyRegistry([ new PreseasonQuestionScoringStrategy(), - new H2hQuestionScoringStrategy() + new H2hQuestionScoringStrategy(), + new RaceBonusQuestionScoringStrategy() ])) { } @@ -59,24 +60,26 @@ public async Task RecalculateAndPersistAsync( .AsNoTracking() .SingleOrDefaultAsync(cancellationToken); + var preseasonImportedTallies = await dbContext.MigrationImportPreseasonImportedTallies + .Where(x => x.ImportRunId == runId) + .AsNoTracking() + .ToListAsync(cancellationToken); + dbContext.MigrationImportCalculatedScores.RemoveRange( dbContext.MigrationImportCalculatedScores.Where(x => x.ImportRunId == runId)); - dbContext.QuestionScores.RemoveRange( - dbContext.QuestionScores.Where(x => x.ImportRunId == runId)); dbContext.MigrationImportPreseasonCalculatedScores.RemoveRange( dbContext.MigrationImportPreseasonCalculatedScores.Where(x => x.ImportRunId == runId)); dbContext.MigrationImportPreseasonCalculatedTotals.RemoveRange( dbContext.MigrationImportPreseasonCalculatedTotals.Where(x => x.ImportRunId == runId)); var genericQuestionAnswers = await dbContext.QuestionAnswers - .Where(x => x.ImportRunId == runId) - .OrderBy(x => x.SourceRow) + .OrderBy(x => x.QuestionTemplateId) + .ThenBy(x => x.ParticipantId) .AsNoTracking() .ToListAsync(cancellationToken); var genericQuestionActuals = await dbContext.QuestionActuals - .Where(x => x.ImportRunId == runId) - .OrderBy(x => x.SourceRow) + .OrderBy(x => x.QuestionTemplateId) .AsNoTracking() .ToListAsync(cancellationToken); @@ -95,6 +98,12 @@ public async Task RecalculateAndPersistAsync( .AsNoTracking() .ToListAsync(cancellationToken); + if (genericQuestionTemplateIds.Length > 0) + { + dbContext.QuestionScores.RemoveRange( + dbContext.QuestionScores.Where(x => genericQuestionTemplateIds.Contains(x.QuestionTemplateId))); + } + if (selections.Count == 0 && preseasonAnswers.Count == 0 && genericQuestionAnswers.Count == 0 && genericQuestionActuals.Count == 0) { await dbContext.SaveChangesAsync(cancellationToken); @@ -148,18 +157,17 @@ public async Task RecalculateAndPersistAsync( genericQuestionTemplates, genericQuestionAnswers, genericQuestionActuals, - preseasonPolicy); + preseasonPolicy, + preseasonImportedTallies); var questionScores = questionScoreComputations .Select(computation => new QuestionScoreEntity { - ImportRunId = runId, QuestionTemplateId = computation.QuestionTemplateId, ParticipantId = computation.ParticipantId, ImportedPoints = computation.ImportedPoints, CalculatedPoints = computation.CalculatedPoints, DeltaPoints = computation.DeltaPoints, - ReasonCode = computation.ReasonCode, RecordedAtUtc = DateTime.UtcNow }) .ToList(); @@ -230,13 +238,23 @@ private IReadOnlyList CalculateGenericQuestionScores( IReadOnlyList templates, IReadOnlyList answers, IReadOnlyList actuals, - MigrationImportPreseasonPolicyEntity? preseasonPolicy) + MigrationImportPreseasonPolicyEntity? preseasonPolicy, + IReadOnlyCollection preseasonImportedTallies) { if (templates.Count == 0 || answers.Count == 0) { return []; } + var importedPointsByQuestionAndSubject = preseasonImportedTallies + .GroupBy( + x => (QuestionKey: x.QuestionKey?.Trim() ?? string.Empty, Subject: x.Subject?.Trim() ?? string.Empty), + new QuestionParticipantKeyComparer()) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(x => x.ImportedPoints.HasValue).First().ImportedPoints, + new QuestionParticipantKeyComparer()); + var scored = new List(); foreach (var categoryGroup in templates.GroupBy(x => x.Category)) { @@ -258,8 +276,8 @@ private IReadOnlyList CalculateGenericQuestionScores( Prompt: template.Prompt, Category: template.Category, ParticipantId: answer.ParticipantId, - PredictedAnswer: answer.NormalizedAnswer, - ActualAnswer: actual?.NormalizedAnswer, + PredictedAnswer: ResolveEffectiveAnswer(answer), + ActualAnswer: ResolveEffectiveAnswer(actual), ImportedPoints: null, CalculatedPoints: 0, DeltaPoints: 0, @@ -278,7 +296,51 @@ private IReadOnlyList CalculateGenericQuestionScores( preseasonPolicy))); } - return scored; + var hydrated = new List(scored.Count); + foreach (var score in scored) + { + var importedPoints = importedPointsByQuestionAndSubject.TryGetValue((score.QuestionId, score.ParticipantId), out var value) + ? value + : null; + + var deltaPoints = importedPoints.HasValue + ? score.CalculatedPoints - importedPoints.Value + : 0; + + hydrated.Add(score with + { + ImportedPoints = importedPoints, + DeltaPoints = deltaPoints + }); + } + + return hydrated; + } + + private sealed class QuestionParticipantKeyComparer : IEqualityComparer<(string QuestionKey, string Subject)> + { + public bool Equals((string QuestionKey, string Subject) x, (string QuestionKey, string Subject) y) + { + return string.Equals(x.QuestionKey, y.QuestionKey, StringComparison.OrdinalIgnoreCase) && + string.Equals(x.Subject, y.Subject, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode((string QuestionKey, string Subject) obj) + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.QuestionKey), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Subject)); + } + } + + private static string? ResolveEffectiveAnswer(QuestionAnswerEntity? answer) + { + return string.IsNullOrWhiteSpace(answer?.OverrideAnswer) ? answer?.ImportedAnswer : answer.OverrideAnswer; + } + + private static string? ResolveEffectiveAnswer(QuestionActualEntity? actual) + { + return string.IsNullOrWhiteSpace(actual?.OverrideAnswer) ? actual?.ImportedAnswer : actual.OverrideAnswer; } private static List CalculatePreseasonScores( diff --git a/src/F1.DataSyncWorker/Services/PreseasonQuestionScoringStrategy.cs b/src/F1.DataSyncWorker/Services/Scoring/PreseasonQuestionScoringStrategy.cs similarity index 88% rename from src/F1.DataSyncWorker/Services/PreseasonQuestionScoringStrategy.cs rename to src/F1.DataSyncWorker/Services/Scoring/PreseasonQuestionScoringStrategy.cs index f823c12..e7e9359 100644 --- a/src/F1.DataSyncWorker/Services/PreseasonQuestionScoringStrategy.cs +++ b/src/F1.DataSyncWorker/Services/Scoring/PreseasonQuestionScoringStrategy.cs @@ -35,7 +35,7 @@ public IReadOnlyList Score(QuestionScoringContext cont foreach (var template in templates) { actualByTemplate.TryGetValue(template.Id, out var actual); - var actualValue = NormalizeToken(actual?.NormalizedAnswer); + var actualValue = NormalizeToken(ResolveEffectiveAnswer(actual)); var actualTokenSet = BuildPreseasonActualTokenSet(actualValue); if (!answersByTemplate.TryGetValue(template.Id, out var participants)) @@ -45,7 +45,7 @@ public IReadOnlyList Score(QuestionScoringContext cont foreach (var participant in participants) { - var predictedValue = NormalizeToken(participant.NormalizedAnswer); + var predictedValue = NormalizeToken(ResolveEffectiveAnswer(participant)); var (points, reasonCode) = ScorePreseasonAnswer( predictedValue, actualValue, @@ -133,4 +133,14 @@ private static HashSet BuildPreseasonActualTokenSet(string? actualValue) [GeneratedRegex("\\s*\\|\\s*", RegexOptions.Compiled)] private static partial Regex PreseasonActualSplitRegex(); + + private static string? ResolveEffectiveAnswer(QuestionAnswerEntity? answer) + { + return string.IsNullOrWhiteSpace(answer?.OverrideAnswer) ? answer?.ImportedAnswer : answer.OverrideAnswer; + } + + private static string? ResolveEffectiveAnswer(QuestionActualEntity? actual) + { + return string.IsNullOrWhiteSpace(actual?.OverrideAnswer) ? actual?.ImportedAnswer : actual.OverrideAnswer; + } } \ No newline at end of file diff --git a/src/F1.DataSyncWorker/Services/QuestionScoreComputation.cs b/src/F1.DataSyncWorker/Services/Scoring/QuestionScoreComputation.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/QuestionScoreComputation.cs rename to src/F1.DataSyncWorker/Services/Scoring/QuestionScoreComputation.cs diff --git a/src/F1.DataSyncWorker/Services/QuestionScoringContext.cs b/src/F1.DataSyncWorker/Services/Scoring/QuestionScoringContext.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/QuestionScoringContext.cs rename to src/F1.DataSyncWorker/Services/Scoring/QuestionScoringContext.cs diff --git a/src/F1.DataSyncWorker/Services/QuestionScoringStrategyRegistry.cs b/src/F1.DataSyncWorker/Services/Scoring/QuestionScoringStrategyRegistry.cs similarity index 100% rename from src/F1.DataSyncWorker/Services/QuestionScoringStrategyRegistry.cs rename to src/F1.DataSyncWorker/Services/Scoring/QuestionScoringStrategyRegistry.cs diff --git a/src/F1.DataSyncWorker/Services/Scoring/RaceBonusQuestionScoringStrategy.cs b/src/F1.DataSyncWorker/Services/Scoring/RaceBonusQuestionScoringStrategy.cs new file mode 100644 index 0000000..8d57bbb --- /dev/null +++ b/src/F1.DataSyncWorker/Services/Scoring/RaceBonusQuestionScoringStrategy.cs @@ -0,0 +1,114 @@ +using F1.Core.Models; +using F1.Infrastructure.Data.Entities; + +namespace F1.DataSyncWorker.Services; + +public sealed class RaceBonusQuestionScoringStrategy : IQuestionScoringStrategy +{ + public QuestionCategory Category => QuestionCategory.RaceBonus; + + public IReadOnlyList Score(QuestionScoringContext context) + { + var templates = context.Templates + .Where(x => x.Category == QuestionCategory.RaceBonus) + .OrderBy(x => x.SortOrder) + .ThenBy(x => x.QuestionId) + .ToList(); + + if (templates.Count == 0) + { + return []; + } + + var templateIds = templates.Select(t => t.Id).ToHashSet(); + + var actualByTemplate = context.Actuals + .Where(x => templateIds.Contains(x.QuestionTemplateId)) + .ToDictionary(x => x.QuestionTemplateId); + + var answersByTemplate = context.Answers + .Where(x => templateIds.Contains(x.QuestionTemplateId)) + .GroupBy(x => x.QuestionTemplateId) + .ToDictionary(x => x.Key, x => x.OrderBy(y => y.ParticipantId, StringComparer.OrdinalIgnoreCase).ToList()); + + var computed = new List(); + + foreach (var template in templates) + { + actualByTemplate.TryGetValue(template.Id, out var actual); + var actualValue = NormalizeValue(ResolveEffectiveAnswer(actual)); + + if (!answersByTemplate.TryGetValue(template.Id, out var participants)) + { + continue; + } + + foreach (var participant in participants) + { + var predictedValue = NormalizeValue(ResolveEffectiveAnswer(participant)); + var (points, reasonCode) = ScoreBonusAnswer( + predictedValue, + actualValue, + context.PreseasonPolicy?.PointsPerQuestion); + + computed.Add(new QuestionScoreComputation( + QuestionTemplateId: template.Id, + QuestionId: template.QuestionId, + Prompt: template.Prompt, + Category: QuestionCategory.RaceBonus, + ParticipantId: participant.ParticipantId, + PredictedAnswer: predictedValue, + ActualAnswer: actualValue, + ImportedPoints: null, + CalculatedPoints: points, + DeltaPoints: 0, + ReasonCode: reasonCode, + SortOrder: template.SortOrder)); + } + } + + return computed; + } + + private static (int Points, string ReasonCode) ScoreBonusAnswer( + string? predictedValue, + string? actualValue, + int? pointsPerQuestion) + { + if (!pointsPerQuestion.HasValue) + { + return (0, "RACE_BONUS_POLICY_MISSING"); + } + + if (string.IsNullOrWhiteSpace(predictedValue)) + { + return (0, "RACE_BONUS_PREDICTION_NULL"); + } + + if (string.IsNullOrWhiteSpace(actualValue)) + { + return (0, "RACE_BONUS_ACTUAL_MISSING"); + } + + return string.Equals(predictedValue, actualValue, StringComparison.OrdinalIgnoreCase) + ? (Math.Max(0, pointsPerQuestion.Value), "RACE_BONUS_EXACT") + : (0, "RACE_BONUS_MISMATCH"); + } + + private static string? NormalizeValue(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToUpperInvariant(); + } + + private static string? ResolveEffectiveAnswer(QuestionAnswerEntity? answer) + { + return string.IsNullOrWhiteSpace(answer?.OverrideAnswer) ? answer?.ImportedAnswer : answer.OverrideAnswer; + } + + private static string? ResolveEffectiveAnswer(QuestionActualEntity? actual) + { + return string.IsNullOrWhiteSpace(actual?.OverrideAnswer) ? actual?.ImportedAnswer : actual.OverrideAnswer; + } +} diff --git a/src/F1.Infrastructure/Data/Entities/MigrationImportConflictDiagnosticEntity.cs b/src/F1.Infrastructure/Data/Entities/MigrationImportConflictDiagnosticEntity.cs new file mode 100644 index 0000000..74c90ac --- /dev/null +++ b/src/F1.Infrastructure/Data/Entities/MigrationImportConflictDiagnosticEntity.cs @@ -0,0 +1,14 @@ +namespace F1.Infrastructure.Data.Entities; + +public sealed class MigrationImportConflictDiagnosticEntity +{ + public long Id { get; set; } + public Guid ImportRunId { get; set; } + public string EntityType { get; set; } = string.Empty; + public string ConflictType { get; set; } = string.Empty; + public string KeyFields { get; set; } = string.Empty; + public string SourceReference { get; set; } = string.Empty; + public string PolicyOutcome { get; set; } = string.Empty; + public string RecommendedAction { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } +} diff --git a/src/F1.Infrastructure/Data/Entities/MigrationImportPreseasonAnswerEntity.cs b/src/F1.Infrastructure/Data/Entities/MigrationImportPreseasonAnswerEntity.cs index 771b80d..ab69c43 100644 --- a/src/F1.Infrastructure/Data/Entities/MigrationImportPreseasonAnswerEntity.cs +++ b/src/F1.Infrastructure/Data/Entities/MigrationImportPreseasonAnswerEntity.cs @@ -10,5 +10,6 @@ public sealed class MigrationImportPreseasonAnswerEntity public string Subject { get; set; } = string.Empty; public string? RawAnswer { get; set; } public string? NormalizedAnswer { get; set; } + public bool? NormalizedAnswerBoolean { get; set; } public bool IsActualOutcome { get; set; } } \ No newline at end of file diff --git a/src/F1.Infrastructure/Data/Entities/MigrationImportRollbackAuditEntity.cs b/src/F1.Infrastructure/Data/Entities/MigrationImportRollbackAuditEntity.cs new file mode 100644 index 0000000..52c1d69 --- /dev/null +++ b/src/F1.Infrastructure/Data/Entities/MigrationImportRollbackAuditEntity.cs @@ -0,0 +1,14 @@ +namespace F1.Infrastructure.Data.Entities; + +public sealed class MigrationImportRollbackAuditEntity +{ + public long Id { get; set; } + public Guid ImportRunId { get; set; } + public string Actor { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public DateTime RequestedAtUtc { get; set; } + public int AffectedRaceCount { get; set; } + public int AffectedSelectionCount { get; set; } + public int AffectedSelectionPositionCount { get; set; } + public string Outcome { get; set; } = string.Empty; +} diff --git a/src/F1.Infrastructure/Data/Entities/MigrationImportRunEntity.cs b/src/F1.Infrastructure/Data/Entities/MigrationImportRunEntity.cs index 3693907..ea14e7d 100644 --- a/src/F1.Infrastructure/Data/Entities/MigrationImportRunEntity.cs +++ b/src/F1.Infrastructure/Data/Entities/MigrationImportRunEntity.cs @@ -21,5 +21,11 @@ public sealed class MigrationImportRunEntity public int PreseasonQuestionDiffCount { get; set; } public int PreseasonTotalDeltaPoints { get; set; } public bool PreseasonIsolationGuardPassed { get; set; } + public string? ParitySnapshotChecksum { get; set; } + public string ParityStatus { get; set; } = "NotCompared"; + public string? ParityComparedChecksum { get; set; } + public Guid? ParityComparedRunId { get; set; } + public string? IdempotencyScopeKey { get; set; } + public string IdempotencyOutcome { get; set; } = "Unknown"; public string? ErrorMessage { get; set; } } \ No newline at end of file diff --git a/src/F1.Infrastructure/Data/Entities/QuestionActualEntity.cs b/src/F1.Infrastructure/Data/Entities/QuestionActualEntity.cs index 62f4d78..f0f2380 100644 --- a/src/F1.Infrastructure/Data/Entities/QuestionActualEntity.cs +++ b/src/F1.Infrastructure/Data/Entities/QuestionActualEntity.cs @@ -3,12 +3,8 @@ namespace F1.Infrastructure.Data.Entities; public sealed class QuestionActualEntity { public long Id { get; set; } - public Guid ImportRunId { get; set; } public long QuestionTemplateId { get; set; } - public string? ActualAnswer { get; set; } - public string? NormalizedAnswer { get; set; } - public int SourceRow { get; set; } - public int SourceColumn { get; set; } - public string? NormalizationDiagnosticsJson { get; set; } + public string? ImportedAnswer { get; set; } + public string? OverrideAnswer { get; set; } public DateTime RecordedAtUtc { get; set; } } \ No newline at end of file diff --git a/src/F1.Infrastructure/Data/Entities/QuestionAnswerEntity.cs b/src/F1.Infrastructure/Data/Entities/QuestionAnswerEntity.cs index a61377b..c2e2972 100644 --- a/src/F1.Infrastructure/Data/Entities/QuestionAnswerEntity.cs +++ b/src/F1.Infrastructure/Data/Entities/QuestionAnswerEntity.cs @@ -3,12 +3,9 @@ namespace F1.Infrastructure.Data.Entities; public sealed class QuestionAnswerEntity { public long Id { get; set; } - public Guid ImportRunId { get; set; } public long QuestionTemplateId { get; set; } public string ParticipantId { get; set; } = string.Empty; public string? ImportedAnswer { get; set; } - public string? NormalizedAnswer { get; set; } - public int SourceRow { get; set; } - public int SourceColumn { get; set; } + public string? OverrideAnswer { get; set; } public DateTime RecordedAtUtc { get; set; } } \ No newline at end of file diff --git a/src/F1.Infrastructure/Data/Entities/QuestionScoreEntity.cs b/src/F1.Infrastructure/Data/Entities/QuestionScoreEntity.cs index ef00f7f..f23adb6 100644 --- a/src/F1.Infrastructure/Data/Entities/QuestionScoreEntity.cs +++ b/src/F1.Infrastructure/Data/Entities/QuestionScoreEntity.cs @@ -3,12 +3,10 @@ namespace F1.Infrastructure.Data.Entities; public sealed class QuestionScoreEntity { public long Id { get; set; } - public Guid ImportRunId { get; set; } public long QuestionTemplateId { get; set; } public string ParticipantId { get; set; } = string.Empty; public int? ImportedPoints { get; set; } public int CalculatedPoints { get; set; } public int DeltaPoints { get; set; } - public string ReasonCode { get; set; } = string.Empty; public DateTime RecordedAtUtc { get; set; } } \ No newline at end of file diff --git a/src/F1.Infrastructure/Data/F1DbContext.cs b/src/F1.Infrastructure/Data/F1DbContext.cs index 3978b32..8a1c461 100644 --- a/src/F1.Infrastructure/Data/F1DbContext.cs +++ b/src/F1.Infrastructure/Data/F1DbContext.cs @@ -43,6 +43,8 @@ public F1DbContext(DbContextOptions options) public DbSet MigrationImportUnresolvedTokens => Set(); public DbSet MigrationImportJolpicaRaceSnapshots => Set(); public DbSet MigrationImportRaceRoundMappings => Set(); + public DbSet MigrationImportConflictDiagnostics => Set(); + public DbSet MigrationImportRollbackAudits => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -156,14 +158,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(x => x.Id); entity.Property(x => x.ParticipantId).HasMaxLength(128).IsRequired(); entity.Property(x => x.ImportedAnswer).HasMaxLength(512); - entity.Property(x => x.NormalizedAnswer).HasMaxLength(512); - entity.HasIndex(x => new { x.ImportRunId, x.QuestionTemplateId, x.ParticipantId }).IsUnique(); - entity.HasIndex(x => new { x.ImportRunId, x.SourceRow, x.SourceColumn }); - - entity.HasOne() - .WithMany() - .HasForeignKey(x => x.ImportRunId) - .OnDelete(DeleteBehavior.Cascade); + entity.Property(x => x.OverrideAnswer).HasMaxLength(512); + entity.HasIndex(x => new { x.QuestionTemplateId, x.ParticipantId }).IsUnique(); entity.HasOne() .WithMany() @@ -175,16 +171,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.ToTable("QuestionActuals"); entity.HasKey(x => x.Id); - entity.Property(x => x.ActualAnswer).HasMaxLength(512); - entity.Property(x => x.NormalizedAnswer).HasMaxLength(512); - entity.Property(x => x.NormalizationDiagnosticsJson).HasColumnType("text"); - entity.HasIndex(x => new { x.ImportRunId, x.QuestionTemplateId }).IsUnique(); - entity.HasIndex(x => new { x.ImportRunId, x.SourceRow, x.SourceColumn }); - - entity.HasOne() - .WithMany() - .HasForeignKey(x => x.ImportRunId) - .OnDelete(DeleteBehavior.Cascade); + entity.Property(x => x.ImportedAnswer).HasMaxLength(512); + entity.Property(x => x.OverrideAnswer).HasMaxLength(512); + entity.HasIndex(x => x.QuestionTemplateId).IsUnique(); entity.HasOne() .WithMany() @@ -197,14 +186,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.ToTable("QuestionScores"); entity.HasKey(x => x.Id); entity.Property(x => x.ParticipantId).HasMaxLength(128).IsRequired(); - entity.Property(x => x.ReasonCode).HasMaxLength(64).IsRequired(); - entity.HasIndex(x => new { x.ImportRunId, x.QuestionTemplateId, x.ParticipantId }).IsUnique(); - entity.HasIndex(x => new { x.ImportRunId, x.DeltaPoints }); - - entity.HasOne() - .WithMany() - .HasForeignKey(x => x.ImportRunId) - .OnDelete(DeleteBehavior.Cascade); + entity.HasIndex(x => new { x.QuestionTemplateId, x.ParticipantId }).IsUnique(); + entity.HasIndex(x => x.DeltaPoints); entity.HasOne() .WithMany() @@ -221,6 +204,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(x => x.Status).HasMaxLength(32).IsRequired(); entity.Property(x => x.PreseasonParseStatus).HasMaxLength(32).IsRequired(); entity.Property(x => x.PreseasonScoringStatus).HasMaxLength(32).IsRequired(); + entity.Property(x => x.ParitySnapshotChecksum).HasMaxLength(128); + entity.Property(x => x.ParityStatus).HasMaxLength(32).IsRequired(); + entity.Property(x => x.ParityComparedChecksum).HasMaxLength(128); + entity.Property(x => x.IdempotencyScopeKey).HasMaxLength(256); + entity.Property(x => x.IdempotencyOutcome).HasMaxLength(32).IsRequired(); entity.Property(x => x.ErrorMessage).HasMaxLength(4000); entity.HasIndex(x => x.SourceFileChecksum); entity.HasIndex(x => x.StartedAtUtc); @@ -269,6 +257,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(x => x.Subject).HasMaxLength(128).IsRequired(); entity.Property(x => x.RawAnswer).HasMaxLength(512); entity.Property(x => x.NormalizedAnswer).HasMaxLength(512); + entity.Property(x => x.NormalizedAnswerBoolean); entity.HasOne() .WithMany() @@ -571,5 +560,40 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(x => new { x.ImportRunId, x.RaceSequence }).IsUnique(); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MigrationImportConflictDiagnostics"); + entity.HasKey(x => x.Id); + entity.Property(x => x.EntityType).HasMaxLength(64).IsRequired(); + entity.Property(x => x.ConflictType).HasMaxLength(64).IsRequired(); + entity.Property(x => x.KeyFields).HasMaxLength(512).IsRequired(); + entity.Property(x => x.SourceReference).HasMaxLength(512).IsRequired(); + entity.Property(x => x.PolicyOutcome).HasMaxLength(32).IsRequired(); + entity.Property(x => x.RecommendedAction).HasMaxLength(256).IsRequired(); + + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.ImportRunId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(x => new { x.ImportRunId, x.EntityType, x.KeyFields }); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("MigrationImportRollbackAudits"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Actor).HasMaxLength(256).IsRequired(); + entity.Property(x => x.Reason).HasMaxLength(2000).IsRequired(); + entity.Property(x => x.Outcome).HasMaxLength(32).IsRequired(); + + entity.HasOne() + .WithMany() + .HasForeignKey(x => x.ImportRunId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(x => new { x.ImportRunId, x.RequestedAtUtc }); + }); } } diff --git a/src/F1.Infrastructure/Migrations/20260707172350_AddCanonicalWriteFields.Designer.cs b/src/F1.Infrastructure/Migrations/20260707172350_AddCanonicalWriteFields.Designer.cs new file mode 100644 index 0000000..f1a212b --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707172350_AddCanonicalWriteFields.Designer.cs @@ -0,0 +1,1816 @@ +// +using System; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + [DbContext(typeof(F1DbContext))] + [Migration("20260707172350_AddCanonicalWriteFields")] + partial class AddCanonicalWriteFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("F1.Core.Models.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Competitions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Driver", b => + { + b.Property("DriverId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermanentNumber") + .HasColumnType("integer"); + + b.HasKey("DriverId"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CircuitName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("FinalDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreQualyDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "Round") + .IsUnique(); + + b.ToTable("Races", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("RaceId", "UserId") + .IsUnique(); + + b.ToTable("Selections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("RawTotal") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportImportedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CircuitName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Season", "Round") + .IsUnique(); + + b.ToTable("MigrationImportJolpicaRaceSnapshots", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("LegacyPoints") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawLegacyPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportLegacyPickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject", "PickType") + .IsUnique(); + + b.ToTable("MigrationImportPickDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject", "IsActualOutcome") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonImportedTallies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CellReference") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ColumnIndex") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PointsPerQuestion") + .HasColumnType("integer"); + + b.Property("RawPointsPerQuestion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonPolicies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonQuestionDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportRaceDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("MappedCircuitId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MappedRaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceSequence") + .HasColumnType("integer"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceRaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Warning") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceSequence") + .IsUnique(); + + b.ToTable("MigrationImportRaceRoundMappings", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRaceSelections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClassificationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RawPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("SectionType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRawRows", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDryRun") + .HasColumnType("boolean"); + + b.Property("MappingWarningCount") + .HasColumnType("integer"); + + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonAnswerCount") + .HasColumnType("integer"); + + b.Property("PreseasonErrorCount") + .HasColumnType("integer"); + + b.Property("PreseasonIsolationGuardPassed") + .HasColumnType("boolean"); + + b.Property("PreseasonParseStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonQuestionDiffCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoredQuestionCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoringStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonTotalDeltaPoints") + .HasColumnType("integer"); + + b.Property("PreseasonWarningCount") + .HasColumnType("integer"); + + b.Property("RawRowCount") + .HasColumnType("integer"); + + b.Property("SourceFileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceFilePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UnresolvedTokenCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SourceFileChecksum"); + + b.HasIndex("StartedAtUtc"); + + b.ToTable("MigrationImportRuns", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawToken") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "RaceCode", "PickType", "Subject", "RawToken") + .IsUnique(); + + b.ToTable("MigrationImportUnresolvedTokens", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("NormalizationDiagnosticsJson") + .HasColumnType("text"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "QuestionTemplateId") + .IsUnique(); + + b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); + + b.ToTable("QuestionActuals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); + + b.ToTable("QuestionAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "DeltaPoints"); + + b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "QuestionId") + .IsUnique(); + + b.HasIndex("CompetitionId", "Season", "Category", "SortOrder"); + + b.ToTable("QuestionTemplates", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.Property("RaceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BonusQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("H2HQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("RaceId"); + + b.ToTable("RaceMetadata", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DriverId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SelectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DriverId"); + + b.HasIndex("SelectionId", "Position") + .IsUnique(); + + b.ToTable("SelectionPositions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.HasOne("F1.Core.Models.Driver", null) + .WithMany() + .HasForeignKey("DriverId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("F1.Core.Models.Selection", null) + .WithMany() + .HasForeignKey("SelectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707172350_AddCanonicalWriteFields.cs b/src/F1.Infrastructure/Migrations/20260707172350_AddCanonicalWriteFields.cs new file mode 100644 index 0000000..14ebcad --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707172350_AddCanonicalWriteFields.cs @@ -0,0 +1,157 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + /// + public partial class AddCanonicalWriteFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IdempotencyOutcome", + table: "MigrationImportRuns", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "IdempotencyScopeKey", + table: "MigrationImportRuns", + type: "character varying(256)", + maxLength: 256, + nullable: true); + + migrationBuilder.AddColumn( + name: "ParityComparedChecksum", + table: "MigrationImportRuns", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "ParityComparedRunId", + table: "MigrationImportRuns", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "ParitySnapshotChecksum", + table: "MigrationImportRuns", + type: "character varying(128)", + maxLength: 128, + nullable: true); + + migrationBuilder.AddColumn( + name: "ParityStatus", + table: "MigrationImportRuns", + type: "character varying(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "MigrationImportConflictDiagnostics", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ImportRunId = table.Column(type: "uuid", nullable: false), + EntityType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + ConflictType = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + KeyFields = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + SourceReference = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + PolicyOutcome = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + RecommendedAction = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MigrationImportConflictDiagnostics", x => x.Id); + table.ForeignKey( + name: "FK_MigrationImportConflictDiagnostics_MigrationImportRuns_Impo~", + column: x => x.ImportRunId, + principalTable: "MigrationImportRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "MigrationImportRollbackAudits", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + ImportRunId = table.Column(type: "uuid", nullable: false), + Actor = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + Reason = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: false), + RequestedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + AffectedRaceCount = table.Column(type: "integer", nullable: false), + AffectedSelectionCount = table.Column(type: "integer", nullable: false), + AffectedSelectionPositionCount = table.Column(type: "integer", nullable: false), + Outcome = table.Column(type: "character varying(32)", maxLength: 32, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_MigrationImportRollbackAudits", x => x.Id); + table.ForeignKey( + name: "FK_MigrationImportRollbackAudits_MigrationImportRuns_ImportRun~", + column: x => x.ImportRunId, + principalTable: "MigrationImportRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_MigrationImportConflictDiagnostics_ImportRunId_EntityType_K~", + table: "MigrationImportConflictDiagnostics", + columns: new[] { "ImportRunId", "EntityType", "KeyFields" }); + + migrationBuilder.CreateIndex( + name: "IX_MigrationImportRollbackAudits_ImportRunId_RequestedAtUtc", + table: "MigrationImportRollbackAudits", + columns: new[] { "ImportRunId", "RequestedAtUtc" }); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "MigrationImportConflictDiagnostics"); + + migrationBuilder.DropTable( + name: "MigrationImportRollbackAudits"); + + migrationBuilder.DropColumn( + name: "IdempotencyOutcome", + table: "MigrationImportRuns"); + + migrationBuilder.DropColumn( + name: "IdempotencyScopeKey", + table: "MigrationImportRuns"); + + migrationBuilder.DropColumn( + name: "ParityComparedChecksum", + table: "MigrationImportRuns"); + + migrationBuilder.DropColumn( + name: "ParityComparedRunId", + table: "MigrationImportRuns"); + + migrationBuilder.DropColumn( + name: "ParitySnapshotChecksum", + table: "MigrationImportRuns"); + + migrationBuilder.DropColumn( + name: "ParityStatus", + table: "MigrationImportRuns"); + + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply.Designer.cs b/src/F1.Infrastructure/Migrations/20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply.Designer.cs new file mode 100644 index 0000000..030ce2e --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply.Designer.cs @@ -0,0 +1,1816 @@ +// +using System; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + [DbContext(typeof(F1DbContext))] + [Migration("20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply")] + partial class RepairCanonicalWriteFieldsAfterEmptyApply + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("F1.Core.Models.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Competitions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Driver", b => + { + b.Property("DriverId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermanentNumber") + .HasColumnType("integer"); + + b.HasKey("DriverId"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CircuitName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("FinalDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreQualyDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "Round") + .IsUnique(); + + b.ToTable("Races", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("RaceId", "UserId") + .IsUnique(); + + b.ToTable("Selections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("RawTotal") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportImportedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CircuitName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Season", "Round") + .IsUnique(); + + b.ToTable("MigrationImportJolpicaRaceSnapshots", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("LegacyPoints") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawLegacyPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportLegacyPickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject", "PickType") + .IsUnique(); + + b.ToTable("MigrationImportPickDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject", "IsActualOutcome") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonImportedTallies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CellReference") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ColumnIndex") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PointsPerQuestion") + .HasColumnType("integer"); + + b.Property("RawPointsPerQuestion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonPolicies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonQuestionDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportRaceDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("MappedCircuitId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MappedRaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceSequence") + .HasColumnType("integer"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceRaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Warning") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceSequence") + .IsUnique(); + + b.ToTable("MigrationImportRaceRoundMappings", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRaceSelections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClassificationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RawPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("SectionType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRawRows", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDryRun") + .HasColumnType("boolean"); + + b.Property("MappingWarningCount") + .HasColumnType("integer"); + + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonAnswerCount") + .HasColumnType("integer"); + + b.Property("PreseasonErrorCount") + .HasColumnType("integer"); + + b.Property("PreseasonIsolationGuardPassed") + .HasColumnType("boolean"); + + b.Property("PreseasonParseStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonQuestionDiffCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoredQuestionCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoringStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonTotalDeltaPoints") + .HasColumnType("integer"); + + b.Property("PreseasonWarningCount") + .HasColumnType("integer"); + + b.Property("RawRowCount") + .HasColumnType("integer"); + + b.Property("SourceFileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceFilePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UnresolvedTokenCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SourceFileChecksum"); + + b.HasIndex("StartedAtUtc"); + + b.ToTable("MigrationImportRuns", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawToken") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "RaceCode", "PickType", "Subject", "RawToken") + .IsUnique(); + + b.ToTable("MigrationImportUnresolvedTokens", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("NormalizationDiagnosticsJson") + .HasColumnType("text"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "QuestionTemplateId") + .IsUnique(); + + b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); + + b.ToTable("QuestionActuals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); + + b.ToTable("QuestionAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "DeltaPoints"); + + b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "QuestionId") + .IsUnique(); + + b.HasIndex("CompetitionId", "Season", "Category", "SortOrder"); + + b.ToTable("QuestionTemplates", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.Property("RaceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BonusQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("H2HQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("RaceId"); + + b.ToTable("RaceMetadata", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DriverId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SelectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DriverId"); + + b.HasIndex("SelectionId", "Position") + .IsUnique(); + + b.ToTable("SelectionPositions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.HasOne("F1.Core.Models.Driver", null) + .WithMany() + .HasForeignKey("DriverId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("F1.Core.Models.Selection", null) + .WithMany() + .HasForeignKey("SelectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply.cs b/src/F1.Infrastructure/Migrations/20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply.cs new file mode 100644 index 0000000..b48e132 --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707172723_RepairCanonicalWriteFieldsAfterEmptyApply.cs @@ -0,0 +1,95 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + /// + public partial class RepairCanonicalWriteFieldsAfterEmptyApply : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + "ALTER TABLE \"MigrationImportRuns\" ADD COLUMN IF NOT EXISTS \"IdempotencyOutcome\" character varying(32) NOT NULL DEFAULT '';", + suppressTransaction: false); + + migrationBuilder.Sql( + "ALTER TABLE \"MigrationImportRuns\" ADD COLUMN IF NOT EXISTS \"IdempotencyScopeKey\" character varying(256);", + suppressTransaction: false); + + migrationBuilder.Sql( + "ALTER TABLE \"MigrationImportRuns\" ADD COLUMN IF NOT EXISTS \"ParityComparedChecksum\" character varying(128);", + suppressTransaction: false); + + migrationBuilder.Sql( + "ALTER TABLE \"MigrationImportRuns\" ADD COLUMN IF NOT EXISTS \"ParityComparedRunId\" uuid;", + suppressTransaction: false); + + migrationBuilder.Sql( + "ALTER TABLE \"MigrationImportRuns\" ADD COLUMN IF NOT EXISTS \"ParitySnapshotChecksum\" character varying(128);", + suppressTransaction: false); + + migrationBuilder.Sql( + "ALTER TABLE \"MigrationImportRuns\" ADD COLUMN IF NOT EXISTS \"ParityStatus\" character varying(32) NOT NULL DEFAULT '';", + suppressTransaction: false); + + migrationBuilder.Sql( + @"CREATE TABLE IF NOT EXISTS ""MigrationImportConflictDiagnostics"" ( + ""Id"" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ""ImportRunId"" uuid NOT NULL, + ""EntityType"" character varying(64) NOT NULL, + ""ConflictType"" character varying(64) NOT NULL, + ""KeyFields"" character varying(512) NOT NULL, + ""SourceReference"" character varying(512) NOT NULL, + ""PolicyOutcome"" character varying(32) NOT NULL, + ""RecommendedAction"" character varying(256) NOT NULL, + ""CreatedAtUtc"" timestamp with time zone NOT NULL, + CONSTRAINT ""FK_MigrationImportConflictDiagnostics_MigrationImportRuns_Impo~"" + FOREIGN KEY (""ImportRunId"") REFERENCES ""MigrationImportRuns"" (""Id"") ON DELETE CASCADE + );", + suppressTransaction: false); + + migrationBuilder.Sql( + @"CREATE TABLE IF NOT EXISTS ""MigrationImportRollbackAudits"" ( + ""Id"" bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ""ImportRunId"" uuid NOT NULL, + ""Actor"" character varying(256) NOT NULL, + ""Reason"" character varying(2000) NOT NULL, + ""RequestedAtUtc"" timestamp with time zone NOT NULL, + ""AffectedRaceCount"" integer NOT NULL, + ""AffectedSelectionCount"" integer NOT NULL, + ""AffectedSelectionPositionCount"" integer NOT NULL, + ""Outcome"" character varying(32) NOT NULL, + CONSTRAINT ""FK_MigrationImportRollbackAudits_MigrationImportRuns_ImportRun~"" + FOREIGN KEY (""ImportRunId"") REFERENCES ""MigrationImportRuns"" (""Id"") ON DELETE CASCADE + );", + suppressTransaction: false); + + migrationBuilder.Sql( + "CREATE INDEX IF NOT EXISTS \"IX_MigrationImportConflictDiagnostics_ImportRunId_EntityType_K~\" ON \"MigrationImportConflictDiagnostics\" (\"ImportRunId\", \"EntityType\", \"KeyFields\");", + suppressTransaction: false); + + migrationBuilder.Sql( + "CREATE INDEX IF NOT EXISTS \"IX_MigrationImportRollbackAudits_ImportRunId_RequestedAtUtc\" ON \"MigrationImportRollbackAudits\" (\"ImportRunId\", \"RequestedAtUtc\");", + suppressTransaction: false); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_MigrationImportRollbackAudits_ImportRunId_RequestedAtUtc\";", suppressTransaction: false); + migrationBuilder.Sql("DROP INDEX IF EXISTS \"IX_MigrationImportConflictDiagnostics_ImportRunId_EntityType_K~\";", suppressTransaction: false); + migrationBuilder.Sql("DROP TABLE IF EXISTS \"MigrationImportRollbackAudits\";", suppressTransaction: false); + migrationBuilder.Sql("DROP TABLE IF EXISTS \"MigrationImportConflictDiagnostics\";", suppressTransaction: false); + migrationBuilder.Sql("ALTER TABLE \"MigrationImportRuns\" DROP COLUMN IF EXISTS \"ParityStatus\";", suppressTransaction: false); + migrationBuilder.Sql("ALTER TABLE \"MigrationImportRuns\" DROP COLUMN IF EXISTS \"ParitySnapshotChecksum\";", suppressTransaction: false); + migrationBuilder.Sql("ALTER TABLE \"MigrationImportRuns\" DROP COLUMN IF EXISTS \"ParityComparedRunId\";", suppressTransaction: false); + migrationBuilder.Sql("ALTER TABLE \"MigrationImportRuns\" DROP COLUMN IF EXISTS \"ParityComparedChecksum\";", suppressTransaction: false); + migrationBuilder.Sql("ALTER TABLE \"MigrationImportRuns\" DROP COLUMN IF EXISTS \"IdempotencyScopeKey\";", suppressTransaction: false); + migrationBuilder.Sql("ALTER TABLE \"MigrationImportRuns\" DROP COLUMN IF EXISTS \"IdempotencyOutcome\";", suppressTransaction: false); + + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707182836_AddQuestionBooleanNormalizationFields.Designer.cs b/src/F1.Infrastructure/Migrations/20260707182836_AddQuestionBooleanNormalizationFields.Designer.cs new file mode 100644 index 0000000..256a898 --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707182836_AddQuestionBooleanNormalizationFields.Designer.cs @@ -0,0 +1,1825 @@ +// +using System; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + [DbContext(typeof(F1DbContext))] + [Migration("20260707182836_AddQuestionBooleanNormalizationFields")] + partial class AddQuestionBooleanNormalizationFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("F1.Core.Models.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Competitions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Driver", b => + { + b.Property("DriverId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermanentNumber") + .HasColumnType("integer"); + + b.HasKey("DriverId"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CircuitName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("FinalDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreQualyDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "Round") + .IsUnique(); + + b.ToTable("Races", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("RaceId", "UserId") + .IsUnique(); + + b.ToTable("Selections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("RawTotal") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportImportedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CircuitName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Season", "Round") + .IsUnique(); + + b.ToTable("MigrationImportJolpicaRaceSnapshots", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("LegacyPoints") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawLegacyPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportLegacyPickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject", "PickType") + .IsUnique(); + + b.ToTable("MigrationImportPickDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject", "IsActualOutcome") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonImportedTallies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CellReference") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ColumnIndex") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PointsPerQuestion") + .HasColumnType("integer"); + + b.Property("RawPointsPerQuestion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonPolicies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonQuestionDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportRaceDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("MappedCircuitId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MappedRaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceSequence") + .HasColumnType("integer"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceRaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Warning") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceSequence") + .IsUnique(); + + b.ToTable("MigrationImportRaceRoundMappings", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRaceSelections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClassificationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RawPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("SectionType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRawRows", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDryRun") + .HasColumnType("boolean"); + + b.Property("MappingWarningCount") + .HasColumnType("integer"); + + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonAnswerCount") + .HasColumnType("integer"); + + b.Property("PreseasonErrorCount") + .HasColumnType("integer"); + + b.Property("PreseasonIsolationGuardPassed") + .HasColumnType("boolean"); + + b.Property("PreseasonParseStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonQuestionDiffCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoredQuestionCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoringStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonTotalDeltaPoints") + .HasColumnType("integer"); + + b.Property("PreseasonWarningCount") + .HasColumnType("integer"); + + b.Property("RawRowCount") + .HasColumnType("integer"); + + b.Property("SourceFileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceFilePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UnresolvedTokenCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SourceFileChecksum"); + + b.HasIndex("StartedAtUtc"); + + b.ToTable("MigrationImportRuns", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawToken") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "RaceCode", "PickType", "Subject", "RawToken") + .IsUnique(); + + b.ToTable("MigrationImportUnresolvedTokens", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("NormalizationDiagnosticsJson") + .HasColumnType("text"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "QuestionTemplateId") + .IsUnique(); + + b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); + + b.ToTable("QuestionActuals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); + + b.ToTable("QuestionAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId"); + + b.HasIndex("ImportRunId", "DeltaPoints"); + + b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "QuestionId") + .IsUnique(); + + b.HasIndex("CompetitionId", "Season", "Category", "SortOrder"); + + b.ToTable("QuestionTemplates", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.Property("RaceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BonusQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("H2HQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("RaceId"); + + b.ToTable("RaceMetadata", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DriverId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SelectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DriverId"); + + b.HasIndex("SelectionId", "Position") + .IsUnique(); + + b.ToTable("SelectionPositions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.HasOne("F1.Core.Models.Driver", null) + .WithMany() + .HasForeignKey("DriverId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("F1.Core.Models.Selection", null) + .WithMany() + .HasForeignKey("SelectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707182836_AddQuestionBooleanNormalizationFields.cs b/src/F1.Infrastructure/Migrations/20260707182836_AddQuestionBooleanNormalizationFields.cs new file mode 100644 index 0000000..c1a1096 --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707182836_AddQuestionBooleanNormalizationFields.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + /// + public partial class AddQuestionBooleanNormalizationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionAnswers", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionActuals", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedAnswerBoolean", + table: "MigrationImportPreseasonAnswers", + type: "boolean", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionAnswers"); + + migrationBuilder.DropColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionActuals"); + + migrationBuilder.DropColumn( + name: "NormalizedAnswerBoolean", + table: "MigrationImportPreseasonAnswers"); + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707184503_RemoveImportRunIdFromQuestionTables.Designer.cs b/src/F1.Infrastructure/Migrations/20260707184503_RemoveImportRunIdFromQuestionTables.Designer.cs new file mode 100644 index 0000000..421ff23 --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707184503_RemoveImportRunIdFromQuestionTables.Designer.cs @@ -0,0 +1,1792 @@ +// +using System; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + [DbContext(typeof(F1DbContext))] + [Migration("20260707184503_RemoveImportRunIdFromQuestionTables")] + partial class RemoveImportRunIdFromQuestionTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("F1.Core.Models.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Competitions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Driver", b => + { + b.Property("DriverId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermanentNumber") + .HasColumnType("integer"); + + b.HasKey("DriverId"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CircuitName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("FinalDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreQualyDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "Round") + .IsUnique(); + + b.ToTable("Races", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("RaceId", "UserId") + .IsUnique(); + + b.ToTable("Selections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("RawTotal") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportImportedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CircuitName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Season", "Round") + .IsUnique(); + + b.ToTable("MigrationImportJolpicaRaceSnapshots", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("LegacyPoints") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawLegacyPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportLegacyPickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject", "PickType") + .IsUnique(); + + b.ToTable("MigrationImportPickDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject", "IsActualOutcome") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonImportedTallies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CellReference") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ColumnIndex") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PointsPerQuestion") + .HasColumnType("integer"); + + b.Property("RawPointsPerQuestion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonPolicies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonQuestionDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportRaceDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("MappedCircuitId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MappedRaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceSequence") + .HasColumnType("integer"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceRaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Warning") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceSequence") + .IsUnique(); + + b.ToTable("MigrationImportRaceRoundMappings", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRaceSelections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClassificationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RawPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("SectionType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRawRows", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDryRun") + .HasColumnType("boolean"); + + b.Property("MappingWarningCount") + .HasColumnType("integer"); + + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonAnswerCount") + .HasColumnType("integer"); + + b.Property("PreseasonErrorCount") + .HasColumnType("integer"); + + b.Property("PreseasonIsolationGuardPassed") + .HasColumnType("boolean"); + + b.Property("PreseasonParseStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonQuestionDiffCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoredQuestionCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoringStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonTotalDeltaPoints") + .HasColumnType("integer"); + + b.Property("PreseasonWarningCount") + .HasColumnType("integer"); + + b.Property("RawRowCount") + .HasColumnType("integer"); + + b.Property("SourceFileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceFilePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UnresolvedTokenCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SourceFileChecksum"); + + b.HasIndex("StartedAtUtc"); + + b.ToTable("MigrationImportRuns", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawToken") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "RaceCode", "PickType", "Subject", "RawToken") + .IsUnique(); + + b.ToTable("MigrationImportUnresolvedTokens", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizationDiagnosticsJson") + .HasColumnType("text"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId") + .IsUnique(); + + b.HasIndex("SourceRow", "SourceColumn"); + + b.ToTable("QuestionActuals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceColumn") + .HasColumnType("integer"); + + b.Property("SourceRow") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.HasIndex("SourceRow", "SourceColumn"); + + b.ToTable("QuestionAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DeltaPoints"); + + b.HasIndex("QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "QuestionId") + .IsUnique(); + + b.HasIndex("CompetitionId", "Season", "Category", "SortOrder"); + + b.ToTable("QuestionTemplates", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.Property("RaceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BonusQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("H2HQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("RaceId"); + + b.ToTable("RaceMetadata", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DriverId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SelectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DriverId"); + + b.HasIndex("SelectionId", "Position") + .IsUnique(); + + b.ToTable("SelectionPositions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.HasOne("F1.Core.Models.Driver", null) + .WithMany() + .HasForeignKey("DriverId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("F1.Core.Models.Selection", null) + .WithMany() + .HasForeignKey("SelectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707184503_RemoveImportRunIdFromQuestionTables.cs b/src/F1.Infrastructure/Migrations/20260707184503_RemoveImportRunIdFromQuestionTables.cs new file mode 100644 index 0000000..ef31a1e --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707184503_RemoveImportRunIdFromQuestionTables.cs @@ -0,0 +1,229 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + /// + public partial class RemoveImportRunIdFromQuestionTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_QuestionActuals_MigrationImportRuns_ImportRunId", + table: "QuestionActuals"); + + migrationBuilder.DropForeignKey( + name: "FK_QuestionAnswers_MigrationImportRuns_ImportRunId", + table: "QuestionAnswers"); + + migrationBuilder.DropForeignKey( + name: "FK_QuestionScores_MigrationImportRuns_ImportRunId", + table: "QuestionScores"); + + migrationBuilder.DropIndex( + name: "IX_QuestionScores_ImportRunId_DeltaPoints", + table: "QuestionScores"); + + migrationBuilder.DropIndex( + name: "IX_QuestionScores_ImportRunId_QuestionTemplateId_ParticipantId", + table: "QuestionScores"); + + migrationBuilder.DropIndex( + name: "IX_QuestionScores_QuestionTemplateId", + table: "QuestionScores"); + + migrationBuilder.DropIndex( + name: "IX_QuestionAnswers_ImportRunId_QuestionTemplateId_ParticipantId", + table: "QuestionAnswers"); + + migrationBuilder.DropIndex( + name: "IX_QuestionAnswers_ImportRunId_SourceRow_SourceColumn", + table: "QuestionAnswers"); + + migrationBuilder.DropIndex( + name: "IX_QuestionAnswers_QuestionTemplateId", + table: "QuestionAnswers"); + + migrationBuilder.DropIndex( + name: "IX_QuestionActuals_ImportRunId_QuestionTemplateId", + table: "QuestionActuals"); + + migrationBuilder.DropIndex( + name: "IX_QuestionActuals_ImportRunId_SourceRow_SourceColumn", + table: "QuestionActuals"); + + migrationBuilder.DropIndex( + name: "IX_QuestionActuals_QuestionTemplateId", + table: "QuestionActuals"); + + migrationBuilder.DropColumn( + name: "ImportRunId", + table: "QuestionScores"); + + migrationBuilder.DropColumn( + name: "ImportRunId", + table: "QuestionAnswers"); + + migrationBuilder.DropColumn( + name: "ImportRunId", + table: "QuestionActuals"); + + migrationBuilder.CreateIndex( + name: "IX_QuestionScores_DeltaPoints", + table: "QuestionScores", + column: "DeltaPoints"); + + migrationBuilder.CreateIndex( + name: "IX_QuestionScores_QuestionTemplateId_ParticipantId", + table: "QuestionScores", + columns: new[] { "QuestionTemplateId", "ParticipantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QuestionAnswers_QuestionTemplateId_ParticipantId", + table: "QuestionAnswers", + columns: new[] { "QuestionTemplateId", "ParticipantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QuestionAnswers_SourceRow_SourceColumn", + table: "QuestionAnswers", + columns: new[] { "SourceRow", "SourceColumn" }); + + migrationBuilder.CreateIndex( + name: "IX_QuestionActuals_QuestionTemplateId", + table: "QuestionActuals", + column: "QuestionTemplateId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QuestionActuals_SourceRow_SourceColumn", + table: "QuestionActuals", + columns: new[] { "SourceRow", "SourceColumn" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_QuestionScores_DeltaPoints", + table: "QuestionScores"); + + migrationBuilder.DropIndex( + name: "IX_QuestionScores_QuestionTemplateId_ParticipantId", + table: "QuestionScores"); + + migrationBuilder.DropIndex( + name: "IX_QuestionAnswers_QuestionTemplateId_ParticipantId", + table: "QuestionAnswers"); + + migrationBuilder.DropIndex( + name: "IX_QuestionAnswers_SourceRow_SourceColumn", + table: "QuestionAnswers"); + + migrationBuilder.DropIndex( + name: "IX_QuestionActuals_QuestionTemplateId", + table: "QuestionActuals"); + + migrationBuilder.DropIndex( + name: "IX_QuestionActuals_SourceRow_SourceColumn", + table: "QuestionActuals"); + + migrationBuilder.AddColumn( + name: "ImportRunId", + table: "QuestionScores", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "ImportRunId", + table: "QuestionAnswers", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.AddColumn( + name: "ImportRunId", + table: "QuestionActuals", + type: "uuid", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000")); + + migrationBuilder.CreateIndex( + name: "IX_QuestionScores_ImportRunId_DeltaPoints", + table: "QuestionScores", + columns: new[] { "ImportRunId", "DeltaPoints" }); + + migrationBuilder.CreateIndex( + name: "IX_QuestionScores_ImportRunId_QuestionTemplateId_ParticipantId", + table: "QuestionScores", + columns: new[] { "ImportRunId", "QuestionTemplateId", "ParticipantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QuestionScores_QuestionTemplateId", + table: "QuestionScores", + column: "QuestionTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_QuestionAnswers_ImportRunId_QuestionTemplateId_ParticipantId", + table: "QuestionAnswers", + columns: new[] { "ImportRunId", "QuestionTemplateId", "ParticipantId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QuestionAnswers_ImportRunId_SourceRow_SourceColumn", + table: "QuestionAnswers", + columns: new[] { "ImportRunId", "SourceRow", "SourceColumn" }); + + migrationBuilder.CreateIndex( + name: "IX_QuestionAnswers_QuestionTemplateId", + table: "QuestionAnswers", + column: "QuestionTemplateId"); + + migrationBuilder.CreateIndex( + name: "IX_QuestionActuals_ImportRunId_QuestionTemplateId", + table: "QuestionActuals", + columns: new[] { "ImportRunId", "QuestionTemplateId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_QuestionActuals_ImportRunId_SourceRow_SourceColumn", + table: "QuestionActuals", + columns: new[] { "ImportRunId", "SourceRow", "SourceColumn" }); + + migrationBuilder.CreateIndex( + name: "IX_QuestionActuals_QuestionTemplateId", + table: "QuestionActuals", + column: "QuestionTemplateId"); + + migrationBuilder.AddForeignKey( + name: "FK_QuestionActuals_MigrationImportRuns_ImportRunId", + table: "QuestionActuals", + column: "ImportRunId", + principalTable: "MigrationImportRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_QuestionAnswers_MigrationImportRuns_ImportRunId", + table: "QuestionAnswers", + column: "ImportRunId", + principalTable: "MigrationImportRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_QuestionScores_MigrationImportRuns_ImportRunId", + table: "QuestionScores", + column: "ImportRunId", + principalTable: "MigrationImportRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707185947_SimplifyQuestionAnswerActualAndScoreFields.Designer.cs b/src/F1.Infrastructure/Migrations/20260707185947_SimplifyQuestionAnswerActualAndScoreFields.Designer.cs new file mode 100644 index 0000000..1f4a86d --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707185947_SimplifyQuestionAnswerActualAndScoreFields.Designer.cs @@ -0,0 +1,1762 @@ +// +using System; +using F1.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + [DbContext(typeof(F1DbContext))] + [Migration("20260707185947_SimplifyQuestionAnswerActualAndScoreFields")] + partial class SimplifyQuestionAnswerActualAndScoreFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.17") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("F1.Core.Models.Competition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Competitions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Driver", b => + { + b.Property("DriverId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Code") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("FullName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Nationality") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PermanentNumber") + .HasColumnType("integer"); + + b.HasKey("DriverId"); + + b.ToTable("Drivers", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.Property("Id") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CircuitName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("FinalDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PreQualyDeadlineUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "Round") + .IsUnique(); + + b.ToTable("Races", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BetType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RaceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SubmittedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("RaceId", "UserId") + .IsUnique(); + + b.ToTable("Selections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("RawTotal") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportImportedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CircuitName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RaceName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("StartTimeUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Season", "Round") + .IsUnique(); + + b.ToTable("MigrationImportJolpicaRaceSnapshots", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("LegacyPoints") + .HasColumnType("integer"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawLegacyPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportLegacyPickScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject", "PickType") + .IsUnique(); + + b.ToTable("MigrationImportPickDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject", "IsActualOutcome") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActualValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Points") + .HasColumnType("integer"); + + b.Property("PredictedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonCalculatedTotals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RawPoints") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonImportedTallies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedTotalPoints") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedTotalPoints") + .HasColumnType("integer"); + + b.Property("NetDeltaPoints") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TopReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TopReasonCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonParticipantDeltaSummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CellReference") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ColumnIndex") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PointsPerQuestion") + .HasColumnType("integer"); + + b.Property("RawPointsPerQuestion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonPolicies", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("QuestionKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("QuestionText") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "QuestionKey", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonQuestionDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportPreseasonReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ExpectedVarianceReasonCode") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ExpectedVarianceRuleId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Explanation") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("IsExpectedVariance") + .HasColumnType("boolean"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "Subject") + .IsUnique(); + + b.ToTable("MigrationImportRaceDiffs", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("MappedCircuitId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MappedRaceName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("RaceSequence") + .HasColumnType("integer"); + + b.Property("Round") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SourceRaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceRowNumber") + .HasColumnType("integer"); + + b.Property("Warning") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceSequence") + .IsUnique(); + + b.ToTable("MigrationImportRaceRoundMappings", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("IsActualOutcome") + .HasColumnType("boolean"); + + b.Property("NormalizedValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawValue") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RaceCode", "PickType", "Subject", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRaceSelections", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClassificationReason") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("RawPayload") + .IsRequired() + .HasColumnType("text"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("SectionType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber") + .IsUnique(); + + b.ToTable("MigrationImportRawRows", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("OccurrenceCount") + .HasColumnType("integer"); + + b.Property("ReasonCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TotalDeltaPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "ReasonCode") + .IsUnique(); + + b.ToTable("MigrationImportReasonCategorySummaries", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ErrorMessage") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("FinishedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("IsDryRun") + .HasColumnType("boolean"); + + b.Property("MappingWarningCount") + .HasColumnType("integer"); + + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonAnswerCount") + .HasColumnType("integer"); + + b.Property("PreseasonErrorCount") + .HasColumnType("integer"); + + b.Property("PreseasonIsolationGuardPassed") + .HasColumnType("boolean"); + + b.Property("PreseasonParseStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonQuestionDiffCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoredQuestionCount") + .HasColumnType("integer"); + + b.Property("PreseasonScoringStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PreseasonTotalDeltaPoints") + .HasColumnType("integer"); + + b.Property("PreseasonWarningCount") + .HasColumnType("integer"); + + b.Property("RawRowCount") + .HasColumnType("integer"); + + b.Property("SourceFileChecksum") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SourceFilePath") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UnresolvedTokenCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SourceFileChecksum"); + + b.HasIndex("StartedAtUtc"); + + b.ToTable("MigrationImportRuns", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("PickType") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RaceCode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RawToken") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RowNumber") + .HasColumnType("integer"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RowNumber", "RaceCode", "PickType", "Subject", "RawToken") + .IsUnique(); + + b.ToTable("MigrationImportUnresolvedTokens", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OverrideAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId") + .IsUnique(); + + b.ToTable("QuestionActuals", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ImportedAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("OverrideAnswer") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionAnswers", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CalculatedPoints") + .HasColumnType("integer"); + + b.Property("DeltaPoints") + .HasColumnType("integer"); + + b.Property("ImportedPoints") + .HasColumnType("integer"); + + b.Property("ParticipantId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("QuestionTemplateId") + .HasColumnType("bigint"); + + b.Property("RecordedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DeltaPoints"); + + b.HasIndex("QuestionTemplateId", "ParticipantId") + .IsUnique(); + + b.ToTable("QuestionScores", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CompetitionId") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("OptionsJson") + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("QuestionId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompetitionId", "Season", "QuestionId") + .IsUnique(); + + b.HasIndex("CompetitionId", "Season", "Category", "SortOrder"); + + b.ToTable("QuestionTemplates", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.Property("RaceId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BonusQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("H2HQuestion") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("RaceId"); + + b.ToTable("RaceMetadata", (string)null); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DriverId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SelectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DriverId"); + + b.HasIndex("SelectionId", "Position") + .IsUnique(); + + b.ToTable("SelectionPositions", (string)null); + }); + + modelBuilder.Entity("F1.Core.Models.Race", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Core.Models.Selection", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportJolpicaRaceSnapshotEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportLegacyPickScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPickDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonCalculatedTotalEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonImportedTallyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonParticipantDeltaSummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonPolicyEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonQuestionDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportPreseasonReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceDiffEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceRoundMappingEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRaceSelectionEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRawRowEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportReasonCategorySummaryEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) + .WithMany() + .HasForeignKey("QuestionTemplateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", b => + { + b.HasOne("F1.Core.Models.Competition", null) + .WithMany() + .HasForeignKey("CompetitionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.RaceMetadataEntity", b => + { + b.HasOne("F1.Core.Models.Race", null) + .WithMany() + .HasForeignKey("RaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("F1.Infrastructure.Data.Entities.SelectionPositionEntity", b => + { + b.HasOne("F1.Core.Models.Driver", null) + .WithMany() + .HasForeignKey("DriverId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("F1.Core.Models.Selection", null) + .WithMany() + .HasForeignKey("SelectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/F1.Infrastructure/Migrations/20260707185947_SimplifyQuestionAnswerActualAndScoreFields.cs b/src/F1.Infrastructure/Migrations/20260707185947_SimplifyQuestionAnswerActualAndScoreFields.cs new file mode 100644 index 0000000..35d42a7 --- /dev/null +++ b/src/F1.Infrastructure/Migrations/20260707185947_SimplifyQuestionAnswerActualAndScoreFields.cs @@ -0,0 +1,280 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace F1.Infrastructure.Migrations +{ + /// + public partial class SimplifyQuestionAnswerActualAndScoreFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_QuestionAnswers_SourceRow_SourceColumn", + table: "QuestionAnswers"); + + migrationBuilder.DropIndex( + name: "IX_QuestionActuals_SourceRow_SourceColumn", + table: "QuestionActuals"); + + migrationBuilder.DropColumn( + name: "ReasonCode", + table: "QuestionScores"); + + migrationBuilder.DropColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionAnswers"); + + migrationBuilder.DropColumn( + name: "SourceColumn", + table: "QuestionAnswers"); + + migrationBuilder.DropColumn( + name: "SourceRow", + table: "QuestionAnswers"); + + migrationBuilder.DropColumn( + name: "NormalizationDiagnosticsJson", + table: "QuestionActuals"); + + migrationBuilder.DropColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionActuals"); + + migrationBuilder.DropColumn( + name: "SourceColumn", + table: "QuestionActuals"); + + migrationBuilder.DropColumn( + name: "SourceRow", + table: "QuestionActuals"); + + migrationBuilder.RenameColumn( + name: "NormalizedAnswer", + table: "QuestionAnswers", + newName: "OverrideAnswer"); + + migrationBuilder.RenameColumn( + name: "NormalizedAnswer", + table: "QuestionActuals", + newName: "OverrideAnswer"); + + migrationBuilder.RenameColumn( + name: "ActualAnswer", + table: "QuestionActuals", + newName: "ImportedAnswer"); + + migrationBuilder.Sql( + @" +UPDATE ""QuestionAnswers"" +SET ""ImportedAnswer"" = CASE lower(trim(""ImportedAnswer"")) + WHEN 'alpine' THEN 'alpine' + WHEN 'alpine f1 team' THEN 'alpine' + WHEN 'amr' THEN 'aston_martin' + WHEN 'aston martin' THEN 'aston_martin' + WHEN 'aston martin f1 team' THEN 'aston_martin' + WHEN 'fer' THEN 'ferrari' + WHEN 'ferrari' THEN 'ferrari' + WHEN 'haas' THEN 'haas' + WHEN 'haas f1 team' THEN 'haas' + WHEN 'mcl' THEN 'mclaren' + WHEN 'mclaren' THEN 'mclaren' + WHEN 'mercedes' THEN 'mercedes' + WHEN 'rb' THEN 'rb' + WHEN 'rbpt' THEN 'red_bull' + WHEN 'rb f1 team' THEN 'rb' + WHEN 'racing bulls' THEN 'rb' + WHEN 'red bull' THEN 'red_bull' + WHEN 'red bull racing' THEN 'red_bull' + WHEN 'sauber' THEN 'sauber' + WHEN 'williams' THEN 'williams' + ELSE ""ImportedAnswer"" +END; + +UPDATE ""QuestionAnswers"" +SET ""OverrideAnswer"" = CASE lower(trim(""OverrideAnswer"")) + WHEN 'alpine' THEN 'alpine' + WHEN 'alpine f1 team' THEN 'alpine' + WHEN 'amr' THEN 'aston_martin' + WHEN 'aston martin' THEN 'aston_martin' + WHEN 'aston martin f1 team' THEN 'aston_martin' + WHEN 'fer' THEN 'ferrari' + WHEN 'ferrari' THEN 'ferrari' + WHEN 'haas' THEN 'haas' + WHEN 'haas f1 team' THEN 'haas' + WHEN 'mcl' THEN 'mclaren' + WHEN 'mclaren' THEN 'mclaren' + WHEN 'mercedes' THEN 'mercedes' + WHEN 'rb' THEN 'rb' + WHEN 'rbpt' THEN 'red_bull' + WHEN 'rb f1 team' THEN 'rb' + WHEN 'racing bulls' THEN 'rb' + WHEN 'red bull' THEN 'red_bull' + WHEN 'red bull racing' THEN 'red_bull' + WHEN 'sauber' THEN 'sauber' + WHEN 'williams' THEN 'williams' + ELSE ""OverrideAnswer"" +END; + +UPDATE ""QuestionActuals"" +SET ""ImportedAnswer"" = CASE lower(trim(""ImportedAnswer"")) + WHEN 'alpine' THEN 'alpine' + WHEN 'alpine f1 team' THEN 'alpine' + WHEN 'amr' THEN 'aston_martin' + WHEN 'aston martin' THEN 'aston_martin' + WHEN 'aston martin f1 team' THEN 'aston_martin' + WHEN 'fer' THEN 'ferrari' + WHEN 'ferrari' THEN 'ferrari' + WHEN 'haas' THEN 'haas' + WHEN 'haas f1 team' THEN 'haas' + WHEN 'mcl' THEN 'mclaren' + WHEN 'mclaren' THEN 'mclaren' + WHEN 'mercedes' THEN 'mercedes' + WHEN 'rb' THEN 'rb' + WHEN 'rbpt' THEN 'red_bull' + WHEN 'rb f1 team' THEN 'rb' + WHEN 'racing bulls' THEN 'rb' + WHEN 'red bull' THEN 'red_bull' + WHEN 'red bull racing' THEN 'red_bull' + WHEN 'sauber' THEN 'sauber' + WHEN 'williams' THEN 'williams' + ELSE ""ImportedAnswer"" +END; + +UPDATE ""QuestionActuals"" +SET ""OverrideAnswer"" = CASE lower(trim(""OverrideAnswer"")) + WHEN 'alpine' THEN 'alpine' + WHEN 'alpine f1 team' THEN 'alpine' + WHEN 'amr' THEN 'aston_martin' + WHEN 'aston martin' THEN 'aston_martin' + WHEN 'aston martin f1 team' THEN 'aston_martin' + WHEN 'fer' THEN 'ferrari' + WHEN 'ferrari' THEN 'ferrari' + WHEN 'haas' THEN 'haas' + WHEN 'haas f1 team' THEN 'haas' + WHEN 'mcl' THEN 'mclaren' + WHEN 'mclaren' THEN 'mclaren' + WHEN 'mercedes' THEN 'mercedes' + WHEN 'rb' THEN 'rb' + WHEN 'rbpt' THEN 'red_bull' + WHEN 'rb f1 team' THEN 'rb' + WHEN 'racing bulls' THEN 'rb' + WHEN 'red bull' THEN 'red_bull' + WHEN 'red bull racing' THEN 'red_bull' + WHEN 'sauber' THEN 'sauber' + WHEN 'williams' THEN 'williams' + ELSE ""OverrideAnswer"" +END; + +UPDATE ""MigrationImportPreseasonAnswers"" +SET ""NormalizedAnswer"" = CASE lower(trim(""NormalizedAnswer"")) + WHEN 'alpine' THEN 'alpine' + WHEN 'alpine f1 team' THEN 'alpine' + WHEN 'amr' THEN 'aston_martin' + WHEN 'aston martin' THEN 'aston_martin' + WHEN 'aston martin f1 team' THEN 'aston_martin' + WHEN 'fer' THEN 'ferrari' + WHEN 'ferrari' THEN 'ferrari' + WHEN 'haas' THEN 'haas' + WHEN 'haas f1 team' THEN 'haas' + WHEN 'mcl' THEN 'mclaren' + WHEN 'mclaren' THEN 'mclaren' + WHEN 'mercedes' THEN 'mercedes' + WHEN 'rb' THEN 'rb' + WHEN 'rbpt' THEN 'red_bull' + WHEN 'rb f1 team' THEN 'rb' + WHEN 'racing bulls' THEN 'rb' + WHEN 'red bull' THEN 'red_bull' + WHEN 'red bull racing' THEN 'red_bull' + WHEN 'sauber' THEN 'sauber' + WHEN 'williams' THEN 'williams' + ELSE ""NormalizedAnswer"" +END; +"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.RenameColumn( + name: "OverrideAnswer", + table: "QuestionAnswers", + newName: "NormalizedAnswer"); + + migrationBuilder.RenameColumn( + name: "OverrideAnswer", + table: "QuestionActuals", + newName: "NormalizedAnswer"); + + migrationBuilder.RenameColumn( + name: "ImportedAnswer", + table: "QuestionActuals", + newName: "ActualAnswer"); + + migrationBuilder.AddColumn( + name: "ReasonCode", + table: "QuestionScores", + type: "character varying(64)", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionAnswers", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceColumn", + table: "QuestionAnswers", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SourceRow", + table: "QuestionAnswers", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "NormalizationDiagnosticsJson", + table: "QuestionActuals", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "NormalizedAnswerBoolean", + table: "QuestionActuals", + type: "boolean", + nullable: true); + + migrationBuilder.AddColumn( + name: "SourceColumn", + table: "QuestionActuals", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SourceRow", + table: "QuestionActuals", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_QuestionAnswers_SourceRow_SourceColumn", + table: "QuestionAnswers", + columns: new[] { "SourceRow", "SourceColumn" }); + + migrationBuilder.CreateIndex( + name: "IX_QuestionActuals_SourceRow_SourceColumn", + table: "QuestionActuals", + columns: new[] { "SourceRow", "SourceColumn" }); + } + } +} diff --git a/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs b/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs index 995e153..e285365 100644 --- a/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs +++ b/src/F1.Infrastructure/Migrations/F1DbContextModelSnapshot.cs @@ -228,6 +228,57 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("MigrationImportCalculatedTotals", (string)null); }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConflictType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EntityType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("KeyFields") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("PolicyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RecommendedAction") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceReference") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "EntityType", "KeyFields"); + + b.ToTable("MigrationImportConflictDiagnostics", (string)null); + }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => { b.Property("Id") @@ -462,6 +513,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasMaxLength(512) .HasColumnType("character varying(512)"); + b.Property("NormalizedAnswerBoolean") + .HasColumnType("boolean"); + b.Property("QuestionKey") .IsRequired() .HasMaxLength(64) @@ -1002,6 +1056,51 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("MigrationImportReasonCategorySummaries", (string)null); }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Actor") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("AffectedRaceCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionCount") + .HasColumnType("integer"); + + b.Property("AffectedSelectionPositionCount") + .HasColumnType("integer"); + + b.Property("ImportRunId") + .HasColumnType("uuid"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RequestedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ImportRunId", "RequestedAtUtc"); + + b.ToTable("MigrationImportRollbackAudits", (string)null); + }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", b => { b.Property("Id") @@ -1015,12 +1114,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("FinishedAtUtc") .HasColumnType("timestamp with time zone"); + b.Property("IdempotencyOutcome") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("IdempotencyScopeKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + b.Property("IsDryRun") .HasColumnType("boolean"); b.Property("MappingWarningCount") .HasColumnType("integer"); + b.Property("ParityComparedChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityComparedRunId") + .HasColumnType("uuid"); + + b.Property("ParitySnapshotChecksum") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ParityStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + b.Property("PreseasonAnswerCount") .HasColumnType("integer"); @@ -1138,17 +1262,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("ActualAnswer") + b.Property("ImportedAnswer") .HasMaxLength(512) .HasColumnType("character varying(512)"); - b.Property("ImportRunId") - .HasColumnType("uuid"); - - b.Property("NormalizationDiagnosticsJson") - .HasColumnType("text"); - - b.Property("NormalizedAnswer") + b.Property("OverrideAnswer") .HasMaxLength(512) .HasColumnType("character varying(512)"); @@ -1158,21 +1276,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("SourceColumn") - .HasColumnType("integer"); - - b.Property("SourceRow") - .HasColumnType("integer"); - b.HasKey("Id"); - b.HasIndex("QuestionTemplateId"); - - b.HasIndex("ImportRunId", "QuestionTemplateId") + b.HasIndex("QuestionTemplateId") .IsUnique(); - b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); - b.ToTable("QuestionActuals", (string)null); }); @@ -1184,14 +1292,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - b.Property("ImportRunId") - .HasColumnType("uuid"); - b.Property("ImportedAnswer") .HasMaxLength(512) .HasColumnType("character varying(512)"); - b.Property("NormalizedAnswer") + b.Property("OverrideAnswer") .HasMaxLength(512) .HasColumnType("character varying(512)"); @@ -1206,21 +1311,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); - b.Property("SourceColumn") - .HasColumnType("integer"); - - b.Property("SourceRow") - .HasColumnType("integer"); - b.HasKey("Id"); - b.HasIndex("QuestionTemplateId"); - - b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + b.HasIndex("QuestionTemplateId", "ParticipantId") .IsUnique(); - b.HasIndex("ImportRunId", "SourceRow", "SourceColumn"); - b.ToTable("QuestionAnswers", (string)null); }); @@ -1238,9 +1333,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("DeltaPoints") .HasColumnType("integer"); - b.Property("ImportRunId") - .HasColumnType("uuid"); - b.Property("ImportedPoints") .HasColumnType("integer"); @@ -1252,21 +1344,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("QuestionTemplateId") .HasColumnType("bigint"); - b.Property("ReasonCode") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); - b.Property("RecordedAtUtc") .HasColumnType("timestamp with time zone"); b.HasKey("Id"); - b.HasIndex("QuestionTemplateId"); - - b.HasIndex("ImportRunId", "DeltaPoints"); + b.HasIndex("DeltaPoints"); - b.HasIndex("ImportRunId", "QuestionTemplateId", "ParticipantId") + b.HasIndex("QuestionTemplateId", "ParticipantId") .IsUnique(); b.ToTable("QuestionScores", (string)null); @@ -1420,6 +1505,15 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportConflictDiagnosticEntity", b => + { + b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) + .WithMany() + .HasForeignKey("ImportRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportImportedTotalEntity", b => { b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) @@ -1582,7 +1676,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportRollbackAuditEntity", b => { b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) .WithMany() @@ -1591,14 +1685,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); - modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + modelBuilder.Entity("F1.Infrastructure.Data.Entities.MigrationImportUnresolvedTokenEntity", b => { b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) .WithMany() .HasForeignKey("ImportRunId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + }); + modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionActualEntity", b => + { b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) .WithMany() .HasForeignKey("QuestionTemplateId") @@ -1608,12 +1705,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionAnswerEntity", b => { - b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) - .WithMany() - .HasForeignKey("ImportRunId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) .WithMany() .HasForeignKey("QuestionTemplateId") @@ -1623,12 +1714,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("F1.Infrastructure.Data.Entities.QuestionScoreEntity", b => { - b.HasOne("F1.Infrastructure.Data.Entities.MigrationImportRunEntity", null) - .WithMany() - .HasForeignKey("ImportRunId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - b.HasOne("F1.Infrastructure.Data.Entities.QuestionTemplateEntity", null) .WithMany() .HasForeignKey("QuestionTemplateId") diff --git a/src/F1.Web/Models/AdminMigrationRunModels.cs b/src/F1.Web/Models/AdminMigrationRunModels.cs index b1f2435..b3eccaf 100644 --- a/src/F1.Web/Models/AdminMigrationRunModels.cs +++ b/src/F1.Web/Models/AdminMigrationRunModels.cs @@ -43,9 +43,19 @@ public sealed record AdminMigrationRunDetailResponse( IReadOnlyList PreseasonParticipantDeltas, IReadOnlyList PreseasonQuestionDiffs, IReadOnlyList PreseasonReasonCategorySummaries, + IReadOnlyList ConflictDiagnostics, IReadOnlyList RaceDiffs, IReadOnlyList PickDiffs); +public sealed record AdminMigrationConflictDiagnostic( + string EntityType, + string ConflictType, + string KeyFields, + string SourceReference, + string PolicyOutcome, + string RecommendedAction, + DateTime CreatedAtUtc); + public sealed record AdminMigrationUnresolvedTokenSummary( string RawToken, int OccurrenceCount, @@ -117,12 +127,14 @@ public sealed record AdminMigrationPickDiff( public sealed record AdminMigrationRunKickoffRequest( string? SourceFilePath, - string Mode); + string Mode, + bool ConfirmNonEmptyStrategy = false); public sealed record AdminMigrationRunKickoffUploadRequest( string FileName, Stream Content, - string Mode); + string Mode, + bool ConfirmNonEmptyStrategy = false); public sealed record AdminMigrationRunKickoffResponse( Guid RunId, @@ -132,7 +144,15 @@ public sealed record AdminMigrationRunKickoffResponse( string SourceFilePath, string SourceFileChecksum, DateTime TriggeredAtUtc, - string RequestedBy); + string RequestedBy, + string NonEmptyDbStrategy, + bool CanonicalDataPresent, + int ExistingDriverCount, + int ExistingRaceCount, + int ExistingSelectionCount, + int EstimatedAffectedRaceCount, + int EstimatedAffectedParticipantCount, + int EstimatedAffectedSelectionCount); public sealed record AdminMigrationQuestionDiffListResponse( int Page, diff --git a/src/F1.Web/Pages/AdminMigrationRuns.razor b/src/F1.Web/Pages/AdminMigrationRuns.razor index 322ab10..867b784 100644 --- a/src/F1.Web/Pages/AdminMigrationRuns.razor +++ b/src/F1.Web/Pages/AdminMigrationRuns.razor @@ -81,6 +81,15 @@ @(IsUploadKickoff ? kickoffUploadFileName : kickoffSourceFilePath).

Impact: this creates a new migration run record and will block duplicate active runs for the same source/checksum.

+ @if (IsWriteKickoff) + { +
+ + +
+ }
@@ -894,6 +903,7 @@ private IBrowserFile? kickoffUploadFile; private string? kickoffUploadFileName; private string kickoffMode = "dry-run"; + private bool kickoffConfirmNonEmptyStrategy; private int page = 1; private int pageSize = 25; @@ -977,6 +987,7 @@ { kickoffSuccessMessage = null; errorMessage = null; + kickoffConfirmNonEmptyStrategy = false; if (IsUploadKickoff && kickoffUploadFile is null) { @@ -1005,6 +1016,12 @@ private async Task ConfirmKickoffAsync() { + if (IsWriteKickoff && !kickoffConfirmNonEmptyStrategy) + { + errorMessage = "Write mode requires explicit non-empty DB strategy confirmation."; + return; + } + isKickoffSubmitting = true; kickoffSuccessMessage = null; errorMessage = null; @@ -1024,13 +1041,15 @@ response = await MigrationRunsApi.StartRunFromUploadAsync(new AdminMigrationRunKickoffUploadRequest( FileName: kickoffUploadFile.Name, Content: uploadStream, - Mode: kickoffMode)); + Mode: kickoffMode, + ConfirmNonEmptyStrategy: kickoffConfirmNonEmptyStrategy)); } else { response = await MigrationRunsApi.StartRunAsync(new AdminMigrationRunKickoffRequest( SourceFilePath: kickoffSourceFilePath, - Mode: kickoffMode)); + Mode: kickoffMode, + ConfirmNonEmptyStrategy: kickoffConfirmNonEmptyStrategy)); } kickoffSuccessMessage = @@ -1057,6 +1076,8 @@ private bool IsUploadKickoff => string.Equals(kickoffSourceKind, "upload", StringComparison.OrdinalIgnoreCase); + private bool IsWriteKickoff => string.Equals(kickoffMode, "write", StringComparison.OrdinalIgnoreCase); + private async Task SelectRunAsync(Guid runId, bool resetTabToOverview = true, bool syncUrl = true) { isLoadingDetail = true; diff --git a/src/F1.Web/Services/Api/MigrationRunsApiService.cs b/src/F1.Web/Services/Api/MigrationRunsApiService.cs index 3e8bcd3..1792394 100644 --- a/src/F1.Web/Services/Api/MigrationRunsApiService.cs +++ b/src/F1.Web/Services/Api/MigrationRunsApiService.cs @@ -182,6 +182,7 @@ public async Task StartRunFromUploadAsync(Admi fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("text/csv"); content.Add(fileContent, "SourceFile", request.FileName); content.Add(new StringContent(request.Mode), "Mode"); + content.Add(new StringContent(request.ConfirmNonEmptyStrategy ? "true" : "false"), "ConfirmNonEmptyStrategy"); using var response = await httpClient.PostAsync("admin/migration-runs/kickoff/upload", content, cancellationToken); return await ApiResponseParser.ReadRequiredJsonAsync( diff --git a/tests/F1.Api.Tests/Controllers/MigrationRunsControllerTests.cs b/tests/F1.Api.Tests/Controllers/MigrationRunsControllerTests.cs index a1c7f8a..9e146a2 100644 --- a/tests/F1.Api.Tests/Controllers/MigrationRunsControllerTests.cs +++ b/tests/F1.Api.Tests/Controllers/MigrationRunsControllerTests.cs @@ -242,8 +242,7 @@ public async Task GetQuestionDiffs_ForwardsFiltersToService() Participant: "Philip", ImportedPoints: 20, CalculatedPoints: 0, - DeltaPoints: -20, - ReasonCode: "PRESEASON_RULE_VARIANCE") + DeltaPoints: -20) ])); var controller = new MigrationRunsController(service.Object) @@ -574,6 +573,68 @@ public async Task KickoffRunFromUpload_WhenFileMissing_ReturnsBadRequest() Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); } + [Fact] + public async Task RollbackRun_WhenReasonMissing_ReturnsBadRequest() + { + var service = new Mock(); + var controller = new MigrationRunsController(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = CreateHttpContext(isAdmin: true) + } + }; + + var result = await controller.RollbackRun(Guid.NewGuid(), new AdminMigrationRollbackRequestDto(" ")); + + var badRequest = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + service.Verify(x => x.RollbackRunAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task RollbackRun_WhenServiceSucceeds_ReturnsOkPayload() + { + var runId = Guid.NewGuid(); + var requestedAtUtc = new DateTime(2026, 7, 7, 8, 0, 0, DateTimeKind.Utc); + var service = new Mock(); + service + .Setup(x => x.RollbackRunAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MigrationRunRollbackResult( + Success: true, + Error: null, + Rollback: new AdminMigrationRollbackResponseDto( + RunId: runId, + Status: "RolledBack", + RequestedAtUtc: requestedAtUtc, + RequestedBy: "admin@example.com", + Outcome: "Completed", + AffectedRaceCount: 1, + AffectedSelectionCount: 2, + AffectedSelectionPositionCount: 6))); + + var controller = new MigrationRunsController(service.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = CreateHttpContext(isAdmin: true) + } + }; + + var result = await controller.RollbackRun(runId, new AdminMigrationRollbackRequestDto("bad canonical write")); + + var ok = Assert.IsType(result); + var payload = Assert.IsType(ok.Value); + Assert.Equal(runId, payload.RunId); + Assert.Equal("RolledBack", payload.Status); + service.Verify(x => x.RollbackRunAsync( + It.Is(command => + command.RunId == runId && + command.RequestedBy == "admin@example.com" && + command.Reason == "bad canonical write"), + It.IsAny()), Times.Once); + } + private static HttpContext CreateHttpContext(bool isAdmin) { var claims = new List diff --git a/tests/F1.Api.Tests/Services/MigrationRunAdminServiceTests.cs b/tests/F1.Api.Tests/Services/MigrationRunAdminServiceTests.cs index de852a5..dc2e10a 100644 --- a/tests/F1.Api.Tests/Services/MigrationRunAdminServiceTests.cs +++ b/tests/F1.Api.Tests/Services/MigrationRunAdminServiceTests.cs @@ -4,12 +4,438 @@ using F1.Infrastructure.Data; using F1.Infrastructure.Data.Entities; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging.Abstractions; namespace F1.Api.Tests.Services; public sealed class MigrationRunAdminServiceTests { + [Fact] + public async Task KickoffRunAsync_WhenWriteModeAndCanonicalDataExistsWithoutConfirmation_FailsValidation() + { + var options = CreateOptions(); + var sourcePath = CreateTempCsv( + "Question,Philip\n" + + "AUS-1,VER\n"); + + try + { + await using (var dbContext = new F1DbContext(options)) + { + dbContext.Drivers.Add(new F1.Core.Models.Driver { DriverId = "VER", FullName = "Max Verstappen" }); + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var result = await service.KickoffRunAsync( + new MigrationRunKickoffCommand(sourcePath, "write", "admin@example.com", ConfirmNonEmptyStrategy: false), + CancellationToken.None); + + Assert.False(result.Success); + Assert.False(result.Conflict); + Assert.NotNull(result.Error); + Assert.Contains("confirmNonEmptyStrategy", result.Error, StringComparison.OrdinalIgnoreCase); + Assert.Null(result.Run); + } + finally + { + File.Delete(sourcePath); + } + } + + [Fact] + public async Task KickoffRunAsync_ReturnsNonEmptyStrategyPreviewMetadata() + { + var options = CreateOptions(); + var sourcePath = CreateTempCsv( + "Question,Philip,Andy\n" + + "AUS-1,VER,NOR\n" + + "AUS-2,PIA,RUS\n" + + "BHR-1,LEC,HAM\n"); + + try + { + await using (var dbContext = new F1DbContext(options)) + { + dbContext.Drivers.Add(new F1.Core.Models.Driver { DriverId = "VER", FullName = "Max Verstappen" }); + dbContext.Races.Add(new F1.Core.Models.Race + { + Id = "race-1", + CompetitionId = 1, + Season = 2025, + Round = 1, + RaceName = "Australian Grand Prix", + CircuitName = "Albert Park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + dbContext.Selections.Add(new F1.Core.Models.Selection + { + Id = Guid.NewGuid(), + UserId = "Philip", + RaceId = "race-1", + BetType = F1.Core.Models.BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }); + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var result = await service.KickoffRunAsync( + new MigrationRunKickoffCommand(sourcePath, "write", "admin@example.com", ConfirmNonEmptyStrategy: true), + CancellationToken.None); + + Assert.True(result.Success); + Assert.NotNull(result.Run); + Assert.Equal("merge_upsert_active_records", result.Run!.NonEmptyDbStrategy); + Assert.True(result.Run.CanonicalDataPresent); + Assert.Equal(1, result.Run.ExistingDriverCount); + Assert.Equal(1, result.Run.ExistingRaceCount); + Assert.Equal(1, result.Run.ExistingSelectionCount); + Assert.Equal(2, result.Run.EstimatedAffectedRaceCount); + Assert.Equal(2, result.Run.EstimatedAffectedParticipantCount); + Assert.Equal(4, result.Run.EstimatedAffectedSelectionCount); + } + finally + { + File.Delete(sourcePath); + } + } + + [Fact] + public async Task RollbackRunAsync_DeletesCanonicalScopeAndPersistsAudit() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + + await using (var dbContext = new F1DbContext(options)) + { + var competition = new F1.Core.Models.Competition + { + Name = "Migration Import 2025", + Year = 2025, + Description = "Rollback scope" + }; + dbContext.Competitions.Add(competition); + await dbContext.SaveChangesAsync(); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "test.csv", + SourceFileChecksum = "abc", + IsDryRun = false, + Status = "Completed", + StartedAtUtc = DateTime.UtcNow, + FinishedAtUtc = DateTime.UtcNow, + RawRowCount = 2 + }); + + dbContext.MigrationImportRaceSelections.Add(new MigrationImportRaceSelectionEntity + { + ImportRunId = runId, + RowNumber = 2, + RaceCode = "albert_park", + PickType = "1", + Subject = "Philip", + NormalizedValue = "VER" + }); + + dbContext.MigrationImportRaceRoundMappings.Add(new MigrationImportRaceRoundMappingEntity + { + ImportRunId = runId, + RaceSequence = 1, + SourceRowNumber = 2, + SourceRaceCode = "AUS-1", + Season = 2025, + Round = 1, + MappedCircuitId = "albert_park", + MappedRaceName = "Australian Grand Prix" + }); + + dbContext.Races.Add(new F1.Core.Models.Race + { + Id = "migration-2025-albert-park", + CompetitionId = competition.Id, + Season = 2025, + Round = 1, + RaceName = "albert_park", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + + dbContext.Races.Add(new F1.Core.Models.Race + { + Id = "migration-2024-albert-park", + CompetitionId = competition.Id, + Season = 2024, + Round = 1, + RaceName = "albert_park", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + + var selectionId = Guid.NewGuid(); + dbContext.Selections.Add(new F1.Core.Models.Selection + { + Id = selectionId, + UserId = "Philip", + RaceId = "migration-2025-albert-park", + BetType = F1.Core.Models.BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }); + + dbContext.SelectionPositions.Add(new SelectionPositionEntity + { + SelectionId = selectionId, + Position = 1, + DriverId = "VER" + }); + + var outOfScopeSelectionId = Guid.NewGuid(); + dbContext.Selections.Add(new F1.Core.Models.Selection + { + Id = outOfScopeSelectionId, + UserId = "Alex", + RaceId = "migration-2024-albert-park", + BetType = F1.Core.Models.BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }); + dbContext.SelectionPositions.Add(new SelectionPositionEntity + { + SelectionId = outOfScopeSelectionId, + Position = 1, + DriverId = "VER" + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var rollback = await service.RollbackRunAsync( + new MigrationRunRollbackCommand(runId, "admin@example.com", "Bad write run"), + CancellationToken.None); + + Assert.True(rollback.Success); + Assert.NotNull(rollback.Rollback); + Assert.Equal("RolledBack", rollback.Rollback!.Status); + + await using var verificationContext = new F1DbContext(options); + Assert.Single(verificationContext.Selections); + Assert.Single(verificationContext.SelectionPositions); + Assert.Empty(verificationContext.Races.Where(x => x.Id == "migration-2025-albert-park")); + Assert.NotNull(await verificationContext.Races.FirstOrDefaultAsync(x => x.Id == "migration-2024-albert-park")); + + var audit = Assert.Single(verificationContext.MigrationImportRollbackAudits); + Assert.Equal("admin@example.com", audit.Actor); + Assert.Equal("Bad write run", audit.Reason); + Assert.Equal("Completed", audit.Outcome); + } + + [Fact] + public async Task RollbackRunAsync_WhenRunIsInProgress_ReturnsValidationError() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "test.csv", + SourceFileChecksum = "abc", + IsDryRun = false, + Status = "Running", + StartedAtUtc = DateTime.UtcNow, + RawRowCount = 1 + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var rollback = await service.RollbackRunAsync( + new MigrationRunRollbackCommand(runId, "admin@example.com", "not allowed in running state"), + CancellationToken.None); + + Assert.False(rollback.Success); + Assert.Equal("Only completed or failed runs can be rolled back.", rollback.Error); + Assert.Null(rollback.Rollback); + } + + [Fact] + public async Task GetRunDetailAsync_IncludesRollbackAuditsWhenPresent() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "test.csv", + SourceFileChecksum = "abc", + IsDryRun = false, + Status = "RolledBack", + StartedAtUtc = DateTime.UtcNow, + FinishedAtUtc = DateTime.UtcNow, + RawRowCount = 2 + }); + + dbContext.MigrationImportRollbackAudits.Add(new MigrationImportRollbackAuditEntity + { + ImportRunId = runId, + Actor = "admin@example.com", + Reason = "compensating canonical write", + RequestedAtUtc = DateTime.UtcNow, + AffectedRaceCount = 1, + AffectedSelectionCount = 2, + AffectedSelectionPositionCount = 4, + Outcome = "Completed" + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var detail = await service.GetRunDetailAsync(runId, "admin@example.com", CancellationToken.None, null); + Assert.NotNull(detail); + Assert.NotNull(detail!.RollbackAudits); + var audit = Assert.Single(detail.RollbackAudits!); + Assert.Equal("admin@example.com", audit.Actor); + Assert.Equal("Completed", audit.Outcome); + Assert.Equal(1, audit.AffectedRaceCount); + Assert.Equal(2, audit.AffectedSelectionCount); + Assert.Equal(4, audit.AffectedSelectionPositionCount); + } + + [Fact] + public async Task GetRunDetailAsync_WhenRunRawRowCountIsZero_UsesStagedRowFallbackCount() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "test.csv", + SourceFileChecksum = "abc", + IsDryRun = false, + Status = "Failed", + StartedAtUtc = DateTime.UtcNow, + FinishedAtUtc = DateTime.UtcNow, + RawRowCount = 0 + }); + + dbContext.MigrationImportRawRows.AddRange( + new MigrationImportRawRowEntity + { + ImportRunId = runId, + RowNumber = 1, + SectionType = "Header", + RawPayload = "Question,Philip", + CreatedAtUtc = DateTime.UtcNow + }, + new MigrationImportRawRowEntity + { + ImportRunId = runId, + RowNumber = 2, + SectionType = "RacePick", + RawPayload = "AUS-1,VER", + CreatedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportPickDiffs.Add(new MigrationImportPickDiffEntity + { + ImportRunId = runId, + RaceCode = "albert_park", + PickType = "1", + Subject = "Philip", + ImportedPoints = 10, + CalculatedPoints = 5, + DeltaPoints = -5, + ReasonCode = "PODIUM_RULE_VARIANCE", + Explanation = "fallback-count" + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var detail = await service.GetRunDetailAsync(runId, "admin@example.com", CancellationToken.None, null); + + Assert.NotNull(detail); + Assert.Equal(2, detail!.RawRowCount); + } + + [Fact] + public async Task GetRunDetailAsync_IncludesConflictDiagnosticsForAdmins() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + + await using (var dbContext = new F1DbContext(options)) + { + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = "test.csv", + SourceFileChecksum = "abc", + IsDryRun = false, + Status = "Failed", + StartedAtUtc = DateTime.UtcNow, + FinishedAtUtc = DateTime.UtcNow, + RawRowCount = 2 + }); + + dbContext.MigrationImportConflictDiagnostics.Add(new MigrationImportConflictDiagnosticEntity + { + ImportRunId = runId, + EntityType = "Selection", + ConflictType = "existing_active_selection", + KeyFields = "raceId:migration-2025-albert-park|subject:Philip", + SourceReference = "row:2|race:albert_park|subject:Philip", + PolicyOutcome = "Failed", + RecommendedAction = "Review conflicting canonical rows and rerun with approved policy.", + CreatedAtUtc = DateTime.UtcNow + }); + + await dbContext.SaveChangesAsync(); + } + + await using var serviceContext = new F1DbContext(options); + var service = new MigrationRunAdminService(serviceContext, NullLogger.Instance); + + var detail = await service.GetRunDetailAsync(runId, "admin@example.com", CancellationToken.None, null); + Assert.NotNull(detail); + Assert.NotNull(detail!.ConflictDiagnostics); + Assert.Single(detail.ConflictDiagnostics!); + Assert.Equal("Selection", detail.ConflictDiagnostics![0].EntityType); + Assert.Equal("Failed", detail.ConflictDiagnostics[0].PolicyOutcome); + } + [Fact] public async Task GetRunsAsync_IncludesPreseasonParticipantDelta_InTotalDeltaPoints() { @@ -610,37 +1036,31 @@ public async Task GetQuestionDiffsAsync_AppliesFiltersAndReturnsStablePagination new QuestionScoreEntity { Id = 1, - ImportRunId = runId, QuestionTemplateId = 2, ParticipantId = "Morgan", ImportedPoints = 20, CalculatedPoints = 0, DeltaPoints = -20, - ReasonCode = "PRESEASON_RULE_VARIANCE", RecordedAtUtc = DateTime.UtcNow }, new QuestionScoreEntity { Id = 2, - ImportRunId = runId, QuestionTemplateId = 2, ParticipantId = "Taylor", ImportedPoints = 20, CalculatedPoints = 20, DeltaPoints = 0, - ReasonCode = "PRESEASON_POINTS_MATCH", RecordedAtUtc = DateTime.UtcNow }, new QuestionScoreEntity { Id = 3, - ImportRunId = runId, QuestionTemplateId = 1, ParticipantId = "Morgan", ImportedPoints = 10, CalculatedPoints = 5, DeltaPoints = -5, - ReasonCode = "H2H_RULE_VARIANCE", RecordedAtUtc = DateTime.UtcNow }); @@ -721,13 +1141,11 @@ public async Task ExportRunDiffsAsync_WhenQuestionDiffExportRequested_IncludesRe dbContext.QuestionScores.Add(new QuestionScoreEntity { Id = 21, - ImportRunId = runId, QuestionTemplateId = 11, ParticipantId = "Philip", ImportedPoints = 5, CalculatedPoints = 0, DeltaPoints = -5, - ReasonCode = "PRESEASON_RULE_VARIANCE", RecordedAtUtc = DateTime.UtcNow }); @@ -752,14 +1170,24 @@ public async Task ExportRunDiffsAsync_WhenQuestionDiffExportRequested_IncludesRe Assert.True(export!.Success); var csv = System.Text.Encoding.UTF8.GetString(export.Payload); - Assert.Contains("category,questionId,questionText,participant,importedPoints,calculatedPoints,deltaPoints,reasonCode", csv, StringComparison.Ordinal); - Assert.Contains("Preseason,PRE-001,Will Team X win?,Philip,5,0,-5,PRESEASON_RULE_VARIANCE", csv, StringComparison.Ordinal); + Assert.Contains("category,questionId,questionText,participant,importedPoints,calculatedPoints,deltaPoints", csv, StringComparison.Ordinal); + Assert.Contains("Preseason,PRE-001,Will Team X win?,Philip,5,0,-5", csv, StringComparison.Ordinal); } private static DbContextOptions CreateOptions() { return new DbContextOptionsBuilder() .UseInMemoryDatabase($"migration-run-admin-service-{Guid.NewGuid():N}") + .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) .Options; } + + private static string CreateTempCsv(string content) + { + var allowedTempRoot = Path.Combine(Path.GetTempPath(), "f1-imports", "tests"); + Directory.CreateDirectory(allowedTempRoot); + var path = Path.Combine(allowedTempRoot, $"f1-admin-migration-{Guid.NewGuid():N}.csv"); + File.WriteAllText(path, content); + return path; + } } \ No newline at end of file diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs index c2142bb..355532c 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationRaceSelectionParserTests.cs @@ -64,21 +64,20 @@ public async Task ParseAndPersistAsync_WhenSeasonQuestionIsH2h_PersistsGenericH2 var optionsModel = JsonSerializer.Deserialize(template.OptionsJson!); Assert.NotNull(optionsModel); - Assert.Equal("HAM", optionsModel!.LeftDriverId); - Assert.Equal("VER", optionsModel.RightDriverId); + Assert.Equal("hamilton", optionsModel!.LeftDriverId); + Assert.Equal("max_verstappen", optionsModel.RightDriverId); Assert.Equal(1, optionsModel.PointsForCorrectPick); var answers = await dbContext.QuestionAnswers - .Where(x => x.ImportRunId == runId) .OrderBy(x => x.ParticipantId) .ToListAsync(); Assert.Equal(2, answers.Count); - Assert.Equal("HAM", answers.Single(x => x.ParticipantId == "Philip").NormalizedAnswer); - Assert.Equal("VER", answers.Single(x => x.ParticipantId == "Andy").NormalizedAnswer); + Assert.Equal("hamilton", answers.Single(x => x.ParticipantId == "Philip").ImportedAnswer); + Assert.Equal("max_verstappen", answers.Single(x => x.ParticipantId == "Andy").ImportedAnswer); var actual = await dbContext.QuestionActuals - .SingleAsync(x => x.ImportRunId == runId); - Assert.Equal("VER", actual.NormalizedAnswer); + .SingleAsync(); + Assert.Equal("max_verstappen", actual.ImportedAnswer); } [Fact] @@ -132,7 +131,7 @@ public async Task ParseAndPersistAsync_WhenPreseasonQuestionRowsExist_PersistsPa var philipRow2 = preseasonAnswers.Single(x => x.RowNumber == 2 && x.Subject == "Philip" && !x.IsActualOutcome); Assert.Equal("PRE-002", philipRow2.QuestionKey); Assert.Equal("At least one driver will win 4 consecutive races?", philipRow2.QuestionText); - Assert.Equal("Y", philipRow2.NormalizedAnswer); + Assert.Equal("YES", philipRow2.NormalizedAnswer); var andyRow2 = preseasonAnswers.Single(x => x.RowNumber == 2 && x.Subject == "Andy" && !x.IsActualOutcome); Assert.Null(andyRow2.NormalizedAnswer); @@ -141,13 +140,13 @@ public async Task ParseAndPersistAsync_WhenPreseasonQuestionRowsExist_PersistsPa Assert.Null(claireRow2.NormalizedAnswer); var daveRow2 = preseasonAnswers.Single(x => x.RowNumber == 2 && x.Subject == "Dave" && !x.IsActualOutcome); - Assert.Equal("N", daveRow2.NormalizedAnswer); + Assert.Equal("NO", daveRow2.NormalizedAnswer); var actualRow2 = preseasonAnswers.Single(x => x.RowNumber == 2 && x.Subject == "ACTUAL" && x.IsActualOutcome); - Assert.Equal("N", actualRow2.NormalizedAnswer); + Assert.Equal("NO", actualRow2.NormalizedAnswer); var actualRow3 = preseasonAnswers.Single(x => x.RowNumber == 3 && x.Subject == "ACTUAL" && x.IsActualOutcome); - Assert.Equal("NOR | VER | PIA", actualRow3.NormalizedAnswer); + Assert.Equal("norris | max_verstappen | piastri", actualRow3.NormalizedAnswer); var questionTemplates = await dbContext.QuestionTemplates .OrderBy(x => x.QuestionId) @@ -155,24 +154,70 @@ public async Task ParseAndPersistAsync_WhenPreseasonQuestionRowsExist_PersistsPa Assert.Equal(new[] { "PRE-002", "PRE-003" }, questionTemplates.Select(x => x.QuestionId).ToArray()); var genericAnswers = await dbContext.QuestionAnswers - .Where(x => x.ImportRunId == runId) - .OrderBy(x => x.SourceRow) + .OrderBy(x => x.QuestionTemplateId) .ThenBy(x => x.ParticipantId) .ToListAsync(); Assert.Equal(MigrationPhil2025CsvContractPolicy.ParticipantColumns.Length * 2, genericAnswers.Count); - var genericPhilipRow2 = genericAnswers.Single(x => x.SourceRow == 2 && x.ParticipantId == "Philip"); - Assert.Equal(2, genericPhilipRow2.SourceRow); - Assert.Equal(MigrationPhil2025CsvContractPolicy.ParticipantStartColumnIndex + 1, genericPhilipRow2.SourceColumn); - Assert.Equal("Y", genericPhilipRow2.NormalizedAnswer); + var genericPhilipRow2 = genericAnswers.Single(x => x.ParticipantId == "Philip" && x.ImportedAnswer == "YES"); + Assert.Equal("YES", genericPhilipRow2.ImportedAnswer); var genericActuals = await dbContext.QuestionActuals - .Where(x => x.ImportRunId == runId) - .OrderBy(x => x.SourceRow) + .OrderBy(x => x.QuestionTemplateId) .ToListAsync(); Assert.Equal(2, genericActuals.Count); - Assert.Equal("NOR | VER | PIA", genericActuals.Single(x => x.SourceRow == 3).NormalizedAnswer); - Assert.Equal("[\"MULTI_TOKEN_ACTUAL_NORMALIZED\"]", genericActuals.Single(x => x.SourceRow == 3).NormalizationDiagnosticsJson); + Assert.Contains(genericActuals, x => x.ImportedAnswer == "norris | max_verstappen | piastri"); + } + + [Fact] + public async Task ParseAndPersistAsync_WhenPhilContractAndMultipleSeasonCompetitions_UsesPhilipCompetitionForGenericQuestions() + { + var runId = Guid.NewGuid(); + var options = CreateOptions(); + await using var dbContext = new F1DbContext(options); + + dbContext.Competitions.AddRange( + new Competition { Id = 1, Name = "Main Competition", Year = 2025, Description = "Default seeded competition" }, + new Competition { Id = 2, Name = "Philip 2025", Year = 2025, Description = "Philip 2025 season competition" }, + new Competition { Id = 3, Name = "David 2025", Year = 2025, Description = "David 2025 season competition" }); + + dbContext.MigrationImportRuns.Add(new MigrationImportRunEntity + { + Id = runId, + SourceFilePath = $"/tmp/{MigrationPhil2025CsvContractPolicy.SourceFileName}", + SourceFileChecksum = "abc", + IsDryRun = true, + Status = "Started", + StartedAtUtc = DateTime.UtcNow + }); + + dbContext.MigrationImportRawRows.AddRange( + new MigrationImportRawRowEntity + { + ImportRunId = runId, + RowNumber = 2, + SectionType = "SeasonQuestionPrediction", + RawPayload = "At least one driver will win 4 consecutive races?,Y,NONE,NOT, ,N,Y,Y,Y,N,Y,N,20" + }, + new MigrationImportRawRowEntity + { + ImportRunId = runId, + RowNumber = 3, + SectionType = "SeasonQuestionPrediction", + RawPayload = "WDC - 1st?,NOR,VER,VER,LEC,PIA,NOR,VER,NOR,VER,NOR,NOR/VER;PIA" + }); + + await dbContext.SaveChangesAsync(); + + var parser = new MigrationRaceSelectionParser(new TestDbContextFactory(options)); + await parser.ParseAndPersistAsync(runId, CancellationToken.None); + + var questionTemplates = await dbContext.QuestionTemplates + .OrderBy(x => x.QuestionId) + .ToListAsync(); + + Assert.Equal(2, questionTemplates.Count); + Assert.All(questionTemplates, template => Assert.Equal(2, template.CompetitionId)); } [Fact] @@ -213,12 +258,12 @@ public async Task ParseAndPersistAsync_WhenPreseasonAnswersContainMalformedToken Assert.Equal("@@@", malformed.RawAnswer); Assert.Equal("@@@", malformed.NormalizedAnswer); - var genericActual = await dbContext.QuestionActuals.SingleAsync(x => x.ImportRunId == runId && x.SourceRow == 2); - Assert.Equal("Y", genericActual.NormalizedAnswer); + var genericActual = await dbContext.QuestionActuals.SingleAsync(); + Assert.Equal("YES", genericActual.ImportedAnswer); var genericPhilip = await dbContext.QuestionAnswers - .SingleAsync(x => x.ImportRunId == runId && x.SourceRow == 2 && x.ParticipantId == "Philip"); - Assert.Equal("@@@", genericPhilip.NormalizedAnswer); + .SingleAsync(x => x.ParticipantId == "Philip"); + Assert.Equal("@@@", genericPhilip.ImportedAnswer); } [Fact] @@ -262,7 +307,7 @@ public async Task ParseAndPersistAsync_WhenRaceRowsExist_ExtractsParticipantPick var ausWinner = selections.Single(x => x.RowNumber == 2 && x.Subject == "Philip" && !x.IsActualOutcome); Assert.Equal("albert_park", ausWinner.RaceCode); Assert.Equal("1", ausWinner.PickType); - Assert.Equal("VER", ausWinner.NormalizedValue); + Assert.Equal("max_verstappen", ausWinner.NormalizedValue); var dnfPhilip = selections.Single(x => x.RowNumber == 3 && x.Subject == "Philip" && !x.IsActualOutcome); Assert.Null(dnfPhilip.NormalizedValue); @@ -271,7 +316,7 @@ public async Task ParseAndPersistAsync_WhenRaceRowsExist_ExtractsParticipantPick Assert.Null(dnfAndy.NormalizedValue); var dnfActual = selections.Single(x => x.RowNumber == 3 && x.Subject == "ACTUAL" && x.IsActualOutcome); - Assert.Equal("SAI DOO", dnfActual.NormalizedValue); + Assert.Equal("sainz doohan", dnfActual.NormalizedValue); Assert.Empty(await dbContext.MigrationImportUnresolvedTokens .Where(x => x.ImportRunId == runId) .ToListAsync()); @@ -310,7 +355,7 @@ public async Task ParseAndPersistAsync_WhenExplicitLRowExists_ParsesActualOutcom Assert.Equal("albert_park", lRowActual.RaceCode); Assert.Equal("2", lRowActual.PickType); - Assert.Equal("NOR", lRowActual.NormalizedValue); + Assert.Equal("norris", lRowActual.NormalizedValue); Assert.True(lRowActual.IsActualOutcome); } @@ -383,10 +428,10 @@ public async Task ParseAndPersistAsync_WhenAliasTokensProvided_NormalizesCaseAnd .OrderBy(x => x.Subject) .ToListAsync(); - Assert.Equal("HUL", selections.Single(x => x.Subject == "Andy").NormalizedValue); - Assert.Equal("BEA", selections.Single(x => x.Subject == "BINGPT").NormalizedValue); - Assert.Equal("BEA", selections.Single(x => x.Subject == "Kevin").NormalizedValue); - Assert.Equal("VER", selections.Single(x => x.Subject == "Philip").NormalizedValue); + Assert.Equal("hulkenberg", selections.Single(x => x.Subject == "Andy").NormalizedValue); + Assert.Equal("bearman", selections.Single(x => x.Subject == "BINGPT").NormalizedValue); + Assert.Equal("bearman", selections.Single(x => x.Subject == "Kevin").NormalizedValue); + Assert.Equal("max_verstappen", selections.Single(x => x.Subject == "Philip").NormalizedValue); Assert.Null(selections.Single(x => x.Subject == "ACTUAL").NormalizedValue); Assert.Empty(await dbContext.MigrationImportUnresolvedTokens.ToListAsync()); } @@ -474,7 +519,7 @@ public async Task ParseAndPersistAsync_WhenDnfContainsMultiTokenActual_OnlyPersi var dnfKevin = await dbContext.MigrationImportRaceSelections .SingleAsync(x => x.ImportRunId == runId && x.RowNumber == 3 && x.Subject == "Kevin" && x.PickType == "DNF"); - Assert.Equal("BOR", dnfKevin.NormalizedValue); + Assert.Equal("bortoleto", dnfKevin.NormalizedValue); var dnfVeronica = await dbContext.MigrationImportRaceSelections .SingleAsync(x => x.ImportRunId == runId && x.RowNumber == 3 && x.Subject == "Veronica" && x.PickType == "DNF"); @@ -482,7 +527,7 @@ public async Task ParseAndPersistAsync_WhenDnfContainsMultiTokenActual_OnlyPersi var dnfActual = await dbContext.MigrationImportRaceSelections .SingleAsync(x => x.ImportRunId == runId && x.RowNumber == 3 && x.Subject == "ACTUAL" && x.PickType == "DNF"); - Assert.Equal("SAI DOO BOR LAW ALO HAD", dnfActual.NormalizedValue); + Assert.Equal("sainz doohan bortoleto lawson alonso hadjar", dnfActual.NormalizedValue); var unresolved = await dbContext.MigrationImportUnresolvedTokens .Where(x => x.ImportRunId == runId) @@ -522,7 +567,7 @@ public async Task ParseAndPersistAsync_WhenLeecAliasProvided_NormalizesToLec() var participantPick = await dbContext.MigrationImportRaceSelections .SingleAsync(x => x.ImportRunId == runId && x.RowNumber == 2 && x.Subject == "New Sexy Ayrton"); - Assert.Equal("LEC", participantPick.NormalizedValue); + Assert.Equal("leclerc", participantPick.NormalizedValue); Assert.Empty(await dbContext.MigrationImportUnresolvedTokens.Where(x => x.ImportRunId == runId).ToListAsync()); } @@ -768,7 +813,7 @@ public async Task ParseAndPersistAsync_WhenPhilContractPodiumContainsNotToken_No var participantPick = await dbContext.MigrationImportRaceSelections .SingleAsync(x => x.ImportRunId == runId && x.RowNumber == 2 && x.Subject == "Pious"); - Assert.Equal("NOR", participantPick.NormalizedValue); + Assert.Equal("norris", participantPick.NormalizedValue); Assert.Empty(await dbContext.MigrationImportUnresolvedTokens.Where(x => x.ImportRunId == runId).ToListAsync()); } diff --git a/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs b/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs index a4fdf50..ecf68a0 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/MigrationScoreRecalculatorTests.cs @@ -350,35 +350,23 @@ public async Task RecalculateAndPersistAsync_WhenGenericPreseasonQuestionsPresen dbContext.QuestionAnswers.AddRange( new QuestionAnswerEntity { - ImportRunId = runId, QuestionTemplateId = 101, ParticipantId = "Philip", ImportedAnswer = "VER", - NormalizedAnswer = "VER", - SourceRow = 2, - SourceColumn = 2, RecordedAtUtc = DateTime.UtcNow }, new QuestionAnswerEntity { - ImportRunId = runId, QuestionTemplateId = 101, ParticipantId = "Andy", ImportedAnswer = "NOR", - NormalizedAnswer = "NOR", - SourceRow = 2, - SourceColumn = 3, RecordedAtUtc = DateTime.UtcNow }); dbContext.QuestionActuals.Add(new QuestionActualEntity { - ImportRunId = runId, QuestionTemplateId = 101, - ActualAnswer = "VER", - NormalizedAnswer = "VER", - SourceRow = 2, - SourceColumn = 12, + ImportedAnswer = "VER", RecordedAtUtc = DateTime.UtcNow }); @@ -388,15 +376,12 @@ public async Task RecalculateAndPersistAsync_WhenGenericPreseasonQuestionsPresen await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); var questionScores = await dbContext.QuestionScores - .Where(x => x.ImportRunId == runId) .OrderBy(x => x.ParticipantId) .ToListAsync(); Assert.Equal(2, questionScores.Count); Assert.Equal(20, questionScores.Single(x => x.ParticipantId == "Philip").CalculatedPoints); - Assert.Equal("PRESEASON_EXACT", questionScores.Single(x => x.ParticipantId == "Philip").ReasonCode); Assert.Equal(0, questionScores.Single(x => x.ParticipantId == "Andy").CalculatedPoints); - Assert.Equal("PRESEASON_MISMATCH", questionScores.Single(x => x.ParticipantId == "Andy").ReasonCode); var legacyScore = await dbContext.MigrationImportPreseasonCalculatedScores .SingleAsync(x => x.ImportRunId == runId && x.Subject == "Philip"); @@ -438,24 +423,16 @@ public async Task RecalculateAndPersistAsync_WhenCategoryStrategyMissing_Persist dbContext.QuestionAnswers.Add(new QuestionAnswerEntity { - ImportRunId = runId, QuestionTemplateId = 201, ParticipantId = "Philip", ImportedAnswer = "NOR", - NormalizedAnswer = "NOR", - SourceRow = 10, - SourceColumn = 2, RecordedAtUtc = DateTime.UtcNow }); dbContext.QuestionActuals.Add(new QuestionActualEntity { - ImportRunId = runId, QuestionTemplateId = 201, - ActualAnswer = "LEC", - NormalizedAnswer = "LEC", - SourceRow = 10, - SourceColumn = 3, + ImportedAnswer = "LEC", RecordedAtUtc = DateTime.UtcNow }); @@ -467,9 +444,8 @@ public async Task RecalculateAndPersistAsync_WhenCategoryStrategyMissing_Persist await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); - var score = await dbContext.QuestionScores.SingleAsync(x => x.ImportRunId == runId && x.ParticipantId == "Philip"); + var score = await dbContext.QuestionScores.SingleAsync(x => x.ParticipantId == "Philip"); Assert.Equal(0, score.CalculatedPoints); - Assert.Equal("QUESTION_CATEGORY_STRATEGY_MISSING", score.ReasonCode); } [Fact] @@ -509,35 +485,23 @@ public async Task RecalculateAndPersistAsync_WhenH2hQuestionConfigured_ScoresCor dbContext.QuestionAnswers.AddRange( new QuestionAnswerEntity { - ImportRunId = runId, QuestionTemplateId = 301, ParticipantId = "Philip", ImportedAnswer = "HAM", - NormalizedAnswer = "HAM", - SourceRow = 30, - SourceColumn = 2, RecordedAtUtc = DateTime.UtcNow }, new QuestionAnswerEntity { - ImportRunId = runId, QuestionTemplateId = 301, ParticipantId = "Andy", ImportedAnswer = "VER", - NormalizedAnswer = "VER", - SourceRow = 30, - SourceColumn = 3, RecordedAtUtc = DateTime.UtcNow }); dbContext.QuestionActuals.Add(new QuestionActualEntity { - ImportRunId = runId, QuestionTemplateId = 301, - ActualAnswer = "VER", - NormalizedAnswer = "VER", - SourceRow = 30, - SourceColumn = 10, + ImportedAnswer = "VER", RecordedAtUtc = DateTime.UtcNow }); @@ -547,15 +511,12 @@ public async Task RecalculateAndPersistAsync_WhenH2hQuestionConfigured_ScoresCor await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); var scores = await dbContext.QuestionScores - .Where(x => x.ImportRunId == runId) .OrderBy(x => x.ParticipantId) .ToListAsync(); Assert.Equal(2, scores.Count); Assert.Equal(0, scores.Single(x => x.ParticipantId == "Philip").CalculatedPoints); - Assert.Equal("H2H_WRONG_PICK", scores.Single(x => x.ParticipantId == "Philip").ReasonCode); Assert.Equal(5, scores.Single(x => x.ParticipantId == "Andy").CalculatedPoints); - Assert.Equal("H2H_CORRECT_PICK", scores.Single(x => x.ParticipantId == "Andy").ReasonCode); } private static MigrationImportRaceSelectionEntity Selection( diff --git a/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkExtensibilityTests.cs b/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkExtensibilityTests.cs index a7306c4..323bf13 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkExtensibilityTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkExtensibilityTests.cs @@ -49,24 +49,16 @@ public async Task RecalculateAndPersistAsync_WhenCustomCategoryStrategyIsRegiste dbContext.QuestionAnswers.Add(new QuestionAnswerEntity { - ImportRunId = runId, QuestionTemplateId = 900, ParticipantId = "Philip", ImportedAnswer = "YES", - NormalizedAnswer = "YES", - SourceRow = 20, - SourceColumn = 2, RecordedAtUtc = DateTime.UtcNow }); dbContext.QuestionActuals.Add(new QuestionActualEntity { - ImportRunId = runId, QuestionTemplateId = 900, - ActualAnswer = "YES", - NormalizedAnswer = "YES", - SourceRow = 20, - SourceColumn = 3, + ImportedAnswer = "YES", RecordedAtUtc = DateTime.UtcNow }); @@ -78,15 +70,16 @@ public async Task RecalculateAndPersistAsync_WhenCustomCategoryStrategyIsRegiste await recalculator.RecalculateAndPersistAsync(runId, CancellationToken.None); - var score = await dbContext.QuestionScores.SingleAsync(x => x.ImportRunId == runId && x.ParticipantId == "Philip"); + var score = await dbContext.QuestionScores.SingleAsync(x => x.ParticipantId == "Philip"); Assert.Equal(7, score.CalculatedPoints); - Assert.Equal("MOCK_MATCH", score.ReasonCode); } [Fact] public void CalculateGenericQuestionScores_ShouldUseStrategyRegistry_WithoutCategorySpecificBranching() { - var sourcePath = GetRepositoryFilePath("src", "F1.DataSyncWorker", "Services", "MigrationScoreRecalculator.cs"); + var sourcePath = FindRepositoryFilePath( + Path.Combine("src", "F1.DataSyncWorker", "Services", "Scoring", "MigrationScoreRecalculator.cs"), + Path.Combine("src", "F1.DataSyncWorker", "Services", "MigrationScoreRecalculator.cs")); var source = File.ReadAllText(sourcePath); var methodStart = source.IndexOf( @@ -144,6 +137,20 @@ private static string GetRepositoryFilePath(params string[] relativePath) throw new InvalidOperationException("Unable to locate repository root."); } + private static string FindRepositoryFilePath(params string[] candidateRelativePaths) + { + foreach (var candidate in candidateRelativePaths) + { + var fullPath = GetRepositoryFilePath(candidate.Split(Path.DirectorySeparatorChar)); + if (File.Exists(fullPath)) + { + return fullPath; + } + } + + throw new FileNotFoundException($"Unable to locate any expected source file path. Candidates: {string.Join(", ", candidateRelativePaths)}"); + } + private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions _options; @@ -182,14 +189,24 @@ public IReadOnlyList Score(QuestionScoringContext cont Prompt: template.Prompt, Category: template.Category, ParticipantId: answer.ParticipantId, - PredictedAnswer: answer.NormalizedAnswer, - ActualAnswer: actual.NormalizedAnswer, + PredictedAnswer: Resolve(answer), + ActualAnswer: Resolve(actual), ImportedPoints: null, - CalculatedPoints: string.Equals(answer.NormalizedAnswer, actual.NormalizedAnswer, StringComparison.OrdinalIgnoreCase) ? 7 : 0, + CalculatedPoints: string.Equals(Resolve(answer), Resolve(actual), StringComparison.OrdinalIgnoreCase) ? 7 : 0, DeltaPoints: 0, - ReasonCode: string.Equals(answer.NormalizedAnswer, actual.NormalizedAnswer, StringComparison.OrdinalIgnoreCase) ? "MOCK_MATCH" : "MOCK_MISS", + ReasonCode: string.Equals(Resolve(answer), Resolve(actual), StringComparison.OrdinalIgnoreCase) ? "MOCK_MATCH" : "MOCK_MISS", SortOrder: template.SortOrder) ]; } + + private static string? Resolve(QuestionAnswerEntity answer) + { + return string.IsNullOrWhiteSpace(answer.OverrideAnswer) ? answer.ImportedAnswer : answer.OverrideAnswer; + } + + private static string? Resolve(QuestionActualEntity actual) + { + return string.IsNullOrWhiteSpace(actual.OverrideAnswer) ? actual.ImportedAnswer : actual.OverrideAnswer; + } } } \ No newline at end of file diff --git a/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkModelContractTests.cs b/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkModelContractTests.cs index 7972e85..8debcd3 100644 --- a/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkModelContractTests.cs +++ b/tests/F1.Infrastructure.Tests/Contracts/QuestionFrameworkModelContractTests.cs @@ -29,12 +29,12 @@ public void OnModelCreating_ConfiguresRequiredQuestionContracts() Assert.False(answer!.FindProperty(nameof(QuestionAnswerEntity.ParticipantId))!.IsNullable); Assert.False(actual!.FindProperty(nameof(QuestionActualEntity.QuestionTemplateId))!.IsNullable); - Assert.False(score!.FindProperty(nameof(QuestionScoreEntity.ReasonCode))!.IsNullable); + Assert.False(score!.FindProperty(nameof(QuestionScoreEntity.ParticipantId))!.IsNullable); Assert.Contains(template.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionTemplateEntity.CompetitionId), nameof(QuestionTemplateEntity.Season), nameof(QuestionTemplateEntity.QuestionId))); - Assert.Contains(answer.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionAnswerEntity.ImportRunId), nameof(QuestionAnswerEntity.QuestionTemplateId), nameof(QuestionAnswerEntity.ParticipantId))); - Assert.Contains(actual.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionActualEntity.ImportRunId), nameof(QuestionActualEntity.QuestionTemplateId))); - Assert.Contains(score.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionScoreEntity.ImportRunId), nameof(QuestionScoreEntity.QuestionTemplateId), nameof(QuestionScoreEntity.ParticipantId))); + Assert.Contains(answer.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionAnswerEntity.QuestionTemplateId), nameof(QuestionAnswerEntity.ParticipantId))); + Assert.Contains(actual.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionActualEntity.QuestionTemplateId))); + Assert.Contains(score.GetIndexes(), index => index.IsUnique && Matches(index.Properties, nameof(QuestionScoreEntity.QuestionTemplateId), nameof(QuestionScoreEntity.ParticipantId))); } [Fact] diff --git a/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs b/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs index cb12bc3..577fab5 100644 --- a/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs +++ b/tests/F1.Infrastructure.Tests/Relational/MigrationImportRunServiceTests.cs @@ -91,6 +91,71 @@ public async Task RunLifecycle_WhenCompletedOrFailed_PersistsExpectedStatusAndMe } } + [Fact] + public async Task RunOnceAsync_WhenWriteModeEnabled_PersistsCanonicalEntitiesAndImportArtifacts() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = false, + Season = 2025 + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await orchestrator.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + var run = await verificationContext.MigrationImportRuns.AsNoTracking().SingleAsync(); + Assert.Equal("Completed", run.Status); + + Assert.NotEmpty(await verificationContext.MigrationImportRaceSelections.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.MigrationImportCalculatedScores.AsNoTracking().ToListAsync()); + + Assert.NotEmpty(await verificationContext.Drivers.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.Races.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.Selections.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.SelectionPositions.AsNoTracking().ToListAsync()); + } + finally + { + File.Delete(sourceFilePath); + } + } + [Fact] public async Task RunOnceAsync_WhenDryRunEnabled_StagesRowsWithoutCreatingDomainEntities() { @@ -98,13 +163,496 @@ public async Task RunOnceAsync_WhenDryRunEnabled_StagesRowsWithoutCreatingDomain await setupContext.Database.EnsureDeletedAsync(); await setupContext.Database.EnsureCreatedAsync(); - var sourceFilePath = await CreateTempCsvAsync("Question,Philip\nAUS-1,NOR\nBAH-HUMBUG,NONE\n"); + var sourceFilePath = await CreateTempCsvAsync("Question,Philip\nAUS-1,NOR\nBAH-HUMBUG,NONE\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + var jolpicaClient = new TrackingJolpicaClient(); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + jolpicaClient, + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = true + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await orchestrator.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + Assert.Empty(await verificationContext.Competitions.AsNoTracking().ToListAsync()); + Assert.Empty(await verificationContext.Drivers.AsNoTracking().ToListAsync()); + Assert.Empty(await verificationContext.Races.AsNoTracking().ToListAsync()); + Assert.Empty(await verificationContext.Selections.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.MigrationImportJolpicaRaceSnapshots.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.MigrationImportRaceRoundMappings.AsNoTracking().ToListAsync()); + Assert.True(jolpicaClient.GetRacesCallCount > 0); + + var run = await verificationContext.MigrationImportRuns.AsNoTracking().SingleAsync(); + Assert.True(run.IsDryRun); + Assert.Equal("Completed", run.Status); + Assert.Equal(3, run.RawRowCount); + Assert.Equal("NotDetected", run.PreseasonParseStatus); + Assert.Equal("NotDetected", run.PreseasonScoringStatus); + Assert.Equal(0, run.PreseasonWarningCount); + Assert.Equal(0, run.PreseasonErrorCount); + Assert.True(run.PreseasonIsolationGuardPassed); + Assert.NotNull(run.ParitySnapshotChecksum); + Assert.Equal("NotCompared", run.ParityStatus); + + var stagedRows = await verificationContext.MigrationImportRawRows.AsNoTracking().OrderBy(x => x.RowNumber).ToListAsync(); + Assert.Equal(3, stagedRows.Count); + Assert.Equal("Header", stagedRows[0].SectionType); + Assert.Equal("RacePick", stagedRows[1].SectionType); + Assert.Equal("RacePick", stagedRows[2].SectionType); + Assert.Equal("Mapped special label to DNF pick type.", stagedRows[2].ClassificationReason); + } + finally + { + File.Delete(sourceFilePath); + } + } + + [Fact] + public async Task RunOnceAsync_WhenDryAndWriteUseSameSource_ParityChecksumsMatch() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var dryRunOrchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = true, + Season = 2025 + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + var writeRunOrchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = false, + Season = 2025 + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await dryRunOrchestrator.RunOnceAsync(CancellationToken.None); + await writeRunOrchestrator.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + var runs = await verificationContext.MigrationImportRuns + .AsNoTracking() + .OrderBy(x => x.StartedAtUtc) + .ToListAsync(); + + Assert.Equal(2, runs.Count); + Assert.Equal("Completed", runs[0].Status); + Assert.Equal("Completed", runs[1].Status); + Assert.NotNull(runs[0].ParitySnapshotChecksum); + Assert.NotNull(runs[1].ParitySnapshotChecksum); + Assert.Equal(runs[0].ParitySnapshotChecksum, runs[1].ParitySnapshotChecksum); + Assert.Equal("NotCompared", runs[0].ParityStatus); + Assert.Equal("Matched", runs[1].ParityStatus); + Assert.Equal(runs[0].Id, runs[1].ParityComparedRunId); + Assert.Equal(runs[0].ParitySnapshotChecksum, runs[1].ParityComparedChecksum); + } + finally + { + File.Delete(sourceFilePath); + } + } + + [Fact] + public async Task RunOnceAsync_WhenWriteRunIsRepeatedWithSameChecksum_DoesNotDuplicateCanonicalRows() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var firstRun = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions { Enabled = true, SourceFilePath = sourceFilePath, DryRun = false, Season = 2025 }), + MigrationExpectedVarianceRuleCatalog.Empty); + + var secondRun = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions { Enabled = true, SourceFilePath = sourceFilePath, DryRun = false, Season = 2025 }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await firstRun.RunOnceAsync(CancellationToken.None); + + await using var firstSnapshotContext = CreateContext(); + var firstSelectionCount = await firstSnapshotContext.Selections.CountAsync(); + var firstPositionCount = await firstSnapshotContext.SelectionPositions.CountAsync(); + + await secondRun.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + var secondSelectionCount = await verificationContext.Selections.CountAsync(); + var secondPositionCount = await verificationContext.SelectionPositions.CountAsync(); + + Assert.Equal(firstSelectionCount, secondSelectionCount); + Assert.Equal(firstPositionCount, secondPositionCount); + + var runs = await verificationContext.MigrationImportRuns.AsNoTracking().OrderBy(x => x.StartedAtUtc).ToListAsync(); + Assert.Equal(2, runs.Count); + Assert.Equal("FirstWrite", runs[0].IdempotencyOutcome); + Assert.Equal("Replayed", runs[1].IdempotencyOutcome); + Assert.Equal(runs[0].IdempotencyScopeKey, runs[1].IdempotencyScopeKey); + } + finally + { + File.Delete(sourceFilePath); + } + } + + [Fact] + public async Task RunOnceAsync_WhenWriteRunChecksumChanges_UsesNewIdempotencyScope() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); + + var sourceFilePathA = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + var sourceFilePathB = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,NOR,VER\n" + + "DNF,NONE,\n" + + "AUS-1,0,10\n" + + "DNF,5,5\n" + + "Result,5\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var runA = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions { Enabled = true, SourceFilePath = sourceFilePathA, DryRun = false, Season = 2025 }), + MigrationExpectedVarianceRuleCatalog.Empty); + + var runB = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions { Enabled = true, SourceFilePath = sourceFilePathB, DryRun = false, Season = 2025 }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await runA.RunOnceAsync(CancellationToken.None); + await runB.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + var runs = await verificationContext.MigrationImportRuns.AsNoTracking().OrderBy(x => x.StartedAtUtc).ToListAsync(); + Assert.Equal(2, runs.Count); + Assert.NotEqual(runs[0].SourceFileChecksum, runs[1].SourceFileChecksum); + Assert.NotEqual(runs[0].IdempotencyScopeKey, runs[1].IdempotencyScopeKey); + Assert.Equal("FirstWrite", runs[0].IdempotencyOutcome); + Assert.Equal("FirstWrite", runs[1].IdempotencyOutcome); + } + finally + { + File.Delete(sourceFilePathA); + File.Delete(sourceFilePathB); + } + } + + [Fact] + public async Task RunOnceAsync_WhenConflictPolicyFail_RecordsDiagnosticsAndFailsRun() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + + var competition = new F1.Core.Models.Competition + { + Name = "Migration Import 2025", + Year = 2025, + Description = "Seeded for conflict policy tests" + }; + setupContext.Competitions.Add(competition); + await setupContext.SaveChangesAsync(); + + setupContext.Drivers.Add(new F1.Core.Models.Driver { DriverId = "OLD", FullName = "Existing Driver", Code = "OLD" }); + setupContext.Races.Add(new F1.Core.Models.Race + { + Id = "migration-2025-albert-park", + CompetitionId = competition.Id, + Season = 2025, + Round = 1, + RaceName = "albert_park", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + var existingSelectionId = Guid.NewGuid(); + setupContext.Selections.Add(new F1.Core.Models.Selection + { + Id = existingSelectionId, + UserId = "Philip", + RaceId = "migration-2025-albert-park", + BetType = F1.Core.Models.BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }); + setupContext.SelectionPositions.Add(new SelectionPositionEntity + { + SelectionId = existingSelectionId, + Position = 1, + DriverId = "OLD" + }); + await setupContext.SaveChangesAsync(); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = false, + Season = 2025, + CanonicalConflictPolicy = "fail" + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await Assert.ThrowsAsync(() => orchestrator.RunOnceAsync(CancellationToken.None)); + + await using var verificationContext = CreateContext(); + var run = await verificationContext.MigrationImportRuns.AsNoTracking().OrderByDescending(x => x.StartedAtUtc).FirstAsync(); + Assert.Equal("Failed", run.Status); + + var diagnostics = await verificationContext.MigrationImportConflictDiagnostics + .AsNoTracking() + .Where(x => x.ImportRunId == run.Id) + .ToListAsync(); + Assert.NotEmpty(diagnostics); + Assert.Contains(diagnostics, x => x.EntityType == "Selection" && x.PolicyOutcome == "Failed"); + } + finally + { + File.Delete(sourceFilePath); + } + } + + [Fact] + public async Task RunOnceAsync_WhenConflictPolicySkip_RecordsDiagnosticsAndSkipsOverwrite() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + + var competition = new F1.Core.Models.Competition + { + Name = "Migration Import 2025", + Year = 2025, + Description = "Seeded for conflict policy tests" + }; + setupContext.Competitions.Add(competition); + await setupContext.SaveChangesAsync(); + + setupContext.Drivers.Add(new F1.Core.Models.Driver { DriverId = "OLD", FullName = "Existing Driver", Code = "OLD" }); + setupContext.Races.Add(new F1.Core.Models.Race + { + Id = "migration-2025-albert-park", + CompetitionId = competition.Id, + Season = 2025, + Round = 1, + RaceName = "albert_park", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + var existingSelectionId = Guid.NewGuid(); + setupContext.Selections.Add(new F1.Core.Models.Selection + { + Id = existingSelectionId, + UserId = "Philip", + RaceId = "migration-2025-albert-park", + BetType = F1.Core.Models.BetType.Regular, + SubmittedAtUtc = DateTime.UtcNow + }); + setupContext.SelectionPositions.Add(new SelectionPositionEntity + { + SelectionId = existingSelectionId, + Position = 1, + DriverId = "OLD" + }); + await setupContext.SaveChangesAsync(); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); try { var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); var runService = new MigrationImportRunService(dbFactory); - var jolpicaClient = new TrackingJolpicaClient(); var orchestrator = new MigrationImportOrchestrator( NullLogger.Instance, @@ -113,7 +661,7 @@ public async Task RunOnceAsync_WhenDryRunEnabled_StagesRowsWithoutCreatingDomain new MigrationRaceSelectionParser(dbFactory), new MigrationRaceRoundMapper( dbFactory, - jolpicaClient, + new TrackingJolpicaClient(), Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), Options.Create(new MigrationImportOptions { Season = 2025 })), new MigrationScoreRecalculator(dbFactory), @@ -125,37 +673,30 @@ public async Task RunOnceAsync_WhenDryRunEnabled_StagesRowsWithoutCreatingDomain { Enabled = true, SourceFilePath = sourceFilePath, - DryRun = true + DryRun = false, + Season = 2025, + CanonicalConflictPolicy = "skip" }), MigrationExpectedVarianceRuleCatalog.Empty); await orchestrator.RunOnceAsync(CancellationToken.None); await using var verificationContext = CreateContext(); - Assert.Empty(await verificationContext.Competitions.AsNoTracking().ToListAsync()); - Assert.Empty(await verificationContext.Drivers.AsNoTracking().ToListAsync()); - Assert.Empty(await verificationContext.Races.AsNoTracking().ToListAsync()); - Assert.Empty(await verificationContext.Selections.AsNoTracking().ToListAsync()); - Assert.Empty(await verificationContext.MigrationImportJolpicaRaceSnapshots.AsNoTracking().ToListAsync()); - Assert.Empty(await verificationContext.MigrationImportRaceRoundMappings.AsNoTracking().ToListAsync()); - Assert.Equal(0, jolpicaClient.GetRacesCallCount); - - var run = await verificationContext.MigrationImportRuns.AsNoTracking().SingleAsync(); - Assert.True(run.IsDryRun); + var run = await verificationContext.MigrationImportRuns.AsNoTracking().OrderByDescending(x => x.StartedAtUtc).FirstAsync(); Assert.Equal("Completed", run.Status); - Assert.Equal(3, run.RawRowCount); - Assert.Equal("NotDetected", run.PreseasonParseStatus); - Assert.Equal("NotDetected", run.PreseasonScoringStatus); - Assert.Equal(0, run.PreseasonWarningCount); - Assert.Equal(0, run.PreseasonErrorCount); - Assert.True(run.PreseasonIsolationGuardPassed); - var stagedRows = await verificationContext.MigrationImportRawRows.AsNoTracking().OrderBy(x => x.RowNumber).ToListAsync(); - Assert.Equal(3, stagedRows.Count); - Assert.Equal("Header", stagedRows[0].SectionType); - Assert.Equal("RacePick", stagedRows[1].SectionType); - Assert.Equal("RacePick", stagedRows[2].SectionType); - Assert.Equal("Mapped special label to DNF pick type.", stagedRows[2].ClassificationReason); + var diagnostics = await verificationContext.MigrationImportConflictDiagnostics + .AsNoTracking() + .Where(x => x.ImportRunId == run.Id) + .ToListAsync(); + Assert.NotEmpty(diagnostics); + Assert.Contains(diagnostics, x => x.PolicyOutcome == "Skipped"); + + var preservedPosition = await verificationContext.SelectionPositions + .AsNoTracking() + .Where(x => x.SelectionId == existingSelectionId) + .SingleAsync(); + Assert.Equal("OLD", preservedPosition.DriverId); } finally { @@ -354,6 +895,7 @@ public async Task RunOnceAsync_WhenWriteModeEnabled_RewritesSelectionRaceCodesTo await using var setupContext = CreateContext(); await setupContext.Database.EnsureDeletedAsync(); await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); var sourceFilePath = await CreateTempCsvAsync( "Question,Philip,,\n" + @@ -428,6 +970,213 @@ public async Task RunOnceAsync_WhenWriteModeEnabled_RewritesSelectionRaceCodesTo } } + [Fact] + public async Task RunOnceAsync_WhenWriteModeEnabled_PersistsCanonicalRaceDomainEntities() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = false, + Season = 2025 + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await orchestrator.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + Assert.NotEmpty(await verificationContext.Drivers.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.Races.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.Selections.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.SelectionPositions.AsNoTracking().ToListAsync()); + } + finally + { + File.Delete(sourceFilePath); + } + } + + [Fact] + public async Task RunOnceAsync_WhenCanonicalRoundAlreadyExists_ReusesExistingRaceInsteadOfInsertingDuplicateRound() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + + var competition = new F1.Core.Models.Competition + { + Name = "Migration Import 2025", + Year = 2025, + Description = "Seeded round collision" + }; + setupContext.Competitions.Add(competition); + await setupContext.SaveChangesAsync(); + + var existingRaceId = "existing-2025-round-1"; + setupContext.Races.Add(new F1.Core.Models.Race + { + Id = existingRaceId, + CompetitionId = competition.Id, + Season = 2025, + Round = 1, + RaceName = "preexisting_race", + CircuitName = "preexisting_race", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + await setupContext.SaveChangesAsync(); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = false, + Season = 2025 + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await orchestrator.RunOnceAsync(CancellationToken.None); + + await using var verificationContext = CreateContext(); + var run = await verificationContext.MigrationImportRuns.AsNoTracking().SingleAsync(); + Assert.Equal("Completed", run.Status); + + Assert.NotNull(await verificationContext.Races.AsNoTracking().FirstOrDefaultAsync(x => x.Id == existingRaceId)); + + var firstSelection = await verificationContext.Selections.AsNoTracking().FirstAsync(); + Assert.Equal(existingRaceId, firstSelection.RaceId); + } + finally + { + File.Delete(sourceFilePath); + } + } + + [Fact] + public async Task RunOnceAsync_WhenCanonicalWriteFailsMidTransaction_RollsBackCanonicalEntities() + { + await using var setupContext = CreateContext(); + await setupContext.Database.EnsureDeletedAsync(); + await setupContext.Database.EnsureCreatedAsync(); + await SeedCanonicalRacesAsync(setupContext, season: 2025); + + var sourceFilePath = await CreateTempCsvAsync( + "Question,Philip,,\n" + + "AUS-1,VER,VER\n" + + "DNF,NONE,\n" + + "AUS-1,10,10\n" + + "DNF,5,5\n" + + "Result,15\n"); + + try + { + var dbFactory = new TestDbContextFactory(_fixture.ConnectionString); + var runService = new MigrationImportRunService(dbFactory); + + var orchestrator = new MigrationImportOrchestrator( + NullLogger.Instance, + runService, + new MigrationImportRowClassifier(), + new MigrationRaceSelectionParser(dbFactory), + new MigrationRaceRoundMapper( + dbFactory, + new TrackingJolpicaClient(), + Options.Create(new DataSyncOptions { HttpRetryCount = 0, HttpRetryDelayMs = 1 }), + Options.Create(new MigrationImportOptions { Season = 2025 })), + new MigrationScoreRecalculator(dbFactory), + new MigrationLegacyScoreImporter(dbFactory), + new MigrationReconciliationService(dbFactory), + dbFactory, + Options.Create(new DataSyncOptions { AutoMigrate = false }), + Options.Create(new MigrationImportOptions + { + Enabled = true, + SourceFilePath = sourceFilePath, + DryRun = false, + Season = 2025, + CanonicalWriteFailureInjectionStage = "after_drivers" + }), + MigrationExpectedVarianceRuleCatalog.Empty); + + await Assert.ThrowsAsync(() => orchestrator.RunOnceAsync(CancellationToken.None)); + + await using var verificationContext = CreateContext(); + var run = await verificationContext.MigrationImportRuns.AsNoTracking().SingleAsync(); + Assert.Equal("Failed", run.Status); + + Assert.Empty(await verificationContext.Drivers.AsNoTracking().ToListAsync()); + Assert.NotEmpty(await verificationContext.Races.AsNoTracking().ToListAsync()); + Assert.Empty(await verificationContext.Selections.AsNoTracking().ToListAsync()); + Assert.Empty(await verificationContext.SelectionPositions.AsNoTracking().ToListAsync()); + } + finally + { + File.Delete(sourceFilePath); + } + } + [Fact] public async Task RunOnceAsync_WhenPhilCsvContainsPreseasonTwentyPointRows_DoesNotImportThemAsRacePoints() { @@ -626,6 +1375,60 @@ private F1DbContext CreateContext() return new F1DbContext(options); } + private static async Task SeedCanonicalRacesAsync(F1DbContext context, int season) + { + var competition = await context.Competitions + .FirstOrDefaultAsync(x => x.Year == season && x.Name == $"Migration Import {season}"); + + if (competition is null) + { + competition = new F1.Core.Models.Competition + { + Name = $"Migration Import {season}", + Year = season, + Description = "Seeded canonical races for migration write tests" + }; + context.Competitions.Add(competition); + await context.SaveChangesAsync(); + } + + var existingRounds = await context.Races + .Where(x => x.CompetitionId == competition.Id && x.Season == season) + .Select(x => x.Round) + .ToListAsync(); + + if (existingRounds.Count == 0) + { + context.Races.AddRange( + new F1.Core.Models.Race + { + Id = $"seed-{season}-round-1", + CompetitionId = competition.Id, + Season = season, + Round = 1, + RaceName = "Australian Grand Prix", + CircuitName = "albert_park", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }, + new F1.Core.Models.Race + { + Id = $"seed-{season}-round-2", + CompetitionId = competition.Id, + Season = season, + Round = 2, + RaceName = "Chinese Grand Prix", + CircuitName = "shanghai", + StartTimeUtc = DateTime.UtcNow, + PreQualyDeadlineUtc = DateTime.UtcNow, + FinalDeadlineUtc = DateTime.UtcNow + }); + + await context.SaveChangesAsync(); + } + } + private sealed class TestDbContextFactory : IDbContextFactory { private readonly DbContextOptions _options; diff --git a/tests/F1.Web.Tests/AdminMigrationRunsTests.cs b/tests/F1.Web.Tests/AdminMigrationRunsTests.cs index a937617..7375638 100644 --- a/tests/F1.Web.Tests/AdminMigrationRunsTests.cs +++ b/tests/F1.Web.Tests/AdminMigrationRunsTests.cs @@ -60,6 +60,7 @@ public void AdminMigrationRuns_ShouldExposeTablistAndSelectedTabAria_WhenRunIsSe PreseasonParticipantDeltas: [], PreseasonQuestionDiffs: [], PreseasonReasonCategorySummaries: [], + ConflictDiagnostics: [], RaceDiffs: [], PickDiffs: []); @@ -151,6 +152,7 @@ public void AdminMigrationRuns_ShouldRenderRunsTable_WhenApiReturnsItems() [ new AdminMigrationPreseasonReasonCategorySummary("PRESEASON_RULE_VARIANCE", 1, -20) ], + ConflictDiagnostics: [], RaceDiffs: [ new AdminMigrationRaceDiff("albert_park", "Philip", 25, 20, -5, "PODIUM_RULE_VARIANCE", "Podium mismatch"), @@ -198,6 +200,7 @@ public void AdminMigrationRuns_ShouldRenderRunsTable_WhenApiReturnsItems() [ new AdminMigrationPreseasonReasonCategorySummary("PRESEASON_RULE_VARIANCE", 1, -20) ], + ConflictDiagnostics: [], RaceDiffs: [ new AdminMigrationRaceDiff("albert_park", "Philip", 25, 20, -5, "PODIUM_RULE_VARIANCE", "Podium mismatch") @@ -463,7 +466,15 @@ public void AdminMigrationRuns_ShouldRequireConfirmation_AndShowSuccess_WhenKick SourceFilePath: "/tmp/import.csv", SourceFileChecksum: "abc123", TriggeredAtUtc: new DateTime(2026, 7, 6, 14, 0, 0, DateTimeKind.Utc), - RequestedBy: "admin@example.com")); + RequestedBy: "admin@example.com", + NonEmptyDbStrategy: "append-with-report", + CanonicalDataPresent: false, + ExistingDriverCount: 0, + ExistingRaceCount: 0, + ExistingSelectionCount: 0, + EstimatedAffectedRaceCount: 0, + EstimatedAffectedParticipantCount: 0, + EstimatedAffectedSelectionCount: 0)); Services.AddSingleton(apiMock.Object); @@ -481,10 +492,35 @@ public void AdminMigrationRuns_ShouldRequireConfirmation_AndShowSuccess_WhenKick apiMock.Verify(x => x.StartRunAsync( It.Is(request => request.SourceFilePath == "/tmp/import.csv" && - request.Mode == "dry-run"), + request.Mode == "dry-run" && + !request.ConfirmNonEmptyStrategy), It.IsAny()), Times.Once); } + [Fact] + public void AdminMigrationRuns_WriteKickoff_ShouldRequireExplicitNonEmptyConfirmation() + { + var apiMock = new Mock(); + apiMock + .Setup(x => x.GetRunsAsync(1, 25, null, null, null, It.IsAny())) + .ReturnsAsync(new AdminMigrationRunListResponse(1, 25, 0, [])); + + Services.AddSingleton(apiMock.Object); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Start Migration Run", cut.Markup)); + + cut.Find("#kickoff-mode").Change("write"); + cut.Find("button.btn.btn-success").Click(); + cut.WaitForAssertion(() => Assert.Contains("Confirm Migration Kickoff", cut.Markup)); + + cut.Find("button.btn.btn-danger").Click(); + + cut.WaitForAssertion(() => + Assert.Contains("Write mode requires explicit non-empty DB strategy confirmation.", cut.Markup)); + apiMock.Verify(x => x.StartRunAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public void AdminMigrationRuns_ShouldShowConflictError_WhenKickoffConflicts() { diff --git a/tests/F1.Web.Tests/Services/Api/MigrationRunsApiServiceTests.cs b/tests/F1.Web.Tests/Services/Api/MigrationRunsApiServiceTests.cs new file mode 100644 index 0000000..6807e10 --- /dev/null +++ b/tests/F1.Web.Tests/Services/Api/MigrationRunsApiServiceTests.cs @@ -0,0 +1,104 @@ +using F1.Web.Models; +using F1.Web.Services.Api; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace F1.Web.Tests.Services.Api; + +public sealed class MigrationRunsApiServiceTests +{ + [Fact] + public async Task StartRunFromUploadAsync_WhenWriteModeAndConfirmed_SendsConfirmNonEmptyStrategyFormField() + { + var runId = Guid.NewGuid(); + var responsePayload = new AdminMigrationRunKickoffResponse( + RunId: runId, + Status: "Queued", + IsDryRun: false, + RequestedMode: "write", + SourceFilePath: "data/imports/uploads/import.csv", + SourceFileChecksum: "abc123", + TriggeredAtUtc: DateTime.UtcNow, + RequestedBy: "admin@example.com", + NonEmptyDbStrategy: "merge_upsert_active_records", + CanonicalDataPresent: true, + ExistingDriverCount: 1, + ExistingRaceCount: 1, + ExistingSelectionCount: 1, + EstimatedAffectedRaceCount: 1, + EstimatedAffectedParticipantCount: 1, + EstimatedAffectedSelectionCount: 1); + + var handler = new CaptureHttpMessageHandler(); + handler.EnqueueResponse(CreateJsonResponse(responsePayload)); + var service = CreateService(handler); + + await using var content = new MemoryStream(Encoding.UTF8.GetBytes("Question,Philip\nAUS-1,VER")); + var result = await service.StartRunFromUploadAsync(new AdminMigrationRunKickoffUploadRequest( + FileName: "import.csv", + Content: content, + Mode: "write", + ConfirmNonEmptyStrategy: true)); + + Assert.Equal(runId, result.RunId); + Assert.NotNull(handler.LastRequest); + Assert.Equal(HttpMethod.Post, handler.LastRequest!.Method); + Assert.Equal("http://localhost/admin/migration-runs/kickoff/upload", handler.LastRequest.RequestUri!.ToString()); + + var multipart = await handler.LastRequest.Content!.ReadAsStringAsync(); + Assert.Contains("ConfirmNonEmptyStrategy", multipart, StringComparison.Ordinal); + Assert.Contains("\r\n\r\ntrue\r\n", multipart, StringComparison.Ordinal); + } + + private static MigrationRunsApiService CreateService(CaptureHttpMessageHandler handler) + { + var httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("http://localhost") + }; + + return new MigrationRunsApiService(httpClient); + } + + private static HttpResponseMessage CreateJsonResponse(T payload, HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new HttpResponseMessage(statusCode) + { + Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json") + }; + } + + private sealed class CaptureHttpMessageHandler : HttpMessageHandler + { + private readonly Queue _responses = new(); + public HttpRequestMessage? LastRequest { get; private set; } + + public void EnqueueResponse(HttpResponseMessage response) + { + _responses.Enqueue(response); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = new HttpRequestMessage(request.Method, request.RequestUri) + { + Content = request.Content is null + ? null + : new StringContent(await request.Content.ReadAsStringAsync(cancellationToken), Encoding.UTF8, request.Content.Headers.ContentType?.MediaType ?? "text/plain") + }; + + foreach (var header in request.Headers) + { + LastRequest.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + if (_responses.Count == 0) + { + throw new InvalidOperationException("No queued HTTP response for request."); + } + + return _responses.Dequeue(); + } + } +} \ No newline at end of file