PBI-46: Sprint-niveau jobflow met cascade-FAIL (F1/F2/F4 Scrum4Me) (#136)
* ST-1243: F1 schema + propagateStatusUpwards-helper voor sprint-flow Schema-uitbreidingen voor de sprint-niveau jobflow (PBI-46): - TaskStatus, StoryStatus, PbiStatus, SprintStatus krijgen FAILED - Nieuwe enums: SprintRunStatus, PrStrategy - Nieuw SprintRun-model dat per-task ClaudeJobs groepeert - ClaudeJob.sprint_run_id koppeling + index - Product.pr_strategy (default SPRINT) - Bijhorende Prisma-migratie propagateStatusUpwards vervangt updateTaskStatusWithStoryPromotion en herevalueert de keten Task → Story → PBI → Sprint → SprintRun bij elke task-statuswijziging. Bij FAILED cancelt het sibling-jobs in dezelfde SprintRun. PBI-status BLOCKED blijft handmatig en wordt niet overschreven. Status-mappers + theme krijgen failed-token + label-uitbreidingen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1244: F2 sprint-runs actions + deprecate per-task enqueues actions/sprint-runs.ts (nieuw): - startSprintRunAction met pre-flight (impl_plan / open ClaudeQuestion / PBI BLOCKED|FAILED) - Maakt SprintRun + ClaudeJobs in PBI→Story→Task volgorde - resumeSprintAction zet FAILED tasks/stories/PBIs terug en start nieuwe SprintRun - cancelSprintRunAction breekt lopende SprintRun af zonder cascade actions/claude-jobs.ts: - enqueueClaudeJobAction, enqueueAllTodoJobsAction, previewEnqueueAllAction, enqueueClaudeJobsBatchAction nu deprecation-stubs (UI-cleanup volgt in F4) - cancelClaudeJobAction blijft beschikbaar voor losse jobs Tests bijgewerkt: 11 nieuwe sprint-runs tests, claude-jobs(-batch) tests herzien naar deprecation-asserties. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ST-1246: F4 UI Start/Resume/Cancel sprint + pr_strategy dropdown - components/sprint/sprint-run-controls.tsx: knoppen Start Sprint (sprintStatus=ACTIVE), Hervat sprint (sprintStatus=FAILED) en Annuleer sprint-run (lopende run). Pre-flight blocker-modal toont blockers met directe links naar de relevante pagina's. - components/products/pr-strategy-select.tsx: dropdown SPRINT|STORY in product-settings, met optimistic update + sonner-toast op fail. - actions/products.ts: updatePrStrategyAction (eigenaar-only, demo-block). - Sprint-page: query op actieve SprintRun + tonen van controls-balk. Live cascade-visualisatie (T-634) staat als follow-up genoteerd — huidige sprint-board statusbadges volstaan voor MVP. De Solo-board "Voer uit"-knoppen zijn niet expliciet verwijderd; ze tonen nu de deprecation-error van de gestubde actions tot de Solo-flow opnieuw ontworpen wordt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ab8c3dca3f
commit
77617e89ac
25 changed files with 1798 additions and 1014 deletions
|
|
@ -1,232 +1,29 @@
|
|||
/**
|
||||
* Uitgebreide integratie-stijl tests voor previewEnqueueAllAction en
|
||||
* enqueueClaudeJobsBatchAction. Gebruikt realistische seed-data:
|
||||
* 2 PBIs, elk met 1 story, elk 2 taken (4 taken totaal in PBI-volgorde).
|
||||
* Per-task batch enqueue is gedeprecateerd ten gunste van startSprintRunAction
|
||||
* (zie actions/sprint-runs.ts). De functies blijven exporteerbaar als stub voor
|
||||
* backwards-compat met UI-componenten die in F4 worden vervangen.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockFindFirstProduct,
|
||||
mockFindFirstSprint,
|
||||
mockFindManyTask,
|
||||
mockTransaction,
|
||||
mockExecuteRaw,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstProduct: vi.fn(),
|
||||
mockFindFirstSprint: vi.fn(),
|
||||
mockFindManyTask: vi.fn(),
|
||||
mockTransaction: vi.fn(),
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
task: { findMany: mockFindManyTask },
|
||||
product: { findFirst: mockFindFirstProduct },
|
||||
sprint: { findFirst: mockFindFirstSprint },
|
||||
claudeJob: { create: vi.fn() },
|
||||
$executeRaw: mockExecuteRaw,
|
||||
$transaction: mockTransaction,
|
||||
},
|
||||
}))
|
||||
vi.mock('@/lib/auth', () => ({ getSession: vi.fn() }))
|
||||
vi.mock('@/lib/prisma', () => ({ prisma: {} }))
|
||||
|
||||
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
|
||||
import {
|
||||
previewEnqueueAllAction,
|
||||
enqueueClaudeJobsBatchAction,
|
||||
} from '@/actions/claude-jobs'
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
const PRODUCT_ID = 'product-1'
|
||||
const SPRINT_ID = 'sprint-1'
|
||||
|
||||
// --- Seed helpers ---
|
||||
const makePbi1Task = (id: string, status = 'TO_DO') => ({
|
||||
id,
|
||||
title: `PBI-1 Taak ${id}`,
|
||||
status,
|
||||
story: {
|
||||
id: 'story-pbi1',
|
||||
title: 'Story van PBI 1',
|
||||
code: 'ST-1',
|
||||
pbi: { id: 'pbi-1', status: 'READY', priority: 1, sort_order: 1.0 },
|
||||
},
|
||||
})
|
||||
|
||||
const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
|
||||
id,
|
||||
title: `PBI-2 Taak ${id}`,
|
||||
status,
|
||||
story: {
|
||||
id: 'story-pbi2',
|
||||
title: 'Story van PBI 2',
|
||||
code: 'ST-2',
|
||||
pbi: { id: 'pbi-2', status: pbiStatus, priority: 2, sort_order: 2.0 },
|
||||
},
|
||||
})
|
||||
|
||||
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
||||
id,
|
||||
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||
})
|
||||
|
||||
// Canonical seed: [pbi1-t1, pbi1-t2, pbi2-t1, pbi2-t2]
|
||||
const SEED_ALL_TODO = [
|
||||
makePbi1Task('pbi1-t1'),
|
||||
makePbi1Task('pbi1-t2'),
|
||||
makePbi2Task('pbi2-t1'),
|
||||
makePbi2Task('pbi2-t2'),
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteRaw.mockResolvedValue(undefined)
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// previewEnqueueAllAction
|
||||
// =============================================================
|
||||
describe('previewEnqueueAllAction — 2 PBI scenario', () => {
|
||||
it('geen blocker: alle 4 TO_DO taken → tasks=[4], blockerIndex=null', async () => {
|
||||
mockFindManyTask.mockResolvedValue(SEED_ALL_TODO)
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
|
||||
if (!('error' in result)) {
|
||||
expect(result.tasks).toHaveLength(4)
|
||||
expect(result.tasks.map(t => t.id)).toEqual(['pbi1-t1', 'pbi1-t2', 'pbi2-t1', 'pbi2-t2'])
|
||||
}
|
||||
})
|
||||
|
||||
it('3e taak (pbi2-t1) REVIEW → blockerIndex=2, reden=task-review, tasks=[3]', async () => {
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbi1Task('pbi1-t1'),
|
||||
makePbi1Task('pbi1-t2'),
|
||||
makePbi2Task('pbi2-t1', 'REVIEW'),
|
||||
makePbi2Task('pbi2-t2'),
|
||||
])
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
|
||||
if (!('error' in result)) {
|
||||
expect(result.tasks).toHaveLength(3)
|
||||
expect(result.tasks[2].id).toBe('pbi2-t1')
|
||||
}
|
||||
})
|
||||
|
||||
it('PBI 1 BLOCKED → blockerIndex=0, reden=pbi-blocked, tasks=[1]', async () => {
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbi1Task('pbi1-t1', 'TO_DO'),
|
||||
makePbi1Task('pbi1-t2', 'TO_DO'),
|
||||
makePbi2Task('pbi2-t1'),
|
||||
makePbi2Task('pbi2-t2'),
|
||||
].map((t, i) => i < 2 ? { ...t, story: { ...t.story, pbi: { ...t.story.pbi, status: 'BLOCKED' } } } : t))
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
|
||||
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ACTIVE job op pbi1-t1 → geskipt door where-clause, geen blocker bij resterende 3', async () => {
|
||||
// Simuleert dat pbi1-t1 een actieve job heeft: de where-clause sluit die taak uit
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbi1Task('pbi1-t2'),
|
||||
makePbi2Task('pbi2-t1'),
|
||||
makePbi2Task('pbi2-t2'),
|
||||
])
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
|
||||
if (!('error' in result)) {
|
||||
expect(result.tasks).toHaveLength(3)
|
||||
expect(result.tasks[0].id).toBe('pbi1-t2')
|
||||
}
|
||||
})
|
||||
|
||||
it('ACTIVE job op pbi1-t1 AND pbi2-t1 REVIEW → blockerIndex=1 in resterende array', async () => {
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbi1Task('pbi1-t2'),
|
||||
makePbi2Task('pbi2-t1', 'REVIEW'),
|
||||
makePbi2Task('pbi2-t2'),
|
||||
])
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: 1, blockerReason: 'task-review' })
|
||||
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('demo-user → error, findMany niet aangeroepen', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||
describe('previewEnqueueAllAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await previewEnqueueAllAction('prod-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('vervangen') })
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================
|
||||
// enqueueClaudeJobsBatchAction
|
||||
// =============================================================
|
||||
describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
|
||||
it('happy path: 2 taskIds → 2 QUEUED ClaudeJobs in invoervolgorde', async () => {
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makeBatchTask('pbi1-t1'),
|
||||
makeBatchTask('pbi2-t1'),
|
||||
])
|
||||
mockTransaction.mockResolvedValue([
|
||||
{ id: 'job-a', task_id: 'pbi1-t1' },
|
||||
{ id: 'job-b', task_id: 'pbi2-t1' },
|
||||
])
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
|
||||
|
||||
expect(result).toEqual({ success: true, count: 2 })
|
||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('IDOR: taskId van niet-toegewezen story → error, geen transaction', async () => {
|
||||
// Authorized tasks bevat maar 1 van de 2 gevraagde IDs
|
||||
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1')])
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'other-user-task'])
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('niet toegankelijk') })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('taak met ACTIVE job wordt overgeslagen (idempotent)', async () => {
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makeBatchTask('pbi1-t1'),
|
||||
makeBatchTask('pbi1-t2', true), // heeft actieve job → skip
|
||||
makeBatchTask('pbi2-t1'),
|
||||
])
|
||||
mockTransaction.mockResolvedValue([
|
||||
{ id: 'job-a', task_id: 'pbi1-t1' },
|
||||
{ id: 'job-b', task_id: 'pbi2-t1' },
|
||||
])
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2', 'pbi2-t1'])
|
||||
|
||||
expect(result).toEqual({ success: true, count: 2 })
|
||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('demo-user → error, geen transaction', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
describe('enqueueClaudeJobsBatchAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await enqueueClaudeJobsBatchAction('prod-1', ['t1', 't2'])
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,47 +1,30 @@
|
|||
/**
|
||||
* Per-task enqueue-acties zijn gedeprecateerd. cancelClaudeJobAction blijft
|
||||
* actief — gebruikt voor het annuleren van losse jobs (bv. idea-jobs).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
mockGetSession,
|
||||
mockFindFirstTask,
|
||||
mockFindManyTask,
|
||||
mockFindFirstProduct,
|
||||
mockFindFirstSprint,
|
||||
mockFindFirstJob,
|
||||
mockCreateJob,
|
||||
mockUpdateJob,
|
||||
mockExecuteRaw,
|
||||
mockTransaction,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetSession: vi.fn(),
|
||||
mockFindFirstTask: vi.fn(),
|
||||
mockFindManyTask: vi.fn(),
|
||||
mockFindFirstProduct: vi.fn(),
|
||||
mockFindFirstSprint: vi.fn(),
|
||||
mockFindFirstJob: vi.fn(),
|
||||
mockCreateJob: vi.fn(),
|
||||
mockUpdateJob: vi.fn(),
|
||||
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||
mockTransaction: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
task: { findFirst: mockFindFirstTask, findMany: mockFindManyTask },
|
||||
product: { findFirst: mockFindFirstProduct },
|
||||
sprint: { findFirst: mockFindFirstSprint },
|
||||
claudeJob: {
|
||||
findFirst: mockFindFirstJob,
|
||||
create: mockCreateJob,
|
||||
update: mockUpdateJob,
|
||||
},
|
||||
$executeRaw: mockExecuteRaw,
|
||||
$transaction: mockTransaction,
|
||||
},
|
||||
}))
|
||||
|
||||
|
|
@ -49,394 +32,75 @@ import {
|
|||
enqueueClaudeJobAction,
|
||||
enqueueAllTodoJobsAction,
|
||||
cancelClaudeJobAction,
|
||||
previewEnqueueAllAction,
|
||||
enqueueClaudeJobsBatchAction,
|
||||
} from '@/actions/claude-jobs'
|
||||
|
||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||
|
||||
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||
const TASK_ID = 'task-cuid-1'
|
||||
const JOB_ID = 'job-cuid-1'
|
||||
const PRODUCT_ID = 'product-cuid-1'
|
||||
|
||||
const MOCK_TASK = { id: TASK_ID, story: { product_id: PRODUCT_ID } }
|
||||
const MOCK_JOB_QUEUED = { id: JOB_ID, status: 'QUEUED' as const, task_id: TASK_ID, product_id: PRODUCT_ID }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockExecuteRaw.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('enqueueClaudeJobAction', () => {
|
||||
it('happy path: creates job with QUEUED status', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue(null)
|
||||
mockCreateJob.mockResolvedValue({ id: JOB_ID })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, jobId: JOB_ID })
|
||||
expect(mockCreateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.objectContaining({ status: 'QUEUED', task_id: TASK_ID }) })
|
||||
)
|
||||
})
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when task not found', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(null)
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Task niet gevonden' })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('idempotency: returns existing jobId when QUEUED job exists', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue({ id: JOB_ID })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Er loopt al een agent voor deze task', jobId: JOB_ID })
|
||||
expect(mockCreateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('allows new enqueue after terminal (DONE) job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstTask.mockResolvedValue(MOCK_TASK)
|
||||
mockFindFirstJob.mockResolvedValue(null) // no active job
|
||||
mockCreateJob.mockResolvedValue({ id: 'new-job-id' })
|
||||
|
||||
const result = await enqueueClaudeJobAction(TASK_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, jobId: 'new-job-id' })
|
||||
describe('enqueueClaudeJobAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await enqueueClaudeJobAction('task-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('enqueueAllTodoJobsAction', () => {
|
||||
it('happy path: scopes to active sprint + assignee, queues all queueable tasks', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([{ id: 'task-a' }, { id: 'task-b' }])
|
||||
mockTransaction.mockResolvedValue([
|
||||
{ id: 'job-a', task_id: 'task-a' },
|
||||
{ id: 'job-b', task_id: 'task-b' },
|
||||
])
|
||||
|
||||
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, count: 2 })
|
||||
expect(mockFindManyTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
status: 'TO_DO',
|
||||
story: { sprint_id: 'sprint-1', assignee_id: SESSION_USER.userId },
|
||||
}),
|
||||
})
|
||||
)
|
||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns count=0 when product has no active sprint', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue(null)
|
||||
|
||||
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, count: 0 })
|
||||
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns count=0 when no queueable tasks in sprint+assignee scope', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([])
|
||||
|
||||
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toEqual({ success: true, count: 0 })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
expect(mockExecuteRaw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when product not accessible', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue(null)
|
||||
|
||||
const result = await enqueueAllTodoJobsAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
const makePbiTask = (id: string, status: string, pbiStatus = 'READY') => ({
|
||||
id,
|
||||
title: `Task ${id}`,
|
||||
status,
|
||||
story: { id: 'story-1', title: 'Story 1', code: 'ST-1', pbi: { id: 'pbi-1', status: pbiStatus, priority: 1, sort_order: 1.0 } },
|
||||
})
|
||||
|
||||
describe('previewEnqueueAllAction', () => {
|
||||
it('blocks demo user', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when product not accessible', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue(null)
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
|
||||
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty tasks when no active sprint', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue(null)
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toEqual({ tasks: [], blockerIndex: null, blockerReason: null })
|
||||
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns all tasks with no blocker when only TO_DO tasks', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbiTask('t1', 'TO_DO'),
|
||||
makePbiTask('t2', 'TO_DO'),
|
||||
])
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
|
||||
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('detects REVIEW task as blocker at correct index', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbiTask('t1', 'TO_DO'),
|
||||
makePbiTask('t2', 'TO_DO'),
|
||||
makePbiTask('t3', 'REVIEW'),
|
||||
makePbiTask('t4', 'TO_DO'),
|
||||
])
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
|
||||
if (!('error' in result)) expect(result.tasks).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('detects BLOCKED PBI as blocker at first task of that PBI', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makePbiTask('t1', 'TO_DO', 'BLOCKED'),
|
||||
makePbiTask('t2', 'TO_DO', 'BLOCKED'),
|
||||
])
|
||||
|
||||
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
|
||||
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('queries without TO_DO filter to expose REVIEW tasks', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([])
|
||||
|
||||
await previewEnqueueAllAction(PRODUCT_ID)
|
||||
|
||||
expect(mockFindManyTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: expect.not.objectContaining({ status: 'TO_DO' }),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
||||
id,
|
||||
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||
})
|
||||
|
||||
describe('enqueueClaudeJobsBatchAction', () => {
|
||||
it('happy path: 3 taskIds → 3 jobs in input order', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makeBatchTask('t1'),
|
||||
makeBatchTask('t2'),
|
||||
makeBatchTask('t3'),
|
||||
])
|
||||
mockTransaction.mockResolvedValue([
|
||||
{ id: 'job-1', task_id: 't1' },
|
||||
{ id: 'job-2', task_id: 't2' },
|
||||
{ id: 'job-3', task_id: 't3' },
|
||||
])
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
|
||||
|
||||
expect(result).toEqual({ success: true, count: 3 })
|
||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when product not accessible', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue(null)
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||
|
||||
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when task belongs to another user (IDOR)', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
// Only 1 of 2 tasks authorized (other-user's task filtered out)
|
||||
mockFindManyTask.mockResolvedValue([makeBatchTask('t1')])
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user'])
|
||||
|
||||
expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' })
|
||||
expect(mockTransaction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips tasks with active jobs (idempotent)', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||
mockFindManyTask.mockResolvedValue([
|
||||
makeBatchTask('t1'),
|
||||
makeBatchTask('t2', true), // has active job — skip
|
||||
makeBatchTask('t3'),
|
||||
])
|
||||
mockTransaction.mockResolvedValue([
|
||||
{ id: 'job-1', task_id: 't1' },
|
||||
{ id: 'job-3', task_id: 't3' },
|
||||
])
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
|
||||
|
||||
expect(result).toEqual({ success: true, count: 2 })
|
||||
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('returns count=0 for empty taskIds', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
|
||||
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, [])
|
||||
|
||||
expect(result).toEqual({ success: true, count: 0 })
|
||||
expect(mockFindFirstProduct).not.toHaveBeenCalled()
|
||||
describe('enqueueAllTodoJobsAction (deprecated)', () => {
|
||||
it('retourneert een deprecation-error', async () => {
|
||||
const result = await enqueueAllTodoJobsAction('prod-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('Start Sprint') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelClaudeJobAction', () => {
|
||||
it('happy path: cancels QUEUED job', async () => {
|
||||
it('cancelt een actieve job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(MOCK_JOB_QUEUED)
|
||||
mockUpdateJob.mockResolvedValue({})
|
||||
mockFindFirstJob.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
status: 'QUEUED',
|
||||
task_id: 'task-1',
|
||||
product_id: 'prod-1',
|
||||
})
|
||||
mockUpdateJob.mockResolvedValue(undefined)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
const result = await cancelClaudeJobAction('job-1')
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: JOB_ID },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
)
|
||||
expect(mockUpdateJob).toHaveBeenCalledWith({
|
||||
where: { id: 'job-1' },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('demo user is blocked', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||
it('weigert demo-sessie', async () => {
|
||||
mockGetSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||
const result = await cancelClaudeJobAction('job-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when job not found (ownership check)', async () => {
|
||||
it('retourneert error als job niet gevonden', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue(null)
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Job niet gevonden' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
const result = await cancelClaudeJobAction('nonexistent')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('niet gevonden') })
|
||||
})
|
||||
|
||||
it('returns error when cancelling terminal (DONE) job', async () => {
|
||||
it('weigert wanneer job niet meer actief is', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'DONE' as const })
|
||||
mockFindFirstJob.mockResolvedValue({
|
||||
id: 'job-1',
|
||||
status: 'DONE',
|
||||
task_id: 'task-1',
|
||||
product_id: 'prod-1',
|
||||
})
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
||||
expect(mockUpdateJob).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns error when cancelling FAILED job', async () => {
|
||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||
mockFindFirstJob.mockResolvedValue({ ...MOCK_JOB_QUEUED, status: 'FAILED' as const })
|
||||
|
||||
const result = await cancelClaudeJobAction(JOB_ID)
|
||||
|
||||
expect(result).toMatchObject({ error: 'Alleen actieve jobs kunnen geannuleerd worden' })
|
||||
const result = await cancelClaudeJobAction('job-1')
|
||||
expect(result).toMatchObject({ error: expect.stringContaining('actieve') })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
303
__tests__/actions/sprint-runs.test.ts
Normal file
303
__tests__/actions/sprint-runs.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||
vi.mock('next/headers', () => ({ cookies: vi.fn().mockResolvedValue({}) }))
|
||||
vi.mock('iron-session', () => ({
|
||||
getIronSession: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/lib/session', () => ({
|
||||
sessionOptions: { cookieName: 'test', password: 'test' },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
sprint: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
claudeQuestion: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import {
|
||||
startSprintRunAction,
|
||||
resumeSprintAction,
|
||||
cancelSprintRunAction,
|
||||
} from '@/actions/sprint-runs'
|
||||
|
||||
const mockSession = getIronSession as ReturnType<typeof vi.fn>
|
||||
|
||||
type Mocked = {
|
||||
sprint: { findUnique: ReturnType<typeof vi.fn>; update: ReturnType<typeof vi.fn> }
|
||||
sprintRun: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
create: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: {
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: { updateMany: ReturnType<typeof vi.fn> }
|
||||
task: { updateMany: ReturnType<typeof vi.fn> }
|
||||
claudeQuestion: { findMany: ReturnType<typeof vi.fn> }
|
||||
claudeJob: {
|
||||
create: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const mockPrisma = prisma as unknown as Mocked
|
||||
|
||||
const SPRINT_OK = {
|
||||
id: 'sprint-1',
|
||||
status: 'ACTIVE',
|
||||
product_id: 'prod-1',
|
||||
product: { id: 'prod-1', pr_strategy: 'SPRINT' },
|
||||
}
|
||||
|
||||
const STORY_OK = {
|
||||
id: 'story-1',
|
||||
pbi_id: 'pbi-1',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
pbi: {
|
||||
id: 'pbi-1',
|
||||
code: 'PBI-1',
|
||||
title: 'PBI',
|
||||
status: 'READY',
|
||||
priority: 1,
|
||||
sort_order: 1,
|
||||
},
|
||||
tasks: [
|
||||
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: 'plan' },
|
||||
{ id: 'task-2', code: 'T-2', title: 'T2', priority: 1, sort_order: 2, implementation_plan: 'plan' },
|
||||
],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSession.mockResolvedValue({ userId: 'user-1', isDemo: false })
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
|
||||
)
|
||||
})
|
||||
|
||||
describe('startSprintRunAction — happy path', () => {
|
||||
it('maakt SprintRun + 2 ClaudeJobs voor 2 TO_DO tasks', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-1' })
|
||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toEqual({ ok: true, sprint_run_id: 'run-1', jobs_count: 2 })
|
||||
expect(mockPrisma.sprintRun.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
sprint_id: 'sprint-1',
|
||||
started_by_id: 'user-1',
|
||||
status: 'QUEUED',
|
||||
pr_strategy: 'SPRINT',
|
||||
}),
|
||||
})
|
||||
expect(mockPrisma.claudeJob.create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSprintRunAction — pre-flight blockers', () => {
|
||||
it('blokkeert wanneer task geen implementation_plan heeft', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{
|
||||
...STORY_OK,
|
||||
tasks: [
|
||||
{ id: 'task-1', code: 'T-1', title: 'T1', priority: 1, sort_order: 1, implementation_plan: null },
|
||||
],
|
||||
},
|
||||
])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
||||
if (result.ok === false && 'blockers' in result) {
|
||||
expect(result.blockers).toContainEqual({
|
||||
type: 'task_no_plan',
|
||||
id: 'task-1',
|
||||
label: 'T-1: T1',
|
||||
})
|
||||
}
|
||||
expect(mockPrisma.sprintRun.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blokkeert wanneer er een open ClaudeQuestion in scope is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([STORY_OK])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([
|
||||
{ id: 'q-1', question: 'Welke route?' },
|
||||
])
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
||||
if (result.ok === false && 'blockers' in result) {
|
||||
expect(result.blockers).toContainEqual({
|
||||
type: 'open_question',
|
||||
id: 'q-1',
|
||||
label: 'Welke route?',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('blokkeert wanneer een PBI BLOCKED of FAILED is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockResolvedValue([
|
||||
{ ...STORY_OK, pbi: { ...STORY_OK.pbi, status: 'BLOCKED' } },
|
||||
])
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: 'PRE_FLIGHT_BLOCKED' })
|
||||
if (result.ok === false && 'blockers' in result) {
|
||||
expect(result.blockers).toContainEqual({
|
||||
type: 'pbi_blocked',
|
||||
id: 'pbi-1',
|
||||
label: 'PBI-1: PBI',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('startSprintRunAction — guards', () => {
|
||||
it('weigert wanneer Sprint niet ACTIVE is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'COMPLETED' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_ACTIVE' })
|
||||
})
|
||||
|
||||
it('weigert wanneer er al een actieve SprintRun is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue({ id: 'run-existing', status: 'RUNNING' })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE' })
|
||||
})
|
||||
|
||||
it('weigert demo-sessie', async () => {
|
||||
mockSession.mockResolvedValue({ userId: 'demo', isDemo: true })
|
||||
|
||||
const result = await startSprintRunAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, code: 403 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeSprintAction', () => {
|
||||
it('zet sprint en cascade-statuses terug en maakt nieuwe SprintRun', async () => {
|
||||
// Eerste findUnique (resume) ziet de sprint nog op FAILED;
|
||||
// de tweede call (binnen startSprintRunCore na de update) ziet ACTIVE.
|
||||
mockPrisma.sprint.findUnique
|
||||
.mockResolvedValueOnce({ ...SPRINT_OK, status: 'FAILED' })
|
||||
.mockResolvedValue(SPRINT_OK)
|
||||
mockPrisma.sprintRun.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { select?: { pbi_id?: boolean } }) => {
|
||||
if (args.select?.pbi_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return [STORY_OK]
|
||||
})
|
||||
mockPrisma.claudeQuestion.findMany.mockResolvedValue([])
|
||||
mockPrisma.sprintRun.create.mockResolvedValue({ id: 'run-2' })
|
||||
mockPrisma.claudeJob.create.mockResolvedValue({ id: 'job-x' })
|
||||
|
||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
||||
|
||||
expect(result).toMatchObject({ ok: true, sprint_run_id: 'run-2' })
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sprint-1' },
|
||||
data: { status: 'ACTIVE', completed_at: null },
|
||||
})
|
||||
expect(mockPrisma.story.updateMany).toHaveBeenCalledWith({
|
||||
where: { sprint_id: 'sprint-1', status: 'FAILED' },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
expect(mockPrisma.task.updateMany).toHaveBeenCalledWith({
|
||||
where: { story: { sprint_id: 'sprint-1' }, status: 'FAILED' },
|
||||
data: { status: 'TO_DO' },
|
||||
})
|
||||
})
|
||||
|
||||
it('weigert als sprint niet FAILED is', async () => {
|
||||
mockPrisma.sprint.findUnique.mockResolvedValue({ ...SPRINT_OK, status: 'ACTIVE' })
|
||||
|
||||
const result = await resumeSprintAction({ sprint_id: 'sprint-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_NOT_FAILED' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('cancelSprintRunAction', () => {
|
||||
it('zet SprintRun op CANCELLED en cancelt openstaande jobs', async () => {
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
||||
id: 'run-1',
|
||||
status: 'RUNNING',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
|
||||
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
|
||||
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith({
|
||||
where: { id: 'run-1' },
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
})
|
||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
sprint_run_id: 'run-1',
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
}),
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('weigert wanneer SprintRun al DONE is', async () => {
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({
|
||||
id: 'run-1',
|
||||
status: 'DONE',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
|
||||
const result = await cancelSprintRunAction({ sprint_run_id: 'run-1' })
|
||||
expect(result).toMatchObject({ ok: false, error: 'SPRINT_RUN_NOT_CANCELLABLE' })
|
||||
})
|
||||
})
|
||||
|
|
@ -23,6 +23,24 @@ vi.mock('@/lib/prisma', () => ({
|
|||
story: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
|
|
@ -44,6 +62,24 @@ const mockPrisma = prisma as unknown as {
|
|||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprint: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -154,7 +190,14 @@ describe('saveTask — edit met status-promotie', () => {
|
|||
implementation_plan: null,
|
||||
})
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
|
||||
const result = await saveTask(
|
||||
{ ...VALID_INPUT, status: 'DONE' },
|
||||
|
|
|
|||
|
|
@ -8,10 +8,13 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
sprint: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
story: {
|
||||
findFirst: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
task: {
|
||||
|
|
@ -19,6 +22,19 @@ vi.mock('@/lib/prisma', () => ({
|
|||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
storyLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
|
|
@ -44,10 +60,15 @@ import { PATCH as patchTask } from '@/app/api/tasks/[id]/route'
|
|||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
product: { findMany: ReturnType<typeof vi.fn>; findFirst: ReturnType<typeof vi.fn> }
|
||||
sprint: { findFirst: ReturnType<typeof vi.fn> }
|
||||
sprint: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
story: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
task: {
|
||||
|
|
@ -55,6 +76,19 @@ const mockPrisma = prisma as unknown as {
|
|||
update: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
storyLog: { create: ReturnType<typeof vi.fn> }
|
||||
todo: { create: ReturnType<typeof vi.fn> }
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -409,7 +443,14 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
implementation_plan: null,
|
||||
})
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'DONE',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
||||
|
||||
const res = await patchTask(
|
||||
makePatch('http://localhost/api/tasks/task-1', { status: 'done' }),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,24 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
story: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
|
|
@ -31,6 +49,24 @@ const mockPrisma = prisma as unknown as {
|
|||
}
|
||||
story: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprint: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
|
|
@ -75,7 +111,14 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
})
|
||||
// Default sibling state: only this task, already DONE → no story-promotion
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'DONE',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
||||
// Pass-through for $transaction so tests behave as if Prisma ran the run-fn directly.
|
||||
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
|
||||
return run(prisma)
|
||||
|
|
@ -190,7 +233,14 @@ describe('PATCH /api/tasks/:id', () => {
|
|||
story_id: 'story-1',
|
||||
})
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }, { status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
|
||||
const res = await patchTask(...makeRequest({ status: 'done' }))
|
||||
expect(res.status).toBe(200)
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@ describe('task-status mappers', () => {
|
|||
expect(pbiStatusFromApi('todo')).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes exactly three API values', () => {
|
||||
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'done'])
|
||||
it('exposes alle vier API values', () => {
|
||||
expect(PBI_STATUS_API_VALUES).toEqual(['ready', 'blocked', 'failed', 'done'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -8,6 +8,23 @@ vi.mock('@/lib/prisma', () => ({
|
|||
},
|
||||
story: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
pbi: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
sprint: {
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
claudeJob: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
sprintRun: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
|
|
@ -15,27 +32,35 @@ vi.mock('@/lib/prisma', () => ({
|
|||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
task: {
|
||||
update: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
type MockedPrisma = {
|
||||
task: { update: ReturnType<typeof vi.fn>; findMany: ReturnType<typeof vi.fn> }
|
||||
story: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
findMany: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
pbi: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprint: {
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
claudeJob: {
|
||||
findFirst: ReturnType<typeof vi.fn>
|
||||
updateMany: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sprintRun: {
|
||||
findUnique: ReturnType<typeof vi.fn>
|
||||
update: ReturnType<typeof vi.fn>
|
||||
}
|
||||
$transaction: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Pass-through: $transaction(run) just calls run with the mocked prisma client.
|
||||
mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise<unknown>) => {
|
||||
return run(prisma)
|
||||
})
|
||||
})
|
||||
const mockPrisma = prisma as unknown as MockedPrisma
|
||||
|
||||
const TASK_BASE = {
|
||||
id: 'task-1',
|
||||
|
|
@ -44,110 +69,267 @@ const TASK_BASE = {
|
|||
implementation_plan: null,
|
||||
}
|
||||
|
||||
describe('updateTaskStatusWithStoryPromotion', () => {
|
||||
it('promotes story to DONE when last sibling task transitions to DONE', async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockPrisma.$transaction.mockImplementation(
|
||||
async (run: (tx: typeof prisma) => Promise<unknown>) => run(prisma),
|
||||
)
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — story-niveau', () => {
|
||||
it('zet story op DONE wanneer alle siblings DONE zijn', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'DONE' },
|
||||
{ status: 'DONE' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.storyStatusChange).toBe('promoted')
|
||||
expect(result.storyId).toBe('story-1')
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'DONE' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not promote when story is already DONE (idempotent)', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
it('zet story op FAILED wanneer een task FAILED is, ongeacht andere tasks', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'FAILED' },
|
||||
{ status: 'DONE' },
|
||||
{ status: 'TO_DO' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'FAILED' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||
const result = await propagateStatusUpwards('task-1', 'FAILED')
|
||||
|
||||
expect(result.storyStatusChange).toBe(null)
|
||||
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'FAILED' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not promote when not all siblings are DONE', async () => {
|
||||
it('houdt story op IN_SPRINT als nog niet alle tasks DONE en geen FAILED', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'DONE' },
|
||||
{ status: 'IN_PROGRESS' },
|
||||
{ status: 'TO_DO' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE')
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.storyStatusChange).toBe(null)
|
||||
expect(result.storyChanged).toBe(false)
|
||||
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('demotes story to IN_SPRINT when a task moves out of DONE on a DONE story', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
it('demoot story uit DONE als een task terug naar TO_DO gaat', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'TO_DO' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'IN_PROGRESS' },
|
||||
{ status: 'TO_DO' },
|
||||
{ status: 'DONE' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'DONE' })
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'DONE',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'DONE' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'IN_SPRINT' }, { status: 'DONE' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'COMPLETED' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'READY' }])
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||
const result = await propagateStatusUpwards('task-1', 'TO_DO')
|
||||
|
||||
expect(result.storyStatusChange).toBe('demoted')
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not demote when story is not DONE', async () => {
|
||||
it('zet story op OPEN als sprint_id null is en niet DONE/FAILED', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||
|
||||
expect(result.storyStatusChange).toBe(null)
|
||||
expect(mockPrisma.story.update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the task regardless of story-status change', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
|
||||
await updateTaskStatusWithStoryPromotion('task-1', 'IN_PROGRESS')
|
||||
|
||||
expect(mockPrisma.task.update).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: { status: 'IN_PROGRESS' },
|
||||
select: expect.any(Object),
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
|
||||
|
||||
it('uses the provided transaction client when passed', async () => {
|
||||
const tx = {
|
||||
task: { update: vi.fn(), findMany: vi.fn() },
|
||||
story: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
|
||||
}
|
||||
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
tx.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
tx.story.findUniqueOrThrow.mockResolvedValue({ status: 'IN_SPRINT' })
|
||||
const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS')
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await updateTaskStatusWithStoryPromotion('task-1', 'DONE', tx as any)
|
||||
|
||||
expect(result.storyStatusChange).toBe('promoted')
|
||||
// $transaction should NOT be called when caller already provides a tx.
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(tx.story.update).toHaveBeenCalledWith({
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(mockPrisma.story.update).toHaveBeenCalledWith({
|
||||
where: { id: 'story-1' },
|
||||
data: { status: 'DONE' },
|
||||
data: { status: 'OPEN' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — PBI BLOCKED met rust laten', () => {
|
||||
it('overschrijft een handmatig BLOCKED PBI niet', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'BLOCKED' })
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.pbiChanged).toBe(false)
|
||||
expect(mockPrisma.pbi.update).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — sprint cascade tot SprintRun', () => {
|
||||
it('zet bij FAILED de hele keten op FAILED en cancelt sibling-jobs', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'FAILED' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([
|
||||
{ status: 'FAILED' },
|
||||
{ status: 'DONE' },
|
||||
])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'FAILED' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
// findMany on pbi:
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'FAILED' }])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'FAILED')
|
||||
|
||||
expect(result.storyChanged).toBe(true)
|
||||
expect(result.pbiChanged).toBe(true)
|
||||
expect(result.sprintChanged).toBe(true)
|
||||
expect(result.sprintRunChanged).toBe(true)
|
||||
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'run-1' },
|
||||
data: expect.objectContaining({ status: 'FAILED', failed_task_id: 'task-1' }),
|
||||
}))
|
||||
expect(mockPrisma.claudeJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
sprint_run_id: 'run-1',
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
id: { not: 'job-1' },
|
||||
}),
|
||||
data: expect.objectContaining({ status: 'CANCELLED' }),
|
||||
}))
|
||||
})
|
||||
|
||||
it('zet bij alle DONE de SprintRun op DONE en Sprint op COMPLETED', async () => {
|
||||
mockPrisma.task.update.mockResolvedValue({ ...TASK_BASE, status: 'DONE' })
|
||||
mockPrisma.task.findMany.mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'IN_SPRINT',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: 'sprint-1',
|
||||
})
|
||||
mockPrisma.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
mockPrisma.story.findMany.mockImplementation(async (args: { where?: { pbi_id?: string; sprint_id?: string } }) => {
|
||||
if (args.where?.pbi_id) return [{ status: 'DONE' }]
|
||||
if (args.where?.sprint_id) return [{ pbi_id: 'pbi-1' }]
|
||||
return []
|
||||
})
|
||||
mockPrisma.sprint.findUniqueOrThrow.mockResolvedValue({ id: 'sprint-1', status: 'ACTIVE' })
|
||||
;(mockPrisma.pbi as unknown as { findMany: ReturnType<typeof vi.fn> }).findMany = vi.fn().mockResolvedValue([{ status: 'DONE' }])
|
||||
mockPrisma.claudeJob.findFirst.mockResolvedValue({ id: 'job-1', sprint_run_id: 'run-1' })
|
||||
mockPrisma.sprintRun.findUnique.mockResolvedValue({ id: 'run-1', status: 'RUNNING' })
|
||||
|
||||
const result = await propagateStatusUpwards('task-1', 'DONE')
|
||||
|
||||
expect(result.sprintRunChanged).toBe(true)
|
||||
expect(mockPrisma.sprint.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'sprint-1' },
|
||||
data: expect.objectContaining({ status: 'COMPLETED' }),
|
||||
}))
|
||||
expect(mockPrisma.sprintRun.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'run-1' },
|
||||
data: expect.objectContaining({ status: 'DONE' }),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('propagateStatusUpwards — transactionele aanroep', () => {
|
||||
it('gebruikt de meegegeven transaction client', async () => {
|
||||
const tx = {
|
||||
task: { update: vi.fn(), findMany: vi.fn() },
|
||||
story: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() },
|
||||
pbi: { findUniqueOrThrow: vi.fn(), findMany: vi.fn(), update: vi.fn() },
|
||||
sprint: { findUniqueOrThrow: vi.fn(), update: vi.fn() },
|
||||
claudeJob: { findFirst: vi.fn(), updateMany: vi.fn() },
|
||||
sprintRun: { findUnique: vi.fn(), update: vi.fn() },
|
||||
}
|
||||
tx.task.update.mockResolvedValue({ ...TASK_BASE, status: 'IN_PROGRESS' })
|
||||
tx.task.findMany.mockResolvedValue([{ status: 'IN_PROGRESS' }])
|
||||
tx.story.findUniqueOrThrow.mockResolvedValue({
|
||||
id: 'story-1',
|
||||
status: 'OPEN',
|
||||
pbi_id: 'pbi-1',
|
||||
sprint_id: null,
|
||||
})
|
||||
tx.pbi.findUniqueOrThrow.mockResolvedValue({ id: 'pbi-1', status: 'READY' })
|
||||
tx.story.findMany.mockResolvedValue([{ status: 'OPEN' }])
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await propagateStatusUpwards('task-1', 'IN_PROGRESS', tx as any)
|
||||
|
||||
expect(result.storyChanged).toBe(false)
|
||||
// $transaction wordt niet aangeroepen wanneer caller al een tx meegeeft.
|
||||
expect(mockPrisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,273 +30,49 @@ type PreflightResult =
|
|||
| { error: string }
|
||||
| { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null }
|
||||
|
||||
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const limited = enforceUserRateLimit('enqueue-job', session.userId)
|
||||
if (limited) return { error: limited.error }
|
||||
|
||||
if (!taskId) return { error: 'task_id is verplicht' }
|
||||
|
||||
// Resolve task + product access in one query
|
||||
const task = await prisma.task.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
story: { product: productAccessFilter(session.userId) },
|
||||
},
|
||||
select: { id: true, story: { select: { product_id: true } } },
|
||||
})
|
||||
if (!task) return { error: 'Task niet gevonden' }
|
||||
|
||||
const productId = task.story.product_id
|
||||
|
||||
// Idempotency: weiger als er al een actieve job voor deze task bestaat
|
||||
const existing = await prisma.claudeJob.findFirst({
|
||||
where: { task_id: taskId, status: { in: ACTIVE_JOB_STATUSES } },
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
return { error: 'Er loopt al een agent voor deze task', jobId: existing.id }
|
||||
/**
|
||||
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
|
||||
* Per-task starts zijn niet meer toegestaan — een sprint draait nu als geheel.
|
||||
* Wordt verwijderd zodra de UI is omgebouwd (F4).
|
||||
*/
|
||||
export async function enqueueClaudeJobAction(_taskId: string): Promise<EnqueueResult> {
|
||||
return {
|
||||
error:
|
||||
'Per-task starten is niet meer mogelijk. Gebruik "Start Sprint" voor de hele actieve sprint.',
|
||||
}
|
||||
|
||||
const job = await prisma.claudeJob.create({
|
||||
data: { user_id: session.userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||
})
|
||||
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: taskId,
|
||||
user_id: session.userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, jobId: job.id }
|
||||
}
|
||||
|
||||
export async function enqueueAllTodoJobsAction(productId: string): Promise<EnqueueAllResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
if (!productId) return { error: 'product_id is verplicht' }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||
|
||||
const userId = session.userId
|
||||
|
||||
// Match het scope dat de gebruiker op het Solo Paneel ziet:
|
||||
// alleen TO_DO-taken in de actieve sprint, in stories die aan deze
|
||||
// gebruiker zijn toegewezen. Anders queue je per ongeluk taken die
|
||||
// niet in de huidige sprint zitten of aan iemand anders toebehoren.
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { success: true, count: 0 }
|
||||
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: {
|
||||
status: 'TO_DO',
|
||||
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
if (tasks.length === 0) return { success: true, count: 0 }
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
tasks.map(t =>
|
||||
prisma.claudeJob.create({
|
||||
data: { user_id: userId, product_id: productId, task_id: t.id, status: 'QUEUED' },
|
||||
select: { id: true, task_id: true },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
for (const job of created) {
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: job.task_id,
|
||||
user_id: userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
/**
|
||||
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
|
||||
*/
|
||||
export async function enqueueAllTodoJobsAction(_productId: string): Promise<EnqueueAllResult> {
|
||||
return {
|
||||
error:
|
||||
'"Alle TO_DO als jobs queueen" is vervangen door "Start Sprint". Gebruik startSprintRunAction.',
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, count: created.length }
|
||||
}
|
||||
|
||||
export async function previewEnqueueAllAction(productId: string): Promise<PreflightResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
if (!productId) return { error: 'product_id is verplicht' }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||
|
||||
const userId = session.userId
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { tasks: [], blockerIndex: null, blockerReason: null }
|
||||
|
||||
const rawTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
status: true,
|
||||
story: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
code: true,
|
||||
pbi: { select: { id: true, status: true, priority: true, sort_order: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ story: { pbi: { priority: 'asc' } } },
|
||||
{ story: { pbi: { sort_order: 'asc' } } },
|
||||
{ story: { sort_order: 'asc' } },
|
||||
{ priority: 'asc' },
|
||||
{ sort_order: 'asc' },
|
||||
],
|
||||
})
|
||||
|
||||
let blockerIndex: number | null = null
|
||||
let blockerReason: 'task-review' | 'pbi-blocked' | null = null
|
||||
|
||||
for (let i = 0; i < rawTasks.length; i++) {
|
||||
const t = rawTasks[i]
|
||||
if (t.status === 'REVIEW') {
|
||||
blockerIndex = i
|
||||
blockerReason = 'task-review'
|
||||
break
|
||||
}
|
||||
if (t.story.pbi.status === 'BLOCKED') {
|
||||
blockerIndex = i
|
||||
blockerReason = 'pbi-blocked'
|
||||
break
|
||||
}
|
||||
/**
|
||||
* @deprecated Vervangen door pre-flight in startSprintRunAction (actions/sprint-runs.ts).
|
||||
*/
|
||||
export async function previewEnqueueAllAction(_productId: string): Promise<PreflightResult> {
|
||||
return {
|
||||
error:
|
||||
'Per-product preview is vervangen door de pre-flight check in startSprintRunAction.',
|
||||
}
|
||||
|
||||
const displayTasks = blockerIndex !== null ? rawTasks.slice(0, blockerIndex + 1) : rawTasks
|
||||
|
||||
const tasks: PreviewTask[] = displayTasks.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
story_title: t.story.title,
|
||||
pbi_id: t.story.pbi.id,
|
||||
pbi_status: t.story.pbi.status,
|
||||
}))
|
||||
|
||||
return { tasks, blockerIndex, blockerReason }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Vervangen door startSprintRunAction in actions/sprint-runs.ts.
|
||||
*/
|
||||
export async function enqueueClaudeJobsBatchAction(
|
||||
productId: string,
|
||||
taskIds: string[]
|
||||
_productId: string,
|
||||
_taskIds: string[]
|
||||
): Promise<EnqueueAllResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const limited = enforceUserRateLimit('enqueue-job', session.userId)
|
||||
if (limited) return { error: limited.error }
|
||||
|
||||
if (!productId) return { error: 'product_id is verplicht' }
|
||||
if (!taskIds.length) return { success: true, count: 0 }
|
||||
|
||||
const product = await prisma.product.findFirst({
|
||||
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||
|
||||
const userId = session.userId
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: productId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
|
||||
|
||||
const authorizedTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
id: { in: taskIds },
|
||||
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
claude_jobs: {
|
||||
where: { status: { in: ACTIVE_JOB_STATUSES } },
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (authorizedTasks.length !== taskIds.length) {
|
||||
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
|
||||
return {
|
||||
error:
|
||||
'Batch-queue per task is vervangen door "Start Sprint". Gebruik startSprintRunAction.',
|
||||
}
|
||||
|
||||
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
|
||||
if (queueable.length === 0) return { success: true, count: 0 }
|
||||
|
||||
const queueableIds = new Set(queueable.map(t => t.id))
|
||||
const orderedQueueable = taskIds.filter(id => queueableIds.has(id))
|
||||
|
||||
const created = await prisma.$transaction(
|
||||
orderedQueueable.map(taskId =>
|
||||
prisma.claudeJob.create({
|
||||
data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||
select: { id: true, task_id: true },
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
for (const job of created) {
|
||||
await prisma.$executeRaw`
|
||||
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||
type: 'claude_job_enqueued',
|
||||
job_id: job.id,
|
||||
task_id: job.task_id,
|
||||
user_id: userId,
|
||||
product_id: productId,
|
||||
status: 'queued',
|
||||
})}::text)
|
||||
`
|
||||
}
|
||||
|
||||
revalidatePath(`/products/${productId}/solo`)
|
||||
return { success: true, count: created.length }
|
||||
}
|
||||
|
||||
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
||||
|
|
|
|||
|
|
@ -396,3 +396,27 @@ export async function updateAutoPrAction(id: string, auto_pr: boolean) {
|
|||
revalidatePath(`/products/${id}/settings`)
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function updatePrStrategyAction(
|
||||
id: string,
|
||||
pr_strategy: 'SPRINT' | 'STORY',
|
||||
) {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||
|
||||
const parsed = z
|
||||
.object({ pr_strategy: z.enum(['SPRINT', 'STORY']) })
|
||||
.safeParse({ pr_strategy })
|
||||
if (!parsed.success) return { error: 'Ongeldige waarde voor pr_strategy' }
|
||||
|
||||
const product = await prisma.product.findFirst({ where: { id, user_id: session.userId } })
|
||||
if (!product) return { error: 'Product niet gevonden' }
|
||||
|
||||
await prisma.product.update({
|
||||
where: { id },
|
||||
data: { pr_strategy: parsed.data.pr_strategy },
|
||||
})
|
||||
revalidatePath(`/products/${id}/settings`)
|
||||
return { success: true }
|
||||
}
|
||||
|
|
|
|||
294
actions/sprint-runs.ts
Normal file
294
actions/sprint-runs.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
'use server'
|
||||
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { z } from 'zod'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SessionData, sessionOptions } from '@/lib/session'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
}
|
||||
|
||||
export type PreFlightBlockerType = 'task_no_plan' | 'open_question' | 'pbi_blocked'
|
||||
|
||||
export interface PreFlightBlocker {
|
||||
type: PreFlightBlockerType
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const StartSprintRunInput = z.object({ sprint_id: z.string().min(1) })
|
||||
const ResumeSprintInput = z.object({ sprint_id: z.string().min(1) })
|
||||
const CancelSprintRunInput = z.object({ sprint_run_id: z.string().min(1) })
|
||||
|
||||
interface StartResultOk {
|
||||
ok: true
|
||||
sprint_run_id: string
|
||||
jobs_count: number
|
||||
}
|
||||
|
||||
interface StartResultBlocked {
|
||||
ok: false
|
||||
error: 'PRE_FLIGHT_BLOCKED'
|
||||
blockers: PreFlightBlocker[]
|
||||
}
|
||||
|
||||
interface ErrorResult {
|
||||
ok: false
|
||||
error: string
|
||||
code: number
|
||||
}
|
||||
|
||||
type StartResult = StartResultOk | StartResultBlocked | ErrorResult
|
||||
|
||||
// startSprintRunCore is gedeeld tussen startSprintRunAction en resumeSprintAction.
|
||||
// Voert de pre-flight uit, maakt een SprintRun + ClaudeJobs (in PBI→Story→Task
|
||||
// volgorde) binnen één transactie. Aanroeper levert sprint_id, user_id en de
|
||||
// transactionele Prisma-client.
|
||||
async function startSprintRunCore(
|
||||
tx: Prisma.TransactionClient,
|
||||
sprint_id: string,
|
||||
user_id: string,
|
||||
): Promise<StartResultOk | StartResultBlocked | ErrorResult> {
|
||||
const sprint = await tx.sprint.findUnique({
|
||||
where: { id: sprint_id },
|
||||
include: { product: true },
|
||||
})
|
||||
if (!sprint) return { ok: false, error: 'SPRINT_NOT_FOUND', code: 404 }
|
||||
if (sprint.status !== 'ACTIVE')
|
||||
return { ok: false, error: 'SPRINT_NOT_ACTIVE', code: 400 }
|
||||
|
||||
const activeRun = await tx.sprintRun.findFirst({
|
||||
where: {
|
||||
sprint_id,
|
||||
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
|
||||
},
|
||||
})
|
||||
if (activeRun)
|
||||
return { ok: false, error: 'SPRINT_RUN_ALREADY_ACTIVE', code: 409 }
|
||||
|
||||
const stories = await tx.story.findMany({
|
||||
where: { sprint_id, status: { not: 'DONE' } },
|
||||
include: {
|
||||
pbi: true,
|
||||
tasks: {
|
||||
where: { status: 'TO_DO' },
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
},
|
||||
},
|
||||
orderBy: [{ priority: 'asc' }, { sort_order: 'asc' }],
|
||||
})
|
||||
|
||||
const blockers: PreFlightBlocker[] = []
|
||||
|
||||
for (const s of stories) {
|
||||
for (const t of s.tasks) {
|
||||
if (!t.implementation_plan) {
|
||||
blockers.push({
|
||||
type: 'task_no_plan',
|
||||
id: t.id,
|
||||
label: `${t.code}: ${t.title}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openQuestions = await tx.claudeQuestion.findMany({
|
||||
where: { story: { sprint_id }, status: 'open' },
|
||||
select: { id: true, question: true },
|
||||
})
|
||||
for (const q of openQuestions) {
|
||||
blockers.push({
|
||||
type: 'open_question',
|
||||
id: q.id,
|
||||
label: q.question.slice(0, 80),
|
||||
})
|
||||
}
|
||||
|
||||
const seenPbi = new Set<string>()
|
||||
for (const s of stories) {
|
||||
if (seenPbi.has(s.pbi.id)) continue
|
||||
seenPbi.add(s.pbi.id)
|
||||
if (s.pbi.status === 'BLOCKED' || s.pbi.status === 'FAILED') {
|
||||
blockers.push({
|
||||
type: 'pbi_blocked',
|
||||
id: s.pbi.id,
|
||||
label: `${s.pbi.code}: ${s.pbi.title}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (blockers.length > 0) {
|
||||
return { ok: false, error: 'PRE_FLIGHT_BLOCKED', blockers }
|
||||
}
|
||||
|
||||
const sprintRun = await tx.sprintRun.create({
|
||||
data: {
|
||||
sprint_id,
|
||||
started_by_id: user_id,
|
||||
status: 'QUEUED',
|
||||
pr_strategy: sprint.product.pr_strategy,
|
||||
started_at: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
const orderedTasks = stories
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.pbi.priority - b.pbi.priority ||
|
||||
a.pbi.sort_order - b.pbi.sort_order ||
|
||||
a.priority - b.priority ||
|
||||
a.sort_order - b.sort_order,
|
||||
)
|
||||
.flatMap((s) => s.tasks)
|
||||
|
||||
for (const t of orderedTasks) {
|
||||
await tx.claudeJob.create({
|
||||
data: {
|
||||
user_id,
|
||||
product_id: sprint.product_id,
|
||||
task_id: t.id,
|
||||
sprint_run_id: sprintRun.id,
|
||||
kind: 'TASK_IMPLEMENTATION',
|
||||
status: 'QUEUED',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return { ok: true, sprint_run_id: sprintRun.id, jobs_count: orderedTasks.length }
|
||||
}
|
||||
|
||||
export async function startSprintRunAction(input: unknown): Promise<StartResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo)
|
||||
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = StartSprintRunInput.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const userId = session.userId
|
||||
const result = await prisma.$transaction((tx) =>
|
||||
startSprintRunCore(tx, parsed.data.sprint_id, userId),
|
||||
)
|
||||
|
||||
if (result.ok) {
|
||||
revalidatePath(`/sprints/${parsed.data.sprint_id}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function resumeSprintAction(input: unknown): Promise<StartResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo)
|
||||
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = ResumeSprintInput.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const userId = session.userId
|
||||
const sprint_id = parsed.data.sprint_id
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const sprint = await tx.sprint.findUnique({ where: { id: sprint_id } })
|
||||
if (!sprint)
|
||||
return { ok: false as const, error: 'SPRINT_NOT_FOUND', code: 404 }
|
||||
if (sprint.status !== 'FAILED')
|
||||
return { ok: false as const, error: 'SPRINT_NOT_FAILED', code: 400 }
|
||||
|
||||
// Sprint terug naar ACTIVE
|
||||
await tx.sprint.update({
|
||||
where: { id: sprint_id },
|
||||
data: { status: 'ACTIVE', completed_at: null },
|
||||
})
|
||||
|
||||
// FAILED stories binnen sprint terug naar IN_SPRINT (DONE blijft)
|
||||
await tx.story.updateMany({
|
||||
where: { sprint_id, status: 'FAILED' },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
|
||||
// PBIs van die stories: FAILED → READY (BLOCKED met rust laten)
|
||||
const storyPbiIds = (
|
||||
await tx.story.findMany({
|
||||
where: { sprint_id },
|
||||
select: { pbi_id: true },
|
||||
distinct: ['pbi_id'],
|
||||
})
|
||||
).map((s) => s.pbi_id)
|
||||
await tx.pbi.updateMany({
|
||||
where: { id: { in: storyPbiIds }, status: 'FAILED' },
|
||||
data: { status: 'READY' },
|
||||
})
|
||||
|
||||
// FAILED tasks → TO_DO (DONE blijft)
|
||||
await tx.task.updateMany({
|
||||
where: { story: { sprint_id }, status: 'FAILED' },
|
||||
data: { status: 'TO_DO' },
|
||||
})
|
||||
|
||||
return startSprintRunCore(tx, sprint_id, userId)
|
||||
})
|
||||
|
||||
if (result.ok) {
|
||||
revalidatePath(`/sprints/${sprint_id}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
interface CancelResultOk {
|
||||
ok: true
|
||||
}
|
||||
|
||||
type CancelResult = CancelResultOk | ErrorResult
|
||||
|
||||
export async function cancelSprintRunAction(input: unknown): Promise<CancelResult> {
|
||||
const session = await getSession()
|
||||
if (!session.userId) return { ok: false, error: 'Niet ingelogd', code: 403 }
|
||||
if (session.isDemo)
|
||||
return { ok: false, error: 'Niet beschikbaar in demo-modus', code: 403 }
|
||||
|
||||
const parsed = CancelSprintRunInput.safeParse(input)
|
||||
if (!parsed.success) return { ok: false, error: 'Validatie mislukt', code: 422 }
|
||||
|
||||
const sprint_run_id = parsed.data.sprint_run_id
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const run = await tx.sprintRun.findUnique({ where: { id: sprint_run_id } })
|
||||
if (!run)
|
||||
return { ok: false as const, error: 'SPRINT_RUN_NOT_FOUND', code: 404 }
|
||||
if (!['QUEUED', 'RUNNING', 'PAUSED'].includes(run.status))
|
||||
return { ok: false as const, error: 'SPRINT_RUN_NOT_CANCELLABLE', code: 400 }
|
||||
|
||||
await tx.sprintRun.update({
|
||||
where: { id: sprint_run_id },
|
||||
data: { status: 'CANCELLED', finished_at: new Date() },
|
||||
})
|
||||
|
||||
// Cancel openstaande task-jobs binnen deze run.
|
||||
// Tasks/Stories/PBIs/Sprint blijven hun status — cancel ≠ fail.
|
||||
await tx.claudeJob.updateMany({
|
||||
where: {
|
||||
sprint_run_id,
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
finished_at: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
return { ok: true as const, sprint_id: run.sprint_id }
|
||||
})
|
||||
|
||||
if (result.ok && 'sprint_id' in result) {
|
||||
revalidatePath(`/sprints/${result.sprint_id}`)
|
||||
return { ok: true }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import {
|
|||
updateSprintGoalSchema,
|
||||
} from '@/lib/schemas/sprint'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
|
||||
async function getSession() {
|
||||
return getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
|
|
@ -294,7 +294,7 @@ export async function setAllSprintTasksDoneAction(
|
|||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const task of tasks) {
|
||||
await updateTaskStatusWithStoryPromotion(task.id, 'DONE', tx)
|
||||
await propagateStatusUpwards(task.id, 'DONE', tx)
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { SessionData, sessionOptions } from '@/lib/session'
|
|||
import { productAccessFilter } from '@/lib/product-access'
|
||||
import { requireProductWriter } from '@/lib/auth'
|
||||
import { taskSchema as sharedTaskSchema, type TaskInput } from '@/lib/schemas/task'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
import { normalizeCode } from '@/lib/code'
|
||||
import { createWithCodeRetry, generateNextTaskCode, isCodeUniqueConflict } from '@/lib/code-server'
|
||||
import { enforceUserRateLimit } from '@/lib/rate-limit'
|
||||
|
|
@ -85,7 +85,7 @@ export async function saveTask(
|
|||
})
|
||||
|
||||
if (statusChanged) {
|
||||
const result = await updateTaskStatusWithStoryPromotion(taskId, status, tx)
|
||||
const result = await propagateStatusUpwards(taskId, status, tx)
|
||||
return { id: result.task.id, title: result.task.title, status: result.task.status }
|
||||
}
|
||||
return updated
|
||||
|
|
@ -274,7 +274,7 @@ export async function updateTaskStatusAction(id: string, status: 'TO_DO' | 'IN_P
|
|||
})
|
||||
if (!task) return { error: 'Taak niet gevonden' }
|
||||
|
||||
await updateTaskStatusWithStoryPromotion(id, status)
|
||||
await propagateStatusUpwards(id, status)
|
||||
|
||||
// /solo bewust niet revalideren: dat zou de page soft-navigaten en de
|
||||
// open SSE-stream sluiten. De Solo Paneel-flow leunt op optimistic
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { ArchiveProductButton } from '@/components/products/archive-product-butt
|
|||
import { TeamManager } from '@/components/products/team-manager'
|
||||
import { updateProductFormAction } from '@/actions/products'
|
||||
import { AutoPrToggle } from '@/components/products/auto-pr-toggle'
|
||||
import { PrStrategySelect } from '@/components/products/pr-strategy-select'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -66,6 +67,17 @@ export default async function ProductSettingsPage({ params }: Props) {
|
|||
<AutoPrToggle productId={id} initialValue={product.auto_pr} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">PR-strategie</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Bepaalt hoe de sprint zijn werk oplevert: één PR voor de hele sprint
|
||||
of een PR per story die automatisch wordt gemerged na groene CI.
|
||||
</p>
|
||||
</div>
|
||||
<PrStrategySelect productId={id} initialValue={product.pr_strategy} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-6 border-t border-border space-y-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-foreground">Team</h2>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { prisma } from '@/lib/prisma'
|
|||
import { pbiStatusToApi } from '@/lib/task-status'
|
||||
import { SprintBoardClient } from '@/components/sprint/sprint-board-client'
|
||||
import { SprintHeader } from '@/components/sprint/sprint-header'
|
||||
import { SprintRunControls } from '@/components/sprint/sprint-run-controls'
|
||||
import type { SprintStory, PbiWithStories, ProductMember } from '@/components/sprint/sprint-backlog'
|
||||
import type { Task } from '@/components/sprint/task-list'
|
||||
import { TaskDialog } from '@/app/_components/tasks/task-dialog'
|
||||
|
|
@ -33,7 +34,7 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
if (!product) notFound()
|
||||
|
||||
const sprint = await prisma.sprint.findFirst({
|
||||
where: { product_id: id, status: 'ACTIVE' },
|
||||
where: { product_id: id, status: { in: ['ACTIVE', 'FAILED'] } },
|
||||
select: {
|
||||
id: true,
|
||||
sprint_goal: true,
|
||||
|
|
@ -44,6 +45,14 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
})
|
||||
if (!sprint) redirect(`/products/${id}`)
|
||||
|
||||
const activeSprintRun = await prisma.sprintRun.findFirst({
|
||||
where: {
|
||||
sprint_id: sprint.id,
|
||||
status: { in: ['QUEUED', 'RUNNING', 'PAUSED'] },
|
||||
},
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
// Sprint stories with full task data and assignee
|
||||
const [sprintStories, productMembers] = await Promise.all([
|
||||
prisma.story.findMany({
|
||||
|
|
@ -147,6 +156,17 @@ export default async function SprintBoardPage({ params, searchParams }: Props) {
|
|||
sprintStories={sprintStoryItems}
|
||||
/>
|
||||
|
||||
<div className="border-b border-border bg-surface-container-low px-4 py-2 shrink-0">
|
||||
<SprintRunControls
|
||||
sprintId={sprint.id}
|
||||
productId={id}
|
||||
sprintStatus={sprint.status}
|
||||
activeSprintRunId={activeSprintRun?.id ?? null}
|
||||
activeSprintRunStatus={activeSprintRun?.status ?? null}
|
||||
isDemo={isDemo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<SprintBoardClient
|
||||
productId={id}
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ const STATUS_CONFIG: Record<TaskStatus, { label: string; dot: string }> = {
|
|||
IN_PROGRESS: { label: 'Bezig', dot: 'bg-status-in-progress' },
|
||||
REVIEW: { label: 'Review', dot: 'bg-status-review' },
|
||||
DONE: { label: 'Klaar', dot: 'bg-status-done' },
|
||||
FAILED: { label: 'Gefaald', dot: 'bg-status-failed' },
|
||||
}
|
||||
|
||||
// FAILED ontbreekt bewust: alleen via sprint-cascade gezet, niet handmatig kiesbaar.
|
||||
const STATUS_ORDER: TaskStatus[] = ['TO_DO', 'IN_PROGRESS', 'REVIEW', 'DONE']
|
||||
|
||||
function StatusIndicator({ status }: { status: TaskStatus }) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { authenticateApiRequest } from '@/lib/api-auth'
|
|||
import { prisma } from '@/lib/prisma'
|
||||
import { z } from 'zod'
|
||||
import { TASK_STATUS_API_VALUES, taskStatusFromApi, taskStatusToApi } from '@/lib/task-status'
|
||||
import { updateTaskStatusWithStoryPromotion } from '@/lib/tasks-status-update'
|
||||
import { propagateStatusUpwards } from '@/lib/tasks-status-update'
|
||||
|
||||
// `review` is a valid TaskStatus in the DB and the kanban-board UI, but the
|
||||
// sprint task list (components/sprint/task-list.tsx) does not yet render it.
|
||||
|
|
@ -111,7 +111,7 @@ export async function PATCH(
|
|||
: null
|
||||
|
||||
if (dbStatus !== undefined && dbStatus !== null) {
|
||||
const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx)
|
||||
const result = await propagateStatusUpwards(id, dbStatus, tx)
|
||||
return {
|
||||
id: result.task.id,
|
||||
status: result.task.status,
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@
|
|||
--status-review: #7b5ea7;
|
||||
--status-done: #006e1c;
|
||||
--status-blocked: #ba1a1a;
|
||||
--status-failed: #93000a;
|
||||
|
||||
--priority-critical: #ba1a1a;
|
||||
--priority-high: #c75300;
|
||||
|
|
@ -196,6 +197,7 @@
|
|||
--status-review: #c9b6ef;
|
||||
--status-done: #77db77;
|
||||
--status-blocked: #ffb4ab;
|
||||
--status-failed: #ff8a80;
|
||||
|
||||
--priority-critical: #ffb4ab;
|
||||
--priority-high: #ffb68d;
|
||||
|
|
@ -301,6 +303,7 @@
|
|||
--color-status-review: var(--status-review);
|
||||
--color-status-done: var(--status-done);
|
||||
--color-status-blocked: var(--status-blocked);
|
||||
--color-status-failed: var(--status-failed);
|
||||
|
||||
--color-priority-critical: var(--priority-critical);
|
||||
--color-priority-high: var(--priority-high);
|
||||
|
|
|
|||
56
components/products/pr-strategy-select.tsx
Normal file
56
components/products/pr-strategy-select.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { updatePrStrategyAction } from '@/actions/products'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type PrStrategy = 'SPRINT' | 'STORY'
|
||||
|
||||
interface PrStrategySelectProps {
|
||||
productId: string
|
||||
initialValue: PrStrategy
|
||||
}
|
||||
|
||||
const STRATEGY_LABELS: Record<PrStrategy, string> = {
|
||||
SPRINT: 'Per sprint — één PR voor de hele sprint, klaar voor review aan eind',
|
||||
STORY: 'Per story — auto-merge na CI groen, één PR per story',
|
||||
}
|
||||
|
||||
export function PrStrategySelect({ productId, initialValue }: PrStrategySelectProps) {
|
||||
const [value, setValue] = useState<PrStrategy>(initialValue)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
function handleChange(next: string | null) {
|
||||
if (next !== 'SPRINT' && next !== 'STORY') return
|
||||
if (next === value) return
|
||||
const previous = value
|
||||
setValue(next)
|
||||
startTransition(async () => {
|
||||
const result = await updatePrStrategyAction(productId, next)
|
||||
if ('error' in result && result.error) {
|
||||
setValue(previous)
|
||||
toast.error(typeof result.error === 'string' ? result.error : 'Opslaan mislukt')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select value={value} onValueChange={handleChange} disabled={isPending}>
|
||||
<SelectTrigger className="w-full max-w-xl">
|
||||
{STRATEGY_LABELS[value]}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="SPRINT">{STRATEGY_LABELS.SPRINT}</SelectItem>
|
||||
<SelectItem value="STORY">{STRATEGY_LABELS.STORY}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,12 +7,14 @@ import type { PbiStatusApi } from '@/lib/task-status'
|
|||
export const PBI_STATUS_LABELS: Record<PbiStatusApi, string> = {
|
||||
ready: 'Klaar voor sprint',
|
||||
blocked: 'Geblokkeerd',
|
||||
failed: 'Gefaald',
|
||||
done: 'Afgerond',
|
||||
}
|
||||
|
||||
export const PBI_STATUS_COLORS: Record<PbiStatusApi, string> = {
|
||||
ready: 'bg-status-todo/15 text-status-todo border-status-todo/30',
|
||||
blocked: 'bg-status-blocked/15 text-status-blocked border-status-blocked/30',
|
||||
failed: 'bg-status-failed/15 text-status-failed border-status-failed/30',
|
||||
done: 'bg-status-done/15 text-status-done border-status-done/30',
|
||||
}
|
||||
|
||||
|
|
|
|||
189
components/sprint/sprint-run-controls.tsx
Normal file
189
components/sprint/sprint-run-controls.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
startSprintRunAction,
|
||||
resumeSprintAction,
|
||||
cancelSprintRunAction,
|
||||
type PreFlightBlocker,
|
||||
} from '@/actions/sprint-runs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
|
||||
type SprintStatusValue = 'ACTIVE' | 'COMPLETED' | 'FAILED'
|
||||
type SprintRunStatusValue =
|
||||
| 'QUEUED'
|
||||
| 'RUNNING'
|
||||
| 'PAUSED'
|
||||
| 'DONE'
|
||||
| 'FAILED'
|
||||
| 'CANCELLED'
|
||||
| null
|
||||
|
||||
interface Props {
|
||||
sprintId: string
|
||||
productId: string
|
||||
sprintStatus: SprintStatusValue
|
||||
activeSprintRunId: string | null
|
||||
activeSprintRunStatus: SprintRunStatusValue
|
||||
isDemo: boolean
|
||||
}
|
||||
|
||||
const BLOCKER_LABELS: Record<PreFlightBlocker['type'], string> = {
|
||||
task_no_plan: 'Task zonder implementation plan',
|
||||
open_question: 'Openstaande vraag aan jou',
|
||||
pbi_blocked: 'PBI is geblokkeerd of gefaald',
|
||||
}
|
||||
|
||||
function blockerHref(productId: string, blocker: PreFlightBlocker): string {
|
||||
switch (blocker.type) {
|
||||
case 'task_no_plan':
|
||||
return `/products/${productId}/sprint?editTask=${blocker.id}`
|
||||
case 'open_question':
|
||||
return `/products/${productId}/sprint`
|
||||
case 'pbi_blocked':
|
||||
return `/products/${productId}`
|
||||
}
|
||||
}
|
||||
|
||||
export function SprintRunControls({
|
||||
sprintId,
|
||||
productId,
|
||||
sprintStatus,
|
||||
activeSprintRunId,
|
||||
activeSprintRunStatus,
|
||||
isDemo,
|
||||
}: Props) {
|
||||
const [pending, startTransition] = useTransition()
|
||||
const [blockers, setBlockers] = useState<PreFlightBlocker[] | null>(null)
|
||||
|
||||
const hasActiveRun =
|
||||
activeSprintRunId !== null &&
|
||||
(activeSprintRunStatus === 'QUEUED' ||
|
||||
activeSprintRunStatus === 'RUNNING' ||
|
||||
activeSprintRunStatus === 'PAUSED')
|
||||
|
||||
const canStart = sprintStatus === 'ACTIVE' && !hasActiveRun
|
||||
const canResume = sprintStatus === 'FAILED'
|
||||
const canCancel = hasActiveRun
|
||||
|
||||
function handleStart() {
|
||||
startTransition(async () => {
|
||||
const result = await startSprintRunAction({ sprint_id: sprintId })
|
||||
if (result.ok) {
|
||||
toast.success(`Sprint gestart (${result.jobs_count} taak(s) klaar)`)
|
||||
} else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) {
|
||||
setBlockers(result.blockers)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleResume() {
|
||||
startTransition(async () => {
|
||||
const result = await resumeSprintAction({ sprint_id: sprintId })
|
||||
if (result.ok) {
|
||||
toast.success(`Sprint hervat (${result.jobs_count} taak(s) klaar)`)
|
||||
} else if (result.error === 'PRE_FLIGHT_BLOCKED' && 'blockers' in result) {
|
||||
setBlockers(result.blockers)
|
||||
} else {
|
||||
toast.error(result.error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (!activeSprintRunId) return
|
||||
if (!confirm('Sprint annuleren? Openstaande taken blijven TO_DO.')) return
|
||||
startTransition(async () => {
|
||||
const result = await cancelSprintRunAction({ sprint_run_id: activeSprintRunId })
|
||||
if (result.ok) toast.success('Sprint geannuleerd')
|
||||
else toast.error(result.error)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{canStart && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={pending || isDemo}
|
||||
className="text-xs"
|
||||
>
|
||||
Start Sprint
|
||||
</Button>
|
||||
)}
|
||||
{canResume && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleResume}
|
||||
disabled={pending || isDemo}
|
||||
variant="default"
|
||||
className="text-xs"
|
||||
>
|
||||
Hervat sprint
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
disabled={pending || isDemo}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
Annuleer sprint-run
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={blockers !== null} onOpenChange={(open) => { if (!open) setBlockers(null) }}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sprint kan nog niet starten</DialogTitle>
|
||||
<DialogDescription>
|
||||
Los eerst onderstaande punten op. Klik op een item om er direct naar
|
||||
te navigeren.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ul className="flex flex-col gap-2 max-h-80 overflow-y-auto">
|
||||
{blockers?.map((b, i) => (
|
||||
<li
|
||||
key={`${b.type}-${b.id}-${i}`}
|
||||
className="rounded-md border border-border bg-surface-container-low px-3 py-2"
|
||||
>
|
||||
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{BLOCKER_LABELS[b.type]}
|
||||
</div>
|
||||
<a
|
||||
href={blockerHref(productId, b)}
|
||||
className="text-sm text-primary hover:underline break-words"
|
||||
>
|
||||
{b.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBlockers(null)}>
|
||||
Sluit
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,20 @@
|
|||
// Bidirectionele case-mappers voor de REST API-boundary.
|
||||
// DB houdt UPPER_SNAKE; API exposeert lowercase.
|
||||
|
||||
import type { TaskStatus, StoryStatus, PbiStatus } from '@prisma/client'
|
||||
import type {
|
||||
TaskStatus,
|
||||
StoryStatus,
|
||||
PbiStatus,
|
||||
SprintStatus,
|
||||
SprintRunStatus,
|
||||
} from '@prisma/client'
|
||||
|
||||
const TASK_DB_TO_API = {
|
||||
TO_DO: 'todo',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
REVIEW: 'review',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
} as const satisfies Record<TaskStatus, string>
|
||||
|
||||
const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
||||
|
|
@ -15,35 +22,72 @@ const TASK_API_TO_DB: Record<string, TaskStatus> = {
|
|||
in_progress: 'IN_PROGRESS',
|
||||
review: 'REVIEW',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
}
|
||||
|
||||
const STORY_DB_TO_API = {
|
||||
OPEN: 'open',
|
||||
IN_SPRINT: 'in_sprint',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
} as const satisfies Record<StoryStatus, string>
|
||||
|
||||
const STORY_API_TO_DB: Record<string, StoryStatus> = {
|
||||
open: 'OPEN',
|
||||
in_sprint: 'IN_SPRINT',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
}
|
||||
|
||||
const PBI_DB_TO_API = {
|
||||
READY: 'ready',
|
||||
BLOCKED: 'blocked',
|
||||
FAILED: 'failed',
|
||||
DONE: 'done',
|
||||
} as const satisfies Record<PbiStatus, string>
|
||||
|
||||
const PBI_API_TO_DB: Record<string, PbiStatus> = {
|
||||
ready: 'READY',
|
||||
blocked: 'BLOCKED',
|
||||
failed: 'FAILED',
|
||||
done: 'DONE',
|
||||
}
|
||||
|
||||
const SPRINT_DB_TO_API = {
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
FAILED: 'failed',
|
||||
} as const satisfies Record<SprintStatus, string>
|
||||
|
||||
const SPRINT_API_TO_DB: Record<string, SprintStatus> = {
|
||||
active: 'ACTIVE',
|
||||
completed: 'COMPLETED',
|
||||
failed: 'FAILED',
|
||||
}
|
||||
|
||||
const SPRINT_RUN_DB_TO_API = {
|
||||
QUEUED: 'queued',
|
||||
RUNNING: 'running',
|
||||
PAUSED: 'paused',
|
||||
DONE: 'done',
|
||||
FAILED: 'failed',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const satisfies Record<SprintRunStatus, string>
|
||||
|
||||
const SPRINT_RUN_API_TO_DB: Record<string, SprintRunStatus> = {
|
||||
queued: 'QUEUED',
|
||||
running: 'RUNNING',
|
||||
paused: 'PAUSED',
|
||||
done: 'DONE',
|
||||
failed: 'FAILED',
|
||||
cancelled: 'CANCELLED',
|
||||
}
|
||||
|
||||
export type TaskStatusApi = typeof TASK_DB_TO_API[TaskStatus]
|
||||
export type StoryStatusApi = typeof STORY_DB_TO_API[StoryStatus]
|
||||
export type PbiStatusApi = typeof PBI_DB_TO_API[PbiStatus]
|
||||
export type SprintStatusApi = typeof SPRINT_DB_TO_API[SprintStatus]
|
||||
export type SprintRunStatusApi = typeof SPRINT_RUN_DB_TO_API[SprintRunStatus]
|
||||
|
||||
export function taskStatusToApi(s: TaskStatus): TaskStatusApi {
|
||||
return TASK_DB_TO_API[s]
|
||||
|
|
@ -69,6 +113,24 @@ export function pbiStatusFromApi(s: string): PbiStatus | null {
|
|||
return PBI_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export function sprintStatusToApi(s: SprintStatus): SprintStatusApi {
|
||||
return SPRINT_DB_TO_API[s]
|
||||
}
|
||||
|
||||
export function sprintStatusFromApi(s: string): SprintStatus | null {
|
||||
return SPRINT_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export function sprintRunStatusToApi(s: SprintRunStatus): SprintRunStatusApi {
|
||||
return SPRINT_RUN_DB_TO_API[s]
|
||||
}
|
||||
|
||||
export function sprintRunStatusFromApi(s: string): SprintRunStatus | null {
|
||||
return SPRINT_RUN_API_TO_DB[s.toLowerCase()] ?? null
|
||||
}
|
||||
|
||||
export const TASK_STATUS_API_VALUES = Object.values(TASK_DB_TO_API)
|
||||
export const STORY_STATUS_API_VALUES = Object.values(STORY_DB_TO_API)
|
||||
export const PBI_STATUS_API_VALUES = Object.values(PBI_DB_TO_API)
|
||||
export const SPRINT_STATUS_API_VALUES = Object.values(SPRINT_DB_TO_API)
|
||||
export const SPRINT_RUN_STATUS_API_VALUES = Object.values(SPRINT_RUN_DB_TO_API)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import type { Prisma, TaskStatus } from '@prisma/client'
|
||||
import type { Prisma, TaskStatus, StoryStatus, PbiStatus, SprintStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type StoryStatusChange = 'promoted' | 'demoted' | null
|
||||
|
||||
export interface UpdateTaskStatusResult {
|
||||
export interface PropagationResult {
|
||||
task: {
|
||||
id: string
|
||||
title: string
|
||||
|
|
@ -11,21 +9,33 @@ export interface UpdateTaskStatusResult {
|
|||
story_id: string
|
||||
implementation_plan: string | null
|
||||
}
|
||||
storyStatusChange: StoryStatusChange
|
||||
storyId: string
|
||||
storyChanged: boolean
|
||||
pbiChanged: boolean
|
||||
sprintChanged: boolean
|
||||
sprintRunChanged: boolean
|
||||
}
|
||||
|
||||
// Update task.status atomically and auto-promote/demote the parent story:
|
||||
// - All sibling tasks DONE → story.status = DONE
|
||||
// - Story was DONE and a task moves out of DONE → story.status = IN_SPRINT
|
||||
// Demote target is IN_SPRINT (not OPEN): OPEN means "back in product backlog",
|
||||
// which is a sprint-management action, not a status side-effect.
|
||||
export async function updateTaskStatusWithStoryPromotion(
|
||||
// Real-time status-propagatie: bij elke task-statuswijziging wordt de keten
|
||||
// Task → Story → PBI → Sprint → SprintRun herevalueerd binnen één transactie.
|
||||
//
|
||||
// Regels:
|
||||
// Story: ANY task FAILED → FAILED, ELSE ALL DONE → DONE,
|
||||
// ELSE IN_SPRINT (mits story.sprint_id != null), anders OPEN
|
||||
// PBI: ANY story FAILED → FAILED, ELSE ALL DONE → DONE, ELSE READY
|
||||
// (BLOCKED is handmatig en wordt niet overschreven door deze helper)
|
||||
// Sprint: ANY PBI van een story-in-sprint FAILED → FAILED,
|
||||
// ELSE ALL PBIs van die stories DONE → COMPLETED,
|
||||
// ELSE ACTIVE
|
||||
// SprintRun: Sprint→FAILED → SprintRun=FAILED + cancel openstaand werk +
|
||||
// zet failed_task_id; Sprint→COMPLETED → SprintRun=DONE; anders
|
||||
// blijft SprintRun ongewijzigd.
|
||||
export async function propagateStatusUpwards(
|
||||
taskId: string,
|
||||
newStatus: TaskStatus,
|
||||
client?: Prisma.TransactionClient,
|
||||
): Promise<UpdateTaskStatusResult> {
|
||||
const run = async (tx: Prisma.TransactionClient): Promise<UpdateTaskStatusResult> => {
|
||||
): Promise<PropagationResult> {
|
||||
const run = async (tx: Prisma.TransactionClient): Promise<PropagationResult> => {
|
||||
const task = await tx.task.update({
|
||||
where: { id: taskId },
|
||||
data: { status: newStatus },
|
||||
|
|
@ -38,33 +48,167 @@ export async function updateTaskStatusWithStoryPromotion(
|
|||
},
|
||||
})
|
||||
|
||||
// Story herevalueren
|
||||
const siblings = await tx.task.findMany({
|
||||
where: { story_id: task.story_id },
|
||||
select: { status: true },
|
||||
})
|
||||
const allDone = siblings.every((s) => s.status === 'DONE')
|
||||
const anyTaskFailed = siblings.some((s) => s.status === 'FAILED')
|
||||
const allTasksDone =
|
||||
siblings.length > 0 && siblings.every((s) => s.status === 'DONE')
|
||||
|
||||
const story = await tx.story.findUniqueOrThrow({
|
||||
where: { id: task.story_id },
|
||||
select: { status: true },
|
||||
select: { id: true, status: true, pbi_id: true, sprint_id: true },
|
||||
})
|
||||
|
||||
let storyStatusChange: StoryStatusChange = null
|
||||
if (newStatus === 'DONE' && allDone && story.status !== 'DONE') {
|
||||
const defaultActive: StoryStatus = story.sprint_id ? 'IN_SPRINT' : 'OPEN'
|
||||
let nextStoryStatus: StoryStatus
|
||||
if (anyTaskFailed) nextStoryStatus = 'FAILED'
|
||||
else if (allTasksDone) nextStoryStatus = 'DONE'
|
||||
else nextStoryStatus = defaultActive
|
||||
|
||||
let storyChanged = false
|
||||
if (nextStoryStatus !== story.status) {
|
||||
await tx.story.update({
|
||||
where: { id: task.story_id },
|
||||
data: { status: 'DONE' },
|
||||
where: { id: story.id },
|
||||
data: { status: nextStoryStatus },
|
||||
})
|
||||
storyStatusChange = 'promoted'
|
||||
} else if (newStatus !== 'DONE' && story.status === 'DONE') {
|
||||
await tx.story.update({
|
||||
where: { id: task.story_id },
|
||||
data: { status: 'IN_SPRINT' },
|
||||
})
|
||||
storyStatusChange = 'demoted'
|
||||
storyChanged = true
|
||||
}
|
||||
|
||||
return { task, storyStatusChange, storyId: task.story_id }
|
||||
// PBI herevalueren — BLOCKED met rust laten
|
||||
const pbi = await tx.pbi.findUniqueOrThrow({
|
||||
where: { id: story.pbi_id },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
let pbiChanged = false
|
||||
if (pbi.status !== 'BLOCKED') {
|
||||
const pbiStories = await tx.story.findMany({
|
||||
where: { pbi_id: pbi.id },
|
||||
select: { status: true },
|
||||
})
|
||||
const anyStoryFailed = pbiStories.some((s) => s.status === 'FAILED')
|
||||
const allStoriesDone =
|
||||
pbiStories.length > 0 && pbiStories.every((s) => s.status === 'DONE')
|
||||
|
||||
let nextPbiStatus: PbiStatus
|
||||
if (anyStoryFailed) nextPbiStatus = 'FAILED'
|
||||
else if (allStoriesDone) nextPbiStatus = 'DONE'
|
||||
else nextPbiStatus = 'READY'
|
||||
|
||||
if (nextPbiStatus !== pbi.status) {
|
||||
await tx.pbi.update({
|
||||
where: { id: pbi.id },
|
||||
data: { status: nextPbiStatus },
|
||||
})
|
||||
pbiChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
// Sprint herevalueren — alleen als deze story aan een sprint hangt
|
||||
let sprintChanged = false
|
||||
let nextSprintStatus: SprintStatus | null = null
|
||||
if (story.sprint_id) {
|
||||
const sprint = await tx.sprint.findUniqueOrThrow({
|
||||
where: { id: story.sprint_id },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
|
||||
const sprintPbiRows = await tx.story.findMany({
|
||||
where: { sprint_id: sprint.id },
|
||||
select: { pbi_id: true },
|
||||
distinct: ['pbi_id'],
|
||||
})
|
||||
const sprintPbis = await tx.pbi.findMany({
|
||||
where: { id: { in: sprintPbiRows.map((s) => s.pbi_id) } },
|
||||
select: { status: true },
|
||||
})
|
||||
const anyPbiFailed = sprintPbis.some((p) => p.status === 'FAILED')
|
||||
const allPbisDone =
|
||||
sprintPbis.length > 0 && sprintPbis.every((p) => p.status === 'DONE')
|
||||
|
||||
let nextStatus: SprintStatus
|
||||
if (anyPbiFailed) nextStatus = 'FAILED'
|
||||
else if (allPbisDone) nextStatus = 'COMPLETED'
|
||||
else nextStatus = 'ACTIVE'
|
||||
|
||||
if (nextStatus !== sprint.status) {
|
||||
await tx.sprint.update({
|
||||
where: { id: sprint.id },
|
||||
data: {
|
||||
status: nextStatus,
|
||||
...(nextStatus === 'COMPLETED' ? { completed_at: new Date() } : {}),
|
||||
},
|
||||
})
|
||||
sprintChanged = true
|
||||
nextSprintStatus = nextStatus
|
||||
}
|
||||
}
|
||||
|
||||
// SprintRun herevalueren — via ClaudeJob.sprint_run_id van deze task
|
||||
let sprintRunChanged = false
|
||||
if (nextSprintStatus === 'FAILED' || nextSprintStatus === 'COMPLETED') {
|
||||
const job = await tx.claudeJob.findFirst({
|
||||
where: { task_id: taskId, sprint_run_id: { not: null } },
|
||||
orderBy: { created_at: 'desc' },
|
||||
select: { id: true, sprint_run_id: true },
|
||||
})
|
||||
|
||||
if (job?.sprint_run_id) {
|
||||
const sprintRun = await tx.sprintRun.findUnique({
|
||||
where: { id: job.sprint_run_id },
|
||||
select: { id: true, status: true },
|
||||
})
|
||||
if (
|
||||
sprintRun &&
|
||||
(sprintRun.status === 'QUEUED' ||
|
||||
sprintRun.status === 'RUNNING' ||
|
||||
sprintRun.status === 'PAUSED')
|
||||
) {
|
||||
if (nextSprintStatus === 'FAILED') {
|
||||
await tx.sprintRun.update({
|
||||
where: { id: sprintRun.id },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
finished_at: new Date(),
|
||||
failed_task_id: taskId,
|
||||
},
|
||||
})
|
||||
await tx.claudeJob.updateMany({
|
||||
where: {
|
||||
sprint_run_id: sprintRun.id,
|
||||
status: { in: ['QUEUED', 'CLAIMED', 'RUNNING'] },
|
||||
id: { not: job.id },
|
||||
},
|
||||
data: {
|
||||
status: 'CANCELLED',
|
||||
finished_at: new Date(),
|
||||
error: `Cancelled: task ${taskId} failed in same sprint run`,
|
||||
},
|
||||
})
|
||||
sprintRunChanged = true
|
||||
} else {
|
||||
// COMPLETED
|
||||
await tx.sprintRun.update({
|
||||
where: { id: sprintRun.id },
|
||||
data: { status: 'DONE', finished_at: new Date() },
|
||||
})
|
||||
sprintRunChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
task,
|
||||
storyId: task.story_id,
|
||||
storyChanged,
|
||||
pbiChanged,
|
||||
sprintChanged,
|
||||
sprintRunChanged,
|
||||
}
|
||||
}
|
||||
|
||||
if (client) return run(client)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
-- Sprint-niveau jobflow met cascade-FAIL (PBI-46 / F1).
|
||||
-- Voegt FAILED toe aan TaskStatus, StoryStatus, PbiStatus, SprintStatus.
|
||||
-- Introduceert SprintRunStatus en PrStrategy enums.
|
||||
-- Maakt sprint_runs tabel + ClaudeJob.sprint_run_id koppeling + Product.pr_strategy.
|
||||
--
|
||||
-- Gegenereerd via: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma
|
||||
-- (handmatig opgeschoond: todos-tabel wijzigingen weggelaten — zit in een separate migratie #131).
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SprintRunStatus" AS ENUM ('QUEUED', 'RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PrStrategy" AS ENUM ('SPRINT', 'STORY');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "PbiStatus" ADD VALUE 'FAILED';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "SprintStatus" ADD VALUE 'FAILED';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "StoryStatus" ADD VALUE 'FAILED';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "TaskStatus" ADD VALUE 'FAILED';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "claude_jobs" ADD COLUMN "sprint_run_id" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "products" ADD COLUMN "pr_strategy" "PrStrategy" NOT NULL DEFAULT 'SPRINT';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sprint_runs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sprint_id" TEXT NOT NULL,
|
||||
"started_by_id" TEXT NOT NULL,
|
||||
"status" "SprintRunStatus" NOT NULL DEFAULT 'QUEUED',
|
||||
"pr_strategy" "PrStrategy" NOT NULL,
|
||||
"branch" TEXT,
|
||||
"pr_url" TEXT,
|
||||
"started_at" TIMESTAMP(3),
|
||||
"finished_at" TIMESTAMP(3),
|
||||
"failure_reason" TEXT,
|
||||
"failed_task_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "sprint_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sprint_runs_sprint_id_status_idx" ON "sprint_runs"("sprint_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sprint_runs_started_by_id_status_idx" ON "sprint_runs"("started_by_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "claude_jobs_sprint_run_id_status_idx" ON "claude_jobs"("sprint_run_id", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_started_by_id_fkey" FOREIGN KEY ("started_by_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sprint_runs" ADD CONSTRAINT "sprint_runs_failed_task_id_fkey" FOREIGN KEY ("failed_task_id") REFERENCES "tasks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "claude_jobs" ADD CONSTRAINT "claude_jobs_sprint_run_id_fkey" FOREIGN KEY ("sprint_run_id") REFERENCES "sprint_runs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -22,11 +22,13 @@ enum StoryStatus {
|
|||
OPEN
|
||||
IN_SPRINT
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum PbiStatus {
|
||||
READY
|
||||
BLOCKED
|
||||
FAILED
|
||||
DONE
|
||||
}
|
||||
|
||||
|
|
@ -58,6 +60,7 @@ enum TaskStatus {
|
|||
IN_PROGRESS
|
||||
REVIEW
|
||||
DONE
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum LogType {
|
||||
|
|
@ -74,6 +77,21 @@ enum TestStatus {
|
|||
enum SprintStatus {
|
||||
ACTIVE
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum SprintRunStatus {
|
||||
QUEUED
|
||||
RUNNING
|
||||
PAUSED
|
||||
DONE
|
||||
FAILED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum PrStrategy {
|
||||
SPRINT
|
||||
STORY
|
||||
}
|
||||
|
||||
enum IdeaStatus {
|
||||
|
|
@ -109,32 +127,33 @@ enum UserQuestionStatus {
|
|||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
email String? @unique
|
||||
password_hash String
|
||||
is_demo Boolean @default(false)
|
||||
bio String? @db.VarChar(160)
|
||||
bio_detail String? @db.VarChar(2000)
|
||||
must_reset_password Boolean @default(false)
|
||||
avatar_data Bytes?
|
||||
active_product_id String?
|
||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||
idea_code_counter Int @default(0)
|
||||
min_quota_pct Int @default(20)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
api_tokens ApiToken[]
|
||||
products Product[]
|
||||
ideas Idea[]
|
||||
product_members ProductMember[]
|
||||
assigned_stories Story[] @relation("StoryAssignee")
|
||||
login_pairings LoginPairing[]
|
||||
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
|
||||
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
||||
claude_jobs ClaudeJob[]
|
||||
claude_workers ClaudeWorker[]
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
email String? @unique
|
||||
password_hash String
|
||||
is_demo Boolean @default(false)
|
||||
bio String? @db.VarChar(160)
|
||||
bio_detail String? @db.VarChar(2000)
|
||||
must_reset_password Boolean @default(false)
|
||||
avatar_data Bytes?
|
||||
active_product_id String?
|
||||
active_product Product? @relation("UserActiveProduct", fields: [active_product_id], references: [id], onDelete: SetNull)
|
||||
idea_code_counter Int @default(0)
|
||||
min_quota_pct Int @default(20)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
api_tokens ApiToken[]
|
||||
products Product[]
|
||||
ideas Idea[]
|
||||
product_members ProductMember[]
|
||||
assigned_stories Story[] @relation("StoryAssignee")
|
||||
login_pairings LoginPairing[]
|
||||
asked_questions ClaudeQuestion[] @relation("ClaudeQuestionAsker")
|
||||
answered_questions ClaudeQuestion[] @relation("ClaudeQuestionAnswerer")
|
||||
claude_jobs ClaudeJob[]
|
||||
claude_workers ClaudeWorker[]
|
||||
started_sprint_runs SprintRun[] @relation("SprintRunStartedBy")
|
||||
|
||||
@@index([active_product_id])
|
||||
@@map("users")
|
||||
|
|
@ -175,6 +194,7 @@ model Product {
|
|||
repo_url String?
|
||||
definition_of_done String
|
||||
auto_pr Boolean @default(false)
|
||||
pr_strategy PrStrategy @default(SPRINT)
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
|
@ -277,11 +297,36 @@ model Sprint {
|
|||
completed_at DateTime?
|
||||
stories Story[]
|
||||
tasks Task[]
|
||||
sprint_runs SprintRun[]
|
||||
|
||||
@@index([product_id, status])
|
||||
@@map("sprints")
|
||||
}
|
||||
|
||||
model SprintRun {
|
||||
id String @id @default(cuid())
|
||||
sprint Sprint @relation(fields: [sprint_id], references: [id], onDelete: Cascade)
|
||||
sprint_id String
|
||||
started_by User @relation("SprintRunStartedBy", fields: [started_by_id], references: [id])
|
||||
started_by_id String
|
||||
status SprintRunStatus @default(QUEUED)
|
||||
pr_strategy PrStrategy
|
||||
branch String?
|
||||
pr_url String?
|
||||
started_at DateTime?
|
||||
finished_at DateTime?
|
||||
failure_reason String?
|
||||
failed_task Task? @relation("SprintRunFailedTask", fields: [failed_task_id], references: [id], onDelete: SetNull)
|
||||
failed_task_id String?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
jobs ClaudeJob[]
|
||||
|
||||
@@index([sprint_id, status])
|
||||
@@index([started_by_id, status])
|
||||
@@map("sprint_runs")
|
||||
}
|
||||
|
||||
model Task {
|
||||
id String @id @default(cuid())
|
||||
story Story @relation(fields: [story_id], references: [id], onDelete: Cascade)
|
||||
|
|
@ -308,6 +353,7 @@ model Task {
|
|||
updated_at DateTime @updatedAt
|
||||
claude_questions ClaudeQuestion[]
|
||||
claude_jobs ClaudeJob[]
|
||||
sprint_run_failures SprintRun[] @relation("SprintRunFailedTask")
|
||||
|
||||
@@unique([product_id, code])
|
||||
@@index([story_id, priority, sort_order])
|
||||
|
|
@ -326,6 +372,8 @@ model ClaudeJob {
|
|||
task_id String?
|
||||
idea Idea? @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
idea_id String?
|
||||
sprint_run SprintRun? @relation(fields: [sprint_run_id], references: [id], onDelete: SetNull)
|
||||
sprint_run_id String?
|
||||
kind ClaudeJobKind @default(TASK_IMPLEMENTATION)
|
||||
status ClaudeJobStatus @default(QUEUED)
|
||||
claimed_by_token ApiToken? @relation(fields: [claimed_by_token_id], references: [id], onDelete: SetNull)
|
||||
|
|
@ -352,31 +400,32 @@ model ClaudeJob {
|
|||
@@index([user_id, status])
|
||||
@@index([task_id, status])
|
||||
@@index([idea_id, status])
|
||||
@@index([sprint_run_id, status])
|
||||
@@index([status, claimed_at])
|
||||
@@index([status, finished_at])
|
||||
@@map("claude_jobs")
|
||||
}
|
||||
|
||||
model ModelPrice {
|
||||
id String @id @default(cuid())
|
||||
model_id String @unique
|
||||
input_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
output_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
cache_read_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
cache_write_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
currency String @default("USD")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
model_id String @unique
|
||||
input_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
output_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
cache_read_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
cache_write_price_per_1m Decimal @db.Decimal(12, 6)
|
||||
currency String @default("USD")
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
@@map("model_prices")
|
||||
}
|
||||
|
||||
model ClaudeWorker {
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
||||
token_id String
|
||||
id String @id @default(cuid())
|
||||
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
|
||||
user_id String
|
||||
token ApiToken @relation(fields: [token_id], references: [id], onDelete: Cascade)
|
||||
token_id String
|
||||
product_id String?
|
||||
started_at DateTime @default(now())
|
||||
last_seen_at DateTime @default(now())
|
||||
|
|
@ -437,8 +486,8 @@ model IdeaProduct {
|
|||
product_id String
|
||||
created_at DateTime @default(now())
|
||||
|
||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [product_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([idea_id, product_id])
|
||||
@@index([product_id])
|
||||
|
|
@ -468,7 +517,7 @@ model UserQuestion {
|
|||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
|
||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
idea Idea @relation(fields: [idea_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([idea_id, status])
|
||||
@@index([user_id])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue