diff --git a/DECISIONS.md b/DECISIONS.md index d6ad062f..c60165ba 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -243,3 +243,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-20] Follow-up (not done): delete_last_voucher RPC can delete the closing storno and flip the closing entry back to posted while closing_entry_id is already NULL, leaving an orphaned live closing entry; should refuse to delete stornos of year_end entries. [2026-07-20] Onboarding backdrop reuses marketing-site halftone webp assets copied into public/illustrations/ (not hotlinked, not regenerated): keeps app self-contained and signup->app visually continuous; decorative art uses plain (physics sizes by %, next/image adds nothing for 1-35KB webp). [2026-07-20] Removed Dependabot entirely (.github/dependabot.yml deleted, open PRs #1083/#1082/#1012 closed) on Emil's request: weekly grouped bumps were noise and the #884 bedrock-sdk incident showed the risk profile. Dependency bumps are now manual/deliberate; the bedrock-sdk 0.29.1 exact pin stays enforced by scripts/checks/no-new-antipatterns.mjs. +[2026-07-20] Bulk reject (/pending) reuses the exact bulk-approve selection set: high-risk and locked-period ops stay one-by-one for reject too, keeping one selection model instead of per-action eligibility. Server-side bulk-reject has NO high-risk skip (rejecting posts nothing), so the API stays permissive; the UI is the gate. diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index be357680..6828b148 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -666,7 +666,8 @@ export default function PendingOperationsPage() { const [isBulkCommitting, setIsBulkCommitting] = useState(false) // Reject dialog state: separate from the generic destructive-confirm so we // can ask for a category + free-text reason that feeds back to the agent. - const [rejectOp, setRejectOp] = useState(null) + // 'bulk' targets the current checkbox selection instead of a single op. + const [rejectTarget, setRejectTarget] = useState(null) const [rejectCategory, setRejectCategory] = useState('') const [rejectReason, setRejectReason] = useState('') const [isRejecting, setIsRejecting] = useState(false) @@ -828,35 +829,63 @@ export default function PendingOperationsPage() { setIsBulkCommitting(false) } - function openRejectDialog(op: PendingOperation) { - setRejectOp(op) + function openRejectDialog(target: PendingOperation | 'bulk') { + setRejectTarget(target) setRejectCategory('') setRejectReason('') } async function handleReject() { - if (!rejectOp) return + if (!rejectTarget) return setIsRejecting(true) try { - const body = - rejectCategory || rejectReason.trim() - ? { - ...(rejectCategory ? { rejection_category: rejectCategory } : {}), - ...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}), - } - : undefined - const res = await fetch(`/api/pending-operations/${rejectOp.id}/reject`, { - method: 'POST', - ...(body - ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } - : {}), - }) - if (!res.ok) { - const json = await res.json().catch(() => ({})) - throw new Error(getErrorMessage(json, { statusCode: res.status })) + const feedback = { + ...(rejectCategory ? { rejection_category: rejectCategory } : {}), + ...(rejectReason.trim() ? { rejection_reason: rejectReason.trim() } : {}), } - toast({ title: 'Avvisad', description: rejectOp.title }) - setRejectOp(null) + + if (rejectTarget === 'bulk') { + const res = await fetch('/api/pending-operations/bulk-reject', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from(selectedIds), ...feedback }), + }) + const json = await res.json().catch(() => ({})) + if (!res.ok) throw new Error(getErrorMessage(json, { statusCode: res.status })) + + const summary = json.data?.summary as + | { rejected: number; skipped: number; failed: number } + | undefined + if (summary) { + const parts: string[] = [] + if (summary.rejected > 0) parts.push(`${summary.rejected} avvisade`) + if (summary.skipped > 0) parts.push(`${summary.skipped} hoppades över`) + if (summary.failed > 0) parts.push(`${summary.failed} misslyckades`) + toast({ + title: summary.failed > 0 ? 'Klart med fel' : 'Avvisade', + description: parts.join(', '), + variant: summary.failed > 0 ? 'destructive' : 'default', + }) + } else { + toast({ title: 'Avvisade' }) + } + setSelectedIds(new Set()) + } else { + const hasFeedback = Object.keys(feedback).length > 0 + const res = await fetch(`/api/pending-operations/${rejectTarget.id}/reject`, { + method: 'POST', + ...(hasFeedback + ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(feedback) } + : {}), + }) + if (!res.ok) { + const json = await res.json().catch(() => ({})) + throw new Error(getErrorMessage(json, { statusCode: res.status })) + } + toast({ title: 'Avvisad', description: rejectTarget.title }) + } + + setRejectTarget(null) fetchOperations() fetchAllCounts() } catch (err) { @@ -1094,8 +1123,19 @@ export default function PendingOperationsPage() { )} + diff --git a/app/api/pending-operations/bulk-reject/__tests__/route.test.ts b/app/api/pending-operations/bulk-reject/__tests__/route.test.ts new file mode 100644 index 00000000..f385e855 --- /dev/null +++ b/app/api/pending-operations/bulk-reject/__tests__/route.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { + createMockRequest, + parseJsonResponse, + createQueuedMockSupabase, +} from '@/tests/helpers' + +const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) + +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), +})) + +const requireWriteMock = vi.fn() +vi.mock('@/lib/auth/require-write', () => ({ + requireWritePermission: (...args: unknown[]) => requireWriteMock(...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' + +type ResultBody = { + data: { + results: Array<{ id: string; status: string; error?: string }> + summary: { total: number; rejected: number; skipped: number; failed: number } + } +} + +describe('POST /api/pending-operations/bulk-reject', () => { + const mockUser = { id: 'user-1', email: 'test@test.se' } + + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: mockUser, supabase: mockSupabase, error: null }) + requireWriteMock.mockResolvedValue({ ok: true }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + 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 403 for a viewer without write permission', async () => { + requireWriteMock.mockResolvedValue({ + ok: false, + response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }), + }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(403) + }) + + it('returns 400 when ids array is empty', async () => { + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [] }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('returns 400 for an unknown rejection_category', async () => { + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [VALID_ID_1], rejection_category: 'not_a_category' }, + }) + const response = await POST(request) + const { status } = await parseJsonResponse(response) + + expect(status).toBe(400) + }) + + it('returns 500 when fetching pending operations fails', async () => { + enqueue({ data: null, error: { message: 'db connection lost' } }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ error: string }>(response) + + expect(status).toBe(500) + // Raw Supabase messages never reach the response field. + expect(body.error).toBe('Åtgärderna kunde inte hämtas. Försök igen.') + }) + + it('reports per-item not-found as failed without running an update', async () => { + // Only the fetch result is enqueued: with no pending ids the route must + // not issue the UPDATE query at all. + enqueue({ data: [] }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'failed', error: 'Åtgärden kunde inte hittas.' }, + ]) + expect(body.data.summary).toEqual({ total: 1, rejected: 0, skipped: 0, failed: 1 }) + }) + + it('skips already-handled operations with a Swedish status label', async () => { + enqueue({ + data: [ + { id: VALID_ID_1, status: 'committed' }, + { id: VALID_ID_2, status: 'rejected' }, + ], + }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [VALID_ID_1, VALID_ID_2] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'skipped', error: 'Redan hanterad (godkänd)' }, + { id: VALID_ID_2, status: 'skipped', error: 'Redan hanterad (avvisad)' }, + ]) + expect(body.data.summary).toEqual({ total: 2, rejected: 0, skipped: 2, failed: 0 }) + }) + + it('rejects pending operations and aggregates a mixed summary', async () => { + enqueue({ + data: [ + { id: VALID_ID_1, status: 'pending' }, + { id: VALID_ID_2, status: 'pending' }, + { id: VALID_ID_3, status: 'committed' }, + ], + }) + // The guarded UPDATE returns the rows it actually flipped. + enqueue({ data: [{ id: VALID_ID_1 }, { id: VALID_ID_2 }] }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { + ids: [VALID_ID_1, VALID_ID_2, VALID_ID_3], + rejection_category: 'duplicate', + rejection_reason: 'Samma underlag stagat två gånger', + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'rejected' }, + { id: VALID_ID_2, status: 'rejected' }, + { id: VALID_ID_3, status: 'skipped', error: 'Redan hanterad (godkänd)' }, + ]) + expect(body.data.summary).toEqual({ total: 3, rejected: 2, skipped: 1, failed: 0 }) + }) + + it('returns 500 when the update fails', async () => { + enqueue({ data: [{ id: VALID_ID_1, status: 'pending' }] }) + enqueue({ data: null, error: { message: 'update exploded' } }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + 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('Operationerna kunde inte avvisas. Försök igen.') + }) + + it('marks a row resolved between read and write as skipped', async () => { + enqueue({ data: [{ id: VALID_ID_1, status: 'pending' }] }) + // Guarded UPDATE found nothing still pending. + enqueue({ data: [] }) + + const request = createMockRequest('/api/pending-operations/bulk-reject', { + method: 'POST', + body: { ids: [VALID_ID_1] }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse(response) + + expect(status).toBe(200) + expect(body.data.results).toEqual([ + { id: VALID_ID_1, status: 'skipped', error: 'Hanterades i en annan session.' }, + ]) + expect(body.data.summary).toEqual({ total: 1, rejected: 0, skipped: 1, failed: 0 }) + }) +}) diff --git a/app/api/pending-operations/bulk-reject/route.ts b/app/api/pending-operations/bulk-reject/route.ts new file mode 100644 index 00000000..f1a556bc --- /dev/null +++ b/app/api/pending-operations/bulk-reject/route.ts @@ -0,0 +1,118 @@ +import { NextResponse } from 'next/server' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateBody } from '@/lib/api/validate' +import { PendingOperationsBulkRejectSchema } from '@/lib/api/schemas' + +interface BulkRejectItemResult { + id: string + status: 'rejected' | 'failed' | 'skipped' + error?: string +} + +// Swedish display labels for already-handled operations; the raw enum values +// are English and must not reach the user-visible per-item error strings. +const STATUS_LABELS_SV: Record = { + committing: 'godkänns just nu', + committed: 'godkänd', + rejected: 'avvisad', + expired: 'utgången', +} + +/** + * POST /api/pending-operations/bulk-reject + * + * Reject up to 100 pending operations in one call. Optionally accepts + * `rejection_category` and `rejection_reason`, applied to every rejected row + * so agents can learn from "no" via gnubok_get_recent_rejections, same as the + * single reject route. Unlike bulk-commit there is no high-risk skip here: + * rejecting posts nothing to the ledger, so it is safe at any risk tier. + */ +export const POST = withRouteContext( + 'pending_operation.bulk_reject', + async (request, { supabase, companyId, log }) => { + const validated = await validateBody(request, PendingOperationsBulkRejectSchema) + if (!validated.success) return validated.response + const { ids, rejection_category } = validated.data + const rejectionReason = validated.data.rejection_reason?.trim() || undefined + + const { data: ops, error: fetchError } = await supabase + .from('pending_operations') + .select('id, status') + .in('id', ids) + .eq('company_id', companyId) + + if (fetchError) { + log.error('failed to fetch pending operations for bulk reject', fetchError) + return NextResponse.json( + { error: 'Åtgärderna kunde inte hämtas. Försök igen.' }, + { status: 500 } + ) + } + + const opsById = new Map( + ((ops ?? []) as Array<{ id: string; status: string }>).map((op) => [op.id, op]) + ) + const pendingIds = ids.filter((id) => opsById.get(id)?.status === 'pending') + + // One guarded UPDATE for the whole batch: the status filter keeps a row + // that was committed in a parallel session from being flipped to rejected + // underneath that approver. Rows the guard filtered out are reported as + // skipped below instead of failing the request. + let rejectedIds = new Set() + if (pendingIds.length > 0) { + const { data: updated, error: updateError } = await supabase + .from('pending_operations') + .update({ + status: 'rejected', + resolved_at: new Date().toISOString(), + ...(rejection_category ? { rejection_category } : {}), + ...(rejectionReason ? { rejection_reason: rejectionReason } : {}), + }) + .in('id', pendingIds) + .eq('company_id', companyId) + .eq('status', 'pending') + .select('id') + + if (updateError) { + log.error('bulk reject update failed', updateError) + return NextResponse.json( + { error: 'Operationerna kunde inte avvisas. Försök igen.' }, + { status: 500 } + ) + } + rejectedIds = new Set( + ((updated ?? []) as Array<{ id: string }>).map((row) => row.id) + ) + } + + const results: BulkRejectItemResult[] = ids.map((id) => { + const op = opsById.get(id) + if (!op) { + return { id, status: 'failed' as const, error: 'Åtgärden kunde inte hittas.' } + } + if (rejectedIds.has(id)) { + return { id, status: 'rejected' as const } + } + if (op.status !== 'pending') { + return { + id, + status: 'skipped' as const, + error: `Redan hanterad (${STATUS_LABELS_SV[op.status] ?? op.status})`, + } + } + // Fetched as pending but not updated: resolved by another session + // between our read and the guarded write. + return { id, status: 'skipped' as const, error: 'Hanterades i en annan session.' } + }) + + const summary = { + total: results.length, + rejected: results.filter((r) => r.status === 'rejected').length, + skipped: results.filter((r) => r.status === 'skipped').length, + failed: results.filter((r) => r.status === 'failed').length, + } + + return NextResponse.json({ data: { results, summary } }) + }, + { requireWrite: true }, +) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index a778b423..c2834864 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -1868,6 +1868,16 @@ export const PendingOperationsBulkSchema = z.object({ ids: z.array(z.string().uuid()).min(1).max(100), }) +// Bulk reject: same id list plus the optional category/reason pair from the +// single reject route. When provided they are applied to every rejected row. +export const PendingOperationsBulkRejectSchema = z.object({ + ids: z.array(z.string().uuid()).min(1).max(100), + rejection_category: z + .enum(['wrong_category', 'wrong_amount', 'duplicate', 'wrong_period', 'other']) + .optional(), + rejection_reason: z.string().max(2000).optional(), +}) + // ============================================================ // Audit trail schemas // ============================================================ diff --git a/messages/en.json b/messages/en.json index 163d25af..63f2a6a0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -419,6 +419,11 @@ "approve_operation_title": "Approve operation", "approve_bulk_title": "Approve {count} operations?", "reject": "Reject", + "reject_selected": "Reject selected ({count})", + "reject_selected_none": "Reject selected", + "reject_count": "Reject {count}", + "reject_bulk_title": "Reject {count} operations?", + "reject_bulk_description": "The reason and note below apply to all selected operations.", "select_all_aria": "Select all", "select_operation_aria": "Select operation", "selected_count": "{count} selected", diff --git a/messages/sv.json b/messages/sv.json index d42f668a..997a46fa 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -419,6 +419,11 @@ "approve_operation_title": "Godkänn operation", "approve_bulk_title": "Godkänn {count} operationer?", "reject": "Avvisa", + "reject_selected": "Avvisa valda ({count})", + "reject_selected_none": "Avvisa valda", + "reject_count": "Avvisa {count}", + "reject_bulk_title": "Avvisa {count} operationer?", + "reject_bulk_description": "Anledning och notering nedan gäller alla valda operationer.", "select_all_aria": "Markera alla", "select_operation_aria": "Välj operation", "selected_count": "{count} valda",