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:
commit
6cda5b4930
6 changed files with 418 additions and 3 deletions
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
85
app/(app)/ideas/[id]/sync-tab-server.ts
Normal file
85
app/(app)/ideas/[id]/sync-tab-server.ts
Normal 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>>>
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
233
components/ideas/idea-sync-tab.tsx
Normal file
233
components/ideas/idea-sync-tab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue