Refactor backend into structured endpoints, contracts, and middleware

This commit is contained in:
2026-01-28 17:05:39 +01:00
parent 3ec1808ad1
commit 9363b029df
4 changed files with 430 additions and 401 deletions

7
Contracts/Dtos.cs Normal file
View File

@@ -0,0 +1,7 @@
namespace GameList.Contracts;
public record SetNameRequest(string Name);
public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl);
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl);
public record VoteRequest(int SuggestionId, int Score);
public record PhaseRequest(GameList.Domain.Phase Phase);

345
Endpoints/ApiRoutes.cs Normal file
View File

@@ -0,0 +1,345 @@
using GameList.Contracts;
using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace GameList.Endpoints;
public static class ApiRoutes
{
public static void MapApi(this IEndpointRouteBuilder app)
{
var api = app.MapGroup("/api");
api.MapGet("/state", async (AppDbContext db) =>
{
var state = await db.AppState.AsNoTracking().FirstAsync();
var summary = new
{
state.CurrentPhase,
state.UpdatedAt,
Players = await db.Players.CountAsync(),
Suggestions = await db.Suggestions.CountAsync(),
Votes = await db.Votes.CountAsync()
};
return Results.Ok(summary);
});
api.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
{
var player = await GetOrCreatePlayer(ctx, db);
return Results.Ok(new { player.Id, player.DisplayName });
});
api.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
{
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 64)
{
return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." });
}
var player = await GetOrCreatePlayer(ctx, db);
player.DisplayName = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new { player.Id, player.DisplayName });
});
api.MapGet("/suggestions/mine", async (HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Suggest)
return PhaseMismatch(Phase.Suggest, phase);
var player = await GetOrCreatePlayer(ctx, db);
var mine = await db.Suggestions.AsNoTracking()
.Where(s => s.PlayerId == player.Id)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.CreatedAt
})
.ToListAsync();
var ordered = mine
.OrderBy(s => s.CreatedAt)
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl));
return Results.Ok(ordered);
});
api.MapPost("/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Suggest)
return PhaseMismatch(Phase.Suggest, phase);
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
{
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
}
var player = await GetOrCreatePlayer(ctx, db);
if (string.IsNullOrWhiteSpace(player.DisplayName))
{
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." });
}
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id);
if (existingCount >= 3)
{
return Results.BadRequest(new { error = "You have reached the 3 suggestion limit." });
}
var suggestion = new Suggestion
{
PlayerId = player.Id,
Name = request.Name.Trim(),
Genre = TrimTo(request.Genre, 50),
Description = TrimTo(request.Description, 500),
ScreenshotUrl = TrimTo(request.ScreenshotUrl, 2048),
YoutubeUrl = TrimTo(request.YoutubeUrl, 2048)
};
db.Suggestions.Add(suggestion);
await db.SaveChangesAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
});
api.MapDelete("/suggestions/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Suggest)
return PhaseMismatch(Phase.Suggest, phase);
var player = await GetOrCreatePlayer(ctx, db);
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." });
db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync();
return Results.NoContent();
});
api.MapGet("/suggestions/all", async (AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase < Phase.Reveal)
return PhaseMismatch(Phase.Reveal, phase);
var all = await db.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
Author = s.Player!.DisplayName,
s.CreatedAt
})
.ToListAsync();
var ordered = all
.OrderBy(s => s.CreatedAt)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.Author
});
return Results.Ok(ordered);
});
api.MapGet("/votes/mine", async (HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Vote)
return PhaseMismatch(Phase.Vote, phase);
var player = await GetOrCreatePlayer(ctx, db);
var votes = await db.Votes.AsNoTracking()
.Where(v => v.PlayerId == player.Id)
.Select(v => new { v.SuggestionId, v.Score })
.ToListAsync();
return Results.Ok(votes);
});
api.MapPost("/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Vote)
return PhaseMismatch(Phase.Vote, phase);
if (request.Score is < 0 or > 10)
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
var player = await GetOrCreatePlayer(ctx, db);
if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." });
var suggestionExists = await db.Suggestions.AnyAsync(s => s.Id == request.SuggestionId);
if (!suggestionExists)
return Results.BadRequest(new { error = "Suggestion not found." });
var vote = await db.Votes.FirstOrDefaultAsync(v => v.PlayerId == player.Id && v.SuggestionId == request.SuggestionId);
if (vote == null)
{
vote = new Vote
{
PlayerId = player.Id,
SuggestionId = request.SuggestionId,
Score = request.Score
};
db.Votes.Add(vote);
}
else
{
vote.Score = request.Score;
}
await db.SaveChangesAsync();
return Results.Ok(new { vote.Id, vote.Score });
});
api.MapGet("/results", async (AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Results)
return PhaseMismatch(Phase.Results, phase);
var results = await db.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Include(s => s.Votes)
.Select(s => new
{
s.Id,
s.Name,
Author = s.Player!.DisplayName,
Total = s.Votes.Sum(v => v.Score),
Count = s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
s.ScreenshotUrl,
s.YoutubeUrl,
s.Description,
s.Genre
})
.OrderByDescending(r => r.Total)
.ToListAsync();
return Results.Ok(results);
});
var admin = api.MapGroup("/admin");
admin.MapPost("/phase", async ([FromBody] PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!IsAuthorized(ctx, config)) return Results.Unauthorized();
var state = await db.AppState.FirstAsync();
state.CurrentPhase = request.Phase;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new { state.CurrentPhase, state.UpdatedAt });
});
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!IsAuthorized(ctx, config)) return Results.Unauthorized();
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
var state = await db.AppState.FirstAsync();
state.CurrentPhase = Phase.Suggest;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new { state.CurrentPhase, state.UpdatedAt });
});
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!IsAuthorized(ctx, config)) return Results.Unauthorized();
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteDeleteAsync();
await db.AppState.ExecuteDeleteAsync();
var fresh = NewAppState();
db.AppState.Add(fresh);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt });
});
}
private static async Task<Player> GetOrCreatePlayer(HttpContext ctx, AppDbContext db)
{
if (!ctx.Items.TryGetValue(Infrastructure.PlayerIdentityExtensions.PlayerCookieName, out var value) || value is not Guid playerId)
{
throw new InvalidOperationException("Player cookie missing.");
}
var existing = await db.Players.FindAsync(playerId);
if (existing != null) return existing;
var player = new Player { Id = playerId };
db.Players.Add(player);
await db.SaveChangesAsync();
return player;
}
private static async Task<Phase> GetPhase(AppDbContext db)
{
var state = await db.AppState.AsNoTracking().FirstAsync();
return state.CurrentPhase;
}
private static IResult PhaseMismatch(Phase required, Phase current) =>
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." });
private static string? TrimTo(string? input, int max) =>
string.IsNullOrWhiteSpace(input)
? null
: input.Trim() is var t && t.Length > 0
? t[..Math.Min(t.Length, max)]
: null;
private static bool IsAuthorized(HttpContext ctx, IConfiguration config)
{
var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault()
?? ctx.Request.Query["key"].FirstOrDefault();
var expected = config["ADMIN_PASSWORD"];
return !string.IsNullOrWhiteSpace(expected) && provided == expected;
}
private static AppState NewAppState() => new()
{
Id = 1,
CurrentPhase = Phase.Suggest,
UpdatedAt = DateTimeOffset.UnixEpoch
};
}

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Diagnostics;
namespace GameList.Infrastructure;
public static class PlayerIdentityExtensions
{
public const string PlayerCookieName = "player";
public static IApplicationBuilder UsePlayerIdentity(this IApplicationBuilder app)
{
app.Use(async (ctx, next) =>
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = !app.ApplicationServices.GetRequiredService<IWebHostEnvironment>().IsDevelopment(),
IsEssential = true,
Expires = DateTimeOffset.UtcNow.AddYears(1)
};
Guid playerId;
if (!ctx.Request.Cookies.TryGetValue(PlayerCookieName, out var value) || !Guid.TryParse(value, out playerId))
{
playerId = Guid.NewGuid();
}
ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), cookieOptions);
ctx.Items[PlayerCookieName] = playerId;
await next();
});
return app;
}
public static IApplicationBuilder UseGlobalExceptionLogging(this IApplicationBuilder app)
{
app.UseExceptionHandler(handler =>
{
handler.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
if (feature?.Error != null)
{
logger.LogError(feature.Error, "Unhandled exception");
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" });
});
});
return app;
}
public static IEndpointRouteBuilder MapHealthChecks(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("/health", () => Results.Ok(new { status = "ok" }));
return endpoints;
}
}

