5bf6013460
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>
95 lines
2.9 KiB
TypeScript
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 });
|
|
}
|