diff --git a/backend/src/Taskdeck.Api/Controllers/LlmQueueController.cs b/backend/src/Taskdeck.Api/Controllers/LlmQueueController.cs index 9b6b24d57..878fb26f6 100644 --- a/backend/src/Taskdeck.Api/Controllers/LlmQueueController.cs +++ b/backend/src/Taskdeck.Api/Controllers/LlmQueueController.cs @@ -49,12 +49,15 @@ public async Task GetUserQueue() [HttpGet("status/{status}")] public async Task GetByStatus(string status) { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + if (!Enum.TryParse(status, true, out var parsedStatus) || !Enum.IsDefined(parsedStatus)) return BadRequest(new ApiErrorResponse( ErrorCodes.ValidationError, $"Invalid status value: {status}")); - var result = await _llmQueueService.GetQueueByStatusAsync(parsedStatus); + var result = await _llmQueueService.GetQueueByStatusAsync(userId, parsedStatus); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } @@ -79,7 +82,10 @@ public async Task ProcessNext() [HttpGet("stats")] public async Task GetQueueStats() { - var result = await _llmQueueService.GetQueueStatsAsync(); + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _llmQueueService.GetQueueStatsAsync(userId); return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } } diff --git a/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs index 425eeed43..0c6066bb1 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs @@ -11,6 +11,8 @@ public interface ILlmQueueRepository : IRepository Task> GetPendingAsync(int limit = 100, CancellationToken cancellationToken = default); Task> GetByUserAsync(Guid userId, CancellationToken cancellationToken = default); Task> GetByStatusAsync(RequestStatus status, CancellationToken cancellationToken = default); + Task> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default); + Task> GetStatusCountsByUserAsync(Guid userId, CancellationToken cancellationToken = default); Task GetNextPendingAsync(CancellationToken cancellationToken = default); Task TryClaimProcessingCaptureAsync( Guid requestId, diff --git a/backend/src/Taskdeck.Application/Services/ILlmQueueService.cs b/backend/src/Taskdeck.Application/Services/ILlmQueueService.cs index 8cc04e0f5..adff77407 100644 --- a/backend/src/Taskdeck.Application/Services/ILlmQueueService.cs +++ b/backend/src/Taskdeck.Application/Services/ILlmQueueService.cs @@ -12,8 +12,8 @@ public interface ILlmQueueService { Task> AddToQueueAsync(Guid userId, CreateLlmRequestDto dto); Task>> GetUserQueueAsync(Guid userId); - Task>> GetQueueByStatusAsync(RequestStatus status); + Task>> GetQueueByStatusAsync(Guid userId, RequestStatus status); Task CancelRequestAsync(Guid requestId, Guid userId); Task> ProcessNextRequestAsync(); - Task> GetQueueStatsAsync(); + Task> GetQueueStatsAsync(Guid userId); } diff --git a/backend/src/Taskdeck.Application/Services/LlmQueueService.cs b/backend/src/Taskdeck.Application/Services/LlmQueueService.cs index e2159b926..3e887f184 100644 --- a/backend/src/Taskdeck.Application/Services/LlmQueueService.cs +++ b/backend/src/Taskdeck.Application/Services/LlmQueueService.cs @@ -83,9 +83,9 @@ public async Task>> GetUserQueueAsync(Guid use return Result.Success(requests.Select(MapToDto)); } - public async Task>> GetQueueByStatusAsync(RequestStatus status) + public async Task>> GetQueueByStatusAsync(Guid userId, RequestStatus status) { - var requests = await _unitOfWork.LlmQueue.GetByStatusAsync(status); + var requests = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, status); return Result.Success(requests.Select(MapToDto)); } @@ -134,18 +134,15 @@ public async Task> ProcessNextRequestAsync() } } - public async Task> GetQueueStatsAsync() + public async Task> GetQueueStatsAsync(Guid userId) { - var pending = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Pending); - var processing = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Processing); - var completed = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Completed); - var failed = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Failed); + var statusCounts = await _unitOfWork.LlmQueue.GetStatusCountsByUserAsync(userId); var stats = new QueueStatsDto( - pending.Count(), - processing.Count(), - completed.Count(), - failed.Count()); + statusCounts.GetValueOrDefault(RequestStatus.Pending, 0), + statusCounts.GetValueOrDefault(RequestStatus.Processing, 0), + statusCounts.GetValueOrDefault(RequestStatus.Completed, 0), + statusCounts.GetValueOrDefault(RequestStatus.Failed, 0)); return Result.Success(stats); } diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs index 18d0568c8..235ec3d46 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs @@ -140,6 +140,36 @@ public async Task> GetByStatusAsync(RequestStatus status .ToListAsync(cancellationToken); } + public async Task> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default) + { + if (_context.Database.IsSqlite()) + { + return await _context.LlmRequests + .FromSqlInterpolated($"SELECT * FROM LlmRequests WHERE UserId = {userId} AND Status = {(int)status} ORDER BY CreatedAt DESC") + .AsNoTracking() + .Include(lr => lr.User) + .Include(lr => lr.Board) + .ToListAsync(cancellationToken); + } + + return await _context.LlmRequests + .AsNoTracking() + .Include(lr => lr.User) + .Include(lr => lr.Board) + .Where(lr => lr.UserId == userId && lr.Status == status) + .OrderByDescending(lr => lr.CreatedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetStatusCountsByUserAsync(Guid userId, CancellationToken cancellationToken = default) + { + return await _context.LlmRequests + .Where(r => r.UserId == userId) + .GroupBy(r => r.Status) + .Select(g => new { Status = g.Key, Count = g.Count() }) + .ToDictionaryAsync(g => g.Status, g => g.Count, cancellationToken); + } + public async Task GetNextPendingAsync(CancellationToken cancellationToken = default) { if (_context.Database.IsSqlite()) diff --git a/backend/tests/Taskdeck.Api.Tests/LlmQueueApiTests.cs b/backend/tests/Taskdeck.Api.Tests/LlmQueueApiTests.cs index 58c9ee9e1..1a327157c 100644 --- a/backend/tests/Taskdeck.Api.Tests/LlmQueueApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/LlmQueueApiTests.cs @@ -239,6 +239,67 @@ public async Task CancelRequest_ShouldReturnForbidden_WhenRequestBelongsToDiffer await ApiTestHarness.AssertForbiddenAsync(response); } + [Fact] + public async Task GetByStatus_ShouldOnlyReturnCurrentUserRequests() + { + using var userAClient = _factory.CreateClient(); + using var userBClient = _factory.CreateClient(); + + var userA = await ApiTestHarness.AuthenticateAsync(userAClient, "llm-status-isolation-userA"); + var boardA = await ApiTestHarness.CreateBoardAsync(userAClient, "llm-status-isolation-board-a"); + + // User A creates a queue item + var createResponse = await userAClient.PostAsJsonAsync( + "/api/llm-queue", + new CreateLlmRequestDto("summarize", "user-a payload", boardA.Id)); + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var createdItem = await createResponse.Content.ReadFromJsonAsync(); + createdItem.Should().NotBeNull(); + + // User B queries the same status — must not see user A's item + await ApiTestHarness.AuthenticateAsync(userBClient, "llm-status-isolation-userB"); + var response = await userBClient.GetAsync("/api/llm-queue/status/Pending"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var items = await response.Content.ReadFromJsonAsync>(); + items.Should().NotBeNull(); + items.Should().NotContain(item => item.UserId == userA.UserId, + "user B must not see user A's queue items"); + } + + [Fact] + public async Task GetQueueStats_ShouldOnlyCountCurrentUserRequests() + { + using var userAClient = _factory.CreateClient(); + using var userBClient = _factory.CreateClient(); + + await ApiTestHarness.AuthenticateAsync(userAClient, "llm-stats-isolation-userA"); + await ApiTestHarness.AuthenticateAsync(userBClient, "llm-stats-isolation-userB"); + + // Verify user B starts with zero pending + var baselineResponse = await userBClient.GetAsync("/api/llm-queue/stats"); + baselineResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var baselineStats = await baselineResponse.Content.ReadFromJsonAsync(); + baselineStats.Should().NotBeNull(); + var baselinePending = baselineStats!.PendingCount; + + // User A creates a queue item (no boardId — queue accepts null board) + var boardA = await ApiTestHarness.CreateBoardAsync(userAClient, "llm-stats-isolation-board"); + var createResponse = await userAClient.PostAsJsonAsync( + "/api/llm-queue", + new CreateLlmRequestDto("summarize", "user-a stats payload", boardA.Id)); + createResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // User B's stats must not change + var afterResponse = await userBClient.GetAsync("/api/llm-queue/stats"); + afterResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var afterStats = await afterResponse.Content.ReadFromJsonAsync(); + afterStats.Should().NotBeNull(); + afterStats!.PendingCount.Should().Be(baselinePending, + "user A's pending items must not appear in user B's stats"); + } + private async Task CreateOwnedBoardAsync(string stem) { await EnsureAuthenticatedAsync(); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs index cb31caa93..973d0f3f5 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs @@ -276,11 +276,11 @@ public async Task GetQueueByStatusAsync_ShouldReturnRequests() new LlmRequest(userId, "voicenote", "payload text") }; - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Pending, default)) + _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Pending, default)) .ReturnsAsync(requests); // Act - var result = await _service.GetQueueByStatusAsync(RequestStatus.Pending); + var result = await _service.GetQueueByStatusAsync(userId, RequestStatus.Pending); // Assert result.IsSuccess.Should().BeTrue(); @@ -447,17 +447,17 @@ public async Task GetQueueStatsAsync_ShouldReturnCorrectCounts() var completedRequests = new List(); var failedRequests = new List(); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Pending, default)) - .ReturnsAsync(pendingRequests); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Processing, default)) - .ReturnsAsync(processingRequests); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Completed, default)) - .ReturnsAsync(completedRequests); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Failed, default)) - .ReturnsAsync(failedRequests); + _llmQueueRepoMock.Setup(r => r.GetStatusCountsByUserAsync(userId, default)) + .ReturnsAsync(new Dictionary + { + { RequestStatus.Pending, pendingRequests.Count }, + { RequestStatus.Processing, processingRequests.Count }, + { RequestStatus.Completed, completedRequests.Count }, + { RequestStatus.Failed, failedRequests.Count }, + }); // Act - var result = await _service.GetQueueStatsAsync(); + var result = await _service.GetQueueStatsAsync(userId); // Assert result.IsSuccess.Should().BeTrue();