Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ COSMOSDB_CONNECTIONSTRING=
# Required.
CLOUDFLARE_AUDIENCE=

# Development-only: when true, selection GET/PUT endpoints use in-memory mock state.
# This is ignored outside Development environment.
DEV_MOCK_CURRENT_SELECTIONS=true

# --- Cloudflare Tunnel (Optional) ---
# Used for the 'cloud' profile to expose the service publicly.
# TUNNEL_TOKEN=
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ Required API values in `.env`:
- `COSMOSDB_CONNECTIONSTRING`: mapped to `CosmosDb__ConnectionString` for `f1-api`.
- `CLOUDFLARE_AUDIENCE`: mapped to `CloudflareAccess__Audience` for `f1-api`.

Optional development toggle in `.env`:

- `DEV_MOCK_CURRENT_SELECTIONS`: mapped to `DevSettings__MockCurrentSelections` for `f1-api`. When `true` in Development, selection GET/PUT endpoints use an in-memory mock store so the UI can be validated without Cosmos data.

Notes:

- `CloudflareAccess__Issuer` is currently set in `docker-compose.yml`.
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
- CosmosDb__ConnectionString=${COSMOSDB_CONNECTIONSTRING}
- CloudflareAccess__Audience=${CLOUDFLARE_AUDIENCE:-CloudflareAccessAudienceNotSet}
- CloudflareAccess__Issuer=https://f1-team.cloudflareaccess.com
- DevSettings__MockCurrentSelections=${DEV_MOCK_CURRENT_SELECTIONS:-false}
image: ghcr.io/philipwoulfe/f1competition:${TAG:-latest}
build:
context: .
Expand Down
180 changes: 179 additions & 1 deletion src/F1.Api/Controllers/SelectionsController.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
using F1.Core.Dtos;
using F1.Core.Interfaces;
using F1.Core.Models;
using F1.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.Collections.Concurrent;
using System.Security.Claims;

namespace F1.Api.Controllers;
Expand All @@ -10,11 +14,19 @@ namespace F1.Api.Controllers;
[Route("selections")]
public class SelectionsController : ControllerBase
{
private static readonly ConcurrentDictionary<string, Selection> MockSelections = new(StringComparer.OrdinalIgnoreCase);
private readonly ISelectionService _selectionService;
private readonly IConfiguration _configuration;
private readonly IHostEnvironment _hostEnvironment;

public SelectionsController(ISelectionService selectionService)
public SelectionsController(
ISelectionService selectionService,
IConfiguration configuration,
IHostEnvironment hostEnvironment)
{
_selectionService = selectionService;
_configuration = configuration;
_hostEnvironment = hostEnvironment;
}

[HttpGet("{raceId}/config")]
Expand All @@ -38,6 +50,11 @@ public async Task<IActionResult> GetMine(string raceId)
return Unauthorized();
}

if (ShouldUseMockCurrentSelections())
{
return Ok(GetOrCreateMockSelection(raceId, userId));
}

var selection = await _selectionService.GetSelectionAsync(raceId, userId);
if (selection is null)
{
Expand All @@ -47,6 +64,25 @@ public async Task<IActionResult> GetMine(string raceId)
return Ok(selection);
}

[HttpGet("current")]
public async Task<IActionResult> GetCurrent()
{
var userId = ResolveUserId();
if (string.IsNullOrWhiteSpace(userId))
{
return Unauthorized();
}

if (ShouldUseMockCurrentSelections())
{
var selection = GetOrCreateMockSelection(SelectionService.AustraliaRaceId2026, userId);
return Ok(MapCurrentSelections(selection));
}

var selections = await _selectionService.GetCurrentSelectionsAsync(userId);
return Ok(selections);
}

