From dbe634d0aa28031791e15511c93cefe2f7a3bfad Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Thu, 7 May 2026 13:00:11 +0200 Subject: [PATCH] Mcp/bulk approval (#412) * feat(pending-operations): implement bulk commit functionality with UI support * feat(pending-operations): add bulk action labels and warnings for confirmation dialogs * feat(pending-operations): enhance bulk commit functionality with rejection handling and summary updates --- app/(dashboard)/pending/page.tsx | 263 +++++++++++++- .../bulk-commit/__tests__/route.test.ts | 326 ++++++++++++++++++ .../pending-operations/bulk-commit/route.ts | 89 +++++ lib/api/schemas.ts | 4 + 4 files changed, 674 insertions(+), 8 deletions(-) create mode 100644 app/api/pending-operations/bulk-commit/__tests__/route.test.ts create mode 100644 app/api/pending-operations/bulk-commit/route.ts diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 6f03f0d5..5db62567 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -1,10 +1,11 @@ 'use client' -import { useState, useEffect, useCallback, Fragment } from 'react' +import { useState, useEffect, useCallback, useMemo, Fragment } from 'react' import { PageHeader } from '@/components/ui/page-header' import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { ConfirmationDialog } from '@/components/ui/confirmation-dialog' import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog' @@ -33,6 +34,50 @@ const operationLabels: Record string> = { + create_transaction: (n) => + n === 1 ? 'En transaktion skapas.' : `${n} transaktioner skapas.`, + create_customer: (n) => (n === 1 ? 'En ny kund skapas.' : `${n} nya kunder skapas.`), + create_invoice: (n) => + n === 1 ? 'Ett fakturautkast skapas (skickas inte).' : `${n} fakturautkast skapas (skickas inte).`, + categorize_transaction: (n) => + n === 1 ? 'En transaktion kategoriseras och bokförs.' : `${n} transaktioner kategoriseras och bokförs.`, + match_transaction_invoice: (n) => + n === 1 ? 'En transaktion matchas mot en faktura.' : `${n} transaktioner matchas mot fakturor.`, + attach_document_to_transaction: (n) => + n === 1 ? 'Ett dokument bifogas en transaktion.' : `${n} dokument bifogas transaktioner.`, + uncategorize_transaction: (n) => + n === 1 ? 'En kategorisering tas bort.' : `${n} kategoriseringar tas bort.`, +} + +function bulkActionLabel(operationType: string, count: number): string { + const fn = bulkActionDescriptions[operationType] + if (fn) return fn(count) + const fallback = operationLabels[operationType]?.label ?? operationType + return `${count} × ${fallback}` +} + +// Full-sentence warning for the single-op confirmation dialog. Phrased so the +// user sees the consequence of clicking Godkänn, not a generic verifikation note. +const singleActionWarnings: Record = { + create_transaction: 'Genom att klicka godkänn så skapar du en transaktion.', + create_customer: 'Genom att klicka godkänn så skapar du en kund.', + create_invoice: 'Genom att klicka godkänn så skapas ett fakturautkast (det skickas inte).', + categorize_transaction: 'Genom att klicka godkänn så kategoriseras transaktionen och en verifikation skapas.', + match_transaction_invoice: 'Genom att klicka godkänn så matchas transaktionen mot fakturan.', + attach_document_to_transaction: 'Genom att klicka godkänn så bifogas dokumentet till transaktionen.', + uncategorize_transaction: 'Genom att klicka godkänn så tas kategoriseringen bort.', + send_invoice: 'Genom att klicka godkänn så skickas fakturan till kunden.', + mark_invoice_paid: 'Genom att klicka godkänn så bokförs en betalning på fakturan.', + mark_invoice_sent: 'Genom att klicka godkänn så märks fakturan som skickad och en verifikation skapas.', +} + +function singleActionWarning(operationType: string): string { + return singleActionWarnings[operationType] ?? '' +} + function formatRelativeTime(dateStr: string): string { const now = new Date() const date = new Date(dateStr) @@ -205,6 +250,9 @@ export default function PendingOperationsPage() { const [selectedOp, setSelectedOp] = useState(null) const [showCommitDialog, setShowCommitDialog] = useState(false) const [isCommitting, setIsCommitting] = useState(false) + const [selectedIds, setSelectedIds] = useState>(new Set()) + const [showBulkDialog, setShowBulkDialog] = useState(false) + const [isBulkCommitting, setIsBulkCommitting] = useState(false) const { toast } = useToast() const { dialogProps, confirm } = useDestructiveConfirm() @@ -224,6 +272,11 @@ export default function PendingOperationsPage() { fetchOperations() }, [fetchOperations]) + // Clear selection when filters/tab change + useEffect(() => { + setSelectedIds(new Set()) + }, [activeTab, sourceFilter]) + async function handleCommit() { if (!selectedOp) return setIsCommitting(true) @@ -245,6 +298,51 @@ export default function PendingOperationsPage() { setIsCommitting(false) } + async function handleBulkCommit(ids: string[]) { + if (ids.length === 0) return + setIsBulkCommitting(true) + try { + const res = await fetch('/api/pending-operations/bulk-commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json.error || 'Misslyckades') + + const summary = json.data?.summary as + | { committed: number; failed: number; skipped: number; rejected: number } + | undefined + + if (summary) { + const parts: string[] = [] + if (summary.committed > 0) parts.push(`${summary.committed} godkända`) + if (summary.failed > 0) parts.push(`${summary.failed} misslyckades`) + if (summary.rejected > 0) parts.push(`${summary.rejected} avvisade`) + if (summary.skipped > 0) parts.push(`${summary.skipped} hoppades över`) + + toast({ + title: summary.failed > 0 ? 'Klart med fel' : 'Godkänt', + description: parts.join(', '), + variant: summary.failed > 0 ? 'destructive' : 'default', + }) + } else { + toast({ title: 'Godkänt' }) + } + + setShowBulkDialog(false) + setSelectedIds(new Set()) + fetchOperations() + } catch (err) { + toast({ + title: 'Misslyckades', + description: err instanceof Error ? err.message : 'Okänt fel', + variant: 'destructive', + }) + } + setIsBulkCommitting(false) + } + async function handleReject(op: PendingOperation) { const ok = await confirm({ title: 'Avvisa operation?', @@ -264,12 +362,6 @@ export default function PendingOperationsPage() { } } - const warningForType: Record = { - categorize_transaction: '', - create_customer: '', - create_invoice: '', - } - const filteredOperations = operations.filter((op) => { switch (sourceFilter) { case 'agent': @@ -282,6 +374,62 @@ export default function PendingOperationsPage() { } }) + const showBulkControls = activeTab === 'pending' + const bulkEligible = useMemo( + () => filteredOperations.filter((op) => op.status === 'pending' && op.risk_level !== 'high'), + [filteredOperations] + ) + const bulkEligibleIds = useMemo(() => bulkEligible.map((op) => op.id), [bulkEligible]) + const allSelected = + bulkEligibleIds.length > 0 && bulkEligibleIds.every((id) => selectedIds.has(id)) + const someSelected = bulkEligibleIds.some((id) => selectedIds.has(id)) + + function toggleSelected(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function toggleSelectAll() { + if (allSelected) { + setSelectedIds(new Set()) + } else { + setSelectedIds(new Set(bulkEligibleIds)) + } + } + + // "Approve all of this type" — find ops with the same operation_type that are bulk-eligible + function selectAllOfType(operationType: string) { + const ids = bulkEligible + .filter((op) => op.operation_type === operationType) + .map((op) => op.id) + setSelectedIds(new Set(ids)) + } + + // Group counts for type-quick-action buttons (only show if 2+ of same type pending) + const typeCounts = useMemo(() => { + const counts = new Map() + for (const op of bulkEligible) { + counts.set(op.operation_type, (counts.get(op.operation_type) ?? 0) + 1) + } + return Array.from(counts.entries()).filter(([, count]) => count >= 2) + }, [bulkEligible]) + + const selectedCount = selectedIds.size + + const selectedBreakdown = useMemo(() => { + const counts = new Map() + for (const op of bulkEligible) { + if (selectedIds.has(op.id)) { + counts.set(op.operation_type, (counts.get(op.operation_type) ?? 0) + 1) + } + } + return Array.from(counts.entries()).map(([type, count]) => ({ type, count })) + }, [bulkEligible, selectedIds]) + return (
+ {showBulkControls && bulkEligible.length > 0 && ( +
+
+ toggleSelectAll()} + aria-label="Markera alla" + /> + +
+ + {typeCounts.length > 0 && selectedCount === 0 && ( +
+ Snabbval: + {typeCounts.map(([type, count]) => { + const config = operationLabels[type] || { label: type } + return ( + + ) + })} +
+ )} + +
+ {selectedCount > 0 && ( + + )} + +
+
+ )} + {isLoading ? ( @@ -336,6 +543,8 @@ export default function PendingOperationsPage() { {filteredOperations.map((op) => { const config = operationLabels[op.operation_type] || { label: op.operation_type, icon: ClipboardCheck, variant: 'default' as const } const isExpanded = expandedId === op.id + const canBulkSelect = showBulkControls && op.status === 'pending' && op.risk_level !== 'high' + const isSelected = selectedIds.has(op.id) return ( setExpandedId(isExpanded ? null : op.id)} > + {canBulkSelect && ( +
e.stopPropagation()} + > + toggleSelected(op.id)} + aria-label="Välj operation" + /> +
+ )}
{config.label} @@ -428,7 +649,7 @@ export default function PendingOperationsPage() { open={showCommitDialog} onOpenChange={setShowCommitDialog} title={selectedOp?.title || 'Godkänn operation'} - warningText={selectedOp ? warningForType[selectedOp.operation_type] : ''} + warningText={selectedOp ? singleActionWarning(selectedOp.operation_type) : ''} confirmLabel="Godkänn" isSubmitting={isCommitting} onConfirm={handleCommit} @@ -436,6 +657,32 @@ export default function PendingOperationsPage() { {selectedOp && } + {/* Bulk commit confirmation dialog */} + handleBulkCommit(Array.from(selectedIds))} + > +
+

Genom att bekräfta utförs följande:

+
    + {selectedBreakdown.map(({ type, count }) => ( +
  • + {bulkActionLabel(type, count)} + {count} +
  • + ))} +
+

+ Operationerna körs i ordning. Misslyckade hoppas över och rapporteras efteråt. +

+
+
+ {/* Reject confirmation dialog */}
diff --git a/app/api/pending-operations/bulk-commit/__tests__/route.test.ts b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts new file mode 100644 index 00000000..ec5c73ed --- /dev/null +++ b/app/api/pending-operations/bulk-commit/__tests__/route.test.ts @@ -0,0 +1,326 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' +import { eventBus } from '@/lib/events/bus' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() +vi.mock('@/lib/supabase/server', () => ({ + createClient: () => Promise.resolve(mockSupabase), +})) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: vi.fn().mockResolvedValue({ ok: true }), +})) + +const mockCommit = vi.fn() +vi.mock('@/lib/pending-operations/commit', () => ({ + commitPendingOperation: (...args: unknown[]) => mockCommit(...args), +})) + +import { POST } from '../route' + +const VALID_ID_1 = '11111111-1111-4111-8111-111111111111' +const VALID_ID_2 = '22222222-2222-4222-8222-222222222222' +const VALID_ID_3 = '33333333-3333-4333-8333-333333333333' +const VALID_ID_4 = '44444444-4444-4444-8444-444444444444' +const VALID_ID_5 = '55555555-5555-4555-8555-555555555555' + +function makeOp(overrides: Record = {}) { + return { + id: VALID_ID_1, + company_id: 'company-1', + user_id: 'user-1', + operation_type: 'categorize_transaction', + status: 'pending', + risk_level: 'low', + title: 'Kategorisera test', + params: {}, + preview_data: {}, + ...overrides, + } +} + +describe('POST /api/pending-operations/bulk-commit', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() + reset() + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } }) + }) + + it('returns 401 when not authenticated', async () => { + mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(401) + expect(body).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when ids array is empty', async () => { + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [] }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(mockCommit).not.toHaveBeenCalled() + }) + + it('returns 400 when ids contain non-UUID values', async () => { + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: ['not-a-uuid'] }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(mockCommit).not.toHaveBeenCalled() + }) + + it('returns 400 when ids exceed 100 items', async () => { + const ids = Array.from({ length: 101 }, (_, i) => { + const hex = i.toString(16).padStart(4, '0') + return `${hex}${hex}${hex}${hex}-${hex}${hex}-4${hex.slice(1)}-8${hex.slice(1)}-${hex}${hex}${hex}${hex}${hex}${hex}` + }) + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + expect(mockCommit).not.toHaveBeenCalled() + }) + + it('returns 500 when fetching pending operations fails', async () => { + enqueue({ data: null, error: { message: 'db connection lost' } }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + expect(body.error).toBe('db connection lost') + }) + + it('reports per-item not-found as failed without calling commit', async () => { + enqueue({ data: [] }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { + results: Array<{ id: string; status: string; error?: string }> + summary: { total: number; committed: number; failed: number; skipped: number; rejected: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'failed', error: 'Operation not found' }, + ]) + expect(body.data.summary).toEqual({ + total: 1, + committed: 0, + failed: 1, + skipped: 0, + rejected: 0, + }) + expect(mockCommit).not.toHaveBeenCalled() + }) + + it('skips non-pending operations and high-risk operations', async () => { + enqueue({ + data: [ + makeOp({ id: VALID_ID_1, status: 'committed' }), + makeOp({ id: VALID_ID_2, status: 'pending', risk_level: 'high' }), + ], + }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1, VALID_ID_2] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { + results: Array<{ id: string; status: string; error?: string }> + summary: { total: number; committed: number; failed: number; skipped: number; rejected: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'skipped', error: 'Already committed' }, + { + id: VALID_ID_2, + status: 'skipped', + error: 'Hög risk — kräver individuellt godkännande', + }, + ]) + expect(body.data.summary).toEqual({ + total: 2, + committed: 0, + failed: 0, + skipped: 2, + rejected: 0, + }) + expect(mockCommit).not.toHaveBeenCalled() + }) + + it('commits pending operations and aggregates summary on the happy path', async () => { + enqueue({ + data: [ + makeOp({ id: VALID_ID_1 }), + makeOp({ id: VALID_ID_2 }), + ], + }) + + mockCommit.mockResolvedValue({ status: 'committed', data: {} }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1, VALID_ID_2] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { + results: Array<{ id: string; status: string }> + summary: { total: number; committed: number; failed: number; skipped: number; rejected: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'committed' }, + { id: VALID_ID_2, status: 'committed' }, + ]) + expect(body.data.summary).toEqual({ + total: 2, + committed: 2, + failed: 0, + skipped: 0, + rejected: 0, + }) + expect(mockCommit).toHaveBeenCalledTimes(2) + expect(mockCommit).toHaveBeenCalledWith( + mockSupabase, + 'user-1', + 'company-1', + expect.objectContaining({ id: VALID_ID_1 }), + { userEmail: 'test@test.se' } + ) + }) + + it('routes auto_rejected results into the rejected bucket', async () => { + enqueue({ data: [makeOp({ id: VALID_ID_1 })] }) + + mockCommit.mockResolvedValue({ + status: 'rejected', + auto_rejected: true, + error: 'Resource already deleted', + http_status: 409, + }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { + results: Array<{ id: string; status: string; error?: string }> + summary: { total: number; committed: number; failed: number; skipped: number; rejected: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'rejected', error: 'Resource already deleted' }, + ]) + expect(body.data.summary).toEqual({ + total: 1, + committed: 0, + failed: 0, + skipped: 0, + rejected: 1, + }) + }) + + it('reports commit failures as failed and aggregates a mixed summary', async () => { + enqueue({ + data: [ + makeOp({ id: VALID_ID_1 }), + makeOp({ id: VALID_ID_2 }), + makeOp({ id: VALID_ID_3, status: 'rejected' }), + makeOp({ id: VALID_ID_4 }), + ], + }) + + mockCommit + .mockResolvedValueOnce({ status: 'committed', data: {} }) + .mockResolvedValueOnce({ status: 'failed', error: 'boom', http_status: 500 }) + .mockResolvedValueOnce({ + status: 'rejected', + auto_rejected: true, + error: 'gone', + http_status: 404, + }) + + const request = createMockRequest('/api/pending-operations/bulk-commit', { + method: 'POST', + body: { ids: [VALID_ID_1, VALID_ID_2, VALID_ID_3, VALID_ID_4, VALID_ID_5] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + data: { + results: Array<{ id: string; status: string; error?: string }> + summary: { total: number; committed: number; failed: number; skipped: number; rejected: number } + } + }>(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'committed' }, + { id: VALID_ID_2, status: 'failed', error: 'boom' }, + { id: VALID_ID_3, status: 'skipped', error: 'Already rejected' }, + { id: VALID_ID_4, status: 'rejected', error: 'gone' }, + { id: VALID_ID_5, status: 'failed', error: 'Operation not found' }, + ]) + expect(body.data.summary).toEqual({ + total: 5, + committed: 1, + failed: 2, + skipped: 1, + rejected: 1, + }) + expect(mockCommit).toHaveBeenCalledTimes(3) + }) +}) diff --git a/app/api/pending-operations/bulk-commit/route.ts b/app/api/pending-operations/bulk-commit/route.ts new file mode 100644 index 00000000..97b18077 --- /dev/null +++ b/app/api/pending-operations/bulk-commit/route.ts @@ -0,0 +1,89 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { ensureInitialized } from '@/lib/init' +import { requireCompanyId } from '@/lib/company/context' +import { requireWritePermission } from '@/lib/auth/require-write' +import { validateBody } from '@/lib/api/validate' +import { PendingOperationsBulkSchema } from '@/lib/api/schemas' +import { commitPendingOperation } from '@/lib/pending-operations/commit' +import type { PendingOperation } from '@/types' + +ensureInitialized() + +interface BulkCommitItemResult { + id: string + status: 'committed' | 'failed' | 'skipped' | 'rejected' + error?: string +} + +export async function POST(request: Request) { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const writeCheck = await requireWritePermission(supabase, user.id) + if (!writeCheck.ok) return writeCheck.response + + const validated = await validateBody(request, PendingOperationsBulkSchema) + if (!validated.success) return validated.response + const { ids } = validated.data + + const companyId = await requireCompanyId(supabase, user.id) + + const { data: ops, error: fetchError } = await supabase + .from('pending_operations') + .select('*') + .in('id', ids) + .eq('company_id', companyId) + + if (fetchError) { + return NextResponse.json({ error: fetchError.message }, { status: 500 }) + } + + const opsById = new Map((ops ?? []).map((op) => [op.id, op as PendingOperation])) + const results: BulkCommitItemResult[] = [] + + for (const id of ids) { + const op = opsById.get(id) + if (!op) { + results.push({ id, status: 'failed', error: 'Operation not found' }) + continue + } + if (op.status !== 'pending') { + results.push({ id, status: 'skipped', error: `Already ${op.status}` }) + continue + } + if (op.risk_level === 'high') { + results.push({ + id, + status: 'skipped', + error: 'Hög risk — kräver individuellt godkännande', + }) + continue + } + + const result = await commitPendingOperation(supabase, user.id, companyId, op, { + userEmail: user.email, + }) + if (result.status === 'committed') { + results.push({ id, status: 'committed' }) + } else if (result.status === 'rejected' && result.auto_rejected) { + results.push({ id, status: 'rejected', error: result.error ?? 'Avvisad' }) + } else { + results.push({ id, status: 'failed', error: result.error ?? 'Misslyckades' }) + } + } + + const summary = { + total: results.length, + committed: results.filter((r) => r.status === 'committed').length, + failed: results.filter((r) => r.status === 'failed').length, + skipped: results.filter((r) => r.status === 'skipped').length, + rejected: results.filter((r) => r.status === 'rejected').length, + } + + return NextResponse.json({ data: { results, summary } }) +} diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index e66b76d0..23158f70 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -573,6 +573,10 @@ export const PendingOperationsQuerySchema = z.object({ offset: z.coerce.number().int().nonnegative().default(0), }) +export const PendingOperationsBulkSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(100), +}) + // ============================================================ // Voucher gap schemas // ============================================================