feat(agent): one-tap "clear the proposals I didn't approve" (BoXon feedback) (#1798)

When the assistant stages several verifikat and the user approves only some,
the rest lingered as pending_operations in Granskning until the 30-day expiry —
manual per-item cleanup. Now:

- lib/agent/pending/reject-conversation-pending.ts rejects a conversation's
  still-pending proposals in one update (guarded on status='pending' so it never
  stamps over a committed verifikat; company-scoped; keyed on the conversation).
- POST /api/agent/conversations/[id]/reject-pending — the chat's "Rensa förslag
  som inte godkänts" button (appears when the thread has staged proposals; drops
  the cards from view).
- Auto-clear on archive: archiving a thread ("I'm done") clears its leftover
  proposals in the PATCH, best-effort.

Kept durable-by-default (proposals still come back on resume) — the button is
explicit user intent, not an auto-reject on every panel close, so resume still
works. 10 tests (helper + endpoint 401/404/404/happy); lint + guards + scoped
typecheck clean. UI button awaits founder visual sign-off.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-22 10:42:22 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Opus 4.8
parent 13b69a2056
commit 1f7acbf144
7 changed files with 243 additions and 0 deletions
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextResponse } from 'next/server'
import { createMockRequest, createMockRouteParams, parseJsonResponse, createQueuedMockSupabase } from '@/tests/helpers'
const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
const requireAuthMock = vi.fn()
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: (...a: unknown[]) => requireAuthMock(...a) }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }) }))
const rejectPending = vi.fn()
vi.mock('@/lib/agent/pending/reject-conversation-pending', () => ({
rejectPendingForConversation: (...a: unknown[]) => rejectPending(...a),
}))
import { POST } from '../reject-pending/route'
const params = () => createMockRouteParams({ id: 'conv-1' })
beforeEach(() => {
vi.clearAllMocks()
reset()
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: mockSupabase, error: null })
rejectPending.mockResolvedValue(3)
})
describe('POST /api/agent/conversations/[id]/reject-pending', () => {
it('401 when unauthenticated', async () => {
requireAuthMock.mockResolvedValue({ user: null, supabase: mockSupabase, error: NextResponse.json({ error: 'x' }, { status: 401 }) })
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
expect((await parseJsonResponse(res)).status).toBe(401)
})
it('404 when the conversation is not the caller’s', async () => {
enqueue({ data: null }) // conversation ownership lookup misses
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
expect(res.status).toBe(404)
expect(rejectPending).not.toHaveBeenCalled()
})
it('404 when the caller is no longer a member of the company', async () => {
enqueue({ data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1' } }) // owns it
enqueue({ data: null }) // membership lookup misses
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
expect(res.status).toBe(404)
expect(rejectPending).not.toHaveBeenCalled()
})
it('clears the conversation’s pending proposals and returns the count', async () => {
enqueue({ data: { id: 'conv-1', company_id: 'company-1', user_id: 'user-1' } })
enqueue({ data: { role: 'owner' } })
const res = await POST(createMockRequest('/x', { method: 'POST' }), params())
const { status, body } = await parseJsonResponse<{ data: { cleared: number } }>(res)
expect(status).toBe(200)
expect(body.data.cleared).toBe(3)
expect(rejectPending).toHaveBeenCalledWith(mockSupabase, 'company-1', 'conv-1')
})
})
@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { rejectPendingForConversation } from '@/lib/agent/pending/reject-conversation-pending'
// POST /api/agent/conversations/[id]/reject-pending
//
// Rejects every still-pending proposal the assistant staged in this
// conversation — the chat's "clear the proposals I didn't approve" action, so
// users don't have to reject leftovers one by one in Granskning.
const notFound = () =>
NextResponse.json(
{
error: {
code: 'CONVERSATION_NOT_FOUND',
message: 'Konversationen hittades inte.',
message_en: 'Conversation not found.',
},
},
{ status: 404 },
)
export const POST = withRouteContext(
'agent.conversations.reject_pending',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, user } = ctx
// Conversations are user-scoped: prove ownership before touching its rows.
const { data: conv } = await supabase
.from('agent_conversations')
.select('id, company_id, user_id')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle()
if (!conv) return notFound()
// Defense in depth alongside RLS: still a member of the conversation's company.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', conv.company_id)
.eq('user_id', user.id)
.maybeSingle()
if (!membership) return notFound()
const cleared = await rejectPendingForConversation(supabase, conv.company_id as string, id)
return NextResponse.json({ data: { cleared } })
},
)
+13
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { rejectPendingForConversation } from '@/lib/agent/pending/reject-conversation-pending'
// GET /api/agent/conversations/[id]
//
@@ -147,6 +148,18 @@ export const PATCH = withRouteContext(
.select('id, intent_id, context_ref, title, pinned, archived, last_message_at, created_at')
.single()
if (error) throw error
// Archiving a thread means "I'm done with it": clear any proposals the
// assistant staged here that the user never acted on, so they don't linger
// in Granskning. Best-effort — a failure here must not fail the archive.
if (body.archived === true) {
try {
await rejectPendingForConversation(supabase, existing.company_id as string, id)
} catch {
// ignored
}
}
return NextResponse.json({ data })
},
)