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
This commit is contained in:
Mattsson
2026-05-07 13:00:11 +02:00
committed by GitHub
parent a295b62bfb
commit dbe634d0aa
4 changed files with 674 additions and 8 deletions
+255 -8
View File
@@ -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, { label: string; icon: typeof ArrowLeftRig
match_transaction_invoice: { label: 'Fakturamatchning', icon: ArrowLeftRight, variant: 'secondary' },
}
// Terse per-type labels used in the bulk confirmation dialog list. Phrased so
// they read naturally under the heading "Genom att bekräfta utförs följande:".
const bulkActionDescriptions: Record<string, (count: number) => 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<string, string> = {
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<PendingOperation | null>(null)
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string, string> = {
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<string, number>()
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<string, number>()
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 (
<div className="space-y-6">
<PageHeader
@@ -305,6 +453,65 @@ export default function PendingOperationsPage() {
</TabsList>
</Tabs>
{showBulkControls && bulkEligible.length > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<Checkbox
id="select-all"
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
onCheckedChange={() => toggleSelectAll()}
aria-label="Markera alla"
/>
<label htmlFor="select-all" className="text-sm cursor-pointer">
{selectedCount > 0
? `${selectedCount} valda`
: `Markera alla (${bulkEligible.length})`}
</label>
</div>
{typeCounts.length > 0 && selectedCount === 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">Snabbval:</span>
{typeCounts.map(([type, count]) => {
const config = operationLabels[type] || { label: type }
return (
<Button
key={type}
size="sm"
variant="outline"
className="h-7 px-2 text-xs"
onClick={() => selectAllOfType(type)}
>
{config.label} ({count})
</Button>
)
})}
</div>
)}
<div className="ml-auto flex items-center gap-2">
{selectedCount > 0 && (
<Button
size="sm"
variant="ghost"
className="h-8 px-3 text-xs"
onClick={() => setSelectedIds(new Set())}
>
Avmarkera
</Button>
)}
<Button
size="sm"
className="h-8 px-3 text-xs"
disabled={selectedCount === 0 || isBulkCommitting}
onClick={() => setShowBulkDialog(true)}
>
Godkänn valda ({selectedCount})
</Button>
</div>
</div>
)}
{isLoading ? (
<Card>
<CardContent className="flex items-center justify-center py-16">
@@ -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 (
<Card
@@ -347,6 +556,18 @@ export default function PendingOperationsPage() {
className="flex items-start justify-between gap-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : op.id)}
>
{canBulkSelect && (
<div
className="flex items-center pt-0.5"
onClick={(e) => e.stopPropagation()}
>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelected(op.id)}
aria-label="Välj operation"
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge variant={config.variant}>{config.label}</Badge>
@@ -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 && <OperationPreview op={selectedOp} />}
</ConfirmationDialog>
{/* Bulk commit confirmation dialog */}
<ConfirmationDialog
open={showBulkDialog}
onOpenChange={setShowBulkDialog}
title={`Godkänn ${selectedCount} operationer?`}
warningText=""
confirmLabel={`Godkänn ${selectedCount}`}
isSubmitting={isBulkCommitting}
onConfirm={() => handleBulkCommit(Array.from(selectedIds))}
>
<div className="space-y-3 text-sm">
<p>Genom att bekräfta utförs följande:</p>
<ul className="space-y-1 rounded-md border bg-muted/30 px-3 py-2">
{selectedBreakdown.map(({ type, count }) => (
<li key={type} className="flex justify-between font-mono tabular-nums">
<span className="font-sans">{bulkActionLabel(type, count)}</span>
<span className="text-muted-foreground">{count}</span>
</li>
))}
</ul>
<p className="text-xs text-muted-foreground">
Operationerna körs i ordning. Misslyckade hoppas över och rapporteras efteråt.
</p>
</div>
</ConfirmationDialog>
{/* Reject confirmation dialog */}
<DestructiveConfirmDialog {...dialogProps} />
</div>
@@ -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<string, unknown> = {}) {
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)
})
})
@@ -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 } })
}
+4
View File
@@ -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
// ============================================================