feat(ST-1107): add Vercel cron expire-questions (+ M10 pairing cleanup)

POST /api/cron/expire-questions:
- Auth via Authorization: Bearer ${CRON_SECRET} (Vercel injecteert dit
  automatisch wanneer de env-var op de project-omgeving staat); 401 als secret
  niet matcht of niet is gezet (faal-veilig — geen open endpoint in dev)
- updateMany op claude_questions WHERE status='open' AND expires_at<now →
  'expired'
- Bonus: zelfde route ruimt M10 login_pairings op (status='pending' AND
  expires_at<now → 'cancelled'). Eén cron-job is goedkoper qua Vercel-budget
  en houdt cleanup-strategie centraal — opvolg-actie uit M10 dat geparkeerd was.

Config:
- vercel.json: crons-entry { path: '/api/cron/expire-questions', schedule: '0 */6 * * *' } (4x/dag)
- lib/env.ts: CRON_SECRET als optional in Zod-schema
- .env.example: documentatie + openssl rand-tip

Tests __tests__/api/cron-expire-questions.test.ts (4 cases):
- 401 zonder Authorization-header
- 401 met verkeerde secret
- 401 als CRON_SECRET niet is gezet (faal-veilig)
- 200 met juiste secret: response { expired_questions, expired_pairings, ran_at }
  + beide updateMany WHERE/data correct

Quality gates: lint 0 errors, tsc clean, vitest 151/151.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-28 01:51:48 +02:00
parent 1f42de5447
commit eeed5d7506
5 changed files with 140 additions and 0 deletions

View file

@ -0,0 +1,44 @@
// ST-1107: Vercel cron handler die verlopen Claude-vragen op 'expired' zet.
//
// Wordt elke 6 uur door Vercel POST'd (zie vercel.ts crons-config). Auth is
// via een gedeeld secret in de Authorization-header — Vercel injecteert
// `Authorization: Bearer <CRON_SECRET>` automatisch wanneer de env-var op de
// project-omgeving staat.
//
// Bonus (ST-1107.4): zelfde route ruimt ook M10's verlopen `pending`
// login_pairings op. Reden: één cron-job is goedkoper qua Vercel-budget en
// houdt de cleanup-strategie centraal.
import { prisma } from '@/lib/prisma'
export const runtime = 'nodejs'
export async function POST(request: Request) {
const auth = request.headers.get('authorization')
const expected = process.env.CRON_SECRET
if (!expected || auth !== `Bearer ${expected}`) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const now = new Date()
// M11: open Claude-vragen → expired
const expiredQuestions = await prisma.claudeQuestion.updateMany({
where: { status: 'open', expires_at: { lt: now } },
data: { status: 'expired' },
})
// M10: pending login_pairings die niet meer bruikbaar zijn → cancelled
// (status='expired' bestaat niet voor pairings; cancelled heeft hetzelfde
// resultaat: niet-claimable, niet meer in de SSE-listener.)
const expiredPairings = await prisma.loginPairing.updateMany({
where: { status: 'pending', expires_at: { lt: now } },
data: { status: 'cancelled' },
})
return Response.json({
expired_questions: expiredQuestions.count,
expired_pairings: expiredPairings.count,
ran_at: now.toISOString(),
})
}