From 3eaaacaeb80e24cc9faa1477694240234d602aea Mon Sep 17 00:00:00 2001 From: Madhura68 Date: Wed, 6 May 2026 13:56:26 +0200 Subject: [PATCH] ST-1243: F1 schema + propagateStatusUpwards-helper voor sprint-flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- __tests__/actions/tasks-dialog.test.ts | 45 ++- __tests__/api/security.test.ts | 45 ++- __tests__/api/tasks.test.ts | 54 ++- __tests__/lib/task-status.test.ts | 4 +- __tests__/lib/tasks-status-update.test.ts | 330 ++++++++++++++---- actions/sprints.ts | 4 +- actions/tasks.ts | 6 +- app/_components/tasks/status-select.tsx | 2 + app/api/tasks/[id]/route.ts | 4 +- app/styles/theme.css | 3 + components/shared/pbi-status-select.tsx | 2 + lib/task-status.ts | 64 +++- lib/tasks-status-update.ts | 198 +++++++++-- .../migration.sql | 71 ++++ prisma/schema.prisma | 135 ++++--- 15 files changed, 808 insertions(+), 159 deletions(-) create mode 100644 prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql diff --git a/__tests__/actions/tasks-dialog.test.ts b/__tests__/actions/tasks-dialog.test.ts index 877aac5..bc3236f 100644 --- a/__tests__/actions/tasks-dialog.test.ts +++ b/__tests__/actions/tasks-dialog.test.ts @@ -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 findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType @@ -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' }, diff --git a/__tests__/api/security.test.ts b/__tests__/api/security.test.ts index 6266cda..ca60059 100644 --- a/__tests__/api/security.test.ts +++ b/__tests__/api/security.test.ts @@ -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; findFirst: ReturnType } - sprint: { findFirst: ReturnType } + sprint: { + findFirst: ReturnType + findUniqueOrThrow: ReturnType + update: ReturnType + } story: { findFirst: ReturnType findUniqueOrThrow: ReturnType + findMany: ReturnType update: ReturnType } task: { @@ -55,6 +76,19 @@ const mockPrisma = prisma as unknown as { update: ReturnType findMany: ReturnType } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType + update: ReturnType + } storyLog: { create: ReturnType } todo: { create: ReturnType } $transaction: ReturnType @@ -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' }), diff --git a/__tests__/api/tasks.test.ts b/__tests__/api/tasks.test.ts index 3b08da7..5862615 100644 --- a/__tests__/api/tasks.test.ts +++ b/__tests__/api/tasks.test.ts @@ -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 + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType @@ -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) => { 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) diff --git a/__tests__/lib/task-status.test.ts b/__tests__/lib/task-status.test.ts index 870b632..9f08a85 100644 --- a/__tests__/lib/task-status.test.ts +++ b/__tests__/lib/task-status.test.ts @@ -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']) }) }) }) diff --git a/__tests__/lib/tasks-status-update.test.ts b/__tests__/lib/tasks-status-update.test.ts index 418caa7..814ec6b 100644 --- a/__tests__/lib/tasks-status-update.test.ts +++ b/__tests__/lib/tasks-status-update.test.ts @@ -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 - findMany: ReturnType - } +type MockedPrisma = { + task: { update: ReturnType; findMany: ReturnType } story: { findUniqueOrThrow: ReturnType + findMany: ReturnType + update: ReturnType + } + pbi: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + sprint: { + findUniqueOrThrow: ReturnType + update: ReturnType + } + claudeJob: { + findFirst: ReturnType + updateMany: ReturnType + } + sprintRun: { + findUnique: ReturnType update: ReturnType } $transaction: ReturnType } -beforeEach(() => { - vi.clearAllMocks() - // Pass-through: $transaction(run) just calls run with the mocked prisma client. - mockPrisma.$transaction.mockImplementation(async (run: (tx: typeof prisma) => Promise) => { - 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) => 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 }).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 }).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 }).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 }).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() + }) +}) diff --git a/actions/sprints.ts b/actions/sprints.ts index 4b6acbf..2784334 100644 --- a/actions/sprints.ts +++ b/actions/sprints.ts @@ -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(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) } }) diff --git a/actions/tasks.ts b/actions/tasks.ts index c0210a6..7b83e03 100644 --- a/actions/tasks.ts +++ b/actions/tasks.ts @@ -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 diff --git a/app/_components/tasks/status-select.tsx b/app/_components/tasks/status-select.tsx index 5ba794d..9409614 100644 --- a/app/_components/tasks/status-select.tsx +++ b/app/_components/tasks/status-select.tsx @@ -14,8 +14,10 @@ const STATUS_CONFIG: Record = { 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 }) { diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index b80a811..4bb2611 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -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, diff --git a/app/styles/theme.css b/app/styles/theme.css index 071598a..a01769b 100644 --- a/app/styles/theme.css +++ b/app/styles/theme.css @@ -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); diff --git a/components/shared/pbi-status-select.tsx b/components/shared/pbi-status-select.tsx index ae93522..9730ef2 100644 --- a/components/shared/pbi-status-select.tsx +++ b/components/shared/pbi-status-select.tsx @@ -7,12 +7,14 @@ import type { PbiStatusApi } from '@/lib/task-status' export const PBI_STATUS_LABELS: Record = { ready: 'Klaar voor sprint', blocked: 'Geblokkeerd', + failed: 'Gefaald', done: 'Afgerond', } export const PBI_STATUS_COLORS: Record = { 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', } diff --git a/lib/task-status.ts b/lib/task-status.ts index 3968042..01c64fc 100644 --- a/lib/task-status.ts +++ b/lib/task-status.ts @@ -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 const TASK_API_TO_DB: Record = { @@ -15,35 +22,72 @@ const TASK_API_TO_DB: Record = { 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 const STORY_API_TO_DB: Record = { 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 const PBI_API_TO_DB: Record = { ready: 'READY', blocked: 'BLOCKED', + failed: 'FAILED', done: 'DONE', } +const SPRINT_DB_TO_API = { + ACTIVE: 'active', + COMPLETED: 'completed', + FAILED: 'failed', +} as const satisfies Record + +const SPRINT_API_TO_DB: Record = { + 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 + +const SPRINT_RUN_API_TO_DB: Record = { + 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) diff --git a/lib/tasks-status-update.ts b/lib/tasks-status-update.ts index ca273ca..30aaa6f 100644 --- a/lib/tasks-status-update.ts +++ b/lib/tasks-status-update.ts @@ -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 { - const run = async (tx: Prisma.TransactionClient): Promise => { +): Promise { + const run = async (tx: Prisma.TransactionClient): Promise => { 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) diff --git a/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql b/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql new file mode 100644 index 0000000..b450a61 --- /dev/null +++ b/prisma/migrations/20260506114500_sprint_run_and_failed_statuses/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 61a1d2c..9fe73bb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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])