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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user