diff --git a/.env.example b/.env.example index 1cac979..ab61549 100644 --- a/.env.example +++ b/.env.example @@ -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="" diff --git a/__tests__/api/cron-expire-questions.test.ts b/__tests__/api/cron-expire-questions.test.ts new file mode 100644 index 0000000..d539d8b --- /dev/null +++ b/__tests__/api/cron-expire-questions.test.ts @@ -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 } + loginPairing: { updateMany: ReturnType } +} + +const SECRET = 'test-cron-secret-abc123' + +function makeReq(headers: Record = {}): 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' }) + }) +}) diff --git a/app/api/cron/expire-questions/route.ts b/app/api/cron/expire-questions/route.ts new file mode 100644 index 0000000..ba72bd1 --- /dev/null +++ b/app/api/cron/expire-questions/route.ts @@ -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 ` 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(), + }) +} diff --git a/lib/env.ts b/lib/env.ts index 32d1650..40d0676 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -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) diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..e41e82c --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/cron/expire-questions", + "schedule": "0 */6 * * *" + } + ] +}