Two gaps discovered during the first live grill-session of IDEA-002:
the agent posted a question, but the user had no UI to answer it.
1. Idea-questions only appeared on the Timeline-tab as read-only entries
2. Notifications-bell fetched + handled story-questions only
This fix:
**Inline answer-form in IdeaTimeline** (components/ideas/idea-timeline.tsx)
- Open questions now render an AnswerForm directly under the question text
- Multi-choice options become clickable buttons (one-click submit); free-text
fallback via collapsed details/textarea
- Plain free-text questions render textarea + Verzend
- Calls existing answerQuestion server-action; toast + router.refresh on success
**Notifications-bell extended for idea-questions**
- stores/notifications-store.ts: NotificationQuestion → discriminated union
(kind: 'story' | 'idea'); forYouCount treats idea-questions as always-for-you
(idea is strictly user_id-only — only the owner sees them)
- components/notifications/notifications-bridge.tsx: parallel fetch of
story-questions (productAccessFilter) + idea-questions (idea.user_id ===
session.userId); merged + sorted by created_at
- components/notifications/notifications-sheet.tsx: renders idea_code/title
for kind='idea'
- components/notifications/answer-modal.tsx: header + open-link branch on
kind (idea → /ideas/[id]?tab=timeline; story → existing /sprint link)
- lib/realtime/use-notifications-realtime.ts: idea-question events also
trigger close+reconnect on 'open' (loads fresh detail) and remove(id) on
non-open — same pattern story-questions already use
- components/shared/notifications-bell.tsx: badge counts idea-questions as
for-you regardless of assignee
**Security gap closed (actions/questions.ts answerQuestion)**
Before: accepted any answer if user has product-access.
After: idea-questions require idea.user_id === session.userId; story-
questions keep the existing productAccessFilter path. (Prisma 7 rejects
\`{ not: null }\` in WHERE; routing happens app-level after a single fetch.)
Tests: 546/546 still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.3 KiB
TypeScript
181 lines
6.3 KiB
TypeScript
'use client'
|
|
|
|
// ST-1105: Modal waar de gebruiker een Claude-vraag beantwoordt (M11).
|
|
//
|
|
// Free-text Textarea (max ANSWER_MAX_CHARS) of multiple-choice via knoppen
|
|
// wanneer de vraag `options` heeft. Submit roept answerQuestion-Server-Action
|
|
// aan via useTransition; bij succes wordt de vraag uit de store verwijderd
|
|
// (optimistisch) en sluit de modal. Demo-modus: textarea readOnly + submit
|
|
// disabled met DemoTooltip.
|
|
|
|
import { useState, useTransition } from 'react'
|
|
import Link from 'next/link'
|
|
import { ExternalLink } from 'lucide-react'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
|
import {
|
|
useDirtyCloseGuard,
|
|
DirtyCloseGuardDialog,
|
|
} from '@/components/shared/use-dirty-close-guard'
|
|
import { useDialogSubmitShortcut } from '@/components/shared/use-dialog-submit-shortcut'
|
|
import {
|
|
entityDialogContentClasses,
|
|
entityDialogFooterClasses,
|
|
entityDialogHeaderClasses,
|
|
} from '@/components/shared/entity-dialog-layout'
|
|
import { ANSWER_MAX_CHARS } from '@/lib/schemas/question-answer'
|
|
import { answerQuestion } from '@/actions/questions'
|
|
import { useNotificationsStore, type NotificationQuestion } from '@/stores/notifications-store'
|
|
|
|
interface AnswerModalProps {
|
|
question: NotificationQuestion | null
|
|
isDemo: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
|
|
const [answer, setAnswer] = useState('')
|
|
const [pending, startTransition] = useTransition()
|
|
|
|
const closeGuard = useDirtyCloseGuard(answer.trim().length > 0, () => {
|
|
setAnswer('')
|
|
onClose()
|
|
})
|
|
|
|
const charsLeft = ANSWER_MAX_CHARS - answer.length
|
|
const tooLong = charsLeft < 0
|
|
const submitDisabled = isDemo || pending || answer.trim().length === 0 || tooLong
|
|
|
|
function submit(text: string) {
|
|
if (!question) return
|
|
startTransition(async () => {
|
|
const res = await answerQuestion(question.id, text)
|
|
if (!res.ok) {
|
|
toast.error(res.error)
|
|
return
|
|
}
|
|
useNotificationsStore.getState().remove(question.id)
|
|
toast.success('Antwoord verstuurd')
|
|
setAnswer('')
|
|
onClose()
|
|
})
|
|
}
|
|
|
|
const handleKeyDown = useDialogSubmitShortcut(() => {
|
|
if (!submitDisabled) submit(answer)
|
|
})
|
|
|
|
if (!question) return null
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={!!question} onOpenChange={(open) => { if (!open) closeGuard.attemptClose() }}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
onKeyDown={handleKeyDown}
|
|
className={entityDialogContentClasses}
|
|
>
|
|
<div className={entityDialogHeaderClasses}>
|
|
<div className="flex flex-col gap-1">
|
|
<DialogTitle className="text-xl font-semibold">Beantwoord Claude</DialogTitle>
|
|
<DialogDescription>
|
|
<span className="font-mono">
|
|
{question.kind === 'idea' ? question.idea_code : (question.story_code ?? 'story')}
|
|
</span>
|
|
{' — '}
|
|
{question.kind === 'idea' ? question.idea_title : question.story_title}
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto px-6 py-6 space-y-6">
|
|
<Link
|
|
href={
|
|
question.kind === 'idea'
|
|
? `/ideas/${question.idea_id}?tab=timeline`
|
|
: `/products/${question.product_id}/sprint`
|
|
}
|
|
className="text-primary inline-flex items-center gap-1 self-start text-xs hover:underline"
|
|
>
|
|
<ExternalLink className="h-3.5 w-3.5" />
|
|
<span>{question.kind === 'idea' ? 'Open idee' : 'Open in Sprint'}</span>
|
|
</Link>
|
|
|
|
<div className="bg-surface-container-low rounded-md border p-3 text-sm whitespace-pre-wrap">
|
|
{question.question}
|
|
</div>
|
|
|
|
{question.options && question.options.length > 0 ? (
|
|
<div className="space-y-2">
|
|
<p className="text-muted-foreground text-xs">Kies een van de opties:</p>
|
|
<div className="flex flex-col gap-2">
|
|
{question.options.map((opt) => (
|
|
<DemoTooltip key={opt} show={isDemo}>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="justify-start"
|
|
disabled={isDemo || pending}
|
|
onClick={() => submit(opt)}
|
|
>
|
|
{opt}
|
|
</Button>
|
|
</DemoTooltip>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<Textarea
|
|
value={answer}
|
|
onChange={(e) => setAnswer(e.target.value)}
|
|
placeholder="Typ je antwoord…"
|
|
rows={5}
|
|
maxLength={ANSWER_MAX_CHARS}
|
|
disabled={isDemo}
|
|
aria-label="Antwoord op Claude's vraag"
|
|
/>
|
|
<div
|
|
className={
|
|
tooLong
|
|
? 'text-error text-right text-xs'
|
|
: 'text-muted-foreground text-right text-xs'
|
|
}
|
|
>
|
|
{charsLeft} tekens over
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={entityDialogFooterClasses}>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={closeGuard.attemptClose} disabled={pending}>
|
|
Annuleren
|
|
</Button>
|
|
{(!question.options || question.options.length === 0) && (
|
|
<DemoTooltip show={isDemo}>
|
|
<Button
|
|
onClick={() => submit(answer)}
|
|
disabled={submitDisabled}
|
|
>
|
|
{pending ? 'Bezig…' : 'Verstuur'}
|
|
</Button>
|
|
</DemoTooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<DirtyCloseGuardDialog guard={closeGuard} />
|
|
</>
|
|
)
|
|
}
|