Skip to content
32 changes: 32 additions & 0 deletions backend/src/Taskdeck.Api/Controllers/CaptureController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,36 @@ public async Task<IActionResult> EnqueueTriage(Guid id, CancellationToken cancel
var result = await _captureService.EnqueueTriageAsync(userId, id, cancellationToken);
return result.IsSuccess ? Accepted(result.Value) : result.ToErrorActionResult();
}

[HttpPost("batch-triage")]
[EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)]
public async Task<IActionResult> BatchTriage([FromBody] BatchTriageRequestDto dto, CancellationToken cancellationToken)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

var result = await _captureService.BatchTriageAsync(userId, dto, cancellationToken);
if (!result.IsSuccess)
return result.ToErrorActionResult();

var batchResult = result.Value;
if (batchResult.Failed > 0 && batchResult.Succeeded > 0)
return StatusCode(207, batchResult);

if (batchResult.Failed > 0 && batchResult.Succeeded == 0)
return UnprocessableEntity(batchResult);

return Ok(batchResult);
}

[HttpPut("{id:guid}/suggestion")]
[EnableRateLimiting(RateLimitingPolicyNames.CaptureWritePerUser)]
public async Task<IActionResult> UpdateSuggestion(Guid id, [FromBody] UpdateCaptureSuggestionDto dto, CancellationToken cancellationToken)
{
if (!TryGetCurrentUserId(out var userId, out var errorResult))
return errorResult!;

var result = await _captureService.UpdateSuggestionAsync(userId, id, dto, cancellationToken);
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
}
}
39 changes: 39 additions & 0 deletions backend/src/Taskdeck.Application/DTOs/CaptureDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,42 @@ public record CaptureTriageProposalResultDto(
string PromptVersion,
string Provider,
string Model);

/// <summary>
/// Describes a single item action within a batch triage request.
/// </summary>
public record BatchTriageItemActionDto(
Guid ItemId,
string Action);

/// <summary>
/// Request payload for batch triage operations.
/// Supported actions: "triage", "ignore", "cancel".
/// </summary>
public record BatchTriageRequestDto(
IReadOnlyList<BatchTriageItemActionDto> Items);

/// <summary>
/// Result for a single item within a batch triage operation.
/// </summary>
public record BatchTriageItemResultDto(
Guid ItemId,
bool Success,
string? ErrorCode = null,
string? ErrorMessage = null);

/// <summary>
/// Aggregate result of a batch triage operation.
/// </summary>
public record BatchTriageResultDto(
int Total,
int Succeeded,
int Failed,
IReadOnlyList<BatchTriageItemResultDto> Results);

/// <summary>
/// Request payload for editing the suggestion text of a capture item before triage.
/// </summary>
public record UpdateCaptureSuggestionDto(
string Text,
string? TitleHint = null);
139 changes: 139 additions & 0 deletions backend/src/Taskdeck.Application/Services/CaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,145 @@ public async Task<Result<CaptureTriageEnqueueResultDto>> EnqueueTriageAsync(
}
}

private static readonly HashSet<string> ValidBatchActions = new(StringComparer.OrdinalIgnoreCase)
{
"triage", "ignore", "cancel"
};

private const int MaxBatchSize = 50;

