feat(solo): previewEnqueueAllAction met blocker-detectie
Voeg previewEnqueueAllAction toe aan actions/claude-jobs.ts: haalt taken op in PBI-volgorde, filtert actieve jobs, detecteert eerste blocker (REVIEW taak of BLOCKED PBI). Retourneert tasks[], blockerIndex en blockerReason. Tests: 7 nieuwe cases voor alle blocker-scenario's en demo-blokkering.
This commit is contained in:
parent
41e7654374
commit
8018920cae
2 changed files with 194 additions and 0 deletions
|
|
@ -49,6 +49,7 @@ import {
|
||||||
enqueueClaudeJobAction,
|
enqueueClaudeJobAction,
|
||||||
enqueueAllTodoJobsAction,
|
enqueueAllTodoJobsAction,
|
||||||
cancelClaudeJobAction,
|
cancelClaudeJobAction,
|
||||||
|
previewEnqueueAllAction,
|
||||||
} from '@/actions/claude-jobs'
|
} from '@/actions/claude-jobs'
|
||||||
|
|
||||||
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||||
|
|
@ -193,6 +194,107 @@ describe('enqueueAllTodoJobsAction', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const makePbiTask = (id: string, status: string, pbiStatus = 'READY') => ({
|
||||||
|
id,
|
||||||
|
title: `Task ${id}`,
|
||||||
|
status,
|
||||||
|
story: { id: 'story-1', title: 'Story 1', code: 'ST-1', pbi: { id: 'pbi-1', status: pbiStatus, priority: 1, sort_order: 1.0 } },
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('previewEnqueueAllAction', () => {
|
||||||
|
it('blocks demo user', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||||
|
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when product not accessible', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
|
||||||
|
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns empty tasks when no active sprint', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toEqual({ tasks: [], blockerIndex: null, blockerReason: null })
|
||||||
|
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns all tasks with no blocker when only TO_DO tasks', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbiTask('t1', 'TO_DO'),
|
||||||
|
makePbiTask('t2', 'TO_DO'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
|
||||||
|
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects REVIEW task as blocker at correct index', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbiTask('t1', 'TO_DO'),
|
||||||
|
makePbiTask('t2', 'TO_DO'),
|
||||||
|
makePbiTask('t3', 'REVIEW'),
|
||||||
|
makePbiTask('t4', 'TO_DO'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
|
||||||
|
if (!('error' in result)) expect(result.tasks).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('detects BLOCKED PBI as blocker at first task of that PBI', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbiTask('t1', 'TO_DO', 'BLOCKED'),
|
||||||
|
makePbiTask('t2', 'TO_DO', 'BLOCKED'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
|
||||||
|
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('queries without TO_DO filter to expose REVIEW tasks', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([])
|
||||||
|
|
||||||
|
await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(mockFindManyTask).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: expect.not.objectContaining({ status: 'TO_DO' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('cancelClaudeJobAction', () => {
|
describe('cancelClaudeJobAction', () => {
|
||||||
it('happy path: cancels QUEUED job', async () => {
|
it('happy path: cancels QUEUED job', async () => {
|
||||||
mockGetSession.mockResolvedValue(SESSION_USER)
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,19 @@ type EnqueueAllResult =
|
||||||
|
|
||||||
type CancelResult = { success: true } | { error: string }
|
type CancelResult = { success: true } | { error: string }
|
||||||
|
|
||||||
|
export type PreviewTask = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: string
|
||||||
|
story_title: string
|
||||||
|
pbi_id: string
|
||||||
|
pbi_status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreflightResult =
|
||||||
|
| { error: string }
|
||||||
|
| { tasks: PreviewTask[]; blockerIndex: number | null; blockerReason: 'task-review' | 'pbi-blocked' | null }
|
||||||
|
|
||||||
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
|
export async function enqueueClaudeJobAction(taskId: string): Promise<EnqueueResult> {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
@ -125,6 +138,85 @@ export async function enqueueAllTodoJobsAction(productId: string): Promise<Enque
|
||||||
return { success: true, count: created.length }
|
return { success: true, count: created.length }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function previewEnqueueAllAction(productId: string): Promise<PreflightResult> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
if (!productId) return { error: 'product_id is verplicht' }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||||
|
|
||||||
|
const userId = session.userId
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { product_id: productId, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) return { tasks: [], blockerIndex: null, blockerReason: null }
|
||||||
|
|
||||||
|
const rawTasks = await prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||||
|
claude_jobs: { none: { status: { in: ACTIVE_JOB_STATUSES } } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
story: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
code: true,
|
||||||
|
pbi: { select: { id: true, status: true, priority: true, sort_order: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ story: { pbi: { priority: 'asc' } } },
|
||||||
|
{ story: { pbi: { sort_order: 'asc' } } },
|
||||||
|
{ story: { sort_order: 'asc' } },
|
||||||
|
{ priority: 'asc' },
|
||||||
|
{ sort_order: 'asc' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
let blockerIndex: number | null = null
|
||||||
|
let blockerReason: 'task-review' | 'pbi-blocked' | null = null
|
||||||
|
|
||||||
|
for (let i = 0; i < rawTasks.length; i++) {
|
||||||
|
const t = rawTasks[i]
|
||||||
|
if (t.status === 'REVIEW') {
|
||||||
|
blockerIndex = i
|
||||||
|
blockerReason = 'task-review'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (t.story.pbi.status === 'BLOCKED') {
|
||||||
|
blockerIndex = i
|
||||||
|
blockerReason = 'pbi-blocked'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayTasks = blockerIndex !== null ? rawTasks.slice(0, blockerIndex + 1) : rawTasks
|
||||||
|
|
||||||
|
const tasks: PreviewTask[] = displayTasks.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
status: t.status,
|
||||||
|
story_title: t.story.title,
|
||||||
|
pbi_id: t.story.pbi.id,
|
||||||
|
pbi_status: t.story.pbi.status,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { tasks, blockerIndex, blockerReason }
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session.userId) return { error: 'Niet ingelogd' }
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue