diff --git a/backend/src/Taskdeck.Api/Controllers/CaptureController.cs b/backend/src/Taskdeck.Api/Controllers/CaptureController.cs index 8ce8a4015..a8015e759 100644 --- a/backend/src/Taskdeck.Api/Controllers/CaptureController.cs +++ b/backend/src/Taskdeck.Api/Controllers/CaptureController.cs @@ -107,4 +107,36 @@ public async Task 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 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 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(); + } } diff --git a/backend/src/Taskdeck.Application/DTOs/CaptureDtos.cs b/backend/src/Taskdeck.Application/DTOs/CaptureDtos.cs index bf18d6fe4..977200fee 100644 --- a/backend/src/Taskdeck.Application/DTOs/CaptureDtos.cs +++ b/backend/src/Taskdeck.Application/DTOs/CaptureDtos.cs @@ -50,3 +50,42 @@ public record CaptureTriageProposalResultDto( string PromptVersion, string Provider, string Model); + +/// +/// Describes a single item action within a batch triage request. +/// +public record BatchTriageItemActionDto( + Guid ItemId, + string Action); + +/// +/// Request payload for batch triage operations. +/// Supported actions: "triage", "ignore", "cancel". +/// +public record BatchTriageRequestDto( + IReadOnlyList Items); + +/// +/// Result for a single item within a batch triage operation. +/// +public record BatchTriageItemResultDto( + Guid ItemId, + bool Success, + string? ErrorCode = null, + string? ErrorMessage = null); + +/// +/// Aggregate result of a batch triage operation. +/// +public record BatchTriageResultDto( + int Total, + int Succeeded, + int Failed, + IReadOnlyList Results); + +/// +/// Request payload for editing the suggestion text of a capture item before triage. +/// +public record UpdateCaptureSuggestionDto( + string Text, + string? TitleHint = null); diff --git a/backend/src/Taskdeck.Application/Services/CaptureService.cs b/backend/src/Taskdeck.Application/Services/CaptureService.cs index 0ada6c215..315342d1b 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -276,6 +276,145 @@ public async Task> EnqueueTriageAsync( } } + private static readonly HashSet ValidBatchActions = new(StringComparer.OrdinalIgnoreCase) + { + "triage", "ignore", "cancel" + }; + + private const int MaxBatchSize = 50; + + public async Task> BatchTriageAsync( + Guid userId, + BatchTriageRequestDto request, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + if (request.Items == null || request.Items.Count == 0) + return Result.Failure(ErrorCodes.ValidationError, "At least one item is required"); + + if (request.Items.Count > MaxBatchSize) + return Result.Failure(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(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(ErrorCodes.ValidationError, + $"Invalid action(s): {string.Join(", ", invalidActions.Select(i => i.Action))}. Valid actions: triage, ignore, cancel"); + + var results = new List(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")); + } + } + + 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 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> UpdateSuggestionAsync( + Guid userId, + Guid itemId, + UpdateCaptureSuggestionDto dto, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + if (string.IsNullOrWhiteSpace(dto.Text)) + return Result.Failure(ErrorCodes.ValidationError, "Text cannot be empty"); + + if (dto.Text.Length > CaptureRequestContract.MaxRawTextLength) + return Result.Failure(ErrorCodes.ValidationError, + $"Text exceeds maximum length of {CaptureRequestContract.MaxRawTextLength} characters"); + + if (dto.TitleHint != null && dto.TitleHint.Length > CaptureRequestContract.MaxTitleHintLength) + return Result.Failure(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(ErrorCodes.NotFound, $"Capture item with ID {itemId} not found"); + + if (item.UserId != userId) + return Result.Failure(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(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 CancelInternalAsync( Guid userId, Guid itemId, diff --git a/backend/src/Taskdeck.Application/Services/ICaptureService.cs b/backend/src/Taskdeck.Application/Services/ICaptureService.cs index f4c898ea2..49506232f 100644 --- a/backend/src/Taskdeck.Application/Services/ICaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/ICaptureService.cs @@ -34,4 +34,15 @@ Task> EnqueueTriageAsync( Guid userId, Guid itemId, CancellationToken cancellationToken = default); + + Task> BatchTriageAsync( + Guid userId, + BatchTriageRequestDto request, + CancellationToken cancellationToken = default); + + Task> UpdateSuggestionAsync( + Guid userId, + Guid itemId, + UpdateCaptureSuggestionDto dto, + CancellationToken cancellationToken = default); } diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs index ebd8b6173..09c3d92a6 100644 --- a/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs +++ b/backend/tests/Taskdeck.Api.Tests/CaptureApiTests.cs @@ -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 + { new(Guid.NewGuid(), "triage") }))); + + await ApiTestHarness.AssertUnauthorizedAsync( + await _client.PutAsJsonAsync($"/api/capture/items/{itemId}/suggestion", + new UpdateCaptureSuggestionDto("edited"))); } [Fact] @@ -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(); + + 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(); + + var response = await _client.PostAsJsonAsync( + "/api/capture/items/batch-triage", + new BatchTriageRequestDto(new List + { + new(item1!.Id, "triage"), + new(item2!.Id, "ignore") + })); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var result = await response.Content.ReadFromJsonAsync(); + 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(); + + var response = await _client.PostAsJsonAsync( + "/api/capture/items/batch-triage", + new BatchTriageRequestDto(new List + { + new(item1!.Id, "triage"), + new(Guid.NewGuid(), "triage") + })); + + response.StatusCode.Should().Be((HttpStatusCode)207); + var result = await response.Content.ReadFromJsonAsync(); + 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())); + + 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(); + + 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(); + 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(); + + 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(); + + 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); diff --git a/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs index 83d92378b..48ab499dd 100644 --- a/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs +++ b/backend/tests/Taskdeck.Application.Tests/Services/CaptureServiceTests.cs @@ -661,4 +661,198 @@ public async Task EnqueueTriageAsync_ShouldReturnConflict_WhenItemIsConverted() result.ErrorMessage.Should().Contain(CaptureStatus.Converted.ToString()); _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never); } + + // ── BatchTriageAsync ── + + [Fact] + public async Task BatchTriageAsync_ShouldReturnValidationError_WhenEmptyList() + { + var userId = Guid.NewGuid(); + var request = new BatchTriageRequestDto(new List()); + + var result = await _service.BatchTriageAsync(userId, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task BatchTriageAsync_ShouldReturnValidationError_WhenInvalidAction() + { + var userId = Guid.NewGuid(); + var request = new BatchTriageRequestDto(new List + { + new(Guid.NewGuid(), "invalid_action") + }); + + var result = await _service.BatchTriageAsync(userId, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("invalid_action"); + } + + [Fact] + public async Task BatchTriageAsync_ShouldReturnValidationError_WhenDuplicateIds() + { + var userId = Guid.NewGuid(); + var duplicateId = Guid.NewGuid(); + var request = new BatchTriageRequestDto(new List + { + new(duplicateId, "triage"), + new(duplicateId, "ignore") + }); + + var result = await _service.BatchTriageAsync(userId, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("Duplicate"); + } + + [Fact] + public async Task BatchTriageAsync_ShouldProcessMultipleItems_WithPartialFailure() + { + var userId = Guid.NewGuid(); + var item1 = new LlmRequest(userId, CaptureRequestContract.RequestTypeV1, "capture 1"); + var item2Id = Guid.NewGuid(); // Non-existent item + + _llmQueueRepositoryMock + .Setup(r => r.GetByIdAsync(item1.Id, default)) + .ReturnsAsync(item1); + _llmQueueRepositoryMock + .Setup(r => r.GetByIdAsync(item2Id, default)) + .ReturnsAsync((LlmRequest?)null); + + var request = new BatchTriageRequestDto(new List + { + new(item1.Id, "triage"), + new(item2Id, "triage") + }); + + var result = await _service.BatchTriageAsync(userId, request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Total.Should().Be(2); + result.Value.Succeeded.Should().Be(1); + result.Value.Failed.Should().Be(1); + result.Value.Results.Should().HaveCount(2); + + result.Value.Results[0].ItemId.Should().Be(item1.Id); + result.Value.Results[0].Success.Should().BeTrue(); + + result.Value.Results[1].ItemId.Should().Be(item2Id); + result.Value.Results[1].Success.Should().BeFalse(); + result.Value.Results[1].ErrorCode.Should().Be(ErrorCodes.NotFound); + } + + [Fact] + public async Task BatchTriageAsync_ShouldSupportIgnoreAction() + { + var userId = Guid.NewGuid(); + var item = new LlmRequest(userId, CaptureRequestContract.RequestTypeV1, "capture to ignore"); + + _llmQueueRepositoryMock + .Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + var request = new BatchTriageRequestDto(new List + { + new(item.Id, "ignore") + }); + + var result = await _service.BatchTriageAsync(userId, request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Succeeded.Should().Be(1); + result.Value.Failed.Should().Be(0); + } + + [Fact] + public async Task BatchTriageAsync_ShouldReturnValidationError_WhenBatchTooLarge() + { + var userId = Guid.NewGuid(); + var items = Enumerable.Range(0, 51) + .Select(i => new BatchTriageItemActionDto(Guid.NewGuid(), "triage")) + .ToList(); + var request = new BatchTriageRequestDto(items); + + var result = await _service.BatchTriageAsync(userId, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("50"); + } + + // ── UpdateSuggestionAsync ── + + [Fact] + public async Task UpdateSuggestionAsync_ShouldUpdateTextForNewItem() + { + var userId = Guid.NewGuid(); + var item = new LlmRequest(userId, CaptureRequestContract.RequestTypeV1, + CaptureRequestContract.SerializePayload( + new CapturePayloadV1(1, CaptureSource.Typed, "original text"))); + + _llmQueueRepositoryMock + .Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + var dto = new UpdateCaptureSuggestionDto("edited text", "New Title"); + var result = await _service.UpdateSuggestionAsync(userId, item.Id, dto); + + result.IsSuccess.Should().BeTrue(); + result.Value.RawText.Should().Be("edited text"); + _unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once); + } + + [Fact] + public async Task UpdateSuggestionAsync_ShouldRejectEmptyText() + { + var userId = Guid.NewGuid(); + var dto = new UpdateCaptureSuggestionDto(" "); + + var result = await _service.UpdateSuggestionAsync(userId, Guid.NewGuid(), dto); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task UpdateSuggestionAsync_ShouldRejectForbiddenUser() + { + var ownerId = Guid.NewGuid(); + var callerId = Guid.NewGuid(); + var item = new LlmRequest(ownerId, CaptureRequestContract.RequestTypeV1, + CaptureRequestContract.SerializePayload( + new CapturePayloadV1(1, CaptureSource.Typed, "original"))); + + _llmQueueRepositoryMock + .Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + var result = await _service.UpdateSuggestionAsync(callerId, item.Id, new UpdateCaptureSuggestionDto("edited")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task UpdateSuggestionAsync_ShouldRejectTriagingItem() + { + var userId = Guid.NewGuid(); + var item = new LlmRequest(userId, CaptureRequestContract.RequestTypeV1, + CaptureRequestContract.SerializePayload( + new CapturePayloadV1(1, CaptureSource.Typed, "original"))); + item.MarkAsProcessing(); + + _llmQueueRepositoryMock + .Setup(r => r.GetByIdAsync(item.Id, default)) + .ReturnsAsync(item); + + var result = await _service.UpdateSuggestionAsync(userId, item.Id, new UpdateCaptureSuggestionDto("edited")); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.Conflict); + } } diff --git a/frontend/taskdeck-web/src/api/captureApi.ts b/frontend/taskdeck-web/src/api/captureApi.ts index e531e479c..9b13762fc 100644 --- a/frontend/taskdeck-web/src/api/captureApi.ts +++ b/frontend/taskdeck-web/src/api/captureApi.ts @@ -1,11 +1,14 @@ import http from './http' import { buildQueryString } from '../utils/queryBuilder' import type { + BatchTriageItemAction, + BatchTriageResult, CaptureItem, CaptureItemSummary, CaptureListQuery, CaptureTriageEnqueueResult, CreateCaptureItemDto, + UpdateCaptureSuggestionDto, } from '../types/capture' function encodePathSegment(value: string): string { @@ -44,4 +47,15 @@ export const captureApi = { const { data } = await http.post(`/capture/items/${pathItemId}/triage`) return data }, + + async batchTriage(items: BatchTriageItemAction[]): Promise { + const { data } = await http.post('/capture/items/batch-triage', { items }) + return data + }, + + async updateSuggestion(itemId: string, dto: UpdateCaptureSuggestionDto): Promise { + const pathItemId = encodePathSegment(itemId) + const { data } = await http.put(`/capture/items/${pathItemId}/suggestion`, dto) + return data + }, } diff --git a/frontend/taskdeck-web/src/store/captureStore.ts b/frontend/taskdeck-web/src/store/captureStore.ts index ac41cf852..5679068cb 100644 --- a/frontend/taskdeck-web/src/store/captureStore.ts +++ b/frontend/taskdeck-web/src/store/captureStore.ts @@ -2,7 +2,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { captureApi } from '../api/captureApi' import { isTriageTerminalStatus } from '../types/capture' -import type { CaptureItem, CaptureItemSummary, CaptureListQuery, CreateCaptureItemDto } from '../types/capture' +import type { BatchTriageAction, BatchTriageResult, CaptureItem, CaptureItemSummary, CaptureListQuery, CreateCaptureItemDto, UpdateCaptureSuggestionDto } from '../types/capture' import { useToastStore } from './toastStore' import { getErrorDisplay } from '../composables/useErrorMapper' import { isDemoMode, DemoModeError } from '../utils/demoMode' @@ -315,6 +315,64 @@ export const useCaptureStore = defineStore('capture', () => { } } + const batchBusy = ref(false) + const batchError = ref(null) + + async function batchTriage(itemIds: string[], action: BatchTriageAction): Promise { + guardDemoMutation() + try { + batchBusy.value = true + batchError.value = null + actionError.value = null + + const batchItems = itemIds.map((id) => ({ itemId: id, action })) + const result = await captureApi.batchTriage(batchItems) + + if (result.succeeded > 0) { + toast.success(`${result.succeeded} of ${result.total} items processed`) + } + if (result.failed > 0) { + const failedMessages = result.results + .filter((r) => !r.success) + .map((r) => r.errorMessage ?? 'Unknown error') + .slice(0, 3) + .join('; ') + toast.error(`${result.failed} item(s) failed: ${failedMessages}`) + } + + // Refresh list to pick up status changes + await fetchItems() + + return result + } catch (e: unknown) { + const message = getErrorDisplay(e, 'Failed to process batch triage').message + batchError.value = message + toast.error(message) + throw e + } finally { + batchBusy.value = false + } + } + + async function updateSuggestion(itemId: string, dto: UpdateCaptureSuggestionDto): Promise { + guardDemoMutation() + try { + actionBusyItemId.value = itemId + actionError.value = null + const updated = await captureApi.updateSuggestion(itemId, dto) + cacheDetail(updated) + toast.success('Capture text updated') + return updated + } catch (e: unknown) { + const message = getErrorDisplay(e, 'Failed to update capture text').message + actionError.value = message + toast.error(message) + throw e + } finally { + actionBusyItemId.value = null + } + } + return { items, detailById, @@ -325,6 +383,8 @@ export const useCaptureStore = defineStore('capture', () => { detailError, actionError, hasItems, + batchBusy, + batchError, cacheDetail, fetchItems, fetchDetail, @@ -335,5 +395,7 @@ export const useCaptureStore = defineStore('capture', () => { triageItem, triagePollingItemId, pollTriageCompletion, + batchTriage, + updateSuggestion, } }) diff --git a/frontend/taskdeck-web/src/tests/api/captureApi.spec.ts b/frontend/taskdeck-web/src/tests/api/captureApi.spec.ts index 7775658a3..6dcd04895 100644 --- a/frontend/taskdeck-web/src/tests/api/captureApi.spec.ts +++ b/frontend/taskdeck-web/src/tests/api/captureApi.spec.ts @@ -6,6 +6,7 @@ vi.mock('../../api/http', () => ({ default: { get: vi.fn(), post: vi.fn(), + put: vi.fn(), }, })) @@ -75,4 +76,49 @@ describe('captureApi', () => { expect(result.status).toBe('Triaging') expect(result.alreadyTriaging).toBe(false) }) + + it('posts batch triage request', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { + total: 2, + succeeded: 2, + failed: 0, + results: [ + { itemId: 'c1', success: true }, + { itemId: 'c2', success: true }, + ], + }, + }) + + const result = await captureApi.batchTriage([ + { itemId: 'c1', action: 'triage' }, + { itemId: 'c2', action: 'ignore' }, + ]) + + expect(http.post).toHaveBeenCalledWith('/capture/items/batch-triage', { + items: [ + { itemId: 'c1', action: 'triage' }, + { itemId: 'c2', action: 'ignore' }, + ], + }) + expect(result.total).toBe(2) + expect(result.succeeded).toBe(2) + }) + + it('puts suggestion update', async () => { + vi.mocked(http.put).mockResolvedValue({ + data: { id: 'capture-3', rawText: 'updated text' }, + }) + + const result = await captureApi.updateSuggestion('capture-3', { + text: 'updated text', + titleHint: 'New Title', + }) + + expect(http.put).toHaveBeenCalledWith('/capture/items/capture-3/suggestion', { + text: 'updated text', + titleHint: 'New Title', + }) + expect(result.rawText).toBe('updated text') + }) }) diff --git a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts index 4f5094f87..25601217d 100644 --- a/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/captureStore.spec.ts @@ -16,6 +16,8 @@ vi.mock('../../api/captureApi', () => ({ ignoreItem: vi.fn(), cancelItem: vi.fn(), enqueueTriage: vi.fn(), + batchTriage: vi.fn(), + updateSuggestion: vi.fn(), }, })) @@ -749,4 +751,136 @@ describe('captureStore', () => { expect(toastMocks.error).toHaveBeenCalledTimes(1) expect(toastMocks.error).toHaveBeenCalledWith('Failed to triage capture item') }) + + // ── Batch triage tests ── + + it('sends batch triage request and refreshes list on success', async () => { + const store = useCaptureStore() + vi.mocked(captureApi.batchTriage).mockResolvedValue({ + total: 2, + succeeded: 2, + failed: 0, + results: [ + { itemId: 'c1', success: true }, + { itemId: 'c2', success: true }, + ], + }) + vi.mocked(captureApi.listItems).mockResolvedValue([]) + + const result = await store.batchTriage(['c1', 'c2'], 'triage') + + expect(captureApi.batchTriage).toHaveBeenCalledWith([ + { itemId: 'c1', action: 'triage' }, + { itemId: 'c2', action: 'triage' }, + ]) + expect(result.succeeded).toBe(2) + expect(toastMocks.success).toHaveBeenCalledWith('2 of 2 items processed') + expect(captureApi.listItems).toHaveBeenCalled() + }) + + it('reports partial batch failures with error toast', async () => { + const store = useCaptureStore() + vi.mocked(captureApi.batchTriage).mockResolvedValue({ + total: 2, + succeeded: 1, + failed: 1, + results: [ + { itemId: 'c1', success: true }, + { itemId: 'c2', success: false, errorCode: 'NotFound', errorMessage: 'Item not found' }, + ], + }) + vi.mocked(captureApi.listItems).mockResolvedValue([]) + + const result = await store.batchTriage(['c1', 'c2'], 'ignore') + + expect(result.failed).toBe(1) + expect(toastMocks.success).toHaveBeenCalledWith('1 of 2 items processed') + expect(toastMocks.error).toHaveBeenCalledWith(expect.stringContaining('1 item(s) failed')) + }) + + it('surfaces error when batch triage API call fails', async () => { + const store = useCaptureStore() + vi.mocked(captureApi.batchTriage).mockRejectedValue(new Error('network')) + + await expect(store.batchTriage(['c1'], 'triage')).rejects.toBeInstanceOf(Error) + + expect(store.batchError).toBe('Failed to process batch triage') + expect(toastMocks.error).toHaveBeenCalledWith('Failed to process batch triage') + }) + + it('tracks batchBusy state during batch operations', async () => { + const store = useCaptureStore() + let resolveBatch: ((value: unknown) => void) | null = null + + vi.mocked(captureApi.batchTriage).mockImplementation(() => new Promise((resolve) => { + resolveBatch = resolve + })) + vi.mocked(captureApi.listItems).mockResolvedValue([]) + + const promise = store.batchTriage(['c1'], 'cancel') + expect(store.batchBusy).toBe(true) + + resolveBatch?.({ total: 1, succeeded: 1, failed: 0, results: [{ itemId: 'c1', success: true }] }) + await promise + + expect(store.batchBusy).toBe(false) + }) + + // ── Suggestion editing tests ── + + it('updates suggestion text and caches result', async () => { + const store = useCaptureStore() + const updated = { + id: 'c10', + userId: 'u1', + boardId: null, + status: 'New' as const, + source: 'Typed' as const, + textExcerpt: 'edited text', + rawText: 'edited full text', + createdAt: new Date().toISOString(), + processedAt: null, + retryCount: 0, + provenance: null, + } + vi.mocked(captureApi.updateSuggestion).mockResolvedValue(updated) + + const result = await store.updateSuggestion('c10', { text: 'edited full text' }) + + expect(captureApi.updateSuggestion).toHaveBeenCalledWith('c10', { text: 'edited full text' }) + expect(result.rawText).toBe('edited full text') + expect(store.detailById.c10?.rawText).toBe('edited full text') + expect(toastMocks.success).toHaveBeenCalledWith('Capture text updated') + }) + + it('surfaces error when suggestion update fails', async () => { + const store = useCaptureStore() + vi.mocked(captureApi.updateSuggestion).mockRejectedValue(new Error('network')) + + await expect(store.updateSuggestion('c11', { text: 'new text' })).rejects.toBeInstanceOf(Error) + + expect(store.actionError).toBe('Failed to update capture text') + expect(toastMocks.error).toHaveBeenCalledWith('Failed to update capture text') + }) + + it('tracks actionBusyItemId during suggestion update', async () => { + const store = useCaptureStore() + let resolveUpdate: ((value: unknown) => void) | null = null + + vi.mocked(captureApi.updateSuggestion).mockImplementation(() => new Promise((resolve) => { + resolveUpdate = resolve + })) + + const promise = store.updateSuggestion('c12', { text: 'updating' }) + expect(store.actionBusyItemId).toBe('c12') + + resolveUpdate?.({ + id: 'c12', userId: 'u1', boardId: null, status: 'New', source: 'Typed', + textExcerpt: 'updating', rawText: 'updating', + createdAt: new Date().toISOString(), processedAt: null, retryCount: 0, + }) + await promise + + expect(store.actionBusyItemId).toBeNull() + }) }) diff --git a/frontend/taskdeck-web/src/tests/views/InboxView.spec.ts b/frontend/taskdeck-web/src/tests/views/InboxView.spec.ts index fff9c8110..aba4360b4 100644 --- a/frontend/taskdeck-web/src/tests/views/InboxView.spec.ts +++ b/frontend/taskdeck-web/src/tests/views/InboxView.spec.ts @@ -109,6 +109,27 @@ const mockCaptureStore = reactive({ triageItem: vi.fn<(itemId: string) => Promise>(), triagePollingItemId: null as string | null, pollTriageCompletion: vi.fn<(itemId: string) => () => void>(), + batchBusy: false, + batchError: null as string | null, + batchTriage: vi.fn<(itemIds: string[], action: string) => Promise<{ + total: number + succeeded: number + failed: number + results: Array<{ itemId: string; success: boolean; errorCode?: string | null; errorMessage?: string | null }> + }>>(), + updateSuggestion: vi.fn<(itemId: string, dto: { text: string; titleHint?: string | null }) => Promise<{ + id: string + userId: string + boardId: string | null + status: string | number + source: string | number + textExcerpt: string + rawText: string + createdAt: string + processedAt: string | null + retryCount: number + provenance?: unknown + }>>(), }) vi.mock('../../store/captureStore', () => ({ @@ -274,6 +295,28 @@ describe('InboxView', () => { mockCaptureStore.triageItem.mockResolvedValue(undefined) mockCaptureStore.triagePollingItemId = null mockCaptureStore.pollTriageCompletion.mockImplementation(() => () => {}) + mockCaptureStore.batchBusy = false + mockCaptureStore.batchError = null + mockCaptureStore.batchTriage.mockResolvedValue({ + total: 0, succeeded: 0, failed: 0, results: [], + }) + mockCaptureStore.updateSuggestion.mockImplementation(async (itemId: string, dto: { text: string; titleHint?: string | null }) => { + const detail = { + id: itemId, + userId: 'user-1', + boardId: null, + status: 'New' as const, + source: 'Typed' as const, + textExcerpt: dto.text.slice(0, 200), + rawText: dto.text, + createdAt: new Date().toISOString(), + processedAt: null, + retryCount: 0, + provenance: null, + } + mockCaptureStore.detailById[itemId] = detail + return detail + }) routerMocks.push.mockReset() routerMocks.replace.mockReset() routerMocks.replace.mockResolvedValue(undefined) @@ -988,4 +1031,197 @@ describe('InboxView', () => { expect(mockCaptureStore.fetchDetail).toHaveBeenLastCalledWith('capture-1', { forceRefresh: true }) expect(wrapper.text()).toContain('Capture Detail') }) + + // ── Batch selection tests ── + + it('renders checkboxes for each inbox item', async () => { + const wrapper = mount(InboxView) + await waitForUi() + + const checkboxes = wrapper.findAll('[data-testid="inbox-item-checkbox"]') + expect(checkboxes.length).toBe(2) + }) + + it('shows batch action bar when items are selected', async () => { + const wrapper = mount(InboxView) + await waitForUi() + + expect(wrapper.find('[data-testid="batch-action-bar"]').exists()).toBe(false) + + const checkboxes = wrapper.findAll('[data-testid="inbox-item-checkbox"]') + await checkboxes[0]?.trigger('click') + await waitForUi() + + expect(wrapper.find('[data-testid="batch-action-bar"]').exists()).toBe(true) + expect(wrapper.text()).toContain('1 selected') + }) + + it('select-all toggles all items', async () => { + const wrapper = mount(InboxView) + await waitForUi() + + const selectAll = wrapper.find('[data-testid="select-all"] input') + await selectAll.trigger('change') + await waitForUi() + + expect(wrapper.text()).toContain('2 selected') + + await selectAll.trigger('change') + await waitForUi() + + expect(wrapper.find('[data-testid="batch-action-bar"]').exists()).toBe(false) + }) + + it('batch triage action calls store with selected ids', async () => { + mockCaptureStore.batchTriage.mockResolvedValue({ + total: 2, succeeded: 2, failed: 0, results: [ + { itemId: 'capture-1', success: true }, + { itemId: 'capture-2', success: true }, + ], + }) + + const wrapper = mount(InboxView) + await waitForUi() + + const selectAll = wrapper.find('[data-testid="select-all"] input') + await selectAll.trigger('change') + await waitForUi() + + const triageBatchBtn = wrapper.find('[data-testid="batch-action-bar"]') + .findAll('button').find((b) => b.text().includes('Triage')) + await triageBatchBtn?.trigger('click') + await waitForUi() + + expect(mockCaptureStore.batchTriage).toHaveBeenCalledWith( + expect.arrayContaining(['capture-1', 'capture-2']), + 'triage', + ) + }) + + it('clears selection after successful batch action', async () => { + mockCaptureStore.batchTriage.mockResolvedValue({ + total: 1, succeeded: 1, failed: 0, results: [ + { itemId: 'capture-1', success: true }, + ], + }) + + const wrapper = mount(InboxView) + await waitForUi() + + const checkboxes = wrapper.findAll('[data-testid="inbox-item-checkbox"]') + await checkboxes[0]?.trigger('click') + await waitForUi() + + const ignoreBatchBtn = wrapper.find('[data-testid="batch-action-bar"]') + .findAll('button').find((b) => b.text().includes('Ignore')) + await ignoreBatchBtn?.trigger('click') + await waitForUi() + + expect(mockCaptureStore.batchTriage).toHaveBeenCalledWith(['capture-1'], 'ignore') + expect(wrapper.find('[data-testid="batch-action-bar"]').exists()).toBe(false) + }) + + // ── Suggestion editing tests ── + + it('shows edit button for new items in detail view', async () => { + mockCaptureStore.fetchDetail.mockImplementationOnce(async (itemId: string) => { + mockCaptureStore.detailById[itemId] = { + id: itemId, + userId: 'user-1', + boardId: null, + status: 'New', + source: 'Typed', + textExcerpt: 'Editable text', + rawText: 'Full editable text', + createdAt: new Date().toISOString(), + processedAt: null, + retryCount: 0, + provenance: null, + } + }) + + const wrapper = mount(InboxView) + await waitForUi() + + await wrapper.get('[role="option"]').trigger('click') + await waitForUi() + + const editBtn = wrapper.find('[data-testid="suggestion-edit-btn"]') + expect(editBtn.exists()).toBe(true) + }) + + it('enters editing mode and saves updated text', async () => { + mockCaptureStore.fetchDetail.mockImplementationOnce(async (itemId: string) => { + mockCaptureStore.detailById[itemId] = { + id: itemId, + userId: 'user-1', + boardId: null, + status: 'New', + source: 'Typed', + textExcerpt: 'Editable text', + rawText: 'Full editable text', + createdAt: new Date().toISOString(), + processedAt: null, + retryCount: 0, + provenance: null, + } + }) + + const wrapper = mount(InboxView) + await waitForUi() + + await wrapper.get('[role="option"]').trigger('click') + await waitForUi() + + await wrapper.get('[data-testid="suggestion-edit-btn"]').trigger('click') + await waitForUi() + + const textarea = wrapper.find('[data-testid="suggestion-edit-textarea"]') + expect(textarea.exists()).toBe(true) + expect((textarea.element as HTMLTextAreaElement).value).toBe('Full editable text') + + await textarea.setValue('Updated capture text') + await wrapper.get('[data-testid="suggestion-save-btn"]').trigger('click') + await waitForUi() + + expect(mockCaptureStore.updateSuggestion).toHaveBeenCalledWith('capture-1', { + text: 'Updated capture text', + titleHint: null, + }) + }) + + it('cancels editing without saving', async () => { + mockCaptureStore.fetchDetail.mockImplementationOnce(async (itemId: string) => { + mockCaptureStore.detailById[itemId] = { + id: itemId, + userId: 'user-1', + boardId: null, + status: 'New', + source: 'Typed', + textExcerpt: 'Editable text', + rawText: 'Full editable text', + createdAt: new Date().toISOString(), + processedAt: null, + retryCount: 0, + provenance: null, + } + }) + + const wrapper = mount(InboxView) + await waitForUi() + + await wrapper.get('[role="option"]').trigger('click') + await waitForUi() + + await wrapper.get('[data-testid="suggestion-edit-btn"]').trigger('click') + await waitForUi() + + expect(wrapper.find('[data-testid="suggestion-edit-textarea"]').exists()).toBe(true) + + await wrapper.get('[data-testid="suggestion-cancel-btn"]').trigger('click') + await waitForUi() + + expect(wrapper.find('[data-testid="suggestion-edit-textarea"]').exists()).toBe(false) + expect(mockCaptureStore.updateSuggestion).not.toHaveBeenCalled() + }) }) diff --git a/frontend/taskdeck-web/src/types/capture.ts b/frontend/taskdeck-web/src/types/capture.ts index b0583552d..6dd4b9423 100644 --- a/frontend/taskdeck-web/src/types/capture.ts +++ b/frontend/taskdeck-web/src/types/capture.ts @@ -79,3 +79,29 @@ export interface CaptureTriageEnqueueResult { status: CaptureStatusValue alreadyTriaging: boolean } + +export type BatchTriageAction = 'triage' | 'ignore' | 'cancel' + +export interface BatchTriageItemAction { + itemId: string + action: BatchTriageAction +} + +export interface BatchTriageItemResult { + itemId: string + success: boolean + errorCode?: string | null + errorMessage?: string | null +} + +export interface BatchTriageResult { + total: number + succeeded: number + failed: number + results: BatchTriageItemResult[] +} + +export interface UpdateCaptureSuggestionDto { + text: string + titleHint?: string | null +} diff --git a/frontend/taskdeck-web/src/views/InboxView.vue b/frontend/taskdeck-web/src/views/InboxView.vue index 110652c0e..dd456134c 100644 --- a/frontend/taskdeck-web/src/views/InboxView.vue +++ b/frontend/taskdeck-web/src/views/InboxView.vue @@ -21,6 +21,79 @@ const activeItemIndex = ref(0) const showCaptureModal = ref(false) let stopTriagePolling: (() => void) | null = null +// Batch selection state +const selectedIds = ref>(new Set()) +const isEditingSuggestion = ref(false) +const editedText = ref('') +const editedTitleHint = ref('') + +const hasSelection = computed(() => selectedIds.value.size > 0) +const selectionCount = computed(() => selectedIds.value.size) +const allSelected = computed(() => + items.value.length > 0 && selectedIds.value.size === items.value.length +) + +function toggleItemSelection(itemId: string) { + const next = new Set(selectedIds.value) + if (next.has(itemId)) { + next.delete(itemId) + } else { + next.add(itemId) + } + selectedIds.value = next +} + +function toggleSelectAll() { + if (allSelected.value) { + selectedIds.value = new Set() + } else { + selectedIds.value = new Set(items.value.map((i) => i.id)) + } +} + +function clearSelection() { + selectedIds.value = new Set() +} + +async function batchAction(action: 'triage' | 'ignore' | 'cancel') { + if (selectedIds.value.size === 0) return + const ids = Array.from(selectedIds.value) + try { + await captureStore.batchTriage(ids, action) + clearSelection() + } catch { + // Store handles toast + } +} + +function startEditSuggestion() { + if (!selectedItem.value) return + editedText.value = selectedItem.value.rawText + editedTitleHint.value = '' + isEditingSuggestion.value = true +} + +function cancelEditSuggestion() { + isEditingSuggestion.value = false + editedText.value = '' + editedTitleHint.value = '' +} + +async function saveEditedSuggestion() { + if (!selectedItemId.value || !editedText.value.trim()) return + try { + await captureStore.updateSuggestion(selectedItemId.value, { + text: editedText.value.trim(), + titleHint: editedTitleHint.value.trim() || null, + }) + isEditingSuggestion.value = false + editedText.value = '' + editedTitleHint.value = '' + } catch { + // Store handles toast + } +} + function openCaptureModal() { showCaptureModal.value = true } @@ -381,6 +454,19 @@ function canMutateSelection(status: CaptureStatusValue | undefined): boolean { const canTriageSelection = canMutateSelection +function canEditSuggestion(status: CaptureStatusValue | undefined): boolean { + if (status === undefined) { + return false + } + + return status === 0 || + status === 'New' || + status === 2 || + status === 'Triaged' || + status === 6 || + status === 'Failed' +} + function triageButtonLabel(status: CaptureStatusValue | undefined): string { if (status === undefined) { return 'Start Triage' @@ -468,6 +554,11 @@ watch(selectedItemId, (itemId, _, onCleanup) => { stopTriagePolling = null } + // Reset editing state when switching items + isEditingSuggestion.value = false + editedText.value = '' + editedTitleHint.value = '' + if (!itemId) { return } @@ -528,10 +619,52 @@ onUnmounted(() => {
-

Items

+
+ +

Items

+
{{ items.length }}
+
+ + + + +
+
{ @click="openItemFromList(items[virtualRow.index]!, virtualRow.index)" >
+ {{ statusLabel(items[virtualRow.index]!.status) }} {{ sourceLabel(items[virtualRow.index]!.source) }}
@@ -647,7 +787,52 @@ onUnmounted(() => {
Loading detail...
-
{{ selectedItem.rawText }}
+