Scrum4Me/app/api/tasks/[id]/route.ts
Janpeter Visser 9794a9baef
M13: Veilige Claude-agent-workflow (Scrum4Me-side) (#26)
* feat: add pushed_at field to ClaudeJob schema

Nullable DateTime column to record when the agent's feature branch was
pushed to origin. Enables the UI to show a 'pushed' state independently
of DONE status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: GitHub-link op DONE-card + pushed_at doorvoer

- lib/job-status-url.ts: getBranchUrl(repoUrl, branch) → GitHub tree URL
- JobState + ClaudeJobEvent: pushed_at? veld toegevoegd
- realtime/solo/route.ts: pushed_at in Prisma-select, JobPayload en mapping
- SoloBoardProps + TaskDetailDialog: repoUrl prop doorgevoerd
- task-detail-dialog: "Open op GitHub"-link als done + pushed_at + branch + repoUrl
- 3 unit-tests voor getBranchUrl; totaal 261 tests groen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add VerifyResult enum, verify_only on Task, verify_result on ClaudeJob

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add verify_result+pushed_at to JobState, VerifyResultApi type, SSE payload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: verify_only field on SoloTask, PATCH route saves verify_only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: TaskDetailDialog — verify_result display + verify_only checkbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: verify_only PATCH + verify_result dialog render + store fix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: document VerifyResult enum, verify_only task field, pushed_at in architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(M13): cron /api/cron/cleanup-agent-artifacts — hard-delete FAILED/CANCELLED jobs >7 days

* feat(M13): add auto_pr field to Product schema + migration

* feat(M13): auto_pr toggle in product settings — server action + UI component + tests

* feat(M13): add pr_url to ClaudeJob schema + migration

* feat(M13): UI — 'Open PR' link on DONE-card; pr_url in JobState + SSE + task-dialog

* feat(M13): add retry_count migration + regen erd

- Migration ALTER TABLE claude_jobs ADD COLUMN retry_count INT DEFAULT 0
  (schema.prisma was reeds bijgewerkt in eerdere commits)
- docs/erd.svg geregenereerd voor de complete M13-schema-wijzigingen
  (verify_result, verify_only, pushed_at, pr_url, auto_pr, retry_count)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 13:42:18 +02:00

129 lines
4.1 KiB
TypeScript

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'
// `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.
// Reject it here until the sprint UI handles REVIEW so external clients don't
// drive tasks into a state the shared UI can't display.
const PATCHABLE_TASK_STATUS = TASK_STATUS_API_VALUES.filter((s) => s !== 'review')
const patchSchema = z
.object({
status: z.enum(PATCHABLE_TASK_STATUS as [string, ...string[]]).optional(),
implementation_plan: z.string().optional(),
verify_only: z.boolean().optional(),
})
.refine(
(data) =>
data.status !== undefined ||
data.implementation_plan !== undefined ||
data.verify_only !== undefined,
{ message: 'Geef minimaal status, implementation_plan of verify_only mee' },
)
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = await authenticateApiRequest(request)
if ('error' in auth) {
return Response.json({ error: auth.error }, { status: auth.status })
}
if (auth.isDemo) {
return Response.json({ error: 'Niet beschikbaar in demo-modus' }, { status: 403 })
}
const { id } = await params
const task = await prisma.task.findFirst({
where: { id },
include: {
story: {
include: {
product: {
include: {
members: {
where: { user_id: auth.userId },
select: { id: true },
},
},
},
},
},
},
})
if (!task) {
return Response.json({ error: 'Taak niet gevonden' }, { status: 404 })
}
const hasAccess =
task.story.product.user_id === auth.userId ||
(task.story.product.members?.length ?? 0) > 0
if (!hasAccess) {
return Response.json({ error: 'Geen toegang' }, { status: 403 })
}
let body: unknown
try {
body = await request.json()
} catch {
return Response.json({ error: 'Malformed JSON' }, { status: 400 })
}
const parsed = patchSchema.safeParse(body)
if (!parsed.success) {
return Response.json({ error: parsed.error.flatten() }, { status: 422 })
}
let dbStatus: ReturnType<typeof taskStatusFromApi> | undefined
if (parsed.data.status !== undefined) {
dbStatus = taskStatusFromApi(parsed.data.status)
if (dbStatus === null) {
return Response.json(
{ error: { fieldErrors: { status: ['Onbekende status'] } } },
{ status: 422 },
)
}
}
// Combine simple field writes (plan, verify_only) into one update call
const simpleData: { implementation_plan?: string; verify_only?: boolean } = {}
if (parsed.data.implementation_plan !== undefined)
simpleData.implementation_plan = parsed.data.implementation_plan
if (parsed.data.verify_only !== undefined)
simpleData.verify_only = parsed.data.verify_only
const updated = await prisma.$transaction(async (tx) => {
const simpleUpdate = Object.keys(simpleData).length > 0
? await tx.task.update({
where: { id },
data: simpleData,
select: { id: true, status: true, implementation_plan: true, verify_only: true },
})
: null
if (dbStatus !== undefined && dbStatus !== null) {
const result = await updateTaskStatusWithStoryPromotion(id, dbStatus, tx)
return {
id: result.task.id,
status: result.task.status,
implementation_plan: result.task.implementation_plan,
verify_only: simpleUpdate?.verify_only,
}
}
if (simpleUpdate) return simpleUpdate
// Should not reach here — patchSchema rejects bodies without recognized fields.
throw new Error('Geen wijzigingen')
})
return Response.json({
id: updated.id,
status: taskStatusToApi(updated.status),
implementation_plan: updated.implementation_plan,
...(updated.verify_only !== undefined && { verify_only: updated.verify_only }),
})
}