From cb23df252bcfbc0d9126a4e95d1205a20f27b15c Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 22:50:46 +0100 Subject: [PATCH 1/8] Add batch triage and suggestion editing backend endpoints Add BatchTriageAsync and UpdateSuggestionAsync to ICaptureService/CaptureService. Add POST /api/capture/items/batch-triage with 207 partial-success semantics. Add PUT /api/capture/items/{id}/suggestion for editing capture text before triage. Batch operations process each item independently with per-item error reporting. Closes #220 (backend portion). --- .../Controllers/CaptureController.cs | 32 +++++ .../Taskdeck.Application/DTOs/CaptureDtos.cs | 39 ++++++ .../Services/CaptureService.cs | 131 ++++++++++++++++++ .../Services/ICaptureService.cs | 11 ++ 4 files changed, 213 insertions(+) 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..d6aa3f49e 100644 --- a/backend/src/Taskdeck.Application/Services/CaptureService.cs +++ b/backend/src/Taskdeck.Application/Services/CaptureService.cs @@ -276,6 +276,137 @@ 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"); + + 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, + null, + 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); } From 1cad747355839f59cb9f1b230b25e7f14d51136f Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Mon, 30 Mar 2026 23:04:04 +0100 Subject: [PATCH 2/8] Add batch triage UI and suggestion editing to Inbox frontend Add multi-select checkboxes with select-all toggle to inbox item list. Add batch action bar for triage/ignore/cancel selected items. Add inline suggestion text editing before triage with save/cancel. Add batchTriage and updateSuggestion to captureStore and captureApi. Add BatchTriageResult and UpdateCaptureSuggestionDto types. --- frontend/taskdeck-web/src/api/captureApi.ts | 14 + .../taskdeck-web/src/store/captureStore.ts | 64 ++++- frontend/taskdeck-web/src/types/capture.ts | 26 ++ frontend/taskdeck-web/src/views/InboxView.vue | 262 +++++++++++++++++- 4 files changed, 363 insertions(+), 3 deletions(-) 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/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..dde1797f4 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 } @@ -468,6 +541,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 +606,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 +774,52 @@ onUnmounted(() => {
Loading detail...
-
{{ selectedItem.rawText }}
+