Skip to content

Commit bb45014

Browse files
authored
Merge pull request #529 from Chris0Jeky/fix/508-queue-user-isolation
fix: scope LLM queue list to authenticated user (#508)
2 parents 2eb3506 + 11e1779 commit bb45014

File tree

7 files changed

+122
-26
lines changed

7 files changed

+122
-26
lines changed

backend/src/Taskdeck.Api/Controllers/LlmQueueController.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,15 @@ public async Task<IActionResult> GetUserQueue()
4949
[HttpGet("status/{status}")]
5050
public async Task<IActionResult> GetByStatus(string status)
5151
{
52+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
53+
return errorResult!;
54+
5255
if (!Enum.TryParse<RequestStatus>(status, true, out var parsedStatus) || !Enum.IsDefined(parsedStatus))
5356
return BadRequest(new ApiErrorResponse(
5457
ErrorCodes.ValidationError,
5558
$"Invalid status value: {status}"));
5659

57-
var result = await _llmQueueService.GetQueueByStatusAsync(parsedStatus);
60+
var result = await _llmQueueService.GetQueueByStatusAsync(userId, parsedStatus);
5861
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
5962
}
6063

@@ -79,7 +82,10 @@ public async Task<IActionResult> ProcessNext()
7982
[HttpGet("stats")]
8083
public async Task<IActionResult> GetQueueStats()
8184
{
82-
var result = await _llmQueueService.GetQueueStatsAsync();
85+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
86+
return errorResult!;
87+
88+
var result = await _llmQueueService.GetQueueStatsAsync(userId);
8389
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
8490
}
8591
}

backend/src/Taskdeck.Application/Interfaces/ILlmQueueRepository.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ public interface ILlmQueueRepository : IRepository<LlmRequest>
1111
Task<IEnumerable<LlmRequest>> GetPendingAsync(int limit = 100, CancellationToken cancellationToken = default);
1212
Task<IEnumerable<LlmRequest>> GetByUserAsync(Guid userId, CancellationToken cancellationToken = default);
1313
Task<IEnumerable<LlmRequest>> GetByStatusAsync(RequestStatus status, CancellationToken cancellationToken = default);
14+
Task<IEnumerable<LlmRequest>> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default);
15+
Task<Dictionary<RequestStatus, int>> GetStatusCountsByUserAsync(Guid userId, CancellationToken cancellationToken = default);
1416
Task<LlmRequest?> GetNextPendingAsync(CancellationToken cancellationToken = default);
1517
Task<bool> TryClaimProcessingCaptureAsync(
1618
Guid requestId,

backend/src/Taskdeck.Application/Services/ILlmQueueService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ public interface ILlmQueueService
1212
{
1313
Task<Result<LlmRequestDto>> AddToQueueAsync(Guid userId, CreateLlmRequestDto dto);
1414
Task<Result<IEnumerable<LlmRequestDto>>> GetUserQueueAsync(Guid userId);
15-
Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(RequestStatus status);
15+
Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(Guid userId, RequestStatus status);
1616
Task<Result> CancelRequestAsync(Guid requestId, Guid userId);
1717
Task<Result<LlmRequestDto>> ProcessNextRequestAsync();
18-
Task<Result<QueueStatsDto>> GetQueueStatsAsync();
18+
Task<Result<QueueStatsDto>> GetQueueStatsAsync(Guid userId);
1919
}

backend/src/Taskdeck.Application/Services/LlmQueueService.cs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,9 @@ public async Task<Result<IEnumerable<LlmRequestDto>>> GetUserQueueAsync(Guid use
8383
return Result.Success(requests.Select(MapToDto));
8484
}
8585

86-
public async Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(RequestStatus status)
86+
public async Task<Result<IEnumerable<LlmRequestDto>>> GetQueueByStatusAsync(Guid userId, RequestStatus status)
8787
{
88-
var requests = await _unitOfWork.LlmQueue.GetByStatusAsync(status);
88+
var requests = await _unitOfWork.LlmQueue.GetByUserAndStatusAsync(userId, status);
8989
return Result.Success(requests.Select(MapToDto));
9090
}
9191

@@ -134,18 +134,15 @@ public async Task<Result<LlmRequestDto>> ProcessNextRequestAsync()
134134
}
135135
}
136136

