using GameList.Contracts; using GameList.Data; using GameList.Domain; using Microsoft.EntityFrameworkCore; namespace GameList.Endpoints; internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) { public async Task GetMineAsync(Guid playerId) { var mine = await db.Suggestions .AsNoTracking() .Where(s => s.PlayerId == playerId) .Select(s => new { s.Id, s.PlayerId, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.CreatedAt, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId }) .ToListAsync(); var ordered = mine .OrderBy(s => s.CreatedAt) .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId)); return Results.Ok(ordered); } public async Task CreateAsync(Guid playerId, SuggestionInput input) { var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) return EndpointHelpers.BadRequestError(validationError); var playerState = await db.Players .AsNoTracking() .Where(p => p.Id == playerId) .Select(p => new { p.DisplayName, p.HasJoker }) .FirstAsync(); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var usingJoker = phase == Phase.Vote && playerState.HasJoker; if (phase != Phase.Suggest && !usingJoker) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId); if (!usingJoker && existingCount >= 5) return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); var suggestion = new Suggestion { PlayerId = playerId, Name = input.Name.Trim(), Genre = EndpointHelpers.TrimTo(input.Genre, 50), Description = EndpointHelpers.TrimTo(input.Description, 500), ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048), YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048), GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048), MinPlayers = input.MinPlayers, MaxPlayers = input.MaxPlayers }; await using var tx = await db.Database.BeginTransactionAsync(); db.Suggestions.Add(suggestion); if (usingJoker) { await db.Players .Where(p => p.Id == playerId) .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); } await db.SaveChangesAsync(); await tx.CommitAsync(); return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id)); } public async Task DeleteAsync(Guid playerId, int suggestionId) { var actor = await db.Players .AsNoTracking() .Where(p => p.Id == playerId) .Select(p => new { p.IsAdmin }) .FirstAsync(); var isAdmin = actor.IsAdmin; if (!isAdmin) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Suggest) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); } var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId); if (suggestion == null) return EndpointHelpers.NotFoundError("Suggestion not found."); await using var tx = await db.Database.BeginTransactionAsync(); await db.Suggestions .Where(s => s.ParentSuggestionId == suggestion.Id) .ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null)); await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync(); db.Suggestions.Remove(suggestion); await db.SaveChangesAsync(); await tx.CommitAsync(); return Results.NoContent(); } public async Task UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) { var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) return EndpointHelpers.BadRequestError(validationError); var actor = await db.Players .AsNoTracking() .Where(p => p.Id == playerId) .Select(p => new { p.IsAdmin }) .FirstAsync(); var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId); if (suggestion == null) return EndpointHelpers.NotFoundError("Suggestion not found."); var isAdmin = actor.IsAdmin; if (!isAdmin) { if (suggestion.PlayerId != playerId) return EndpointHelpers.UnauthorizedError(); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase == Phase.Results) return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); if (phase == Phase.Suggest) { suggestion.Name = input.Name.Trim(); } else if (phase != Phase.Vote) { return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); } ApplyEditableFields(suggestion, input); } else { suggestion.Name = input.Name.Trim(); ApplyEditableFields(suggestion, input); } await db.SaveChangesAsync(); return Results.Ok(new SuggestionUpdatedResponse( suggestion.Id, suggestion.Name, suggestion.Genre, suggestion.Description, suggestion.ScreenshotUrl, suggestion.YoutubeUrl, suggestion.GameUrl, suggestion.MinPlayers, suggestion.MaxPlayers )); } public async Task GetAllAsync(Guid playerId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase < Phase.Vote) return EndpointHelpers.PhaseMismatch(Phase.Vote, 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, s.GameUrl, s.MinPlayers, s.MaxPlayers, Author = s.Player!.DisplayName, s.CreatedAt, s.ParentSuggestionId, IsOwner = s.PlayerId == playerId }) .ToListAsync(); var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); var ordered = all.OrderBy(s => s.CreatedAt).Select(s => { var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); return new { s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.Author, s.ParentSuggestionId, s.IsOwner, LinkedIds = linkedIds, LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() }; }); return Results.Ok(ordered); } private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input) { suggestion.Genre = EndpointHelpers.TrimTo(input.Genre, 50); suggestion.Description = EndpointHelpers.TrimTo(input.Description, 500); suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048); suggestion.YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048); suggestion.GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048); suggestion.MinPlayers = input.MinPlayers; suggestion.MaxPlayers = input.MaxPlayers; } }