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,49 @@
import { describe, it, expect } from 'vitest'
import type { SupabaseClient } from '@supabase/supabase-js'
import { rejectPendingForConversation } from '../reject-conversation-pending'
function makeSupabase(rows: unknown[]) {
const calls: { table?: string; update?: Record<string, unknown>; filters: [string, unknown][] } = {
filters: [],
}
const chain = {
from(table: string) {
calls.table = table
return chain
},
update(payload: Record<string, unknown>) {
calls.update = payload
return chain
},
eq(col: string, val: unknown) {
calls.filters.push([col, val])
return chain
},
select: async () => ({ data: rows }),
}
return { supabase: chain as unknown as SupabaseClient, calls }
}
describe('rejectPendingForConversation', () => {
it('rejects only pending rows of the conversation and returns the count', async () => {
const { supabase, calls } = makeSupabase([{ id: 'a' }, { id: 'b' }])
const n = await rejectPendingForConversation(supabase, 'company-1', 'conv-1')
expect(n).toBe(2)
expect(calls.table).toBe('pending_operations')
expect(calls.update).toMatchObject({ status: 'rejected', rejection_reason: 'Ej godkänd i assistenten' })
expect(calls.update?.resolved_at).toEqual(expect.any(String))
// Guarded to this company, only pending rows, only this conversation.
expect(calls.filters).toEqual(
expect.arrayContaining([
['company_id', 'company-1'],
['status', 'pending'],
['agent_metadata->>conversation_id', 'conv-1'],
]),
)
})
it('returns 0 when nothing was pending', async () => {
const { supabase } = makeSupabase([])
expect(await rejectPendingForConversation(supabase, 'c1', 'conv-1')).toBe(0)
})
})
@@ -0,0 +1,33 @@
import type { SupabaseClient } from '@supabase/supabase-js'
/**
* Reject every still-pending proposal the assistant staged in one conversation.
*
* When the assistant proposes several verifikat and the user approves only some,
* the rest linger as pending_operations in Granskning until the 30-day expiry —
* the "manually clean up afterward" friction users reported. This clears the
* leftovers in one shot: the chat's "clear the rest" button and the auto-clean
* on conversation archive both call it.
*
* Only `status = 'pending'` rows are touched (never a committing/committed one,
* which would stamp 'rejected' over a posted verifikat — the same guard the
* single/bulk reject routes use). Company-scoped. Returns how many were cleared.
*/
export async function rejectPendingForConversation(
supabase: SupabaseClient,
companyId: string,
conversationId: string,
): Promise<number> {
const { data } = await supabase
.from('pending_operations')
.update({
status: 'rejected',
resolved_at: new Date().toISOString(),
rejection_reason: 'Ej godkänd i assistenten',
})
.eq('company_id', companyId)
.eq('status', 'pending')
.eq('agent_metadata->>conversation_id', conversationId)
.select('id')
return data?.length ?? 0
}