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:
2026-07-08 22:08:14 +02:00
parent 4e38b5a791
commit 5bf6013460
161 changed files with 27152 additions and 1161 deletions
@@ -17,7 +17,9 @@ const ENTRIES = [
{
conceptId: 'c1',
conceptName: 'Closures',
blueprintId: 'bp1',
blueprintTitle: 'JavaScript',
blueprintIntentKey: 'javascript',
score: 0.8,
lastSeen: NOW,
nextReview: LATER,
@@ -25,7 +27,9 @@ const ENTRIES = [
{
conceptId: 'c2',
conceptName: 'Promises',
blueprintId: 'bp1',
blueprintTitle: 'JavaScript',
blueprintIntentKey: 'javascript',
score: 0.45,
lastSeen: NOW,
nextReview: NOW,
+40 -5
View File
@@ -1,4 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { eq, and, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { mastery, concepts, blueprints, journeyInstances } from '@/lib/db/schema';
import { getMasteryMap } from '@/lib/db/queries';
import { getOptionalSession } from '@/lib/auth-helpers';
@@ -16,14 +19,46 @@ export async function GET(req: NextRequest): Promise<NextResponse> {
const entries = await getMasteryMap(userId);
// Group by blueprintTitle for the map view
const grouped: Record<string, { blueprintTitle: string; concepts: typeof entries }> = {};
// Group by blueprintIntentKey for the map view
const grouped: Record<string, { blueprintId: string; blueprintTitle: string; blueprintIntentKey: string; concepts: typeof entries }> = {};
for (const entry of entries) {
if (!grouped[entry.blueprintTitle]) {
grouped[entry.blueprintTitle] = { blueprintTitle: entry.blueprintTitle, concepts: [] };
const key = entry.blueprintIntentKey;
if (!grouped[key]) {
grouped[key] = { blueprintId: entry.blueprintId, blueprintTitle: entry.blueprintTitle, blueprintIntentKey: key, concepts: [] };
}
grouped[entry.blueprintTitle].concepts.push(entry);
grouped[key].concepts.push(entry);
}
return NextResponse.json({ groups: Object.values(grouped) });
}
/**
* DELETE /api/mastery?blueprintId=<id>&userId=<id>
* Removes all mastery records and the journey instance for a blueprint.
*/
export async function DELETE(req: NextRequest): Promise<NextResponse> {
const session = await getOptionalSession();
const userId = session?.user?.id ?? req.nextUrl.searchParams.get('userId');
const blueprintId = req.nextUrl.searchParams.get('blueprintId');
if (!userId || !blueprintId) {
return NextResponse.json({ error: 'Missing userId or blueprintId' }, { status: 400 });
}
const conceptIds = (
await db.select({ id: concepts.id }).from(concepts).where(eq(concepts.blueprintId, blueprintId))
).map((r) => r.id);
await db.transaction(async (tx) => {
if (conceptIds.length > 0) {
await tx.delete(mastery).where(
and(eq(mastery.userId, userId), inArray(mastery.conceptId, conceptIds)),
);
}
await tx.delete(journeyInstances).where(
and(eq(journeyInstances.userId, userId), eq(journeyInstances.blueprintId, blueprintId)),
);
});
return NextResponse.json({ ok: true });
}