From a5f62a0323e40f34b2e0c1087c806060a758a93f Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 00:16:52 +0200 Subject: [PATCH 1/5] feat(T-559): pg_notify-trigger op story_logs voor sync-tab realtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AFTER INSERT op story_logs emit op scrum4me_changes channel met entity:'story_log'. Trigger resolved product_id en idea_id via story → pbi → product/idea zodat SSE-route kan filteren zonder extra DB-call per event. Migratie toegepast op Neon productie-DB. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 prisma/migrations/20260506001700_story_logs_notify/migration.sql diff --git a/prisma/migrations/20260506001700_story_logs_notify/migration.sql b/prisma/migrations/20260506001700_story_logs_notify/migration.sql new file mode 100644 index 0000000..6d246b5 --- /dev/null +++ b/prisma/migrations/20260506001700_story_logs_notify/migration.sql @@ -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(); From e1da9aae43994862c18094c1d1bbd61b30d5a444 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 00:17:38 +0200 Subject: [PATCH 2/5] feat(T-560): SSE-route accepteert story_log-payloads StoryLogPayload type toegevoegd aan NotifyPayload union. In de notification-handler: idea_id-pad checkt accessibleIdeaIds (M12 user-private), fallback op product_id check accessibleProductIds. Consistent met question-payload-pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/realtime/notifications/route.ts | 34 ++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/app/api/realtime/notifications/route.ts b/app/api/realtime/notifications/route.ts index 1d32a2f..4f42d05 100644 --- a/app/api/realtime/notifications/route.ts +++ b/app/api/realtime/notifications/route.ts @@ -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. From f4f02bd0d2d5887abc5cd647adc1364d554bac51 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 00:18:21 +0200 Subject: [PATCH 3/5] feat(T-561): loadIdeaSyncData server-loader voor Sync-tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Joint Idea → PBI → Stories → Tasks → ClaudeJobs + StoryLog in één prisma.findFirst-call. user_id-scope conform M12-keuze 2 (strikt user_id-only). Filtert ClaudeJob op kind=TASK_IMPLEMENTATION en neemt laatste 20 story-logs per story. Returns null als idea geen pbi_id heeft — caller render geen tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/ideas/[id]/sync-tab-server.ts | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 app/(app)/ideas/[id]/sync-tab-server.ts diff --git a/app/(app)/ideas/[id]/sync-tab-server.ts b/app/(app)/ideas/[id]/sync-tab-server.ts new file mode 100644 index 0000000..ae93465 --- /dev/null +++ b/app/(app)/ideas/[id]/sync-tab-server.ts @@ -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>> From dbf30a2fcb9db81bce77264c9b6ec7d2e80653c8 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 00:20:12 +0200 Subject: [PATCH 4/5] feat(T-562): IdeaSyncTab component met StoryLog-hergebruik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toont per Story onder de gekoppelde PBI: status-badge, taak-rij met job-status (incl. SKIPPED), branch, pushed_at, pr_url, en bestaande -component voor activity-log. PBI-header met PR-link en gemerged-badge. Realtime: subscribed op /api/realtime/notifications. Bij story_log- event waar story_id matcht, of claude_job_status voor dit idea → router.refresh() (server-render levert nieuwe data). MD3-tokens overal: bg-status-todo/in-progress/done, bg-surface- container, bg-muted/60. Geen bg-blue-500. Co-Authored-By: Claude Opus 4.7 (1M context) --- components/ideas/idea-sync-tab.tsx | 233 +++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 components/ideas/idea-sync-tab.tsx diff --git a/components/ideas/idea-sync-tab.tsx b/components/ideas/idea-sync-tab.tsx new file mode 100644 index 0000000..b05e46b --- /dev/null +++ b/components/ideas/idea-sync-tab.tsx @@ -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 . 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 = { + TO_DO: 'TO-DO', + IN_PROGRESS: 'Bezig', + REVIEW: 'Review', + DONE: 'Klaar', +} + +const TASK_STATUS_COLOR: Record = { + 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 ( +
+ {/* Header: PBI-link + PR-status */} +
+ + {pbi.code} + + {pbi.title} +
+ {pbi.pr_url && ( + + PR open + + )} + {pbi.pr_merged_at && ( + + Gemerged {formatRelative(pbi.pr_merged_at)} + + )} +
+
+ + {/* Stories */} + {pbi.stories.length === 0 && ( +

+ Deze PBI heeft nog geen stories. +

+ )} + {pbi.stories.map((story) => ( +
+ + + {story.code} + + {story.title} + + {TASK_STATUS_LABEL[story.status] ?? story.status} + + + +
+ {/* Tasks + jobs */} + {story.tasks.length === 0 ? ( +

Geen taken.

+ ) : ( +
    + {story.tasks.map((task) => { + const latestJob = task.claude_jobs[0] + return ( +
  • + + {task.code} + + {task.title} + + {TASK_STATUS_LABEL[task.status] ?? task.status} + + {latestJob ? ( + + {JOB_STATUS_LABELS[jobStatusKey(latestJob.status)] ?? + latestJob.status} + + ) : ( + + Geen job + + )} + {latestJob?.branch && ( + + {latestJob.branch} + + )} + {latestJob?.pushed_at && ( + + gepusht {formatRelative(latestJob.pushed_at)} + + )} + {latestJob?.pr_url && ( + + PR + + )} +
  • + ) + })} +
+ )} + + {/* Activity log (StoryLog hergebruik) */} +
+

+ Activiteit +

+ ({ + 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} + /> +
+
+
+ ))} +
+ ) +} From 678069a3d8cf0b0c5fb0f23f0d8be6e4f91e68c6 Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 00:21:59 +0200 Subject: [PATCH 5/5] feat(T-563): integreer Sync-tab in IdeaDetailLayout + page-loader - TabKey union uitgebreid met 'sync'. - Sync-tab alleen zichtbaar als syncData !== null && idea.status === 'planned' (M12 keuze 6: na Materialiseer-actie). - page.tsx roept loadIdeaSyncData alleen aan bij PLANNED + pbi_id, anders null doorgeven aan layout. - showSync-flag bepaalt of de tab in TAB_KEYS array zit en in de UI gerenderd wordt. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/(app)/ideas/[id]/page.tsx | 10 ++++++++++ components/ideas/idea-detail-layout.tsx | 15 +++++++++++++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/(app)/ideas/[id]/page.tsx b/app/(app)/ideas/[id]/page.tsx index 0c1cfb0..10439f2 100644 --- a/app/(app)/ideas/[id]/page.tsx +++ b/app/(app)/ideas/[id]/page.tsx @@ -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 ( ) } diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx index f6798d0..4b962d9 100644 --- a/components/ideas/idea-detail-layout.tsx +++ b/components/ideas/idea-detail-layout.tsx @@ -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[0]> = { draft: 'DRAFT', @@ -38,7 +40,7 @@ const API_TO_DB: Record[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) => (