using GameList.Data; using GameList.Domain; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); Directory.CreateDirectory(dataDirectory); var configuredConnection = builder.Configuration.GetConnectionString("Default"); var dbPath = Path.Combine(dataDirectory, "gamelist.db"); var connectionBuilder = new SqliteConnectionStringBuilder(); if (string.IsNullOrWhiteSpace(configuredConnection)) { connectionBuilder.DataSource = dbPath; } else { 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); } } var connectionString = connectionBuilder.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); var app = builder.Build(); app.UseExceptionHandler(handler => { handler.Run(async context => { var feature = context.Features.Get(); var logger = context.RequestServices.GetRequiredService().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" }); }); }); // Ensure database and migrations are applied on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } app.UseDefaultFiles(); app.UseStaticFiles(); 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.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.Run(); static async Task 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 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);