using LudosData.Api.Auth;
using LudosData.Api.Contracts;
using LudosData.Api.Data;
using LudosData.Api.Domain;
using LudosData.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LudosData.Api.Controllers;
///
/// The user's game library.
///
/// Every query starts from Where(g => g.OwnerId == currentUserId), taken from
/// the JWT subject. The old API took the owner id from a client-supplied query
/// parameter (filter[]=userId,eq,N), which meant any valid token could read
/// any other user's library by editing the number.
///
[ApiController]
[Route("api/games")]
[Authorize]
public class GamesController(
LudosDbContext db,
IImageStorage images,
ILogger logger) : ControllerBase
{
[HttpGet]
public async Task>> List([FromQuery] GameQuery query, CancellationToken ct)
{
var ownerId = User.GetUserId();
var q = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var term = query.Search.Trim();
q = q.Where(g =>
EF.Functions.Like(g.Title, $"%{term}%") ||
(g.Developer != null && EF.Functions.Like(g.Developer, $"%{term}%")) ||
(g.Publisher != null && EF.Functions.Like(g.Publisher, $"%{term}%")));
}
if (!string.IsNullOrWhiteSpace(query.System)) q = q.Where(g => g.System == query.System);
if (!string.IsNullOrWhiteSpace(query.Genre)) q = q.Where(g => g.Genre == query.Genre);
if (query.Own is { } own) q = q.Where(g => g.Own == own);
if (query.Dumped is { } dumped) q = q.Where(g => g.Dumped == dumped);
if (query.Played is { } played) q = q.Where(g => g.Played == played);
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
var total = await q.CountAsync(ct);
q = ApplySort(q, query.Sort, query.Dir);
var items = await q
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(ct);
return Ok(new PagedResult(
items.Select(g => ToResponse(g, ownerId)).ToList(),
query.Page,
query.PageSize,
total));
}
[HttpGet("{id:int}")]
public async Task> Get(int id, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.AsNoTracking()
.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
// A game belonging to someone else is reported as 404, not 403 — the
// response should not confirm that the id exists.
return game is null ? NotFound() : Ok(ToResponse(game, ownerId));
}
[HttpGet("facets")]
public async Task> Facets(CancellationToken ct)
{
var ownerId = User.GetUserId();
var mine = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
var systems = await mine
.Where(g => g.System != null && g.System != "")
.Select(g => g.System!)
.Distinct().OrderBy(s => s).ToListAsync(ct);
var genres = await mine
.Where(g => g.Genre != null && g.Genre != "")
.Select(g => g.Genre!)
.Distinct().OrderBy(s => s).ToListAsync(ct);
return Ok(new FacetsResponse(systems, genres));
}
[HttpPost]
public async Task> Create(GameRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = new Game { OwnerId = ownerId };
Apply(request, game);
db.Games.Add(game);
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} created game {GameId}", ownerId, game.Id);
return CreatedAtAction(nameof(Get), new { id = game.Id }, ToResponse(game, ownerId));
}
[HttpPut("{id:int}")]
public async Task> Update(int id, GameRequest request, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
if (game is null) return NotFound();
Apply(request, game);
await db.SaveChangesAsync(ct);
return Ok(ToResponse(game, ownerId));
}
[HttpDelete("{id:int}")]
public async Task Delete(int id, CancellationToken ct)
{
var ownerId = User.GetUserId();
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
if (game is null) return NotFound();
db.Games.Remove(game);
await db.SaveChangesAsync(ct);
logger.LogInformation("User {OwnerId} deleted game {GameId}", ownerId, id);
return NoContent();
}
private static IQueryable ApplySort(IQueryable q, string sort, string dir)
{
var descending = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase);
// Allow-list rather than reflecting over the string, so the sort parameter
// cannot reach the query shape in any way the API does not define.
return (sort?.ToLowerInvariant()) switch
{
"system" => descending ? q.OrderByDescending(g => g.System) : q.OrderBy(g => g.System),
"genre" => descending ? q.OrderByDescending(g => g.Genre) : q.OrderBy(g => g.Genre),
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
};
}
private static void Apply(GameRequest request, Game game)
{
game.Title = request.Title.Trim();
game.System = request.System?.Trim();
game.Genre = request.Genre?.Trim();
game.Year = request.Year?.Trim();
game.Developer = request.Developer?.Trim();
game.Publisher = request.Publisher?.Trim();
game.Art = request.Art?.Trim();
game.Description = request.Description;
game.Own = request.Own;
game.Dumped = request.Dumped;
game.Played = request.Played;
game.Finished = request.Finished;
}
private GameResponse ToResponse(Game g, string ownerId) => new(
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt);
}