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>
This commit is contained in:
Janpeter Visser 2026-05-05 13:05:39 +02:00
parent 02a7f59897
commit 9e8f33b96e
9 changed files with 281 additions and 52 deletions

View file

@ -87,20 +87,26 @@ export function AnswerModal({ question, isDemo, onClose }: AnswerModalProps) {
<div className="flex flex-col gap-1">
<DialogTitle className="text-xl font-semibold">Beantwoord Claude</DialogTitle>
<DialogDescription>
<span className="font-mono">{question.story_code ?? 'story'}</span>
<span className="font-mono">
{question.kind === 'idea' ? question.idea_code : (question.story_code ?? 'story')}
</span>
{' — '}
{question.story_title}
{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={`/products/${question.product_id}/sprint`}
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>Open in Sprint</span>
<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">

View file

@ -48,9 +48,10 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
},
})
const initial: NotificationQuestion[] = openQuestions.flatMap((q) => {
const storyQuestions: NotificationQuestion[] = openQuestions.flatMap((q) => {
if (!q.story || q.story_id === null) return []
return [{
kind: 'story' as const,
id: q.id,
product_id: q.product_id,
story_id: q.story_id,
@ -65,5 +66,45 @@ export async function NotificationsBridge({ userId }: NotificationsBridgeProps)
}]
})
// M12 hotfix: idea-questions ook in de bel. user_id-only scope (idee is privé).
const ideaOpenQuestions = await prisma.claudeQuestion.findMany({
where: {
status: 'open',
expires_at: { gt: new Date() },
idea: { user_id: userId },
},
orderBy: { created_at: 'desc' },
take: 100,
select: {
id: true,
product_id: true,
idea_id: true,
question: true,
options: true,
created_at: true,
expires_at: true,
idea: { select: { id: true, code: true, title: true } },
},
})
const ideaQuestions: NotificationQuestion[] = ideaOpenQuestions.flatMap((q) => {
if (!q.idea || q.idea_id === null) return []
return [{
kind: 'idea' as const,
id: q.id,
product_id: q.product_id,
idea_id: q.idea_id,
idea_code: q.idea.code,
idea_title: q.idea.title,
question: q.question,
options: Array.isArray(q.options) ? (q.options as string[]) : null,
created_at: q.created_at.toISOString(),
expires_at: q.expires_at.toISOString(),
}]
})
const initial: NotificationQuestion[] = [...storyQuestions, ...ideaQuestions]
.sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
return <NotificationsRealtimeMount initial={initial} />
}

View file

@ -54,7 +54,12 @@ export function NotificationsSheet({
) : (
<ul className="mt-4 flex flex-col gap-2 px-4 pb-4">
{questions.map((q) => {
const forYou = q.assignee_id === currentUserId
// story-questions: forYou wanneer assignee = ingelogd; idee-vragen
// zijn altijd "voor jou" (idee is strikt user_id-only).
const forYou =
q.kind === 'idea' || q.assignee_id === currentUserId
const code = q.kind === 'idea' ? q.idea_code : (q.story_code ?? '—')
const title = q.kind === 'idea' ? q.idea_title : q.story_title
return (
<li key={q.id}>
<button
@ -67,12 +72,8 @@ export function NotificationsSheet({
)}
>
<div className="flex items-baseline gap-2">
<span className="font-mono text-xs opacity-80">
{q.story_code ?? '—'}
</span>
<span className="line-clamp-1 text-sm font-medium">
{q.story_title}
</span>
<span className="font-mono text-xs opacity-80">{code}</span>
<span className="line-clamp-1 text-sm font-medium">{title}</span>
</div>
<p
className={cn(