Merge pull request #107 from madhura68/feat/pbi-36-sync-tab

feat(PBI-36 ST-1219): Sync-tab op Idea-detail
This commit is contained in:
Janpeter Visser 2026-05-06 00:43:30 +02:00 committed by GitHub
commit 6cda5b4930
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 418 additions and 3 deletions

View file

@ -7,6 +7,7 @@ import { prisma } from '@/lib/prisma'
import { productAccessFilter } from '@/lib/product-access'
import { ideaToDto } from '@/lib/idea-dto'
import { IdeaDetailLayout } from '@/components/ideas/idea-detail-layout'
import { loadIdeaSyncData } from './sync-tab-server'
export const dynamic = 'force-dynamic'
@ -76,6 +77,14 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps
select: { id: true, question: true, answer: true, status: true, created_at: true },
})
// Sync-tab data — alleen geladen als idea PLANNED is en pbi_id gevuld.
// loadIdeaSyncData past zelf user_id-scope toe en retourneert null als
// het idee geen pbi heeft.
const syncData =
idea.status === 'PLANNED' && idea.pbi_id
? await loadIdeaSyncData(id, session.userId)
: null
return (
<IdeaDetailLayout
idea={ideaToDto(idea)}
@ -107,6 +116,7 @@ export default async function IdeaDetailPage({ params, searchParams }: PageProps
}))}
isDemo={session.isDemo ?? false}
initialTab={tab ?? 'idee'}
syncData={syncData}
/>
)
}

View file

@ -0,0 +1,85 @@
import 'server-only'
import { prisma } from '@/lib/prisma'
// Server-only loader voor de Sync-tab op /ideas/[id].
// Joint Idea → PBI → Stories → Tasks → ClaudeJobs + StoryLog.
// Auth-scope: strikt user_id-only conform M12-keuze 2.
//
// Returns null wanneer:
// - idea bestaat niet of behoort niet aan user
// - idea heeft geen pbi_id (status !== PLANNED, dus sync-tab niet relevant)
//
// Caller (page.tsx) moet de tab niet renderen als deze null retourneert.
export async function loadIdeaSyncData(ideaId: string, userId: string) {
const idea = await prisma.idea.findFirst({
where: { id: ideaId, user_id: userId },
select: {
id: true,
code: true,
title: true,
status: true,
pbi_id: true,
product: { select: { id: true, name: true, repo_url: true } },
pbi: {
select: {
id: true,
code: true,
title: true,
pr_url: true,
pr_merged_at: true,
stories: {
orderBy: { sort_order: 'asc' },
select: {
id: true,
code: true,
title: true,
status: true,
tasks: {
orderBy: { sort_order: 'asc' },
select: {
id: true,
code: true,
title: true,
status: true,
claude_jobs: {
where: { kind: 'TASK_IMPLEMENTATION' },
orderBy: { created_at: 'desc' },
select: {
id: true,
status: true,
branch: true,
pushed_at: true,
pr_url: true,
error: true,
summary: true,
created_at: true,
finished_at: true,
},
},
},
},
logs: {
orderBy: { created_at: 'desc' },
take: 20,
select: {
id: true,
type: true,
content: true,
status: true,
commit_hash: true,
commit_message: true,
created_at: true,
},
},
},
},
},
},
},
})
if (!idea || !idea.pbi) return null
return idea
}
export type IdeaSyncData = NonNullable<Awaited<ReturnType<typeof loadIdeaSyncData>>>

View file