public async Task<Result<BatchTriageResultDto>> BatchTriageAsync(
Guid userId,
BatchTriageRequestDto request,
CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure<BatchTriageResultDto>(ErrorCodes.ValidationError, "UserId cannot be empty");

if (request.Items == null || request.Items.Count == 0)
return Result.Failure<BatchTriageResultDto>(ErrorCodes.ValidationError, "At least one item is required");

if (request.Items.Count > MaxBatchSize)
return Result.Failure<BatchTriageResultDto>(ErrorCodes.ValidationError, $"Batch size cannot exceed {MaxBatchSize}");

var duplicateIds = request.Items
.GroupBy(i => i.ItemId)
.Where(g => g.Count() > 1)
.Select(g => g.Key)
.ToList();
if (duplicateIds.Count > 0)
return Result.Failure<BatchTriageResultDto>(ErrorCodes.ValidationError, "Duplicate item IDs in batch request");

var invalidActions = request.Items
.Where(i => !ValidBatchActions.Contains(i.Action))
.ToList();
if (invalidActions.Count > 0)
return Result.Failure<BatchTriageResultDto>(ErrorCodes.ValidationError,
$"Invalid action(s): {string.Join(", ", invalidActions.Select(i => i.Action))}. Valid actions: triage, ignore, cancel");

var results = new List<BatchTriageItemResultDto>(request.Items.Count);

foreach (var itemAction in request.Items)
{
try
{
var actionResult = itemAction.Action.ToLowerInvariant() switch
{
"triage" => await ExecuteBatchItemTriageAsync(userId, itemAction.ItemId, cancellationToken),
"ignore" => await CancelInternalAsync(userId, itemAction.ItemId, cancellationToken),
"cancel" => await CancelInternalAsync(userId, itemAction.ItemId, cancellationToken),
_ => Result.Failure(ErrorCodes.ValidationError, $"Unknown action: {itemAction.Action}")
};

results.Add(new BatchTriageItemResultDto(
itemAction.ItemId,
actionResult.IsSuccess,
actionResult.IsSuccess ? null : actionResult.ErrorCode,
actionResult.IsSuccess ? null : actionResult.ErrorMessage));
}
catch (Exception)
{
results.Add(new BatchTriageItemResultDto(
itemAction.ItemId,
false,
ErrorCodes.UnexpectedError,
"An unexpected error occurred while processing this item"));
}
}
Comment on lines +317 to +343
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The BatchTriageAsync method processes items sequentially and performs a database save (SaveChangesAsync) for every item in the batch. For a batch size of up to 50, this results in significant overhead due to multiple database roundtrips and sequential execution. It is recommended to refactor the logic to perform updates in memory and call SaveChangesAsync once at the end of the batch, or at least use a single transaction to batch the operations.


var succeeded = results.Count(r => r.Success);
var failed = results.Count(r => !r.Success);

return Result.Success(new BatchTriageResultDto(
results.Count,
succeeded,
failed,
results));
}

private async Task<Result> ExecuteBatchItemTriageAsync(
Guid userId,
Guid itemId,
CancellationToken cancellationToken)
{
var triageResult = await EnqueueTriageAsync(userId, itemId, cancellationToken);
return triageResult.IsSuccess
? Result.Success()
: Result.Failure(triageResult.ErrorCode, triageResult.ErrorMessage);
}

public async Task<Result<CaptureItemDto>> UpdateSuggestionAsync(
Guid userId,
Guid itemId,
UpdateCaptureSuggestionDto dto,
CancellationToken cancellationToken = default)
{
if (userId == Guid.Empty)
return Result.Failure<CaptureItemDto>(ErrorCodes.ValidationError, "UserId cannot be empty");

if (string.IsNullOrWhiteSpace(dto.Text))
return Result.Failure<CaptureItemDto>(ErrorCodes.ValidationError, "Text cannot be empty");

if (dto.Text.Length > CaptureRequestContract.MaxRawTextLength)
return Result.Failure<CaptureItemDto>(ErrorCodes.ValidationError,
$"Text exceeds maximum length of {CaptureRequestContract.MaxRawTextLength} characters");

if (dto.TitleHint != null && dto.TitleHint.Length > CaptureRequestContract.MaxTitleHintLength)
return Result.Failure<CaptureItemDto>(ErrorCodes.ValidationError,
$"Title hint exceeds maximum length of {CaptureRequestContract.MaxTitleHintLength} characters");

var item = await _unitOfWork.LlmQueue.GetByIdAsync(itemId, cancellationToken);
if (item == null || !CaptureRequestContract.IsCaptureRequestType(item.RequestType))
return Result.Failure<CaptureItemDto>(ErrorCodes.NotFound, $"Capture item with ID {itemId} not found");

if (item.UserId != userId)
return Result.Failure<CaptureItemDto>(ErrorCodes.Forbidden, "You do not have permission to modify this capture item");

var currentPayload = ParsePayload(item);
var currentStatus = ResolveCaptureStatus(item, currentPayload);

if (currentStatus != CaptureStatus.New && currentStatus != CaptureStatus.Failed &&
currentStatus != CaptureStatus.Triaged)
{
return Result.Failure<CaptureItemDto>(ErrorCodes.Conflict,
$"Capture item in status {currentStatus} cannot be edited");
}

var updatedPayload = new CapturePayloadV1(
currentPayload.Version,
currentPayload.Source,
dto.Text,
currentPayload.ClientCreatedAt,
dto.TitleHint ?? currentPayload.TitleHint,
currentPayload.ExternalRef,
currentPayload.Provenance);

item.UpdatePayload(CaptureRequestContract.SerializePayload(updatedPayload));
await _unitOfWork.SaveChangesAsync(cancellationToken);

return Result.Success(MapToDetailDto(item, updatedPayload));
}