137-
public async Task<Result<QueueStatsDto>> GetQueueStatsAsync()
137+
public async Task<Result<QueueStatsDto>> GetQueueStatsAsync(Guid userId)
138138
{
139-
var pending = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Pending);
140-
var processing = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Processing);
141-
var completed = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Completed);
142-
var failed = await _unitOfWork.LlmQueue.GetByStatusAsync(RequestStatus.Failed);
139+
var statusCounts = await _unitOfWork.LlmQueue.GetStatusCountsByUserAsync(userId);
143140

144141
var stats = new QueueStatsDto(
145-
pending.Count(),
146-
processing.Count(),
147-
completed.Count(),
148-
failed.Count());
142+
statusCounts.GetValueOrDefault(RequestStatus.Pending, 0),
143+
statusCounts.GetValueOrDefault(RequestStatus.Processing, 0),
144+
statusCounts.GetValueOrDefault(RequestStatus.Completed, 0),
145+
statusCounts.GetValueOrDefault(RequestStatus.Failed, 0));
149146

150147
return Result.Success(stats);
151148
}

backend/src/Taskdeck.Infrastructure/Repositories/LlmQueueRepository.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,36 @@ public async Task<IEnumerable<LlmRequest>> GetByStatusAsync(RequestStatus status
140140
.ToListAsync(cancellationToken);
141141
}
142142

143+
public async Task<IEnumerable<LlmRequest>> GetByUserAndStatusAsync(Guid userId, RequestStatus status, CancellationToken cancellationToken = default)
144+
{
145+
if (_context.Database.IsSqlite())
146+
{
147+
return await _context.LlmRequests
148+
.FromSqlInterpolated($"SELECT * FROM LlmRequests WHERE UserId = {userId} AND Status = {(int)status} ORDER BY CreatedAt DESC")
149+
.AsNoTracking()
150+
.Include(lr => lr.User)
151+
.Include(lr => lr.Board)
152+
.ToListAsync(cancellationToken);
153+
}
154+
155+
return await _context.LlmRequests
156+
.AsNoTracking()
157+
.Include(lr => lr.User)
158+
.Include(lr => lr.Board)
159+
.Where(lr => lr.UserId == userId && lr.Status == status)
160+
.OrderByDescending(lr => lr.CreatedAt)
161+
.ToListAsync(cancellationToken);
162+
}
163+
164+
public async Task<Dictionary<RequestStatus, int>> GetStatusCountsByUserAsync(Guid userId, CancellationToken cancellationToken = default)
165+
{
166+
return await _context.LlmRequests
167+
.Where(r => r.UserId == userId)
168+
.GroupBy(r => r.Status)
169+
.Select(g => new { Status = g.Key, Count = g.Count() })
170+
.ToDictionaryAsync(g => g.Status, g => g.Count, cancellationToken);
171+
}
172+
143173
public async Task<LlmRequest?> GetNextPendingAsync(CancellationToken cancellationToken = default)
144174
{
145175
if (_context.Database.IsSqlite())

backend/tests/Taskdeck.Api.Tests/LlmQueueApiTests.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,67 @@ public async Task CancelRequest_ShouldReturnForbidden_WhenRequestBelongsToDiffer
239239
await ApiTestHarness.AssertForbiddenAsync(response);
240240
}
241241

