Å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.
109 lines
4.0 KiB
TypeScript
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 },
|
|
)
|