@ -53,7 +53,20 @@ interface IdeaJobPayload {
status: string
}
type NotifyPayload = QuestionPayload | IdeaJobPayload
// Story-log-payloads: emitted by notify_story_log_change trigger op story_logs
// (T-559). Carries product_id voor productAccessFilter en optioneel idea_id
// voor user-private idea-access (M12 keuze 2). log_type is informatief.
interface StoryLogPayload {
op: 'INSERT'
entity: 'story_log'
id: string
story_id: string
product_id: string | null
idea_id: string | null
log_type: 'IMPLEMENTATION_PLAN' | 'COMMIT' | 'TEST_RESULT' | string
}
type NotifyPayload = QuestionPayload | IdeaJobPayload | StoryLogPayload
function isQuestionPayload(p: NotifyPayload): p is QuestionPayload {
return 'entity' in p && p.entity === 'question'
@ -69,6 +82,10 @@ function isIdeaJobPayload(p: NotifyPayload): p is IdeaJobPayload {
)
}
function isStoryLogPayload(p: NotifyPayload): p is StoryLogPayload {
return 'entity' in p && p.entity === 'story_log'
}
export async function GET(request: NextRequest) {
const session = await getSession()
if (!session.userId) {
@ -164,6 +181,21 @@ export async function GET(request: NextRequest) {
return
}
if (isStoryLogPayload(payload)) {
// Sync-tab (PBI-36 ST-1219): story_log-event moet door als óf de
// story bij een user-eigen idee hoort, óf de user productAccess
// heeft (voor non-Idea views). idea_id-pad heeft voorrang —
// sluit aan op M12 strikt user_id-only voor ideas.
if (payload.idea_id && accessibleIdeaIds.has(payload.idea_id)) {
enqueue(`data: ${msg.payload}\n\n`)
return
}
if (payload.product_id && accessibleProductIds.has(payload.product_id)) {
enqueue(`data: ${msg.payload}\n\n`)
}
return
}
if (!isQuestionPayload(payload)) return
// Idea-question: alleen voor de eigenaar van het idee.

View file

@ -25,7 +25,9 @@ import { IdeaRowActions } from '@/components/ideas/idea-row-actions'
import { IdeaMdEditor } from '@/components/ideas/idea-md-editor'
import { IdeaPbiLinkCard } from '@/components/ideas/idea-pbi-link-card'
import { IdeaTimeline } from '@/components/ideas/idea-timeline'
import { IdeaSyncTab } from '@/components/ideas/idea-sync-tab'
import { DownloadMdButton } from '@/components/ideas/download-md-button'
import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server'
const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]> = {
draft: 'DRAFT',
@ -38,7 +40,7 @@ const API_TO_DB: Record<IdeaStatusApi, Parameters<typeof getIdeaStatusBadge>[0]>
planned: 'PLANNED',
}
type TabKey = 'idee' | 'grill' | 'plan' | 'timeline'
type TabKey = 'idee' | 'grill' | 'plan' | 'timeline' | 'sync'
interface IdeaLog {
id: string
@ -82,6 +84,7 @@ interface Props {
userQuestions: IdeaUserQuestionDto[]
isDemo: boolean
initialTab: string
syncData: IdeaSyncData | null
}
export function IdeaDetailLayout({
@ -94,12 +97,16 @@ export function IdeaDetailLayout({
userQuestions,
isDemo,
initialTab,
syncData,
}: Props) {
const router = useRouter()
const searchParams = useSearchParams()
const [pending, startTransition] = useTransition()
const TAB_KEYS: TabKey[] = ['idee', 'grill', 'plan', 'timeline']
const showSync = syncData !== null && idea.status === 'planned'
const TAB_KEYS: TabKey[] = showSync
? ['idee', 'grill', 'plan', 'timeline', 'sync']
: ['idee', 'grill', 'plan', 'timeline']
const tab = (TAB_KEYS.includes(initialTab as TabKey) ? initialTab : 'idee') as TabKey
function setTab(key: TabKey) {
@ -170,6 +177,9 @@ export function IdeaDetailLayout({
{ key: 'grill' as TabKey, label: 'Grill', disabled: !grill_md, hasContent: !!grill_md },
{ key: 'plan' as TabKey, label: 'Plan', disabled: !plan_md, hasContent: !!plan_md },
{ key: 'timeline' as TabKey, label: 'Timeline', disabled: false, hasContent: true },
...(showSync
? [{ key: 'sync' as TabKey, label: 'Sync', disabled: false, hasContent: true }]
: []),
] as const).map((t) => (
<button
key={t.key}
@ -227,6 +237,7 @@ export function IdeaDetailLayout({
/>
)}
{tab === 'timeline' && <IdeaTimeline logs={logs} questions={questions} />}
{tab === 'sync' && showSync && syncData && <IdeaSyncTab data={syncData} />}
</div>
)
}

View file

@ -0,0 +1,233 @@
'use client'
// Sync-tab op /ideas/[id] (PBI-36 ST-1219). Toont per Story onder de
// gekoppelde PBI: status, job-rij (ClaudeJobs incl. branch/pushed_at/pr_url),
// en de bestaande activity-log via <StoryLog>. Realtime refresh via
// notifications-SSE: bij elk story_log of relevant claude_job-event triggeren
// we router.refresh() (server-render verzorgt nieuwe data).
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { StoryLog } from '@/components/shared/story-log'
import { JOB_STATUS_LABELS, JOB_STATUS_COLORS } from '@/components/shared/job-status'
import type { ClaudeJobStatusApi } from '@/lib/job-status'
import type { IdeaSyncData } from '@/app/(app)/ideas/[id]/sync-tab-server'
interface Props {
data: IdeaSyncData
}
const TASK_STATUS_LABEL: Record<string, string> = {
TO_DO: 'TO-DO',
IN_PROGRESS: 'Bezig',
REVIEW: 'Review',
DONE: 'Klaar',
}
const TASK_STATUS_COLOR: Record<string, string> = {
TO_DO: 'bg-status-todo/15 text-status-todo border-status-todo/30',
IN_PROGRESS: 'bg-status-in-progress/15 text-status-in-progress border-status-in-progress/30',
REVIEW: 'bg-warning/15 text-warning border-warning/30',
DONE: 'bg-status-done/15 text-status-done border-status-done/30',
}
function formatRelative(iso: string | Date | null): string {
if (!iso) return '—'
const d = typeof iso === 'string' ? new Date(iso) : iso
const diffMs = Date.now() - d.getTime()
const min = Math.round(diffMs / 60_000)
if (min < 1) return 'zojuist'
if (min < 60) return `${min} min geleden`
const h = Math.round(min / 60)
if (h < 24) return `${h} u geleden`
return d.toLocaleDateString('nl-NL', { day: 'numeric', month: 'short' })
}
function jobStatusKey(dbStatus: string): ClaudeJobStatusApi {
return dbStatus.toLowerCase() as ClaudeJobStatusApi
}
export function IdeaSyncTab({ data }: Props) {
const router = useRouter()
const pbi = data.pbi
const storyIdsKey = pbi ? pbi.stories.map((s) => s.id).join(',') : ''
// Realtime refresh op story_log inserts en idea-job updates.
// Listen op de bestaande user-scoped notifications stream (SSE-route filtert
// al op accessibleIdeaIds + accessibleProductIds).
useEffect(() => {
if (!storyIdsKey) return
const storyIds = new Set(storyIdsKey.split(','))
const es = new EventSource('/api/realtime/notifications')
es.addEventListener('message', (ev) => {
try {
const payload = JSON.parse(ev.data)
if (payload.entity === 'story_log' && storyIds.has(payload.story_id)) {
router.refresh()
return
}
if (payload.type === 'claude_job_status' && payload.idea_id === data.id) {
router.refresh()
}
} catch {
// niet-JSON of niet-relevant — negeren
}
})
es.addEventListener('error', () => {
// EventSource probeert zelf opnieuw te verbinden; geen actie nodig.
})
return () => {
es.close()
}
}, [data.id, storyIdsKey, router])
if (!pbi) return null
return (
<div className="space-y-4">
{/* Header: PBI-link + PR-status */}
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-surface-container p-3">
<a
href={`/backlog/${pbi.id}`}
className="font-mono text-sm text-primary hover:underline"
>
{pbi.code}
</a>
<span className="text-sm font-medium">{pbi.title}</span>
<div className="ml-auto flex items-center gap-2">
{pbi.pr_url && (
<a
href={pbi.pr_url}
target="_blank"
rel="noreferrer"
className="text-xs text-primary underline"
>
PR open
</a>
)}
{pbi.pr_merged_at && (
<Badge className="bg-status-done/15 text-status-done border-status-done/30">
Gemerged {formatRelative(pbi.pr_merged_at)}
</Badge>
)}
</div>
</div>
{/* Stories */}
{pbi.stories.length === 0 && (
<p className="text-sm text-muted-foreground italic">
Deze PBI heeft nog geen stories.
</p>
)}
{pbi.stories.map((story) => (
<details
key={story.id}
open
className="rounded-md border border-border bg-card"
>
<summary className="flex cursor-pointer flex-wrap items-center gap-2 px-3 py-2">
<span className="font-mono text-xs text-muted-foreground">
{story.code}
</span>
<span className="text-sm font-medium">{story.title}</span>
<Badge
className={`ml-auto ${TASK_STATUS_COLOR[story.status] ?? 'bg-muted'}`}
>
{TASK_STATUS_LABEL[story.status] ?? story.status}
</Badge>
</summary>
<div className="space-y-3 border-t border-border px-3 py-3">
{/* Tasks + jobs */}
{story.tasks.length === 0 ? (
<p className="text-xs text-muted-foreground">Geen taken.</p>
) : (
<ul className="space-y-2">
{story.tasks.map((task) => {
const latestJob = task.claude_jobs[0]
return (
<li
key={task.id}
className="flex flex-wrap items-center gap-2 rounded border border-border/60 bg-surface-container/40 px-2 py-1.5 text-xs"
>
<span className="font-mono text-muted-foreground">
{task.code}
</span>
<span className="flex-1 truncate">{task.title}</span>
<Badge
className={`${TASK_STATUS_COLOR[task.status] ?? 'bg-muted'}`}
>
{TASK_STATUS_LABEL[task.status] ?? task.status}
</Badge>
{latestJob ? (
<Badge
className={
JOB_STATUS_COLORS[jobStatusKey(latestJob.status)] ??
'bg-muted'
}
>
{JOB_STATUS_LABELS[jobStatusKey(latestJob.status)] ??
latestJob.status}
</Badge>
) : (
<Badge className="bg-muted/60 text-muted-foreground italic">
Geen job
</Badge>
)}
{latestJob?.branch && (
<span className="font-mono text-muted-foreground">
{latestJob.branch}
</span>
)}
{latestJob?.pushed_at && (
<span className="text-muted-foreground">
gepusht {formatRelative(latestJob.pushed_at)}
</span>
)}
{latestJob?.pr_url && (
<a
href={latestJob.pr_url}
target="_blank"
rel="noreferrer"
className="text-primary underline"
>
PR
</a>
)}
</li>
)
})}
</ul>
)}
{/* Activity log (StoryLog hergebruik) */}
<div>
<h4 className="mb-1 text-xs font-medium text-muted-foreground">
Activiteit
</h4>
<StoryLog
logs={story.logs.map((l) => ({
id: l.id,
type: l.type,
content: l.content,
status: l.status,
commit_hash: l.commit_hash,
commit_message: l.commit_message,
created_at:
typeof l.created_at === 'string'
? l.created_at
: l.created_at.toISOString(),
}))}
repoUrl={data.product?.repo_url ?? null}
/>
</div>
</div>
</details>
))}
</div>
)
}

View file

@ -0,0 +1,44 @@
-- pg_notify trigger op story_logs: emit AFTER INSERT op het gedeelde
-- 'scrum4me_changes'-channel zodat de Sync-tab op /ideas/[id] real-time
-- nieuwe IMPLEMENTATION_PLAN/COMMIT/TEST_RESULT-entries kan tonen zonder
-- handmatige refresh.
--
-- Payload-format consistent met andere triggers in deze codebase:
-- {op:'INSERT', entity:'story_log', id, story_id, product_id, idea_id?}
--
-- product_id en idea_id worden afgeleid via story → pbi → product en
-- story → pbi → idea (1:1 via Idea.pbi_id). Hierdoor kan de SSE-route
-- filteren op productAccessFilter én op user-eigen ideeën zonder extra
-- DB-call per event.
CREATE OR REPLACE FUNCTION notify_story_log_change() RETURNS TRIGGER AS $$
DECLARE
v_product_id text;
v_idea_id text;
payload json;
BEGIN
SELECT p.product_id, i.id
INTO v_product_id, v_idea_id
FROM stories s
JOIN pbis p ON p.id = s.pbi_id
LEFT JOIN ideas i ON i.pbi_id = p.id
WHERE s.id = NEW.story_id;
payload := json_build_object(
'op', TG_OP,
'entity', 'story_log',
'id', NEW.id,
'story_id', NEW.story_id,
'product_id', v_product_id,
'idea_id', v_idea_id,
'log_type', NEW.type
);
PERFORM pg_notify('scrum4me_changes', payload::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER story_logs_notify
AFTER INSERT ON story_logs
FOR EACH ROW EXECUTE FUNCTION notify_story_log_change();