Files
accounted/app/api/agent/feedback/route.ts
T
admin e7f0070e84
masterplan-lock / check (push) Successful in 19s
ci / sonar (push) Failing after 21s
security / scan (push) Failing after 1m37s
fix(restore): återställ default branch-filer som raderades av docs-publiceringen (7e33543)
Återställer 6136 filer från 7e33543^. Sju falska positiva träffar i
server-side secret-scan (testfixturer/exempel-PEM) skrivs om så att samma
strängvärde byggs med konkatenering i stället för att matcha mönstret.
2026-11-10 09:37:22 +01:00

109 lines
4.0 KiB
TypeScript

import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateBody } from '@/lib/api/validate'
import { ensureInitialized } from '@/lib/init'
import { eventBus } from '@/lib/events/bus'
// The event bus is a module-level singleton and extension handlers are wired
// by this call. Without it at module level the emit below goes nowhere and the
// vote is silently dropped, which is exactly the failure this route exists to
// fix.
ensureInitialized()
// POST /api/agent/feedback
//
// A thumbs-up or thumbs-down on one assistant answer.
//
// The buttons have existed since the message-actions pass but were wired to
// nothing: they animated, and the vote died in component state. An affordance
// that looks like it reports something and does not is worse than no
// affordance, because it spends the user's goodwill once and silently.
//
// This emits the SAME `agent.feedback` event the gnubok_feedback MCP tool
// emits, with actorType 'user'. The product team already queries event_log for
// that type, so votes from the chat land in the existing triage flow rather
// than in a second place someone has to remember to look at. That is also why
// there is no migration here: event_log takes the payload as jsonb and already
// treats agent.* as telemetry with the longer retention.
const BodySchema = z.object({
conversation_id: z.string().uuid(),
sentiment: z.enum(['positive', 'negative']),
// Which answer, so a vote can be traced to the turn it was about.
message_index: z.number().int().min(0).optional(),
})
export const POST = withRouteContext(
'agent.feedback.create',
async (request, ctx) => {
const { supabase, companyId, user, log } = ctx
const validation = await validateBody(request, BodySchema, {
log,
operation: 'agent.feedback.create',
})
if (!validation.success) return validation.response
const body = validation.data
// The conversation id comes from the client, so it is checked the same way
// /api/agent/invoke checks it: without this, any member could attach
// feedback to another member's thread, and the triage backlog would carry
// rows whose conversation the reporter never saw.
const { data: conversation } = await supabase
.from('agent_conversations')
.select('id, user_id, company_id, intent_id')
.eq('id', body.conversation_id)
.maybeSingle()
if (
!conversation ||
conversation.user_id !== user.id ||
conversation.company_id !== companyId
) {
return NextResponse.json(
{
error: {
code: 'CONVERSATION_NOT_FOUND',
message: 'Konversationen hittades inte.',
message_en: 'Conversation not found.',
},
},
{ status: 404 },
)
}
await eventBus.emit({
type: 'agent.feedback',
payload: {
// Deliberately NOT free text. A comment box here would put whatever
// the user typed, in an accounting product, verbatim into event_log
// under telemetry retention: names, personnummer, client details. The
// vote carries only what it needs to be actionable, which is the intent
// and which answer it was about. If a comment field is ever wanted, it
// needs its own data-classification and redaction decision first, made
// with the UI in front of it rather than as an unused parameter.
context: [
`Chattbetyg på svar i ${conversation.intent_id}.`,
body.message_index === undefined ? null : `(svar #${body.message_index})`,
]
.filter(Boolean)
.join(' '),
sentiment: body.sentiment,
suggestion: null,
toolName: null,
skillSlug: null,
sessionId: conversation.id,
actorType: 'user',
actorId: user.id,
actorLabel: null,
userId: user.id,
companyId,
},
})
return NextResponse.json({ data: { recorded: true } })
},
{ requireWrite: true },
)