private async Task<Result> CancelInternalAsync(
Guid userId,
Guid itemId,
Expand Down
11 changes: 11 additions & 0 deletions backend/src/Taskdeck.Application/Services/ICaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,15 @@ Task<Result<CaptureTriageEnqueueResultDto>> EnqueueTriageAsync(
Guid userId,
Guid itemId,
CancellationToken cancellationToken = default);

Task<Result<BatchTriageResultDto>> BatchTriageAsync(
Guid userId,
BatchTriageRequestDto request,
CancellationToken cancellationToken = default);

Task<Result<CaptureItemDto>> UpdateSuggestionAsync(
Guid userId,
Guid itemId,
UpdateCaptureSuggestionDto dto,
CancellationToken cancellationToken = default);
}
143 changes: 143 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ await ApiTestHarness.AssertUnauthorizedAsync(

await ApiTestHarness.AssertUnauthorizedAsync(
await _client.PostAsync($"/api/capture/items/{itemId}/triage", null));

await ApiTestHarness.AssertUnauthorizedAsync(
await _client.PostAsJsonAsync("/api/capture/items/batch-triage",
new BatchTriageRequestDto(new List<BatchTriageItemActionDto>
{ new(Guid.NewGuid(), "triage") })));

await ApiTestHarness.AssertUnauthorizedAsync(
await _client.PutAsJsonAsync($"/api/capture/items/{itemId}/suggestion",
new UpdateCaptureSuggestionDto("edited")));
}

[Fact]
Expand Down Expand Up @@ -515,6 +524,140 @@ public async Task Triage_ShouldReturnForbidden_WhenCaptureBelongsToDifferentUser
await ApiTestHarness.AssertForbiddenAsync(response);
}

[Fact]
public async Task BatchTriage_ShouldTriageMultipleItems()
{
await AuthenticateAsAsync("capture-batch-triage");

var create1 = await _client.PostAsJsonAsync(
"/api/capture/items",
new CreateCaptureItemDto(null, "batch item 1"));
create1.StatusCode.Should().Be(HttpStatusCode.Created);
var item1 = await create1.Content.ReadFromJsonAsync<CaptureItemDto>();

var create2 = await _client.PostAsJsonAsync(
"/api/capture/items",
new CreateCaptureItemDto(null, "batch item 2"));
create2.StatusCode.Should().Be(HttpStatusCode.Created);
var item2 = await create2.Content.ReadFromJsonAsync<CaptureItemDto>();

var response = await _client.PostAsJsonAsync(
"/api/capture/items/batch-triage",
new BatchTriageRequestDto(new List<BatchTriageItemActionDto>
{
new(item1!.Id, "triage"),
new(item2!.Id, "ignore")
}));

response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<BatchTriageResultDto>();
result.Should().NotBeNull();
result!.Total.Should().Be(2);
result.Succeeded.Should().Be(2);
result.Failed.Should().Be(0);
}

