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:
parent
1f42de5447
commit
eeed5d7506
5 changed files with 140 additions and 0 deletions
|
|
@ -8,3 +8,9 @@ SESSION_SECRET="replace-with-at-least-32-characters"
|
|||
|
||||
# Optional; Vercel and Node set this automatically in deployed environments.
|
||||
NODE_ENV="development"
|
||||
|
||||
# M11 (ST-1107) — shared secret between Vercel cron-trigger and the
|
||||
# /api/cron/expire-questions handler. Required in production; optional in
|
||||
# local dev (the route returns 401 if the Authorization header doesn't match).
|
||||
# Generate with: openssl rand -base64 32
|
||||
CRON_SECRET=""
|
||||
|
|
|
|||
77
__tests__/api/cron-expire-questions.test.ts
Normal file
77
__tests__/api/cron-expire-questions.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
claudeQuestion: { updateMany: vi.fn() },
|
||||
loginPairing: { updateMany: vi.fn() },
|
||||
},
|
||||
}))
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { POST } from '@/app/api/cron/expire-questions/route'
|
||||
|
||||
const mockPrisma = prisma as unknown as {
|
||||
claudeQuestion: { updateMany: ReturnType<typeof vi.fn> }
|
||||
loginPairing: { updateMany: ReturnType<typeof vi.fn> }
|
||||
}
|
||||
|
||||
const SECRET = 'test-cron-secret-abc123'
|
||||
|
||||
function makeReq(headers: Record<string, string> = {}): Request {
|
||||
return new Request('http://localhost:3000/api/cron/expire-questions', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
process.env.CRON_SECRET = SECRET
|
||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 0 })
|
||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 0 })
|
||||
})
|
||||
|
||||
describe('POST /api/cron/expire-questions', () => {
|
||||
it('401 zonder Authorization-header', async () => {
|
||||
const res = await POST(makeReq())
|
||||
expect(res.status).toBe(401)
|
||||
expect(mockPrisma.claudeQuestion.updateMany).not.toHaveBeenCalled()
|
||||
expect(mockPrisma.loginPairing.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('401 met verkeerde secret', async () => {
|
||||
const res = await POST(makeReq({ authorization: 'Bearer wrong-secret' }))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('401 als CRON_SECRET niet is gezet (faal-veilig)', async () => {
|
||||
delete process.env.CRON_SECRET
|
||||
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('200 met juiste secret + beide updateMany aangeroepen', async () => {
|
||||
mockPrisma.claudeQuestion.updateMany.mockResolvedValue({ count: 3 })
|
||||
mockPrisma.loginPairing.updateMany.mockResolvedValue({ count: 1 })
|
||||
|
||||
const res = await POST(makeReq({ authorization: 'Bearer ' + SECRET }))
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body).toMatchObject({
|
||||
expired_questions: 3,
|
||||
expired_pairings: 1,
|
||||
})
|
||||
expect(body.ran_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||||
|
||||
// Question-update: status='open' AND expires_at < now → 'expired'
|
||||
const qArg = mockPrisma.claudeQuestion.updateMany.mock.calls[0][0]
|
||||
expect(qArg.where).toMatchObject({ status: 'open' })
|
||||
expect(qArg.where.expires_at).toMatchObject({ lt: expect.any(Date) })
|
||||
expect(qArg.data).toEqual({ status: 'expired' })
|
||||
|
||||
// Pairing-update: status='pending' AND expires_at < now → 'cancelled'
|
||||
const pArg = mockPrisma.loginPairing.updateMany.mock.calls[0][0]
|
||||
expect(pArg.where).toMatchObject({ status: 'pending' })
|
||||
expect(pArg.data).toEqual({ status: 'cancelled' })
|
||||
})
|
||||
})
|
||||
44
app/api/cron/expire-questions/route.ts
Normal file
44
app/api/cron/expire-questions/route.ts
Normal 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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -5,6 +5,10 @@ const envSchema = z.object({
|
|||
DIRECT_URL: z.string().optional(),
|
||||
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
// M11 (ST-1107) — gedeeld geheim tussen Vercel cron-trigger en
|
||||
// /api/cron/expire-questions. In productie verplicht; lokaal dev mag missen
|
||||
// (de cron-route geeft 401 als de header niet matcht).
|
||||
CRON_SECRET: z.string().optional(),
|
||||
})
|
||||
|
||||
const parsed = envSchema.safeParse(process.env)
|
||||
|
|
|
|||
9
vercel.json
Normal file
9
vercel.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/expire-questions",
|
||||
"schedule": "0 */6 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue