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/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>> 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. 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) => (