feat(pending): bulk reject selected operations in granskning (#1085)

* feat(pending): bulk reject selected operations in granskning

The granskning queue could approve selected operations in bulk but
rejection was one row at a time. Adds:

- POST /api/pending-operations/bulk-reject: one guarded UPDATE
  (status='pending' filter) so rows resolved in a parallel session are
  reported as skipped instead of being flipped; optional
  rejection_category/rejection_reason applied to every rejected row so
  agents still learn from bulk 'no'. No high-risk skip server-side:
  rejecting posts nothing to the ledger.
- 'Avvisa valda' button next to 'Godkänn valda'; the existing reject
  dialog doubles as bulk confirmation (category + note apply to all).
- Route tests: 401/403/400/500, not-found, already-handled skip,
  read-write race, happy path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(pending): disable both bulk buttons while either bulk action is in flight

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-20 20:32:31 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 2908a951ab
commit d860567976
7 changed files with 447 additions and 29 deletions
@@ -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<ResultBody>(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<ResultBody>(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<ResultBody>(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<ResultBody>(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 })
})
})
@@ -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<string, string> = {
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<string>()
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 },
)