[HttpPut("{raceId}/mine")]
public async Task<IActionResult> UpsertMine(string raceId, [FromBody] SelectionSubmissionDto submission)
{
Expand All @@ -56,6 +92,18 @@ public async Task<IActionResult> UpsertMine(string raceId, [FromBody] SelectionS
return Unauthorized();
}

if (ShouldUseMockCurrentSelections())
{
var validationMessage = ValidateMockSubmission(submission);
if (validationMessage is not null)
{
return BadRequest(new { message = validationMessage });
}

var selection = UpsertMockSelection(raceId, userId, submission);
return Ok(selection);
}

try
{
var selection = await _selectionService.UpsertSelectionAsync(raceId, userId, submission);
Expand All @@ -76,4 +124,134 @@ public async Task<IActionResult> UpsertMine(string raceId, [FromBody] SelectionS
return User.FindFirstValue(ClaimTypes.Email)
?? Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault();
}

private bool ShouldUseMockCurrentSelections()
{
return _hostEnvironment.IsDevelopment()
&& _configuration.GetValue<bool>("DevSettings:MockCurrentSelections");
}

private static Selection GetOrCreateMockSelection(string raceId, string userId)
{
var key = BuildMockSelectionKey(raceId, userId);
return MockSelections.GetOrAdd(key, _ => BuildDefaultMockSelection(raceId, userId));
}

private static Selection UpsertMockSelection(string raceId, string userId, SelectionSubmissionDto submission)
{
var orderedSelections = submission.OrderedSelections;
var key = BuildMockSelectionKey(raceId, userId);
var updated = new Selection
{
Id = MockSelections.TryGetValue(key, out var existing) ? existing.Id : Guid.NewGuid(),
RaceId = raceId,
UserId = userId,
OrderedSelections = orderedSelections,
BetType = submission.BetType,
SubmittedAtUtc = DateTime.UtcNow,
IsLocked = false
};

MockSelections[key] = updated;
return updated;
}

private static Selection BuildDefaultMockSelection(string raceId, string userId)
{
return new Selection
{
Id = Guid.NewGuid(),
RaceId = raceId,
UserId = userId,
BetType = BetType.Regular,
SubmittedAtUtc = new DateTime(2026, 3, 6, 9, 0, 0, DateTimeKind.Utc),
IsLocked = false,
OrderedSelections =
[
new SelectionPosition { Position = 1, DriverId = "max_verstappen" },
new SelectionPosition { Position = 2, DriverId = "lando_norris" },
new SelectionPosition { Position = 3, DriverId = "charles_leclerc" },
new SelectionPosition { Position = 4, DriverId = "oscar_piastri" },
new SelectionPosition { Position = 5, DriverId = "lewis_hamilton" }
]
};
}

private static IReadOnlyList<CurrentSelectionDto> MapCurrentSelections(Selection selection)
{
var userName = selection.UserId.Split('@')[0];
var orderedSelections = selection.OrderedSelections;
var rows = new List<CurrentSelectionDto>(orderedSelections.Count);

foreach (var selectionItem in orderedSelections)
{
var driverId = selectionItem.DriverId;
if (string.IsNullOrWhiteSpace(driverId))
{
continue;
}

rows.Add(new CurrentSelectionDto
{
Position = selectionItem.Position,
UserId = selection.UserId,
UserName = userName,
DriverId = driverId,
DriverName = ResolveMockDriverName(driverId),
SelectionType = selection.BetType.ToString(),
Timestamp = selection.SubmittedAtUtc
});
}

return rows;
}

private static string ResolveMockDriverName(string driverId)
{
return driverId switch
{
"max_verstappen" => "Max Verstappen",
"lando_norris" => "Lando Norris",
"charles_leclerc" => "Charles Leclerc",
"oscar_piastri" => "Oscar Piastri",
"lewis_hamilton" => "Lewis Hamilton",
"leclerc" => "Charles Leclerc",
"norris" => "Lando Norris",
"hamilton" => "Lewis Hamilton",
"piastri" => "Oscar Piastri",
_ => driverId
};
}

private static string? ValidateMockSubmission(SelectionSubmissionDto submission)
{
var orderedSelections = submission.OrderedSelections;

var distinctCount = orderedSelections
.Select(item => item.DriverId)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();

var distinctPositions = orderedSelections
.Select(item => item.Position)
.Distinct()
.Count();

if (orderedSelections.Count != 5 || distinctCount != 5 || distinctPositions != 5)
{
return "Exactly 5 unique drivers must be selected.";
}
Comment thread
PhilipWoulfe marked this conversation as resolved.

if (orderedSelections.Any(item => item.Position < 1 || item.Position > 5))
{
return "Selection positions must be between 1 and 5.";
}
Comment thread
PhilipWoulfe marked this conversation as resolved.

return null;
}

private static string BuildMockSelectionKey(string raceId, string userId)
{
return $"{raceId}::{userId}";
}
}
3 changes: 2 additions & 1 deletion src/F1.Api/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
},
"DevSettings": {
"SimulateCloudflare": true,
"MockEmail": "dev-user@example.com"
"MockEmail": "dev-user@example.com",
"MockCurrentSelections": true
}
}

12 changes: 12 additions & 0 deletions src/F1.Core/Dtos/CurrentSelectionDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace F1.Core.Dtos;

public class CurrentSelectionDto
{
public int Position { get; set; }
public string UserId { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string DriverId { get; set; } = string.Empty;
public string DriverName { get; set; } = string.Empty;
public string SelectionType { get; set; } = string.Empty;
public DateTime Timestamp { get; set; }
}
3 changes: 2 additions & 1 deletion src/F1.Core/Dtos/SelectionSubmissionDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace F1.Core.Dtos;

public class SelectionSubmissionDto
{
public List<string> Selections { get; set; } = [];
public List<SelectionPosition> OrderedSelections { get; set; } = [];
Comment thread
PhilipWoulfe marked this conversation as resolved.

public BetType BetType { get; set; }
}
1 change: 1 addition & 0 deletions src/F1.Core/Interfaces/ISelectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace F1.Core.Interfaces;
public interface ISelectionService
{
Task<Selection?> GetSelectionAsync(string raceId, string userId);
Task<IReadOnlyList<CurrentSelectionDto>> GetCurrentSelectionsAsync(string userId);
Task<Selection> UpsertSelectionAsync(string raceId, string userId, SelectionSubmissionDto submission);
int CalculateScore(BetType betType, bool isPerfectTopFive, int basePoints, bool submittedBeforePreQualyDeadline);
RaceConfigDto? GetRaceConfig(string raceId);
Expand Down
8 changes: 7 additions & 1 deletion src/F1.Core/Models/Selection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ public class Selection

public string UserId { get; set; } = string.Empty;
public string RaceId { get; set; } = string.Empty;
public List<string> Selections { get; set; } = [];
public List<SelectionPosition> OrderedSelections { get; set; } = [];
public BetType BetType { get; set; }
Comment thread
PhilipWoulfe marked this conversation as resolved.
public DateTime SubmittedAtUtc { get; set; }
public bool IsLocked { get; set; }
}

public class SelectionPosition
{
public int Position { get; set; }
public string DriverId { get; set; } = string.Empty;
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public CosmosSelectionRepository(CosmosClient cosmosClient, IConfiguration confi
public async Task<Selection?> GetSelectionAsync(string raceId, string userId)
{
var queryDefinition = new QueryDefinition(
"SELECT TOP 1 * FROM c WHERE c.raceId = @raceId AND c.userId = @userId")
"SELECT TOP 1 * FROM c WHERE c.raceId = @raceId AND c.userId = @userId ORDER BY c._ts DESC")
.WithParameter("@raceId", raceId)
.WithParameter("@userId", userId);

Expand Down
Loading
Loading