Split API into phase-specific endpoint files
This commit is contained in:
60
Endpoints/AdminEndpoints.cs
Normal file
60
Endpoints/AdminEndpoints.cs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
public static class AdminEndpoints
|
||||||
|
{
|
||||||
|
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
var admin = app.MapGroup("/api/admin");
|
||||||
|
|
||||||
|
admin.MapPost("/phase", async ([FromBody] Contracts.PhaseRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) =>
|
||||||
|
{
|
||||||
|
if (!EndpointHelpers.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 (!EndpointHelpers.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 (!EndpointHelpers.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 = EndpointHelpers.NewAppState();
|
||||||
|
db.AppState.Add(fresh);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
await tx.CommitAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { fresh.CurrentPhase, fresh.UpdatedAt });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
56
Endpoints/EndpointHelpers.cs
Normal file
56
Endpoints/EndpointHelpers.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
internal static class EndpointHelpers
|
||||||
|
{
|
||||||
|
public 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Phase> GetPhase(AppDbContext db)
|
||||||
|
{
|
||||||
|
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||||
|
return state.CurrentPhase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IResult PhaseMismatch(Phase required, Phase current) =>
|
||||||
|
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Current phase is {current}." });
|
||||||
|
|
||||||
|
public 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;
|
||||||
|
|
||||||
|
public 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AppState NewAppState() => new()
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
CurrentPhase = Phase.Suggest,
|
||||||
|
UpdatedAt = DateTimeOffset.UnixEpoch
|
||||||
|
};
|
||||||
|
}
|
||||||
39
Endpoints/ResultsEndpoints.cs
Normal file
39
Endpoints/ResultsEndpoints.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
public static class ResultsEndpoints
|
||||||
|
{
|
||||||
|
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/results", async (AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase != Phase.Results)
|
||||||
|
return EndpointHelpers.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
45
Endpoints/StateEndpoints.cs
Normal file
45
Endpoints/StateEndpoints.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using GameList.Contracts;
|
||||||
|
using GameList.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
public static class StateEndpoints
|
||||||
|
{
|
||||||
|
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/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);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var player = await EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
||||||
|
return Results.Ok(new { player.Id, player.DisplayName });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/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 EndpointHelpers.GetOrCreatePlayer(ctx, db);
|
||||||
|
player.DisplayName = request.Name.Trim();
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return Results.Ok(new { player.Id, player.DisplayName });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
134
Endpoints/SuggestEndpoints.cs
Normal file
134
Endpoints/SuggestEndpoints.cs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
using GameList.Contracts;
|
||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
public static class SuggestEndpoints
|
||||||
|
{
|
||||||
|
public static void MapSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/suggestions/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase != Phase.Suggest)
|
||||||
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
|
||||||
|
var player = await EndpointHelpers.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/suggestions", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase != Phase.Suggest)
|
||||||
|
return EndpointHelpers.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 EndpointHelpers.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 = EndpointHelpers.TrimTo(request.Genre, 50),
|
||||||
|
Description = EndpointHelpers.TrimTo(request.Description, 500),
|
||||||
|
ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048),
|
||||||
|
YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048)
|
||||||
|
};
|
||||||
|
|
||||||
|
db.Suggestions.Add(suggestion);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapDelete("/api/suggestions/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase != Phase.Suggest)
|
||||||
|
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||||
|
|
||||||
|
var player = await EndpointHelpers.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/suggestions/all", async (AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase < Phase.Reveal)
|
||||||
|
return EndpointHelpers.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Endpoints/VoteEndpoints.cs
Normal file
66
Endpoints/VoteEndpoints.cs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
using GameList.Contracts;
|
||||||
|
using GameList.Data;
|
||||||
|
using GameList.Domain;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace GameList.Endpoints;
|
||||||
|
|
||||||
|
public static class VoteEndpoints
|
||||||
|
{
|
||||||
|
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.MapGet("/api/votes/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase != Phase.Vote)
|
||||||
|
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||||
|
|
||||||
|
var player = await EndpointHelpers.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);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/votes", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var phase = await EndpointHelpers.GetPhase(db);
|
||||||
|
if (phase != Phase.Vote)
|
||||||
|
return EndpointHelpers.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 EndpointHelpers.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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,10 @@ app.UseStaticFiles();
|
|||||||
app.UsePlayerIdentity();
|
app.UsePlayerIdentity();
|
||||||
|
|
||||||
app.MapHealthChecks();
|
app.MapHealthChecks();
|
||||||
app.MapApi();
|
app.MapStateEndpoints();
|
||||||
|
app.MapSuggestEndpoints();
|
||||||
|
app.MapVoteEndpoints();
|
||||||
|
app.MapResultsEndpoints();
|
||||||
|
app.MapAdminEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
Reference in New Issue
Block a user