Skip to content

Commit f920b14

Browse files
PhilipWoulfeCopilotCopilot
authored
Feat/#86 retrieve selections (#118)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: PhilipWoulfe <8867626+PhilipWoulfe@users.noreply.github.com>
1 parent 8352ecb commit f920b14

25 files changed

Lines changed: 877 additions & 58 deletions

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ COSMOSDB_CONNECTIONSTRING=
3434
# Required.
3535
CLOUDFLARE_AUDIENCE=
3636

37+
# Development-only: when true, selection GET/PUT endpoints use in-memory mock state.
38+
# This is ignored outside Development environment.
39+
DEV_MOCK_CURRENT_SELECTIONS=true
40+
3741
# --- Cloudflare Tunnel (Optional) ---
3842
# Used for the 'cloud' profile to expose the service publicly.
3943
# TUNNEL_TOKEN=

F1Competition.sln

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F1.Infrastructure", "src\F1
1313
EndProject
1414
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F1.Services", "src\F1.Services\F1.Services.csproj", "{4AE3B62E-A936-4170-8E5F-17C9B23A02FB}"
1515
EndProject
16-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.Config", "src\PopulateF1Database.Config\PopulateF1Database.Config.csproj", "{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}"
17-
EndProject
18-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.DataAccess", "src\PopulateF1Database.DataAccess\PopulateF1Database.DataAccess.csproj", "{A8236C71-91ED-406F-AB4C-7601A5F070F5}"
19-
EndProject
20-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.Models", "src\PopulateF1Database.Models\PopulateF1Database.Models.csproj", "{32C40E51-3FF3-42BB-BD62-122D02ADD66F}"
21-
EndProject
22-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database", "src\PopulateF1Database\PopulateF1Database.csproj", "{04789E1D-BF87-4DBE-896E-3568B67D71E2}"
23-
EndProject
24-
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PopulateF1Database.Services", "src\PopulateF1Database.Services\PopulateF1Database.Services.csproj", "{512973AF-5DBE-481A-9819-1C52B041429F}"
25-
EndProject
2616
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{F238F935-2CD0-4D1E-8DCB-C42A0E241EFA}"
2717
EndProject
2818
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F1.Web", "src\F1.Web\F1.Web.csproj", "{F53769FD-1C8E-405A-9602-B3AD895025EF}"
@@ -56,26 +46,6 @@ Global
5646
{4AE3B62E-A936-4170-8E5F-17C9B23A02FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
5747
{4AE3B62E-A936-4170-8E5F-17C9B23A02FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
5848
{4AE3B62E-A936-4170-8E5F-17C9B23A02FB}.Release|Any CPU.Build.0 = Release|Any CPU
59-
{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
60-
{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Debug|Any CPU.Build.0 = Debug|Any CPU
61-
{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Release|Any CPU.ActiveCfg = Release|Any CPU
62-
{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775}.Release|Any CPU.Build.0 = Release|Any CPU
63-
{A8236C71-91ED-406F-AB4C-7601A5F070F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
64-
{A8236C71-91ED-406F-AB4C-7601A5F070F5}.Debug|Any CPU.Build.0 = Debug|Any CPU
65-
{A8236C71-91ED-406F-AB4C-7601A5F070F5}.Release|Any CPU.ActiveCfg = Release|Any CPU
66-
{A8236C71-91ED-406F-AB4C-7601A5F070F5}.Release|Any CPU.Build.0 = Release|Any CPU
67-
{32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
68-
{32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Debug|Any CPU.Build.0 = Debug|Any CPU
69-
{32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Release|Any CPU.ActiveCfg = Release|Any CPU
70-
{32C40E51-3FF3-42BB-BD62-122D02ADD66F}.Release|Any CPU.Build.0 = Release|Any CPU
71-
{04789E1D-BF87-4DBE-896E-3568B67D71E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
72-
{04789E1D-BF87-4DBE-896E-3568B67D71E2}.Debug|Any CPU.Build.0 = Debug|Any CPU
73-
{04789E1D-BF87-4DBE-896E-3568B67D71E2}.Release|Any CPU.ActiveCfg = Release|Any CPU
74-
{04789E1D-BF87-4DBE-896E-3568B67D71E2}.Release|Any CPU.Build.0 = Release|Any CPU
75-
{512973AF-5DBE-481A-9819-1C52B041429F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
76-
{512973AF-5DBE-481A-9819-1C52B041429F}.Debug|Any CPU.Build.0 = Debug|Any CPU
77-
{512973AF-5DBE-481A-9819-1C52B041429F}.Release|Any CPU.ActiveCfg = Release|Any CPU
78-
{512973AF-5DBE-481A-9819-1C52B041429F}.Release|Any CPU.Build.0 = Release|Any CPU
7949
{F53769FD-1C8E-405A-9602-B3AD895025EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
8050
{F53769FD-1C8E-405A-9602-B3AD895025EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
8151
{F53769FD-1C8E-405A-9602-B3AD895025EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -94,11 +64,6 @@ Global
9464
{BB36B68F-9369-4AFB-A449-4591D74E64D3} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
9565
{0151CA68-EF87-44B3-BDAB-FA7046C5A6EE} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
9666
{4AE3B62E-A936-4170-8E5F-17C9B23A02FB} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
97-
{DBD8C5EB-E9A6-4BD0-A325-DEAC1D6C4775} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
98-
{A8236C71-91ED-406F-AB4C-7601A5F070F5} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
99-
{32C40E51-3FF3-42BB-BD62-122D02ADD66F} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
100-
{04789E1D-BF87-4DBE-896E-3568B67D71E2} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
101-
{512973AF-5DBE-481A-9819-1C52B041429F} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
10267
{F53769FD-1C8E-405A-9602-B3AD895025EF} = {C0F4BFC0-DF48-4844-B797-293F59F4BDA4}
10368
{7C50243B-A8CF-4D46-AFD9-10B3CEC57863} = {F238F935-2CD0-4D1E-8DCB-C42A0E241EFA}
10469
{CEE8247D-AF0A-45FB-9CE6-A78E3FCCC245} = {F238F935-2CD0-4D1E-8DCB-C42A0E241EFA}

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ Required API values in `.env`:
102102
- `COSMOSDB_CONNECTIONSTRING`: mapped to `CosmosDb__ConnectionString` for `f1-api`.
103103
- `CLOUDFLARE_AUDIENCE`: mapped to `CloudflareAccess__Audience` for `f1-api`.
104104

105+
Optional development toggle in `.env`:
106+
107+
- `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.
108+
105109
Notes:
106110

107111
- `CloudflareAccess__Issuer` is currently set in `docker-compose.yml`.

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ services:
77
- CosmosDb__ConnectionString=${COSMOSDB_CONNECTIONSTRING}
88
- CloudflareAccess__Audience=${CLOUDFLARE_AUDIENCE:-CloudflareAccessAudienceNotSet}
99
- CloudflareAccess__Issuer=https://f1-team.cloudflareaccess.com
10+
- DevSettings__MockCurrentSelections=${DEV_MOCK_CURRENT_SELECTIONS:-false}
1011
image: ghcr.io/philipwoulfe/f1competition:${TAG:-latest}
1112
build:
1213
context: .

src/F1.Api/Controllers/SelectionsController.cs

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
using F1.Core.Dtos;
22
using F1.Core.Interfaces;
3+
using F1.Core.Models;
34
using F1.Services;
45
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.Extensions.Configuration;
7+
using Microsoft.Extensions.Hosting;
8+
using System.Collections.Concurrent;
59
using System.Security.Claims;
610

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

15-
public SelectionsController(ISelectionService selectionService)
22+
public SelectionsController(
23+
ISelectionService selectionService,
24+
IConfiguration configuration,
25+
IHostEnvironment hostEnvironment)
1626
{
1727
_selectionService = selectionService;
28+
_configuration = configuration;
29+
_hostEnvironment = hostEnvironment;
1830
}
1931

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

53+
if (ShouldUseMockCurrentSelections())
54+
{
55+
return Ok(GetOrCreateMockSelection(raceId, userId));
56+
}
57+
4158
var selection = await _selectionService.GetSelectionAsync(raceId, userId);
4259
if (selection is null)
4360
{
@@ -47,6 +64,25 @@ public async Task<IActionResult> GetMine(string raceId)
4764
return Ok(selection);
4865
}
4966

67+
[HttpGet("current")]
68+
public async Task<IActionResult> GetCurrent()
69+
{
70+
var userId = ResolveUserId();
71+
if (string.IsNullOrWhiteSpace(userId))
72+
{
73+
return Unauthorized();
74+
}
75+
76+
if (ShouldUseMockCurrentSelections())
77+
{
78+
var selection = GetOrCreateMockSelection(SelectionService.AustraliaRaceId2026, userId);
79+
return Ok(MapCurrentSelections(selection));
80+
}
81+
82+
var selections = await _selectionService.GetCurrentSelectionsAsync(userId);
83+
return Ok(selections);
84+
}
85+
5086
[HttpPut("{raceId}/mine")]
5187
public async Task<IActionResult> UpsertMine(string raceId, [FromBody] SelectionSubmissionDto submission)
5288
{
@@ -56,6 +92,18 @@ public async Task<IActionResult> UpsertMine(string raceId, [FromBody] SelectionS
5692
return Unauthorized();
5793
}
5894

95+
if (ShouldUseMockCurrentSelections())
96+
{
97+
var validationMessage = ValidateMockSubmission(submission);
98+
if (validationMessage is not null)
99+
{
100+
return BadRequest(new { message = validationMessage });
101+
}
102+
103+
var selection = UpsertMockSelection(raceId, userId, submission);
104+
return Ok(selection);
105+
}
106+
59107
try
60108
{
61109
var selection = await _selectionService.UpsertSelectionAsync(raceId, userId, submission);
@@ -76,4 +124,138 @@ public async Task<IActionResult> UpsertMine(string raceId, [FromBody] SelectionS
76124
return User.FindFirstValue(ClaimTypes.Email)
77125
?? Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault();
78126
}
127+
128+
private bool ShouldUseMockCurrentSelections()
129+
{
130+
return _hostEnvironment.IsDevelopment()
131+
&& _configuration.GetValue<bool>("DevSettings:MockCurrentSelections");
132+
}
133+
134+
private static Selection GetOrCreateMockSelection(string raceId, string userId)
135+
{
136+
var key = BuildMockSelectionKey(raceId, userId);
137+
return MockSelections.GetOrAdd(key, _ => BuildDefaultMockSelection(raceId, userId));
138+
}
139+
140+
private static Selection UpsertMockSelection(string raceId, string userId, SelectionSubmissionDto submission)
141+
{
142+
var orderedSelections = submission.OrderedSelections;
143+
var key = BuildMockSelectionKey(raceId, userId);
144+
var updated = new Selection
145+
{
146+
Id = MockSelections.TryGetValue(key, out var existing) ? existing.Id : Guid.NewGuid(),
147+
RaceId = raceId,
148+
UserId = userId,
149+
OrderedSelections = orderedSelections,
150+
BetType = submission.BetType,
151+
SubmittedAtUtc = DateTime.UtcNow,
152+
IsLocked = false
153+
};
154+
155+
MockSelections[key] = updated;
156+
return updated;
157+
}
158+
159+
private static Selection BuildDefaultMockSelection(string raceId, string userId)
160+
{
161+
return new Selection
162+
{
163+
Id = Guid.NewGuid(),
164+
RaceId = raceId,
165+
UserId = userId,
166+
BetType = BetType.Regular,
167+
SubmittedAtUtc = new DateTime(2026, 3, 6, 9, 0, 0, DateTimeKind.Utc),
168+
IsLocked = false,
169+
OrderedSelections =
170+
[
171+
new SelectionPosition { Position = 1, DriverId = "max_verstappen" },
172+
new SelectionPosition { Position = 2, DriverId = "lando_norris" },
173+
new SelectionPosition { Position = 3, DriverId = "charles_leclerc" },
174+
new SelectionPosition { Position = 4, DriverId = "oscar_piastri" },
175+
new SelectionPosition { Position = 5, DriverId = "lewis_hamilton" }
176+
]
177+
};
178+
}
179+
180+
private static IReadOnlyList<CurrentSelectionDto> MapCurrentSelections(Selection selection)
181+
{
182+
var userName = selection.UserId.Split('@')[0];
183+
var orderedSelections = selection.OrderedSelections;
184+
var rows = new List<CurrentSelectionDto>(orderedSelections.Count);
185+
186+
foreach (var selectionItem in orderedSelections)
187+
{
188+
var driverId = selectionItem.DriverId;
189+
if (string.IsNullOrWhiteSpace(driverId))
190+
{
191+
continue;
192+
}
193+
194+
rows.Add(new CurrentSelectionDto
195+
{
196+
Position = selectionItem.Position,
197+
UserId = selection.UserId,
198+
UserName = userName,
199+
DriverId = driverId,
200+
DriverName = ResolveMockDriverName(driverId),
201+
SelectionType = selection.BetType.ToString(),
202+
Timestamp = selection.SubmittedAtUtc
203+
});
204+
}
205+
206+
return rows;
207+
}
208+
209+
private static string ResolveMockDriverName(string driverId)
210+
{
211+
return driverId switch
212+
{
213+
"max_verstappen" => "Max Verstappen",
214+
"lando_norris" => "Lando Norris",
215+
"charles_leclerc" => "Charles Leclerc",
216+
"oscar_piastri" => "Oscar Piastri",
217+
"lewis_hamilton" => "Lewis Hamilton",
218+
"leclerc" => "Charles Leclerc",
219+
"norris" => "Lando Norris",
220+
"hamilton" => "Lewis Hamilton",
221+
"piastri" => "Oscar Piastri",
222+
_ => driverId
223+
};
224+
}
225+
226+
private static string? ValidateMockSubmission(SelectionSubmissionDto submission)
227+
{
228+
var validSelections = submission.OrderedSelections
229+
.Where(item => !string.IsNullOrWhiteSpace(item.DriverId))
230+
.ToList();
231+
232+
var distinctPositions = validSelections
233+
.Select(item => item.Position)
234+
.Distinct()
235+
.Count();
236+
237+
var distinctCount = submission.OrderedSelections
238+
.Where(item => !string.IsNullOrWhiteSpace(item.DriverId))
239+
.Select(item => item.DriverId)
240+
.Distinct(StringComparer.OrdinalIgnoreCase)
241+
.Count();
242+
243+
var totalCount = submission.OrderedSelections.Count;
244+
if (totalCount != 5 || validSelections.Count != 5 || distinctCount != 5 || distinctPositions != 5)
245+
{
246+
return "Exactly 5 unique drivers must be selected.";
247+
}
248+
249+
if (validSelections.Any(item => item.Position < 1 || item.Position > 5))
250+
{
251+
return "Selection positions must be between 1 and 5.";
252+
}
253+
254+
return null;
255+
}
256+
257+
private static string BuildMockSelectionKey(string raceId, string userId)
258+
{
259+
return $"{raceId}::{userId}";
260+
}
79261
}

src/F1.Api/appsettings.Development.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
},
1616
"DevSettings": {
1717
"SimulateCloudflare": true,
18-
"MockEmail": "dev-user@example.com"
18+
"MockEmail": "dev-user@example.com",
19+
"MockCurrentSelections": true
1920
}
2021
}
2122

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace F1.Core.Dtos;
2+
3+
public class CurrentSelectionDto
4+
{
5+
public int Position { get; set; }
6+
public string UserId { get; set; } = string.Empty;
7+
public string UserName { get; set; } = string.Empty;
8+
public string DriverId { get; set; } = string.Empty;
9+
public string DriverName { get; set; } = string.Empty;
10+
public string SelectionType { get; set; } = string.Empty;
11+
public DateTime Timestamp { get; set; }
12+
}

src/F1.Core/Dtos/SelectionSubmissionDto.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace F1.Core.Dtos;
44

55
public class SelectionSubmissionDto
66
{
7-
public List<string> Selections { get; set; } = [];
7+
public List<SelectionPosition> OrderedSelections { get; set; } = [];
8+
89
public BetType BetType { get; set; }
910
}

src/F1.Core/Interfaces/ISelectionService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ namespace F1.Core.Interfaces;
66
public interface ISelectionService
77
{
88
Task<Selection?> GetSelectionAsync(string raceId, string userId);
9+
Task<IReadOnlyList<CurrentSelectionDto>> GetCurrentSelectionsAsync(string userId);
910
Task<Selection> UpsertSelectionAsync(string raceId, string userId, SelectionSubmissionDto submission);
1011
int CalculateScore(BetType betType, bool isPerfectTopFive, int basePoints, bool submittedBeforePreQualyDeadline);
1112
RaceConfigDto? GetRaceConfig(string raceId);

src/F1.Core/Models/Selection.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,14 @@ public class Selection
99

1010
public string UserId { get; set; } = string.Empty;
1111
public string RaceId { get; set; } = string.Empty;
12-
public List<string> Selections { get; set; } = [];
12+
public List<SelectionPosition> OrderedSelections { get; set; } = [];
1313
public BetType BetType { get; set; }
1414
public DateTime SubmittedAtUtc { get; set; }
1515
public bool IsLocked { get; set; }
1616
}
17+
18+
public class SelectionPosition
19+
{
20+
public int Position { get; set; }
21+
public string DriverId { get; set; } = string.Empty;
22+
}

0 commit comments

Comments
 (0)