Add event-driven state sync with ETag optimization
This commit is contained in:
3
API.md
3
API.md
@@ -14,7 +14,8 @@ The first account created with a valid `adminKey` becomes both `IsAdmin=true` an
|
|||||||
Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
|
Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
|
||||||
|
|
||||||
## State (requires auth)
|
## State (requires auth)
|
||||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
GET /api/state — returns caller identity (`id`, `username`, `displayName`, `isAdmin`, `isOwner`) plus currentPhase, votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes). Supports conditional reads with `ETag`/`If-None-Match`; unchanged state returns HTTP `304`.
|
||||||
|
GET /api/events/state — server-sent events stream for state invalidation (`ready` and `state` events with monotonic version payload) for event-driven client refresh.
|
||||||
GET /api/me — id, displayName, username, isAdmin, isOwner, currentPhase, votesFinal
|
GET /api/me — id, displayName, username, isAdmin, isOwner, currentPhase, votesFinal
|
||||||
|
|
||||||
## Player (requires auth)
|
## Player (requires auth)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public record AuthSessionResponse(Guid Id, string Username, string? DisplayName,
|
|||||||
|
|
||||||
public record AuthOptionsResponse(bool OwnerExists);
|
public record AuthOptionsResponse(bool OwnerExists);
|
||||||
|
|
||||||
public record StateSummaryResponse(Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes);
|
public record StateSummaryResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes);
|
||||||
|
|
||||||
public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);
|
public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
|
using GameList.Infrastructure;
|
||||||
|
|
||||||
namespace GameList.Endpoints;
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
@@ -8,14 +9,70 @@ public static class StateEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api").RequireAuthorization();
|
var group = app.MapGroup("/api").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) =>
|
||||||
{
|
{
|
||||||
|
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||||
|
if (notifier.MatchesCurrentEtag(ctx.Request.Headers.IfNoneMatch))
|
||||||
|
{
|
||||||
|
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||||
|
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||||
|
}
|
||||||
|
|
||||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
if (player is null)
|
if (player is null)
|
||||||
return EndpointHelpers.UnauthorizedError();
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
var result = await service.GetStateAsync(player);
|
var result = await service.GetStateAsync(player);
|
||||||
return result.ToHttpResult(Results.Ok);
|
return result.ToHttpResult(payload =>
|
||||||
|
{
|
||||||
|
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||||
|
return Results.Ok(payload);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||||
|
if (player is null)
|
||||||
|
return EndpointHelpers.UnauthorizedError();
|
||||||
|
|
||||||
|
ctx.Response.ContentType = "text/event-stream";
|
||||||
|
ctx.Response.Headers.CacheControl = "no-cache";
|
||||||
|
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||||
|
|
||||||
|
var observedVersion = notifier.CurrentVersion;
|
||||||
|
await WriteStateEventAsync(ctx, "ready", observedVersion, ctx.RequestAborted);
|
||||||
|
|
||||||
|
while (!ctx.RequestAborted.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted);
|
||||||
|
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted);
|
||||||
|
var completed = await Task.WhenAny(changeTask, heartbeatTask);
|
||||||
|
|
||||||
|
if (completed == changeTask)
|
||||||
|
{
|
||||||
|
observedVersion = await changeTask;
|
||||||
|
await WriteStateEventAsync(ctx, "state", observedVersion, ctx.RequestAborted);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await ctx.Response.WriteAsync(": ping\n\n", ctx.RequestAborted);
|
||||||
|
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Empty;
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||||
@@ -49,4 +106,11 @@ public static class StateEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
||||||
|
await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken);
|
||||||
|
await ctx.Response.Body.FlushAsync(cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,34 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
|||||||
{
|
{
|
||||||
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
||||||
{
|
{
|
||||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
var state = await db.AppState
|
||||||
|
.AsNoTracking()
|
||||||
|
.Select(s => new
|
||||||
|
{
|
||||||
|
s.ResultsOpen,
|
||||||
|
s.UpdatedAt,
|
||||||
|
Players = db.Players.Count(),
|
||||||
|
Suggestions = db.Suggestions.Count(),
|
||||||
|
Votes = db.Votes.Count()
|
||||||
|
})
|
||||||
|
.SingleAsync();
|
||||||
|
|
||||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||||
var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync());
|
var summary = new StateSummaryResponse(
|
||||||
|
player.Id,
|
||||||
|
player.Username,
|
||||||
|
player.DisplayName,
|
||||||
|
player.IsAdmin,
|
||||||
|
player.IsOwner,
|
||||||
|
phase,
|
||||||
|
player.VotesFinal,
|
||||||
|
player.HasJoker,
|
||||||
|
state.ResultsOpen,
|
||||||
|
state.UpdatedAt,
|
||||||
|
state.Players,
|
||||||
|
state.Suggestions,
|
||||||
|
state.Votes
|
||||||
|
);
|
||||||
return ServiceResult<StateSummaryResponse>.Success(summary);
|
return ServiceResult<StateSummaryResponse>.Success(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ public class StateTests
|
|||||||
|
|
||||||
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
||||||
|
|
||||||
|
Assert.True(Guid.TryParse(state.GetProperty("id").GetString(), out _));
|
||||||
|
Assert.Equal("payload", state.GetProperty("username").GetString());
|
||||||
|
Assert.Equal("payload-name", state.GetProperty("displayName").GetString());
|
||||||
|
Assert.False(state.GetProperty("isAdmin").GetBoolean());
|
||||||
|
Assert.False(state.GetProperty("isOwner").GetBoolean());
|
||||||
Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
|
Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
|
||||||
Assert.False(state.GetProperty("votesFinal").GetBoolean());
|
Assert.False(state.GetProperty("votesFinal").GetBoolean());
|
||||||
Assert.True(state.GetProperty("hasJoker").GetBoolean());
|
Assert.True(state.GetProperty("hasJoker").GetBoolean());
|
||||||
@@ -335,6 +340,118 @@ public class StateTests
|
|||||||
|
|
||||||
Assert.Equal(Phase.Results, phase);
|
Assert.Equal(Phase.Results, phase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task State_endpoint_supports_conditional_get_with_etag()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var client = factory.CreateClientWithCookies();
|
||||||
|
await client.RegisterAsync("etag");
|
||||||
|
|
||||||
|
var first = await client.GetAsync("/api/state");
|
||||||
|
first.EnsureSuccessStatusCode();
|
||||||
|
var firstEtag = first.Headers.ETag?.ToString();
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
||||||
|
|
||||||
|
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||||
|
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||||
|
var notModified = await client.SendAsync(conditional);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||||
|
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
||||||
|
|
||||||
|
await client.CreateSuggestionAsync("etag-changed");
|
||||||
|
|
||||||
|
var stale = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||||
|
stale.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||||
|
var changed = await client.SendAsync(stale);
|
||||||
|
|
||||||
|
changed.EnsureSuccessStatusCode();
|
||||||
|
Assert.NotEqual(firstEtag, changed.Headers.ETag?.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task State_events_endpoint_emits_state_change_after_mutation()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var watcher = factory.CreateClientWithCookies();
|
||||||
|
await watcher.RegisterAsync("watcher");
|
||||||
|
|
||||||
|
using var streamResponse = await watcher.GetAsync("/api/events/state", HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
streamResponse.EnsureSuccessStatusCode();
|
||||||
|
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
|
||||||
|
|
||||||
|
await using var stream = await streamResponse.Content.ReadAsStreamAsync();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
var readyVersion = await ReadSseEventVersionAsync(reader, "ready", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
var mutator = factory.CreateClientWithCookies();
|
||||||
|
var register = await mutator.RegisterAsync("mutator");
|
||||||
|
register.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var changedVersion = await ReadSseEventVersionAsync(reader, "state", TimeSpan.FromSeconds(3));
|
||||||
|
Assert.True(changedVersion > readyVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Login_does_not_invalidate_state_etag()
|
||||||
|
{
|
||||||
|
await using var factory = new TestWebApplicationFactory();
|
||||||
|
var client = factory.CreateClientWithCookies();
|
||||||
|
await client.RegisterAsync("quietetag");
|
||||||
|
|
||||||
|
var first = await client.GetAsync("/api/state");
|
||||||
|
first.EnsureSuccessStatusCode();
|
||||||
|
var firstEtag = first.Headers.ETag?.ToString();
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
||||||
|
|
||||||
|
var loginClient = factory.CreateClientWithCookies();
|
||||||
|
var login = await loginClient.LoginAsync("quietetag", "Pass123!");
|
||||||
|
login.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||||
|
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||||
|
var notModified = await client.SendAsync(conditional);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||||
|
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<long> ReadSseEventVersionAsync(StreamReader reader, string expectedEventName, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(timeout);
|
||||||
|
var eventName = string.Empty;
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var line = await reader.ReadLineAsync(cts.Token);
|
||||||
|
if (line is null)
|
||||||
|
throw new Xunit.Sdk.XunitException("SSE stream closed unexpectedly.");
|
||||||
|
|
||||||
|
if (line.Length == 0)
|
||||||
|
{
|
||||||
|
eventName = string.Empty;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
eventName = line["event: ".Length..].Trim();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!line.StartsWith("data: ", StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!string.Equals(eventName, expectedEventName, StringComparison.Ordinal))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var payload = line["data: ".Length..].Trim();
|
||||||
|
if (long.TryParse(payload, out var version))
|
||||||
|
return version;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using GameList.Data;
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
|
||||||
namespace GameList.Infrastructure;
|
namespace GameList.Infrastructure;
|
||||||
@@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next)
|
|||||||
if (context.User.Identity?.IsAuthenticated == true)
|
if (context.User.Identity?.IsAuthenticated == true)
|
||||||
{
|
{
|
||||||
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null)
|
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId))
|
||||||
{
|
{
|
||||||
await context.SignOutAsync();
|
await context.SignOutAsync();
|
||||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var player = await db.Players.FindAsync(playerId);
|
||||||
|
if (player is null)
|
||||||
|
{
|
||||||
|
await context.SignOutAsync();
|
||||||
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Items[nameof(Player)] = player;
|
||||||
}
|
}
|
||||||
|
|
||||||
await next(context);
|
await next(context);
|
||||||
|
|||||||
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
namespace GameList.Infrastructure;
|
||||||
|
|
||||||
|
public sealed class StateChangeNotificationMiddleware(RequestDelegate next)
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext context, StateChangeNotifier notifier)
|
||||||
|
{
|
||||||
|
await next(context);
|
||||||
|
|
||||||
|
if (ShouldNotify(context))
|
||||||
|
notifier.NotifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldNotify(HttpContext context)
|
||||||
|
{
|
||||||
|
if (context.Response.StatusCode >= StatusCodes.Status400BadRequest)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!HttpMethods.IsPost(context.Request.Method)
|
||||||
|
&& !HttpMethods.IsPut(context.Request.Method)
|
||||||
|
&& !HttpMethods.IsDelete(context.Request.Method))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var path = context.Request.Path;
|
||||||
|
|
||||||
|
return path.StartsWithSegments("/api/suggestions", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWithSegments("/api/votes", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWithSegments("/api/me/phase", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| path.StartsWithSegments("/api/auth/register", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
Infrastructure/StateChangeNotifier.cs
Normal file
69
Infrastructure/StateChangeNotifier.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace GameList.Infrastructure;
|
||||||
|
|
||||||
|
public sealed class StateChangeNotifier
|
||||||
|
{
|
||||||
|
private readonly string _instanceId = Guid.NewGuid().ToString("N");
|
||||||
|
private long _version = 1;
|
||||||
|
private TaskCompletionSource<long> _nextChange = CreateWaiter();
|
||||||
|
|
||||||
|
public long CurrentVersion => Interlocked.Read(ref _version);
|
||||||
|
|
||||||
|
public string CurrentEtag => $"\"{_instanceId}:{CurrentVersion}\"";
|
||||||
|
|
||||||
|
public long NotifyChange()
|
||||||
|
{
|
||||||
|
var newVersion = Interlocked.Increment(ref _version);
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var waiter = Volatile.Read(ref _nextChange);
|
||||||
|
var replacement = CreateWaiter();
|
||||||
|
if (Interlocked.CompareExchange(ref _nextChange, replacement, waiter) != waiter)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
waiter.TrySetResult(newVersion);
|
||||||
|
return newVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool MatchesCurrentEtag(StringValues ifNoneMatchValues)
|
||||||
|
{
|
||||||
|
if (StringValues.IsNullOrEmpty(ifNoneMatchValues))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var current = CurrentEtag;
|
||||||
|
foreach (var raw in ifNoneMatchValues)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (part == "*" || string.Equals(part, current, StringComparison.Ordinal))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<long> WaitForChangeAsync(long observedVersion, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var current = CurrentVersion;
|
||||||
|
if (current > observedVersion)
|
||||||
|
return current;
|
||||||
|
|
||||||
|
var waiter = Volatile.Read(ref _nextChange);
|
||||||
|
var signaled = await waiter.Task.WaitAsync(cancellationToken);
|
||||||
|
if (signaled > observedVersion)
|
||||||
|
return signaled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TaskCompletionSource<long> CreateWaiter() => new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ builder.Services.AddScoped<AdminWorkflowService>();
|
|||||||
builder.Services.AddScoped<ResultsWorkflowService>();
|
builder.Services.AddScoped<ResultsWorkflowService>();
|
||||||
builder.Services.AddScoped<StateWorkflowService>();
|
builder.Services.AddScoped<StateWorkflowService>();
|
||||||
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
||||||
|
builder.Services.AddSingleton<StateChangeNotifier>();
|
||||||
|
|
||||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
||||||
|
|
||||||
@@ -152,6 +153,7 @@ app.UseGlobalExceptionLogging();
|
|||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
app.UseMiddleware<StateChangeNotificationMiddleware>();
|
||||||
|
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
|||||||
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
|
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
|
||||||
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
|
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
|
||||||
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||||
|
- Realtime sync: `/api/events/state` (SSE) plus `ETag`-based conditional `/api/state` reads to reduce polling load.
|
||||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||||
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
|
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
|
||||||
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
||||||
|
|||||||
2
TESTS.md
2
TESTS.md
@@ -44,6 +44,8 @@ stateDiagram-v2
|
|||||||
|
|
||||||
### 2) State & Phase Alignment (/api/state, /api/me)
|
### 2) State & Phase Alignment (/api/state, /api/me)
|
||||||
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401.
|
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401.
|
||||||
|
- /api/state supports `ETag`/`If-None-Match` and returns 304 when unchanged.
|
||||||
|
- /api/events/state (SSE) emits invalidation events after successful state mutations.
|
||||||
- GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal).
|
- GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal).
|
||||||
- /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked.
|
- /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked.
|
||||||
- /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.
|
- /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
updatePhaseNav,
|
updatePhaseNav,
|
||||||
configureUiRuntime,
|
configureUiRuntime,
|
||||||
} from "./js/ui.js";
|
} from "./js/ui.js";
|
||||||
|
import { api } from "./js/api.js";
|
||||||
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
|
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
|
||||||
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
||||||
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
||||||
@@ -29,11 +30,16 @@ import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
|
|||||||
|
|
||||||
const REFRESH_MIN_MS = 3000;
|
const REFRESH_MIN_MS = 3000;
|
||||||
const REFRESH_MAX_MS = 20000;
|
const REFRESH_MAX_MS = 20000;
|
||||||
|
const EVENTS_RECONNECT_MIN_MS = 1000;
|
||||||
|
const EVENTS_RECONNECT_MAX_MS = 15000;
|
||||||
let refreshInFlight = null;
|
let refreshInFlight = null;
|
||||||
let refreshTimerId = null;
|
let refreshTimerId = null;
|
||||||
let refreshSchedulerStarted = false;
|
let refreshSchedulerStarted = false;
|
||||||
let unchangedRefreshCycles = 0;
|
let unchangedRefreshCycles = 0;
|
||||||
let nextRefreshDelayMs = REFRESH_MIN_MS;
|
let nextRefreshDelayMs = REFRESH_MIN_MS;
|
||||||
|
let stateEventSource = null;
|
||||||
|
let eventsReconnectTimerId = null;
|
||||||
|
let eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
|
||||||
|
|
||||||
async function runSerializedRefresh() {
|
async function runSerializedRefresh() {
|
||||||
if (refreshInFlight) return refreshInFlight;
|
if (refreshInFlight) return refreshInFlight;
|
||||||
@@ -47,11 +53,79 @@ async function refreshWithUiErrorHandling() {
|
|||||||
try {
|
try {
|
||||||
const changed = await runSerializedRefresh();
|
const changed = await runSerializedRefresh();
|
||||||
updateRefreshCadence(changed === true);
|
updateRefreshCadence(changed === true);
|
||||||
|
if (state.isAuthenticated) {
|
||||||
|
ensureStateEventStream();
|
||||||
|
} else {
|
||||||
|
closeStateEventStream();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Back off after transient failures to avoid hammering server/dependencies.
|
// Back off after transient failures to avoid hammering server/dependencies.
|
||||||
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
|
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
|
||||||
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
|
if (handleAuthError(err, clearUserState)) {
|
||||||
|
closeStateEventStream();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast(err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeStateEventStream() {
|
||||||
|
if (eventsReconnectTimerId !== null) {
|
||||||
|
window.clearTimeout(eventsReconnectTimerId);
|
||||||
|
eventsReconnectTimerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateEventSource) {
|
||||||
|
stateEventSource.close();
|
||||||
|
stateEventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStateEventReconnect() {
|
||||||
|
if (eventsReconnectTimerId !== null || !state.isAuthenticated) return;
|
||||||
|
|
||||||
|
eventsReconnectTimerId = window.setTimeout(() => {
|
||||||
|
eventsReconnectTimerId = null;
|
||||||
|
ensureStateEventStream();
|
||||||
|
}, eventsReconnectDelayMs);
|
||||||
|
|
||||||
|
eventsReconnectDelayMs = Math.min(
|
||||||
|
Math.round(eventsReconnectDelayMs * 1.8),
|
||||||
|
EVENTS_RECONNECT_MAX_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStateEventStream() {
|
||||||
|
if (!state.isAuthenticated || typeof window.EventSource === "undefined") {
|
||||||
|
closeStateEventStream();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stateEventSource) return;
|
||||||
|
|
||||||
|
stateEventSource = new EventSource(api.stateEventsUrl(), {
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
stateEventSource.onopen = () => {
|
||||||
|
eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
|
||||||
|
};
|
||||||
|
|
||||||
|
stateEventSource.onerror = () => {
|
||||||
|
if (!stateEventSource) return;
|
||||||
|
stateEventSource.close();
|
||||||
|
stateEventSource = null;
|
||||||
|
scheduleStateEventReconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
stateEventSource.addEventListener("state", () => {
|
||||||
|
unchangedRefreshCycles = 0;
|
||||||
|
nextRefreshDelayMs = baseRefreshDelayForPhase();
|
||||||
|
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||||
|
refreshWithUiErrorHandling();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleNextRefresh() {
|
function scheduleNextRefresh() {
|
||||||
@@ -119,6 +193,9 @@ function setupHandlers() {
|
|||||||
setupAdminHandlers({ runSerializedRefresh });
|
setupAdminHandlers({ runSerializedRefresh });
|
||||||
setupVoteNavigationHandlers({ runSerializedRefresh });
|
setupVoteNavigationHandlers({ runSerializedRefresh });
|
||||||
setupLanguageSwitchers();
|
setupLanguageSwitchers();
|
||||||
|
document.getElementById("logout")?.addEventListener("click", () => {
|
||||||
|
closeStateEventStream();
|
||||||
|
});
|
||||||
|
|
||||||
onLanguageChange(() => {
|
onLanguageChange(() => {
|
||||||
updateLanguageButtons();
|
updateLanguageButtons();
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander:
|
|||||||
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
|
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
|
||||||
In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat.
|
In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat.
|
||||||
|
|
||||||
|
### Muss ich die Seite manuell aktualisieren?
|
||||||
|
|
||||||
|
Normalerweise nicht. Pick'n'Play erhält Live-Updates vom Server und nutzt nur dann periodische Prüfungen, wenn der Live-Kanal vorübergehend nicht verfügbar ist.
|
||||||
|
|
||||||
## Spiele vorschlagen
|
## Spiele vorschlagen
|
||||||
|
|
||||||
### Wie viele Spiele kann ich vorschlagen?
|
### Wie viele Spiele kann ich vorschlagen?
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ Each player progresses independently through the phases:
|
|||||||
Click **"Next"** to move forward. Admins can move themselves backward if needed.
|
Click **"Next"** to move forward. Admins can move themselves backward if needed.
|
||||||
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
|
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
|
||||||
|
|
||||||
|
### Do I need to refresh the page manually?
|
||||||
|
|
||||||
|
Usually no. Pick'n'Play receives live server updates and falls back to periodic checks if the live channel is temporarily unavailable.
|
||||||
|
|
||||||
## Suggesting Games
|
## Suggesting Games
|
||||||
|
|
||||||
### How many games can I suggest?
|
### How many games can I suggest?
|
||||||
|
|||||||
@@ -18,24 +18,53 @@ async function request(path, { method = "GET", body } = {}) {
|
|||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) throw await toApiError(res);
|
||||||
|
return res.status === 204 ? null : res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestState(ifNoneMatch) {
|
||||||
|
const headers = { ...defaultHeaders };
|
||||||
|
if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch;
|
||||||
|
|
||||||
|
const res = await fetch(withBase("/api/state"), {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 304) {
|
||||||
|
return {
|
||||||
|
notModified: true,
|
||||||
|
etag: res.headers.get("ETag"),
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw await toApiError(res);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notModified: false,
|
||||||
|
etag: res.headers.get("ETag"),
|
||||||
|
data: await res.json(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toApiError(res) {
|
||||||
let msg = `${res.status}`;
|
let msg = `${res.status}`;
|
||||||
try {
|
try {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
msg =
|
msg = data.error || data.detail || data.title || JSON.stringify(data);
|
||||||
data.error || data.detail || data.title || JSON.stringify(data);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
const err = new Error(msg);
|
const err = new Error(msg);
|
||||||
err.status = res.status;
|
err.status = res.status;
|
||||||
throw err;
|
return err;
|
||||||
}
|
|
||||||
return res.status === 204 ? null : res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
state: () => request("/api/state"),
|
state: (ifNoneMatch) => requestState(ifNoneMatch),
|
||||||
|
stateEventsUrl: () => withBase("/api/events/state"),
|
||||||
me: () => request("/api/me"),
|
me: () => request("/api/me"),
|
||||||
authOptions: () => request("/api/auth/options"),
|
authOptions: () => request("/api/auth/options"),
|
||||||
register: (payload) =>
|
register: (payload) =>
|
||||||
|
|||||||
@@ -18,14 +18,27 @@ import {
|
|||||||
import { state, clearUserState } from "./state.js";
|
import { state, clearUserState } from "./state.js";
|
||||||
|
|
||||||
export async function loadState() {
|
export async function loadState() {
|
||||||
const [me, stateData] = await Promise.all([api.me(), api.state()]);
|
const stateResponse = await api.state(state.stateEtag);
|
||||||
|
if (stateResponse?.etag) state.stateEtag = stateResponse.etag;
|
||||||
|
if (stateResponse?.notModified) return false;
|
||||||
|
|
||||||
|
const stateData = stateResponse.data;
|
||||||
state.isAuthenticated = true;
|
state.isAuthenticated = true;
|
||||||
state.me = me;
|
state.me = {
|
||||||
state.hasJoker = me.hasJoker ?? false;
|
id: stateData.id,
|
||||||
|
username: stateData.username,
|
||||||
|
displayName: stateData.displayName,
|
||||||
|
isAdmin: stateData.isAdmin,
|
||||||
|
isOwner: stateData.isOwner,
|
||||||
|
currentPhase: stateData.currentPhase,
|
||||||
|
votesFinal: stateData.votesFinal,
|
||||||
|
hasJoker: stateData.hasJoker,
|
||||||
|
};
|
||||||
|
state.hasJoker = stateData.hasJoker ?? false;
|
||||||
state.prevPhase = state.phase;
|
state.prevPhase = state.phase;
|
||||||
state.phase = stateData.currentPhase;
|
state.phase = stateData.currentPhase;
|
||||||
state.resultsOpen = stateData.resultsOpen;
|
state.resultsOpen = stateData.resultsOpen;
|
||||||
state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false;
|
state.votesFinal = stateData.votesFinal ?? false;
|
||||||
state.counts = stateData;
|
state.counts = stateData;
|
||||||
if (state.prevPhase !== state.phase && state.phase === "Vote") {
|
if (state.prevPhase !== state.phase && state.phase === "Vote") {
|
||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
@@ -34,6 +47,7 @@ export async function loadState() {
|
|||||||
renderWelcome();
|
renderWelcome();
|
||||||
renderPhasePill();
|
renderPhasePill();
|
||||||
renderCounts();
|
renderCounts();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSuggestData() {
|
export async function loadSuggestData() {
|
||||||
@@ -105,7 +119,19 @@ export async function refreshPhaseData() {
|
|||||||
try {
|
try {
|
||||||
const prevPhase = state.phase;
|
const prevPhase = state.phase;
|
||||||
const prevResultsOpen = state.resultsOpen;
|
const prevResultsOpen = state.resultsOpen;
|
||||||
await loadState();
|
const stateChanged = await loadState();
|
||||||
|
const adminCard = document.getElementById("admin-card");
|
||||||
|
const adminPanelVisible =
|
||||||
|
!!adminCard && !adminCard.classList.contains("hidden");
|
||||||
|
|
||||||
|
if (!stateChanged) {
|
||||||
|
if (state.me?.isAdmin && adminPanelVisible) {
|
||||||
|
state.adminVoteStatus = await adminApi.voteStatus();
|
||||||
|
}
|
||||||
|
updatePhaseNav();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadSuggestData(),
|
loadSuggestData(),
|
||||||
loadSuggestionsData(),
|
loadSuggestionsData(),
|
||||||
@@ -117,9 +143,6 @@ export async function refreshPhaseData() {
|
|||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
await loadVoteData();
|
await loadVoteData();
|
||||||
}
|
}
|
||||||
const adminCard = document.getElementById("admin-card");
|
|
||||||
const adminPanelVisible =
|
|
||||||
!!adminCard && !adminCard.classList.contains("hidden");
|
|
||||||
if (state.me?.isAdmin && adminPanelVisible) {
|
if (state.me?.isAdmin && adminPanelVisible) {
|
||||||
state.adminVoteStatus = await adminApi.voteStatus();
|
state.adminVoteStatus = await adminApi.voteStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const state = {
|
|||||||
votesRendered: false,
|
votesRendered: false,
|
||||||
adminVoteStatus: null,
|
adminVoteStatus: null,
|
||||||
adminStatusSelectActive: false,
|
adminStatusSelectActive: false,
|
||||||
|
stateEtag: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function clearUserState() {
|
export function clearUserState() {
|
||||||
@@ -34,7 +35,9 @@ export function clearUserState() {
|
|||||||
state.myVotes = [];
|
state.myVotes = [];
|
||||||
state.results = [];
|
state.results = [];
|
||||||
state.votesRendered = false;
|
state.votesRendered = false;
|
||||||
|
state.adminVoteStatus = null;
|
||||||
state.adminStatusSelectActive = false;
|
state.adminStatusSelectActive = false;
|
||||||
|
state.stateEtag = null;
|
||||||
const adminCard = document.getElementById("admin-card");
|
const adminCard = document.getElementById("admin-card");
|
||||||
if (adminCard) adminCard.classList.add("hidden");
|
if (adminCard) adminCard.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user