View File

@@ -1,11 +1,8 @@
using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using GameList.Endpoints;
using GameList.Infrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args);
@@ -15,31 +12,25 @@ Directory.CreateDirectory(dataDirectory);
var configuredConnection = builder.Configuration.GetConnectionString("Default");
var dbPath = Path.Combine(dataDirectory, "gamelist.db");
var connectionBuilder = new SqliteConnectionStringBuilder();
var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection)
? $"Data Source={dbPath}"
: configuredConnection);
if (string.IsNullOrWhiteSpace(configuredConnection))
if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase))
{
connectionBuilder.DataSource = dbPath;
var fileName = Path.GetFileName(connectionBuilder.DataSource);
connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName);
}
else
else if (!Path.IsPathRooted(connectionBuilder.DataSource))
{
connectionBuilder = new SqliteConnectionStringBuilder(configuredConnection);
if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase))
{
var fileName = Path.GetFileName(connectionBuilder.DataSource);
connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName);
}
else if (!Path.IsPathRooted(connectionBuilder.DataSource))
{
connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory);
}
connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory);
}
var connectionString = connectionBuilder.ToString();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(connectionString));
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
@@ -47,22 +38,7 @@ builder.Services.ConfigureHttpJsonOptions(options =>
var app = builder.Build();
app.UseExceptionHandler(handler =>
{
handler.Run(async context =>
{
var feature = context.Features.Get<IExceptionHandlerFeature>();
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
if (feature?.Error != null)
{
logger.LogError(feature.Error, "Unhandled exception");
}
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/json";
await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" });
});
});
app.UseGlobalExceptionLogging();
// Ensure database and migrations are applied on startup
using (var scope = app.Services.CreateScope())
@@ -73,371 +49,9 @@ using (var scope = app.Services.CreateScope())
app.UseDefaultFiles();
app.UseStaticFiles();
app.UsePlayerIdentity();
const string PlayerCookieName = "player";
// Issue/refresh anonymous player cookie and stash the Guid in Items
app.Use(async (ctx, next) =>
{
var cookieOptions = new CookieOptions
{
HttpOnly = true,
SameSite = SameSiteMode.Strict,
Secure = !app.Environment.IsDevelopment(),
IsEssential = true,
Expires = DateTimeOffset.UtcNow.AddYears(1)
};
Guid playerId;
if (!ctx.Request.Cookies.TryGetValue(PlayerCookieName, out var value) || !Guid.TryParse(value, out playerId))
{
playerId = Guid.NewGuid();
}
ctx.Response.Cookies.Append(PlayerCookieName, playerId.ToString(), cookieOptions);
ctx.Items[PlayerCookieName] = playerId;
await next();
});
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
var api = app.MapGroup("/api");
api.MapGet("/state", async (AppDbContext db) =>
{
var state = await db.AppState.AsNoTracking().FirstAsync();
var summary = new
{
state.CurrentPhase,
state.UpdatedAt,
Players = await db.Players.CountAsync(),
Suggestions = await db.Suggestions.CountAsync(),
Votes = await db.Votes.CountAsync()
};
return Results.Ok(summary);
});
api.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
{
var player = await GetOrCreatePlayer(ctx, db);
return Results.Ok(new { player.Id, player.DisplayName });
});
api.MapPost("/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) =>
{
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 64)
{
return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." });
}
var player = await GetOrCreatePlayer(ctx, db);
player.DisplayName = request.Name.Trim();
await db.SaveChangesAsync();
return Results.Ok(new { player.Id, player.DisplayName });
});
api.MapGet("/suggestions/mine", async (HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Suggest)
return PhaseMismatch(Phase.Suggest, phase);
var player = await GetOrCreatePlayer(ctx, db);
var mine = await db.Suggestions.AsNoTracking()
.Where(s => s.PlayerId == player.Id)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.CreatedAt
})
.ToListAsync();
var ordered = mine
.OrderBy(s => s.CreatedAt)
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl));
return Results.Ok(ordered);
});
api.MapPost("/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Suggest)
return PhaseMismatch(Phase.Suggest, phase);
if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 100)
{
return Results.BadRequest(new { error = "Name is required and must be <= 100 characters." });
}
var player = await GetOrCreatePlayer(ctx, db);
if (string.IsNullOrWhiteSpace(player.DisplayName))
{
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." });
}
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id);
if (existingCount >= 3)
{
return Results.BadRequest(new { error = "You have reached the 3 suggestion limit." });
}
var suggestion = new Suggestion
{
PlayerId = player.Id,
Name = request.Name.Trim(),
Genre = TrimTo(request.Genre, 50),
Description = TrimTo(request.Description, 500),
ScreenshotUrl = TrimTo(request.ScreenshotUrl, 2048),
YoutubeUrl = TrimTo(request.YoutubeUrl, 2048)
};
db.Suggestions.Add(suggestion);
await db.SaveChangesAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
});
api.MapDelete("/suggestions/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Suggest)
return PhaseMismatch(Phase.Suggest, phase);
var player = await GetOrCreatePlayer(ctx, db);
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
if (suggestion == null)
return Results.NotFound(new { error = "Suggestion not found." });
db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync();
return Results.NoContent();
});
api.MapGet("/suggestions/all", async (AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase < Phase.Reveal)
return PhaseMismatch(Phase.Reveal, phase);
var all = await db.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
Author = s.Player!.DisplayName,
s.CreatedAt
})
.ToListAsync();
var ordered = all
.OrderBy(s => s.CreatedAt)
.Select(s => new
{
s.Id,
s.Name,
s.Genre,
s.Description,
s.ScreenshotUrl,
s.YoutubeUrl,
s.Author
});
return Results.Ok(ordered);
});
api.MapGet("/votes/mine", async (HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Vote)
return PhaseMismatch(Phase.Vote, phase);
var player = await GetOrCreatePlayer(ctx, db);
var votes = await db.Votes.AsNoTracking()
.Where(v => v.PlayerId == player.Id)
.Select(v => new { v.SuggestionId, v.Score })
.ToListAsync();
return Results.Ok(votes);
});
api.MapPost("/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Vote)
return PhaseMismatch(Phase.Vote, phase);
if (request.Score is < 0 or > 10)
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
var player = await GetOrCreatePlayer(ctx, db);
if (string.IsNullOrWhiteSpace(player.DisplayName))
return Results.BadRequest(new { error = "Set a display name before voting." });
var suggestionExists = await db.Suggestions.AnyAsync(s => s.Id == request.SuggestionId);
if (!suggestionExists)
return Results.BadRequest(new { error = "Suggestion not found." });
var vote = await db.Votes.FirstOrDefaultAsync(v => v.PlayerId == player.Id && v.SuggestionId == request.SuggestionId);
if (vote == null)
{
vote = new Vote
{
PlayerId = player.Id,
SuggestionId = request.SuggestionId,
Score = request.Score
};
db.Votes.Add(vote);
}
else
{
vote.Score = request.Score;
}
await db.SaveChangesAsync();
return Results.Ok(new { vote.Id, vote.Score });
});
api.MapGet("/results", async (AppDbContext db) =>
{
var phase = await GetPhase(db);
if (phase != Phase.Results)
return PhaseMismatch(Phase.Results, phase);
var results = await db.Suggestions.AsNoTracking()
.Include(s => s.Player)
.Include(s => s.Votes)
.Select(s => new
{
s.Id,
s.Name,
Author = s.Player!.DisplayName,
Total = s.Votes.Sum(v => v.Score),
Count = s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
s.ScreenshotUrl,
s.YoutubeUrl,
s.Description,
s.Genre
})
.OrderByDescending(r => r.Total)
.ToListAsync();
return Results.Ok(results);
});
var admin = api.MapGroup("/admin");
admin.MapPost("/phase", async ([FromBody] PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!IsAuthorized(ctx, config)) return Results.Unauthorized();
var state = await db.AppState.FirstAsync();
state.CurrentPhase = request.Phase;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new { state.CurrentPhase, state.UpdatedAt });
});
admin.MapPost("/reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!IsAuthorized(ctx, config)) return Results.Unauthorized();
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
var state = await db.AppState.FirstAsync();
state.CurrentPhase = Phase.Suggest;
state.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
return Results.Ok(new { state.CurrentPhase, state.UpdatedAt });
});
admin.MapPost("/factory-reset", async (HttpContext ctx, AppDbContext db, IConfiguration config) =>
{
if (!IsAuthorized(ctx, config)) return Results.Unauthorized();
await using var tx = await db.Database.BeginTransactionAsync();
await db.Votes.ExecuteDeleteAsync();
await db.Suggestions.ExecuteDeleteAsync();
await db.Players.ExecuteDeleteAsync();
await db.AppState.ExecuteDeleteAsync();
var fresh = NewAppState();
db.AppState.Add(fresh);
await db.SaveChangesAsync();
await tx.CommitAsync();
return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt });
});
app.MapHealthChecks();
app.MapApi();
app.Run();
static async Task<Player> GetOrCreatePlayer(HttpContext ctx, AppDbContext db)
{
if (!ctx.Items.TryGetValue("player", out var value) || value is not Guid playerId)
{
throw new InvalidOperationException("Player cookie missing.");
}
var existing = await db.Players.FindAsync(playerId);
if (existing != null) return existing;
var player = new Player { Id = playerId };
db.Players.Add(player);
await db.SaveChangesAsync();
return player;
}
static async Task<Phase> GetPhase(AppDbContext db)
{
var state = await db.AppState.AsNoTracking().FirstAsync();
return state.CurrentPhase;
}
static IResult PhaseMismatch(Phase required, Phase current) =>
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." });
static string? TrimTo(string? input, int max) =>
string.IsNullOrWhiteSpace(input)
? null
: input.Trim() is var t && t.Length > 0
? t[..Math.Min(t.Length, max)]
: null;
static bool IsAuthorized(HttpContext ctx, IConfiguration config)
{
var provided = ctx.Request.Headers["X-Admin-Key"].FirstOrDefault()
?? ctx.Request.Query["key"].FirstOrDefault();
var expected = config["ADMIN_PASSWORD"];
return !string.IsNullOrWhiteSpace(expected) && provided == expected;
}
static AppState NewAppState() => new()
{
Id = 1,
CurrentPhase = Phase.Suggest,
UpdatedAt = DateTimeOffset.UnixEpoch
};
public record SetNameRequest(string Name);
public record SuggestionRequest(string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl);
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl);
public record VoteRequest(int SuggestionId, int Score);
public record PhaseRequest(Phase Phase);