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(); IReadOnlyList 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)) .ToList(); return ServiceResult>.Success(ordered); } public async Task> CreateAsync(Guid playerId, SuggestionInput input) { var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) return ServiceResult.Failure(ServiceError.BadRequest(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 ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) return ServiceResult.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions.")); var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId); if (!usingJoker && existingCount >= 5) return ServiceResult.Failure(ServiceError.BadRequest("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); try { await db.SaveChangesAsync(); 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 tx.CommitAsync(); } catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError)) { return ServiceResult.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit.")); } return ServiceResult.Success(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 ServiceResult.Failure(ServiceError.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 ServiceResult.Failure(ServiceError.NotFound("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 ServiceResult.Success(default); } public async Task> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) { 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 ServiceResult.Failure(ServiceError.NotFound("Suggestion not found.")); var shouldValidateScreenshot = ShouldValidateScreenshotReachability(input.ScreenshotUrl, suggestion.ScreenshotUrl); var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory, shouldValidateScreenshot); if (validationError is not null) return ServiceResult.Failure(ServiceError.BadRequest(validationError)); var isAdmin = actor.IsAdmin; if (!isAdmin) { if (suggestion.PlayerId != playerId) return ServiceResult.Failure(ServiceError.Unauthorized()); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase == Phase.Results) return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); if (phase == Phase.Suggest) { suggestion.Name = input.Name.Trim(); } else if (phase != Phase.Vote) { return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); } ApplyEditableFields(suggestion, input); } else { suggestion.Name = input.Name.Trim(); ApplyEditableFields(suggestion, input); } await db.SaveChangesAsync(); return ServiceResult.Success(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 ServiceResult>.Failure(ServiceError.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); IReadOnlyList ordered = all.OrderBy(s => s.CreatedAt).Select(s => { var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); return new SuggestionAllDto( 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.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() ); }).ToList(); return ServiceResult>.Success(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; } private static bool ShouldValidateScreenshotReachability(string? requestedScreenshotUrl, string? existingScreenshotUrl) { var normalizedRequested = EndpointHelpers.TrimTo(requestedScreenshotUrl, 2048); return !string.Equals(normalizedRequested, existingScreenshotUrl, StringComparison.Ordinal); } }