Files
Curio/src/app/api/cron/review-digest/route.ts
T
arnaudne 5bf6013460 feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
Progressive lesson streaming via onSegment callback (fixes SSE for non-English
users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers,
Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval
runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema,
mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions),
image queries, source citations, view transitions, reading animations, i18n
(next-intl), PDF export, surprise endpoint, and 402 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:08:14 +02:00

95 lines
2.9 KiB
TypeScript

import { createHmac } from 'crypto';
import { NextRequest, NextResponse } from 'next/server';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { jobs } from '@/lib/db/schema';
import { getUsersWithDueReviews } from '@/lib/db/queries';
import { reviewDigestEmail } from '@/lib/email/templates';
import { sendEmail } from '@/lib/email';
const BASE_URL = process.env.AUTH_URL ?? 'http://localhost:3000';
export function signUserId(userId: string): string {
const secret = process.env.UNSUBSCRIBE_SECRET ?? 'dev-secret-change-in-prod';
return createHmac('sha256', secret).update(userId).digest('hex');
}
export async function GET(request: NextRequest): Promise<NextResponse> {
// Gate by env flag — feature is opt-in
if (process.env.REVIEW_DIGEST_ENABLED !== 'true') {
return NextResponse.json({ skipped: true, reason: 'REVIEW_DIGEST_ENABLED is not set' });
}
// Auth: Vercel Cron sends Authorization: Bearer <CRON_SECRET>
const cronSecret = process.env.CRON_SECRET;
if (cronSecret) {
const auth = request.headers.get('Authorization');
if (auth !== `Bearer ${cronSecret}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
const now = new Date();
const dateKey = now.toISOString().slice(0, 10); // yyyy-mm-dd
const recipients = await getUsersWithDueReviews(now);
let sent = 0;
let skipped = 0;
for (const recipient of recipients) {
const idempotencyKey = `digest:${recipient.userId}:${dateKey}`;
// Idempotency: skip if already sent today
const [existing] = await db
.select({ id: jobs.id, status: jobs.status, attempts: jobs.attempts })
.from(jobs)
.where(eq(jobs.idempotencyKey, idempotencyKey))
.limit(1);
if (existing?.status === 'done') {
skipped++;
continue;
}
// Insert or update job row
if (!existing) {
await db.insert(jobs).values({
type: 'review-digest',
idempotencyKey,
status: 'running',
payloadJson: { userId: recipient.userId, date: dateKey },
attempts: 1,
}).onConflictDoNothing();
} else {
await db
.update(jobs)
.set({ status: 'running', attempts: (existing.attempts ?? 0) + 1 })
.where(eq(jobs.id, existing.id));
}
const sig = signUserId(recipient.userId);
const unsubscribeUrl = `${BASE_URL}/api/unsubscribe?userId=${encodeURIComponent(recipient.userId)}&sig=${sig}`;
const reviewUrl = `${BASE_URL}/review`;
const email = reviewDigestEmail({
name: recipient.name,
dueCount: recipient.dueCount,
topConcepts: recipient.topConcepts,
reviewUrl,
unsubscribeUrl,
locale: recipient.locale,
});
await sendEmail({ to: recipient.email, ...email });
await db
.update(jobs)
.set({ status: 'done' })
.where(eq(jobs.idempotencyKey, idempotencyKey));
sent++;
}
return NextResponse.json({ sent, skipped, total: recipients.length, date: dateKey });
}