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>
This commit is contained in:
Janpeter Visser 2026-05-06 13:56:26 +02:00
parent ab8c3dca3f
commit 3eaaacaeb8
15 changed files with 808 additions and 159 deletions

View file

@ -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' },

View file

@ -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' }),

View file

@ -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)

View file

@ -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'])
})
})
})

View file

@ -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()
})
})

View file

@ -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)
}
})

View file

@ -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

View file

@ -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 }) {

View file

@ -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,

View file

@ -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);

View file

@ -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',
}

View file

@ -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)

View file

@ -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)

View file

@ -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;

View file

@ -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])