[Fact]
public async Task BatchTriage_ShouldReturnPartialSuccess_WhenSomeItemsFail()
{
await AuthenticateAsAsync("capture-batch-partial");

var create1 = await _client.PostAsJsonAsync(
"/api/capture/items",
new CreateCaptureItemDto(null, "batch partial item 1"));
create1.StatusCode.Should().Be(HttpStatusCode.Created);
var item1 = await create1.Content.ReadFromJsonAsync<CaptureItemDto>();

var response = await _client.PostAsJsonAsync(
"/api/capture/items/batch-triage",
new BatchTriageRequestDto(new List<BatchTriageItemActionDto>
{
new(item1!.Id, "triage"),
new(Guid.NewGuid(), "triage")
}));

response.StatusCode.Should().Be((HttpStatusCode)207);
var result = await response.Content.ReadFromJsonAsync<BatchTriageResultDto>();
result.Should().NotBeNull();
result!.Total.Should().Be(2);
result.Succeeded.Should().Be(1);
result.Failed.Should().Be(1);
}

[Fact]
public async Task BatchTriage_ShouldReturnBadRequest_WhenEmptyItems()
{
await AuthenticateAsAsync("capture-batch-empty");

var response = await _client.PostAsJsonAsync(
"/api/capture/items/batch-triage",
new BatchTriageRequestDto(new List<BatchTriageItemActionDto>()));

await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.BadRequest, "ValidationError");
}

[Fact]
public async Task UpdateSuggestion_ShouldUpdateCaptureText()
{
await AuthenticateAsAsync("capture-edit-suggestion");

var createResponse = await _client.PostAsJsonAsync(
"/api/capture/items",
new CreateCaptureItemDto(null, "original text for editing"));
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await createResponse.Content.ReadFromJsonAsync<CaptureItemDto>();

var response = await _client.PutAsJsonAsync(
$"/api/capture/items/{created!.Id}/suggestion",
new UpdateCaptureSuggestionDto("edited text with improvements", "New Title"));

response.StatusCode.Should().Be(HttpStatusCode.OK);
var updated = await response.Content.ReadFromJsonAsync<CaptureItemDto>();
updated.Should().NotBeNull();
updated!.RawText.Should().Be("edited text with improvements");
}

[Fact]
public async Task UpdateSuggestion_ShouldReturnForbidden_WhenItemBelongsToDifferentUser()
{
using var ownerClient = _factory.CreateClient();
using var outsiderClient = _factory.CreateClient();

await ApiTestHarness.AuthenticateAsync(ownerClient, "capture-edit-owner");
await ApiTestHarness.AuthenticateAsync(outsiderClient, "capture-edit-outsider");

var createResponse = await ownerClient.PostAsJsonAsync(
"/api/capture/items",
new CreateCaptureItemDto(null, "owner edit payload"));
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await createResponse.Content.ReadFromJsonAsync<CaptureItemDto>();

var response = await outsiderClient.PutAsJsonAsync(
$"/api/capture/items/{created!.Id}/suggestion",
new UpdateCaptureSuggestionDto("hacked text"));
await ApiTestHarness.AssertForbiddenAsync(response);
}

[Fact]
public async Task UpdateSuggestion_ShouldReturnConflict_WhenItemIsTriaging()
{
await AuthenticateAsAsync("capture-edit-conflict");

var createResponse = await _client.PostAsJsonAsync(
"/api/capture/items",
new CreateCaptureItemDto(null, "triaging edit payload"));
createResponse.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await createResponse.Content.ReadFromJsonAsync<CaptureItemDto>();

await _client.PostAsync($"/api/capture/items/{created!.Id}/triage", null);

var response = await _client.PutAsJsonAsync(
$"/api/capture/items/{created.Id}/suggestion",
new UpdateCaptureSuggestionDto("edited while triaging"));

await ApiTestHarness.AssertErrorContractAsync(response, HttpStatusCode.Conflict, "Conflict");
}

private async Task AuthenticateAsAsync(string stem)
{
await ApiTestHarness.AuthenticateAsync(_client, stem);
Expand Down
Loading
Loading