Scrum4Me/components/notifications/answer-modal.tsx
Madhura68 9e8f33b96e fix(m12): user can answer idea-questions — inline + bell support
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>
2026-05-05 13:05:39 +02:00

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} />
</>
)
}