From 7fc43e2775474d1aff53a99f7a519fb8754cd8d9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:28 +0100 Subject: [PATCH 01/11] Add GetByUserAndStatusAsync to ILlmQueueRepository for user-scoped status queries --- .../src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs index 425eeed43..09321c7a5 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs @@ -11,6 +11,7 @@ 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 GetNextPendingAsync(CancellationToken cancellationToken = default); Task TryClaimProcessingCaptureAsync( Guid requestId, From 39667c5a68533eb002c2ce24eeb4edeb047cf473 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:36 +0100 Subject: [PATCH 02/11] Implement GetByUserAndStatusAsync in LlmQueueRepository with UserId predicate --- .../Repositories/LlmQueueRepository.cs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs index 18d0568c8..c9b6fb3bd 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs @@ -140,6 +140,23 @@ 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") + .Include(lr => lr.Board) + .ToListAsync(cancellationToken); + } + + return await _context.LlmRequests + .Include(lr => lr.Board) + .Where(lr => lr.UserId == userId && lr.Status == status) + .OrderByDescending(lr => lr.CreatedAt) + .ToListAsync(cancellationToken); + } + public async Task GetNextPendingAsync(CancellationToken cancellationToken = default) { if (_context.Database.IsSqlite()) From fb70e04512178ff559e90da4a69076ff0018586f Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:40 +0100 Subject: [PATCH 03/11] Scope GetQueueByStatusAsync and GetQueueStatsAsync to userId in ILlmQueueService --- backend/src/Taskdeck.Application/Services/ILlmQueueService.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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); } From a112890fe9de541d14a6bb3877d27d87297bb314 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:42 +0100 Subject: [PATCH 04/11] Filter GetQueueByStatusAsync and GetQueueStatsAsync by userId in LlmQueueService --- .../Services/LlmQueueService.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/LlmQueueService.cs b/backend/src/Taskdeck.Application/Services/LlmQueueService.cs index e2159b926..da276702a 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,12 +134,12 @@ 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 pending = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Pending); + var processing = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Processing); + var completed = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Completed); + var failed = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Failed); var stats = new QueueStatsDto( pending.Count(), From ae7f792c4647cf29976c5ef52401fc55842b32ad Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:46 +0100 Subject: [PATCH 05/11] Extract userId from claims in GetByStatus and GetQueueStats to prevent cross-user data leak (#508) --- .../src/Taskdeck.Api/Controllers/LlmQueueController.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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(); } } From b92c664b0db90446f961de393cfab74a3deeccef Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:54 +0100 Subject: [PATCH 06/11] Update LlmQueueServiceTests to use new GetByUserAndStatusAsync mock signatures --- .../Services/LlmQueueServiceTests.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs index cb31caa93..14f0f6aa3 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)) + _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Pending, default)) .ReturnsAsync(pendingRequests); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Processing, default)) + _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Processing, default)) .ReturnsAsync(processingRequests); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Completed, default)) + _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Completed, default)) .ReturnsAsync(completedRequests); - _llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Failed, default)) + _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Failed, default)) .ReturnsAsync(failedRequests); // Act - var result = await _service.GetQueueStatsAsync(); + var result = await _service.GetQueueStatsAsync(userId); // Assert result.IsSuccess.Should().BeTrue(); From 7d295a9fc74e1ceccd1aa23d8d98d062e7241816 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:53:57 +0100 Subject: [PATCH 07/11] Add cross-user isolation tests for GetByStatus and GetQueueStats endpoints --- .../Taskdeck.Api.Tests/LlmQueueApiTests.cs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) 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(); From 664290cdf89eb522c47f27ea12176db7b45f9dc9 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 15:54:14 +0100 Subject: [PATCH 08/11] Add GetStatusCountsByUserAsync to ILlmQueueRepository Adds a single GROUP BY method returning Dictionary to replace the four-call pattern in GetQueueStatsAsync. --- .../src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs b/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs index 09321c7a5..0c6066bb1 100644 --- a/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs @@ -12,6 +12,7 @@ public interface ILlmQueueRepository : IRepository 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, From fbcd9a7e89955f9f39775a5ead518c0a0289d5b1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 15:54:33 +0100 Subject: [PATCH 09/11] Implement GetStatusCountsByUserAsync and add AsNoTracking to LlmQueueRepository Adds GROUP BY implementation for GetStatusCountsByUserAsync. Adds AsNoTracking() to both SQLite and LINQ branches of GetByUserAndStatusAsync to eliminate EF change-tracking overhead. Also adds Include(lr => lr.User) for consistency with GetByStatusAsync. --- .../Repositories/LlmQueueRepository.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs index c9b6fb3bd..235ec3d46 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs @@ -146,17 +146,30 @@ public async Task> GetByUserAndStatusAsync(Guid userId, { 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()) From 8867e9aae0d8e7b30904c3945d973c7e564d7718 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 15:54:48 +0100 Subject: [PATCH 10/11] Replace 4 DB calls in GetQueueStatsAsync with single GROUP BY query Uses new GetStatusCountsByUserAsync repository method to fetch all status counts in one round-trip. Builds QueueStatsDto from the returned dictionary using GetValueOrDefault(status, 0) per status key. --- .../Services/LlmQueueService.cs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/backend/src/Taskdeck.Application/Services/LlmQueueService.cs b/backend/src/Taskdeck.Application/Services/LlmQueueService.cs index da276702a..3e887f184 100644 --- a/backend/src/Taskdeck.Application/Services/LlmQueueService.cs +++ b/backend/src/Taskdeck.Application/Services/LlmQueueService.cs @@ -136,16 +136,13 @@ public async Task> ProcessNextRequestAsync() public async Task> GetQueueStatsAsync(Guid userId) { - var pending = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Pending); - var processing = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Processing); - var completed = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, RequestStatus.Completed); - var failed = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, 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); } From 11e177991519847eb841e1119911ff94cf878db1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 15:55:51 +0100 Subject: [PATCH 11/11] Update LlmQueueServiceTests to mock GetStatusCountsByUserAsync Updates GetQueueStatsAsync_ShouldReturnCorrectCounts to use the new single-query method instead of four GetByUserAndStatusAsync stubs. --- .../Services/LlmQueueServiceTests.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs index 14f0f6aa3..973d0f3f5 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs @@ -447,14 +447,14 @@ public async Task GetQueueStatsAsync_ShouldReturnCorrectCounts() var completedRequests = new List(); var failedRequests = new List(); - _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Pending, default)) - .ReturnsAsync(pendingRequests); - _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Processing, default)) - .ReturnsAsync(processingRequests); - _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Completed, default)) - .ReturnsAsync(completedRequests); - _llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, 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(userId);