242+
[Fact]
243+
public async Task GetByStatus_ShouldOnlyReturnCurrentUserRequests()
244+
{
245+
using var userAClient = _factory.CreateClient();
246+
using var userBClient = _factory.CreateClient();
247+
248+
var userA = await ApiTestHarness.AuthenticateAsync(userAClient, "llm-status-isolation-userA");
249+
var boardA = await ApiTestHarness.CreateBoardAsync(userAClient, "llm-status-isolation-board-a");
250+
251+
// User A creates a queue item
252+
var createResponse = await userAClient.PostAsJsonAsync(
253+
"/api/llm-queue",
254+
new CreateLlmRequestDto("summarize", "user-a payload", boardA.Id));
255+
createResponse.StatusCode.Should().Be(HttpStatusCode.OK);
256+
var createdItem = await createResponse.Content.ReadFromJsonAsync<LlmRequestDto>();
257+
createdItem.Should().NotBeNull();
258+
259+
// User B queries the same status — must not see user A's item
260+
await ApiTestHarness.AuthenticateAsync(userBClient, "llm-status-isolation-userB");
261+
var response = await userBClient.GetAsync("/api/llm-queue/status/Pending");
262+
263+
response.StatusCode.Should().Be(HttpStatusCode.OK);
264+
265+
var items = await response.Content.ReadFromJsonAsync<List<LlmRequestDto>>();
266+
items.Should().NotBeNull();
267+
items.Should().NotContain(item => item.UserId == userA.UserId,
268+
"user B must not see user A's queue items");
269+
}
270+
271+
[Fact]
272+
public async Task GetQueueStats_ShouldOnlyCountCurrentUserRequests()
273+
{
274+
using var userAClient = _factory.CreateClient();
275+
using var userBClient = _factory.CreateClient();
276+
277+
await ApiTestHarness.AuthenticateAsync(userAClient, "llm-stats-isolation-userA");
278+
await ApiTestHarness.AuthenticateAsync(userBClient, "llm-stats-isolation-userB");
279+
280+
// Verify user B starts with zero pending
281+
var baselineResponse = await userBClient.GetAsync("/api/llm-queue/stats");
282+
baselineResponse.StatusCode.Should().Be(HttpStatusCode.OK);
283+
var baselineStats = await baselineResponse.Content.ReadFromJsonAsync<QueueStatsDto>();
284+
baselineStats.Should().NotBeNull();
285+
var baselinePending = baselineStats!.PendingCount;
286+
287+
// User A creates a queue item (no boardId — queue accepts null board)
288+
var boardA = await ApiTestHarness.CreateBoardAsync(userAClient, "llm-stats-isolation-board");
289+
var createResponse = await userAClient.PostAsJsonAsync(
290+
"/api/llm-queue",
291+
new CreateLlmRequestDto("summarize", "user-a stats payload", boardA.Id));
292+
createResponse.StatusCode.Should().Be(HttpStatusCode.OK);
293+
294+
// User B's stats must not change
295+
var afterResponse = await userBClient.GetAsync("/api/llm-queue/stats");
296+
afterResponse.StatusCode.Should().Be(HttpStatusCode.OK);
297+
var afterStats = await afterResponse.Content.ReadFromJsonAsync<QueueStatsDto>();
298+
afterStats.Should().NotBeNull();
299+
afterStats!.PendingCount.Should().Be(baselinePending,
300+
"user A's pending items must not appear in user B's stats");
301+
}
302+
242303
private async Task<Guid> CreateOwnedBoardAsync(string stem)
243304
{
244305
await EnsureAuthenticatedAsync();

backend/tests/Taskdeck.Application.Tests/Services/LlmQueueServiceTests.cs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,11 @@ public async Task GetQueueByStatusAsync_ShouldReturnRequests()
276276
new LlmRequest(userId, "voicenote", "payload text")
277277
};
278278

279-
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Pending, default))
279+
_llmQueueRepoMock.Setup(r => r.GetByUserAndStatusAsync(userId, RequestStatus.Pending, default))
280280
.ReturnsAsync(requests);
281281

282282
// Act
283-
var result = await _service.GetQueueByStatusAsync(RequestStatus.Pending);
283+
var result = await _service.GetQueueByStatusAsync(userId, RequestStatus.Pending);
284284

285285
// Assert
286286
result.IsSuccess.Should().BeTrue();
@@ -447,17 +447,17 @@ public async Task GetQueueStatsAsync_ShouldReturnCorrectCounts()
447447
var completedRequests = new List<LlmRequest>();
448448
var failedRequests = new List<LlmRequest>();
449449

450-
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Pending, default))
451-
.ReturnsAsync(pendingRequests);
452-
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Processing, default))
453-
.ReturnsAsync(processingRequests);
454-
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Completed, default))
455-
.ReturnsAsync(completedRequests);
456-
_llmQueueRepoMock.Setup(r => r.GetByStatusAsync(RequestStatus.Failed, default))
457-
.ReturnsAsync(failedRequests);
450+
_llmQueueRepoMock.Setup(r => r.GetStatusCountsByUserAsync(userId, default))
451+
.ReturnsAsync(new Dictionary<RequestStatus, int>
452+
{
453+
{ RequestStatus.Pending, pendingRequests.Count },
454+
{ RequestStatus.Processing, processingRequests.Count },
455+
{ RequestStatus.Completed, completedRequests.Count },
456+
{ RequestStatus.Failed, failedRequests.Count },
457+
});
458458

459459
// Act
460-
var result = await _service.GetQueueStatsAsync();
460+
var result = await _service.GetQueueStatsAsync(userId);
461461

462462
// Assert
463463
result.IsSuccess.Should().BeTrue();

0 commit comments

Comments
 (0)