Compare commits
6 commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92278f9ee | ||
|
|
94b99198ed | ||
|
|
80a7d793b6 | ||
|
|
3ca842ff80 | ||
|
|
8018920cae | ||
|
|
41e7654374 |
8 changed files with 1059 additions and 4 deletions
232
__tests__/actions/claude-jobs-batch.test.ts
Normal file
232
__tests__/actions/claude-jobs-batch.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
/**
|
||||||
|
* Uitgebreide integratie-stijl tests voor previewEnqueueAllAction en
|
||||||
|
* enqueueClaudeJobsBatchAction. Gebruikt realistische seed-data:
|
||||||
|
* 2 PBIs, elk met 1 story, elk 2 taken (4 taken totaal in PBI-volgorde).
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
|
||||||
|
const {
|
||||||
|
mockGetSession,
|
||||||
|
mockFindFirstProduct,
|
||||||
|
mockFindFirstSprint,
|
||||||
|
mockFindManyTask,
|
||||||
|
mockTransaction,
|
||||||
|
mockExecuteRaw,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
mockGetSession: vi.fn(),
|
||||||
|
mockFindFirstProduct: vi.fn(),
|
||||||
|
mockFindFirstSprint: vi.fn(),
|
||||||
|
mockFindManyTask: vi.fn(),
|
||||||
|
mockTransaction: vi.fn(),
|
||||||
|
mockExecuteRaw: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
|
||||||
|
vi.mock('@/lib/prisma', () => ({
|
||||||
|
prisma: {
|
||||||
|
task: { findMany: mockFindManyTask },
|
||||||
|
product: { findFirst: mockFindFirstProduct },
|
||||||
|
sprint: { findFirst: mockFindFirstSprint },
|
||||||
|
claudeJob: { create: vi.fn() },
|
||||||
|
$executeRaw: mockExecuteRaw,
|
||||||
|
$transaction: mockTransaction,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
|
||||||
|
|
||||||
|
const SESSION_USER = { userId: 'user-1', isDemo: false }
|
||||||
|
const SESSION_DEMO = { userId: 'demo-1', isDemo: true }
|
||||||
|
const PRODUCT_ID = 'product-1'
|
||||||
|
const SPRINT_ID = 'sprint-1'
|
||||||
|
|
||||||
|
// --- Seed helpers ---
|
||||||
|
const makePbi1Task = (id: string, status = 'TO_DO') => ({
|
||||||
|
id,
|
||||||
|
title: `PBI-1 Taak ${id}`,
|
||||||
|
status,
|
||||||
|
story: {
|
||||||
|
id: 'story-pbi1',
|
||||||
|
title: 'Story van PBI 1',
|
||||||
|
code: 'ST-1',
|
||||||
|
pbi: { id: 'pbi-1', status: 'READY', priority: 1, sort_order: 1.0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const makePbi2Task = (id: string, status = 'TO_DO', pbiStatus = 'READY') => ({
|
||||||
|
id,
|
||||||
|
title: `PBI-2 Taak ${id}`,
|
||||||
|
status,
|
||||||
|
story: {
|
||||||
|
id: 'story-pbi2',
|
||||||
|
title: 'Story van PBI 2',
|
||||||
|
code: 'ST-2',
|
||||||
|
pbi: { id: 'pbi-2', status: pbiStatus, priority: 2, sort_order: 2.0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
||||||
|
id,
|
||||||
|
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Canonical seed: [pbi1-t1, pbi1-t2, pbi2-t1, pbi2-t2]
|
||||||
|
const SEED_ALL_TODO = [
|
||||||
|
makePbi1Task('pbi1-t1'),
|
||||||
|
makePbi1Task('pbi1-t2'),
|
||||||
|
makePbi2Task('pbi2-t1'),
|
||||||
|
makePbi2Task('pbi2-t2'),
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockExecuteRaw.mockResolvedValue(undefined)
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: SPRINT_ID })
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// previewEnqueueAllAction
|
||||||
|
// =============================================================
|
||||||
|
describe('previewEnqueueAllAction — 2 PBI scenario', () => {
|
||||||
|
it('geen blocker: alle 4 TO_DO taken → tasks=[4], blockerIndex=null', async () => {
|
||||||
|
mockFindManyTask.mockResolvedValue(SEED_ALL_TODO)
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
|
||||||
|
if (!('error' in result)) {
|
||||||
|
expect(result.tasks).toHaveLength(4)
|
||||||
|
expect(result.tasks.map(t => t.id)).toEqual(['pbi1-t1', 'pbi1-t2', 'pbi2-t1', 'pbi2-t2'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3e taak (pbi2-t1) REVIEW → blockerIndex=2, reden=task-review, tasks=[3]', async () => {
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbi1Task('pbi1-t1'),
|
||||||
|
makePbi1Task('pbi1-t2'),
|
||||||
|
makePbi2Task('pbi2-t1', 'REVIEW'),
|
||||||
|
makePbi2Task('pbi2-t2'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: 2, blockerReason: 'task-review' })
|
||||||
|
if (!('error' in result)) {
|
||||||
|
expect(result.tasks).toHaveLength(3)
|
||||||
|
expect(result.tasks[2].id).toBe('pbi2-t1')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('PBI 1 BLOCKED → blockerIndex=0, reden=pbi-blocked, tasks=[1]', async () => {
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbi1Task('pbi1-t1', 'TO_DO'),
|
||||||
|
makePbi1Task('pbi1-t2', 'TO_DO'),
|
||||||
|
makePbi2Task('pbi2-t1'),
|
||||||
|
makePbi2Task('pbi2-t2'),
|
||||||
|
].map((t, i) => i < 2 ? { ...t, story: { ...t.story, pbi: { ...t.story.pbi, status: 'BLOCKED' } } } : t))
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: 0, blockerReason: 'pbi-blocked' })
|
||||||
|
if (!('error' in result)) expect(result.tasks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACTIVE job op pbi1-t1 → geskipt door where-clause, geen blocker bij resterende 3', async () => {
|
||||||
|
// Simuleert dat pbi1-t1 een actieve job heeft: de where-clause sluit die taak uit
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbi1Task('pbi1-t2'),
|
||||||
|
makePbi2Task('pbi2-t1'),
|
||||||
|
makePbi2Task('pbi2-t2'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: null, blockerReason: null })
|
||||||
|
if (!('error' in result)) {
|
||||||
|
expect(result.tasks).toHaveLength(3)
|
||||||
|
expect(result.tasks[0].id).toBe('pbi1-t2')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ACTIVE job op pbi1-t1 AND pbi2-t1 REVIEW → blockerIndex=1 in resterende array', async () => {
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makePbi1Task('pbi1-t2'),
|
||||||
|
makePbi2Task('pbi2-t1', 'REVIEW'),
|
||||||
|
makePbi2Task('pbi2-t2'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ blockerIndex: 1, blockerReason: 'task-review' })
|
||||||
|
if (!('error' in result)) expect(result.tasks).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demo-user → error, findMany niet aangeroepen', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||||
|
|
||||||
|
const result = await previewEnqueueAllAction(PRODUCT_ID)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||||
|
expect(mockFindManyTask).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =============================================================
|
||||||
|
// enqueueClaudeJobsBatchAction
|
||||||
|
// =============================================================
|
||||||
|
describe('enqueueClaudeJobsBatchAction — 2 PBI scenario', () => {
|
||||||
|
it('happy path: 2 taskIds → 2 QUEUED ClaudeJobs in invoervolgorde', async () => {
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makeBatchTask('pbi1-t1'),
|
||||||
|
makeBatchTask('pbi2-t1'),
|
||||||
|
])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-a', task_id: 'pbi1-t1' },
|
||||||
|
{ id: 'job-b', task_id: 'pbi2-t1' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi2-t1'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 2 })
|
||||||
|
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IDOR: taskId van niet-toegewezen story → error, geen transaction', async () => {
|
||||||
|
// Authorized tasks bevat maar 1 van de 2 gevraagde IDs
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('pbi1-t1')])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'other-user-task'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: expect.stringContaining('niet toegankelijk') })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('taak met ACTIVE job wordt overgeslagen (idempotent)', async () => {
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makeBatchTask('pbi1-t1'),
|
||||||
|
makeBatchTask('pbi1-t2', true), // heeft actieve job → skip
|
||||||
|
makeBatchTask('pbi2-t1'),
|
||||||
|
])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-a', task_id: 'pbi1-t1' },
|
||||||
|
{ id: 'job-b', task_id: 'pbi2-t1' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['pbi1-t1', 'pbi1-t2', 'pbi2-t1'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 2 })
|
||||||
|
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('demo-user → error, geen transaction', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: expect.stringContaining('demo') })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -49,6 +49,8 @@ import {
|
||||||
enqueueClaudeJobAction,
|
enqueueClaudeJobAction,
|
||||||
enqueueAllTodoJobsAction,
|
enqueueAllTodoJobsAction,
|
||||||
cancelClaudeJobAction,
|
cancelClaudeJobAction,
|
||||||
|
previewEnqueueAllAction,
|
||||||
|
enqueueClaudeJobsBatchAction,
|
||||||
} 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 +195,196 @@ 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' }),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const makeBatchTask = (id: string, hasActiveJob = false) => ({
|
||||||
|
id,
|
||||||
|
claude_jobs: hasActiveJob ? [{ id: 'job-active' }] : [],
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('enqueueClaudeJobsBatchAction', () => {
|
||||||
|
it('happy path: 3 taskIds → 3 jobs in input order', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makeBatchTask('t1'),
|
||||||
|
makeBatchTask('t2'),
|
||||||
|
makeBatchTask('t3'),
|
||||||
|
])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-1', task_id: 't1' },
|
||||||
|
{ id: 'job-2', task_id: 't2' },
|
||||||
|
{ id: 'job-3', task_id: 't3' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 3 })
|
||||||
|
expect(mockExecuteRaw).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks demo user', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_DEMO)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Niet beschikbaar in demo-modus' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when product not accessible', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Geen toegang tot dit product' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns error when task belongs to another user (IDOR)', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
// Only 1 of 2 tasks authorized (other-user's task filtered out)
|
||||||
|
mockFindManyTask.mockResolvedValue([makeBatchTask('t1')])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't-other-user'])
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' })
|
||||||
|
expect(mockTransaction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips tasks with active jobs (idempotent)', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
mockFindFirstProduct.mockResolvedValue({ id: PRODUCT_ID })
|
||||||
|
mockFindFirstSprint.mockResolvedValue({ id: 'sprint-1' })
|
||||||
|
mockFindManyTask.mockResolvedValue([
|
||||||
|
makeBatchTask('t1'),
|
||||||
|
makeBatchTask('t2', true), // has active job — skip
|
||||||
|
makeBatchTask('t3'),
|
||||||
|
])
|
||||||
|
mockTransaction.mockResolvedValue([
|
||||||
|
{ id: 'job-1', task_id: 't1' },
|
||||||
|
{ id: 'job-3', task_id: 't3' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, ['t1', 't2', 't3'])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 2 })
|
||||||
|
expect(mockExecuteRaw).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns count=0 for empty taskIds', async () => {
|
||||||
|
mockGetSession.mockResolvedValue(SESSION_USER)
|
||||||
|
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(PRODUCT_ID, [])
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, count: 0 })
|
||||||
|
expect(mockFindFirstProduct).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('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)
|
||||||
|
|
|
||||||
114
__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx
Normal file
114
__tests__/components/solo/batch-enqueue-blocker-dialog.test.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react'
|
||||||
|
|
||||||
|
vi.mock('@/components/ui/dialog', () => ({
|
||||||
|
Dialog: ({ open, children }: { open: boolean; onOpenChange?: (v: boolean) => void; children: React.ReactNode }) =>
|
||||||
|
open ? <div data-testid="dialog">{children}</div> : null,
|
||||||
|
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/ui/button', () => ({
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
variant?: string
|
||||||
|
}) => (
|
||||||
|
<button onClick={onClick} disabled={disabled} data-variant={variant}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
|
||||||
|
r ? <>{r}</> : <>{children}</>,
|
||||||
|
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<span data-testid="tooltip-content">{children}</span>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { BatchEnqueueBlockerDialog } from '@/components/solo/batch-enqueue-blocker-dialog'
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = {
|
||||||
|
open: true,
|
||||||
|
onOpenChange: vi.fn(),
|
||||||
|
prefixCount: 3,
|
||||||
|
blockerReason: 'task-review' as const,
|
||||||
|
blockerLabel: 'Story X — Task Y (in review)',
|
||||||
|
onConfirm: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('BatchEnqueueBlockerDialog', () => {
|
||||||
|
it('renders title and blocker info for task-review', () => {
|
||||||
|
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading')).toHaveTextContent('Blokkade gedetecteerd')
|
||||||
|
expect(screen.getByText(/Een taak staat op 'review'/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Story X — Task Y/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders correct blocker label for pbi-blocked', () => {
|
||||||
|
render(
|
||||||
|
<BatchEnqueueBlockerDialog
|
||||||
|
{...DEFAULT_PROPS}
|
||||||
|
blockerReason="pbi-blocked"
|
||||||
|
blockerLabel="PBI Z — geblokkeerd"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText(/De PBI is geblokkeerd/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/PBI Z/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onConfirm when primary button is clicked', () => {
|
||||||
|
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/Stuur 3 taken tot aan blokkade/))
|
||||||
|
|
||||||
|
expect(DEFAULT_PROPS.onConfirm).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls onCancel when cancel button is clicked', () => {
|
||||||
|
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Annuleer'))
|
||||||
|
|
||||||
|
expect(DEFAULT_PROPS.onCancel).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables confirm button and shows tooltip when prefixCount is 0', () => {
|
||||||
|
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={0} />)
|
||||||
|
|
||||||
|
const confirmBtn = screen.getByText(/Stuur 0/).closest('button')
|
||||||
|
expect(confirmBtn).toBeDisabled()
|
||||||
|
expect(screen.getByTestId('tooltip-content')).toHaveTextContent('Geen taken vóór blokkade')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not render when open is false', () => {
|
||||||
|
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} open={false} />)
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses singular taak when prefixCount is 1', () => {
|
||||||
|
render(<BatchEnqueueBlockerDialog {...DEFAULT_PROPS} prefixCount={1} />)
|
||||||
|
|
||||||
|
expect(screen.getByText(/Stuur 1 taak tot aan blokkade/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/1 taak vóór de blokkade/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
204
__tests__/components/solo/solo-board-batch-enqueue.test.tsx
Normal file
204
__tests__/components/solo/solo-board-batch-enqueue.test.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
// @vitest-environment jsdom
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||||
|
|
||||||
|
const { mockPreviewEnqueueAllAction, mockEnqueueClaudeJobsBatchAction } = vi.hoisted(() => ({
|
||||||
|
mockPreviewEnqueueAllAction: vi.fn(),
|
||||||
|
mockEnqueueClaudeJobsBatchAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/actions/claude-jobs', () => ({
|
||||||
|
previewEnqueueAllAction: mockPreviewEnqueueAllAction,
|
||||||
|
enqueueClaudeJobsBatchAction: mockEnqueueClaudeJobsBatchAction,
|
||||||
|
cancelClaudeJobAction: vi.fn(),
|
||||||
|
enqueueClaudeJobAction: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
|
||||||
|
vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() } }))
|
||||||
|
vi.mock('@dnd-kit/core', () => ({
|
||||||
|
DndContext: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
DragOverlay: () => null,
|
||||||
|
PointerSensor: class {},
|
||||||
|
useSensor: vi.fn(() => ({})),
|
||||||
|
useSensors: vi.fn(() => []),
|
||||||
|
closestCorners: vi.fn(),
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/ui/button', () => ({
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}) => (
|
||||||
|
<button onClick={onClick} disabled={disabled}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/ui/dialog', () => ({
|
||||||
|
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||||
|
open ? <div data-testid="dialog">{children}</div> : null,
|
||||||
|
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/ui/tooltip', () => ({
|
||||||
|
TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
TooltipTrigger: ({ render: r, children }: { render?: React.ReactElement; children?: React.ReactNode }) =>
|
||||||
|
r ? <>{r}</> : <>{children}</>,
|
||||||
|
TooltipContent: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/shared/demo-tooltip', () => ({
|
||||||
|
DemoTooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/split-pane/split-pane', () => ({
|
||||||
|
SplitPane: ({ panes }: { panes: React.ReactNode[] }) => <>{panes}</>,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/solo/solo-column', () => ({
|
||||||
|
SoloColumn: () => <div data-testid="solo-column" />,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/solo/solo-task-card', () => ({
|
||||||
|
SoloTaskCardOverlay: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/solo/task-detail-dialog', () => ({
|
||||||
|
TaskDetailDialog: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@/components/solo/unassigned-stories-sheet', () => ({
|
||||||
|
UnassignedStoriesSheet: () => null,
|
||||||
|
}))
|
||||||
|
vi.mock('@/lib/task-status', () => ({
|
||||||
|
taskStatusToApi: (s: string) => s.toLowerCase(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useSoloStore } from '@/stores/solo-store'
|
||||||
|
import { SoloBoard } from '@/components/solo/solo-board'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const PRODUCT_ID = 'prod-1'
|
||||||
|
const TODO_TASK = {
|
||||||
|
id: 't1',
|
||||||
|
title: 'Task 1',
|
||||||
|
description: null,
|
||||||
|
implementation_plan: null,
|
||||||
|
priority: 1,
|
||||||
|
sort_order: 1,
|
||||||
|
status: 'TO_DO' as const,
|
||||||
|
verify_only: false,
|
||||||
|
verify_required: 'ALIGNED_OR_PARTIAL' as const,
|
||||||
|
story_id: 'story-1',
|
||||||
|
story_code: 'ST-1',
|
||||||
|
story_title: 'Story 1',
|
||||||
|
task_code: 'ST-1.1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PROPS = {
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
sprintGoal: 'Sprint goal',
|
||||||
|
tasks: [TODO_TASK],
|
||||||
|
unassignedStories: [],
|
||||||
|
isDemo: false,
|
||||||
|
currentUserId: 'user-1',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_NO_BLOCKER = {
|
||||||
|
tasks: [{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' }],
|
||||||
|
blockerIndex: null,
|
||||||
|
blockerReason: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREVIEW_WITH_BLOCKER = {
|
||||||
|
tasks: [
|
||||||
|
{ id: 't1', title: 'Task 1', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
|
||||||
|
{ id: 't2', title: 'Task 2', status: 'TO_DO', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
|
||||||
|
{ id: 't3', title: 'Task Review', status: 'REVIEW', story_title: 'Story 1', pbi_id: 'pbi-1', pbi_status: 'READY' },
|
||||||
|
],
|
||||||
|
blockerIndex: 2,
|
||||||
|
blockerReason: 'task-review' as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useSoloStore.setState({ tasks: {}, claudeJobsByTaskId: {}, connectedWorkers: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SoloBoard — batch-enqueue flow', () => {
|
||||||
|
it('no blocker: calls enqueueClaudeJobsBatchAction with TO_DO task IDs directly', async () => {
|
||||||
|
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_NO_BLOCKER)
|
||||||
|
mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 1 })
|
||||||
|
|
||||||
|
render(<SoloBoard {...DEFAULT_PROPS} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/Start agents/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPreviewEnqueueAllAction).toHaveBeenCalledWith(PRODUCT_ID)
|
||||||
|
expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1'])
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('1 agent'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocker: shows dialog when preview returns blockerIndex', async () => {
|
||||||
|
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
|
||||||
|
|
||||||
|
render(<SoloBoard {...DEFAULT_PROPS} />)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/Start agents/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('dialog')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/Blokkade gedetecteerd/)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocker dialog confirm: enqueues prefix tasks and closes', async () => {
|
||||||
|
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
|
||||||
|
mockEnqueueClaudeJobsBatchAction.mockResolvedValue({ success: true, count: 2 })
|
||||||
|
|
||||||
|
render(<SoloBoard {...DEFAULT_PROPS} />)
|
||||||
|
fireEvent.click(screen.getByText(/Start agents/))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTestId('dialog'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/Stuur 2 taken tot aan blokkade/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockEnqueueClaudeJobsBatchAction).toHaveBeenCalledWith(PRODUCT_ID, ['t1', 't2'])
|
||||||
|
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('2 agents'))
|
||||||
|
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocker dialog cancel: closes dialog without enqueuing', async () => {
|
||||||
|
mockPreviewEnqueueAllAction.mockResolvedValue(PREVIEW_WITH_BLOCKER)
|
||||||
|
|
||||||
|
render(<SoloBoard {...DEFAULT_PROPS} />)
|
||||||
|
fireEvent.click(screen.getByText(/Start agents/))
|
||||||
|
|
||||||
|
await waitFor(() => screen.getByTestId('dialog'))
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Annuleer'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(mockEnqueueClaudeJobsBatchAction).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preview error: shows toast without opening dialog', async () => {
|
||||||
|
mockPreviewEnqueueAllAction.mockResolvedValue({ error: 'Geen toegang' })
|
||||||
|
|
||||||
|
render(<SoloBoard {...DEFAULT_PROPS} />)
|
||||||
|
fireEvent.click(screen.getByText(/Start agents/))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Geen toegang')
|
||||||
|
})
|
||||||
|
expect(screen.queryByTestId('dialog')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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,160 @@ 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 enqueueClaudeJobsBatchAction(
|
||||||
|
productId: string,
|
||||||
|
taskIds: string[]
|
||||||
|
): Promise<EnqueueAllResult> {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session.userId) return { error: 'Niet ingelogd' }
|
||||||
|
if (session.isDemo) return { error: 'Niet beschikbaar in demo-modus' }
|
||||||
|
|
||||||
|
if (!productId) return { error: 'product_id is verplicht' }
|
||||||
|
if (!taskIds.length) return { success: true, count: 0 }
|
||||||
|
|
||||||
|
const product = await prisma.product.findFirst({
|
||||||
|
where: { id: productId, ...productAccessFilter(session.userId) },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!product) return { error: 'Geen toegang tot dit product' }
|
||||||
|
|
||||||
|
const userId = session.userId
|
||||||
|
|
||||||
|
const sprint = await prisma.sprint.findFirst({
|
||||||
|
where: { product_id: productId, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!sprint) return { error: 'Geen actieve sprint gevonden' }
|
||||||
|
|
||||||
|
const authorizedTasks = await prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
id: { in: taskIds },
|
||||||
|
story: { sprint_id: sprint.id, assignee_id: userId },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
claude_jobs: {
|
||||||
|
where: { status: { in: ACTIVE_JOB_STATUSES } },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authorizedTasks.length !== taskIds.length) {
|
||||||
|
return { error: 'Een of meer taken zijn niet toegankelijk voor deze gebruiker' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueable = authorizedTasks.filter(t => t.claude_jobs.length === 0)
|
||||||
|
if (queueable.length === 0) return { success: true, count: 0 }
|
||||||
|
|
||||||
|
const queueableIds = new Set(queueable.map(t => t.id))
|
||||||
|
const orderedQueueable = taskIds.filter(id => queueableIds.has(id))
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
orderedQueueable.map(taskId =>
|
||||||
|
prisma.claudeJob.create({
|
||||||
|
data: { user_id: userId, product_id: productId, task_id: taskId, status: 'QUEUED' },
|
||||||
|
select: { id: true, task_id: true },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const job of created) {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
SELECT pg_notify('scrum4me_changes', ${JSON.stringify({
|
||||||
|
type: 'claude_job_enqueued',
|
||||||
|
job_id: job.id,
|
||||||
|
task_id: job.task_id,
|
||||||
|
user_id: userId,
|
||||||
|
product_id: productId,
|
||||||
|
status: 'queued',
|
||||||
|
})}::text)
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/products/${productId}/solo`)
|
||||||
|
return { success: true, count: created.length }
|
||||||
|
}
|
||||||
|
|
||||||
export async function cancelClaudeJobAction(jobId: string): Promise<CancelResult> {
|
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' }
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ export default async function SoloProductPage({ params }: Props) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
|
{ story: { pbi: { priority: 'asc' } } },
|
||||||
|
{ story: { pbi: { sort_order: 'asc' } } },
|
||||||
{ story: { sort_order: 'asc' } },
|
{ story: { sort_order: 'asc' } },
|
||||||
{ priority: 'asc' },
|
{ priority: 'asc' },
|
||||||
{ sort_order: 'asc' },
|
{ sort_order: 'asc' },
|
||||||
|
|
|
||||||
87
components/solo/batch-enqueue-blocker-dialog.tsx
Normal file
87
components/solo/batch-enqueue-blocker-dialog.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
interface BatchEnqueueBlockerDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (v: boolean) => void
|
||||||
|
prefixCount: number
|
||||||
|
blockerReason: 'task-review' | 'pbi-blocked'
|
||||||
|
blockerLabel: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCKER_REASON_LABELS: Record<BatchEnqueueBlockerDialogProps['blockerReason'], string> = {
|
||||||
|
'task-review': "Een taak staat op 'review'",
|
||||||
|
'pbi-blocked': 'De PBI is geblokkeerd',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchEnqueueBlockerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
prefixCount,
|
||||||
|
blockerReason,
|
||||||
|
blockerLabel,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: BatchEnqueueBlockerDialogProps) {
|
||||||
|
const noTasksBeforeBlocker = prefixCount === 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Blokkade gedetecteerd</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-3 py-2 text-sm text-foreground">
|
||||||
|
<p>
|
||||||
|
{BLOCKER_REASON_LABELS[blockerReason]}:{' '}
|
||||||
|
<span className="font-medium">{blockerLabel}</span>.
|
||||||
|
</p>
|
||||||
|
{noTasksBeforeBlocker ? (
|
||||||
|
<p className="text-muted-foreground">Er zijn geen taken vóór de blokkade om in te plannen.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
{prefixCount === 1
|
||||||
|
? `Er is ${prefixCount} taak vóór de blokkade.`
|
||||||
|
: `Er zijn ${prefixCount} taken vóór de blokkade.`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2 border-t border-outline-variant">
|
||||||
|
<Button variant="ghost" onClick={onCancel}>
|
||||||
|
Annuleer
|
||||||
|
</Button>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={noTasksBeforeBlocker}
|
||||||
|
>
|
||||||
|
{prefixCount === 1
|
||||||
|
? `Stuur ${prefixCount} taak tot aan blokkade`
|
||||||
|
: `Stuur ${prefixCount} taken tot aan blokkade`}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{noTasksBeforeBlocker && (
|
||||||
|
<TooltipContent side="top" className="text-xs">
|
||||||
|
Geen taken vóór blokkade
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,8 @@ import {
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useSoloStore } from '@/stores/solo-store'
|
import { useSoloStore } from '@/stores/solo-store'
|
||||||
import { taskStatusToApi } from '@/lib/task-status'
|
import { taskStatusToApi } from '@/lib/task-status'
|
||||||
import { enqueueAllTodoJobsAction } from '@/actions/claude-jobs'
|
import { previewEnqueueAllAction, enqueueClaudeJobsBatchAction } from '@/actions/claude-jobs'
|
||||||
|
import { BatchEnqueueBlockerDialog } from './batch-enqueue-blocker-dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
import { DemoTooltip } from '@/components/shared/demo-tooltip'
|
||||||
import { SplitPane } from '@/components/split-pane/split-pane'
|
import { SplitPane } from '@/components/split-pane/split-pane'
|
||||||
|
|
@ -61,6 +62,15 @@ export function SoloBoard({
|
||||||
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
|
const [unassignedStories, setUnassignedStories] = useState(initialUnassigned)
|
||||||
const [, startTransition] = useTransition()
|
const [, startTransition] = useTransition()
|
||||||
const [batchPending, startBatchTransition] = useTransition()
|
const [batchPending, startBatchTransition] = useTransition()
|
||||||
|
const [confirmPending, startConfirmTransition] = useTransition()
|
||||||
|
|
||||||
|
type BlockerDialogState = {
|
||||||
|
prefixCount: number
|
||||||
|
blockerReason: 'task-review' | 'pbi-blocked'
|
||||||
|
blockerLabel: string
|
||||||
|
prefixIds: string[]
|
||||||
|
}
|
||||||
|
const [blockerDialog, setBlockerDialog] = useState<BlockerDialogState | null>(null)
|
||||||
|
|
||||||
const taskKey = initialTasks.map(t => t.id).join(',')
|
const taskKey = initialTasks.map(t => t.id).join(',')
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -140,7 +150,42 @@ export function SoloBoard({
|
||||||
function handleStartAll() {
|
function handleStartAll() {
|
||||||
if (queueableCount === 0) return
|
if (queueableCount === 0) return
|
||||||
startBatchTransition(async () => {
|
startBatchTransition(async () => {
|
||||||
const result = await enqueueAllTodoJobsAction(productId)
|
const preview = await previewEnqueueAllAction(productId)
|
||||||
|
if ('error' in preview) {
|
||||||
|
toast.error(preview.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (preview.blockerIndex === null) {
|
||||||
|
const todoIds = preview.tasks.filter(t => t.status === 'TO_DO').map(t => t.id)
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(productId, todoIds)
|
||||||
|
if ('error' in result) {
|
||||||
|
toast.error(result.error)
|
||||||
|
} else if (result.count === 0) {
|
||||||
|
toast.info('Geen taken om te starten')
|
||||||
|
} else {
|
||||||
|
toast.success(`${result.count} ${result.count === 1 ? 'agent' : 'agents'} ingeschakeld`)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const blockerTask = preview.tasks[preview.blockerIndex]
|
||||||
|
const blockerLabel = preview.blockerReason === 'task-review'
|
||||||
|
? `${blockerTask.story_title} — ${blockerTask.title}`
|
||||||
|
: blockerTask.story_title
|
||||||
|
setBlockerDialog({
|
||||||
|
prefixCount: preview.blockerIndex,
|
||||||
|
blockerReason: preview.blockerReason!,
|
||||||
|
blockerLabel,
|
||||||
|
prefixIds: preview.tasks.slice(0, preview.blockerIndex).map(t => t.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlockerConfirm() {
|
||||||
|
if (!blockerDialog) return
|
||||||
|
const { prefixIds } = blockerDialog
|
||||||
|
setBlockerDialog(null)
|
||||||
|
startConfirmTransition(async () => {
|
||||||
|
const result = await enqueueClaudeJobsBatchAction(productId, prefixIds)
|
||||||
if ('error' in result) {
|
if ('error' in result) {
|
||||||
toast.error(result.error)
|
toast.error(result.error)
|
||||||
} else if (result.count === 0) {
|
} else if (result.count === 0) {
|
||||||
|
|
@ -159,9 +204,9 @@ export function SoloBoard({
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleStartAll}
|
onClick={handleStartAll}
|
||||||
disabled={isDemo || batchPending || queueableCount === 0}
|
disabled={isDemo || batchPending || confirmPending || queueableCount === 0}
|
||||||
>
|
>
|
||||||
{batchPending ? 'Starten…' : `Start agents (${queueableCount})`}
|
{batchPending || confirmPending ? 'Starten…' : `Start agents (${queueableCount})`}
|
||||||
</Button>
|
</Button>
|
||||||
</DemoTooltip>
|
</DemoTooltip>
|
||||||
{sprintGoal && (
|
{sprintGoal && (
|
||||||
|
|
@ -234,6 +279,18 @@ export function SoloBoard({
|
||||||
onOpenChange={setSheetOpen}
|
onOpenChange={setSheetOpen}
|
||||||
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
|
onClaim={(id) => setUnassignedStories(prev => prev.filter(s => s.id !== id))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{blockerDialog && (
|
||||||
|
<BatchEnqueueBlockerDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(v) => { if (!v) setBlockerDialog(null) }}
|
||||||
|
prefixCount={blockerDialog.prefixCount}
|
||||||
|
blockerReason={blockerDialog.blockerReason}
|
||||||
|
blockerLabel={blockerDialog.blockerLabel}
|
||||||
|
onConfirm={handleBlockerConfirm}
|
||||||
|
onCancel={() => setBlockerDialog(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue