feat(sessions): startDueSession + POST /api/sessions/due

This commit is contained in:
2026-05-21 07:00:00 +02:00
parent fb25f48f04
commit 754f8b6fc6
3 changed files with 108 additions and 3 deletions

View File

@@ -3,7 +3,7 @@ import { attemptCreateSchema, sessionStartSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
abandonSession, endSession, getActiveSession, getNextItem, getSessionState,
recordAttempt, startSession,
recordAttempt, startSession, startDueSession,
} from '../services/sessions.js';
import { ApiError } from '../lib/errors.js';
@@ -17,6 +17,12 @@ export function sessionsRouter(db: Db): Router {
} catch (e) { next(e); }
});
r.post('/due', async (req, res, next) => {
try {
res.status(201).json(await startDueSession(db, req.user!.id));
} catch (e) { next(e); }
});
r.get('/active', async (req, res, next) => {
try { res.json(await getActiveSession(db, req.user!.id)); } catch (e) { next(e); }
});

View File

@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb, createUserDirect } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { createCard } from './cards.js';
import { startSession, getNextItem, recordAttempt, endSession, getActiveSession } from './sessions.js';
import { startSession, getNextItem, recordAttempt, endSession, getActiveSession, startDueSession } from './sessions.js';
import { cardProgress } from '../db/schema.js';
let env: ReturnType<typeof makeTestDb>;
let owner: Awaited<ReturnType<typeof createUserDirect>>;
@@ -88,3 +89,31 @@ describe('session engine', () => {
expect(after).toBeNull();
});
});
describe('startDueSession', () => {
it('builds a session over all due cards of readable lessons', async () => {
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLesson(env.db, u.id, { name: 'L' });
const c1 = await createCard(env.db, u.id, l.id, { question: 'q1', answer: 'a' });
const c2 = await createCard(env.db, u.id, l.id, { question: 'q2', answer: 'a' });
const c3 = await createCard(env.db, u.id, l.id, { question: 'q3', answer: 'a' });
const now = Math.floor(Date.now() / 1000);
env.db.insert(cardProgress).values([
{ cardId: c1.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: 0 },
{ cardId: c2.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now - 1 },
{ cardId: c3.id, direction: 'forward', userId: u.id, box: 1, nextDueAt: now + 86400 },
]).run();
const s = await startDueSession(env.db, u.id);
expect(s.queue).toHaveLength(2);
expect(s.queue.map((q) => q.cardId).sort()).toEqual([c1.id, c2.id].sort());
});
it('returns empty queue when nothing is due', async () => {
const u = await createUserDirect(env.db, { email: 'u@example.com' });
await createLesson(env.db, u.id, { name: 'L' });
const s = await startDueSession(env.db, u.id);
expect(s.queue).toHaveLength(0);
});
});

View File

@@ -1,6 +1,6 @@
import { and, eq, inArray, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js';
import { attempts, cardProgress, cards, lessons, lessonSubscriptions, sessions } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type {
AttemptCreateInput, QueueItem, SessionRow, SessionStartInput,
@@ -113,6 +113,76 @@ export async function startSession(
return { session: rowToSession(row!), queue };
}
export async function startDueSession(db: Db, userId: number): Promise<StartedSession> {
const now = nowSec();
const ownerLessons = db.select({ id: lessons.id }).from(lessons).where(eq(lessons.ownerId, userId)).all();
const subRoots = db.select({ id: lessonSubscriptions.lessonId }).from(lessonSubscriptions)
.where(eq(lessonSubscriptions.userId, userId)).all();
const curatedRoots = db.select({ id: lessons.id }).from(lessons)
.where(and(eq(lessons.visibility, 'shared'), eq(lessons.isCurated, true))).all();
const allLessons = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all();
const byParent = new Map<number | null, typeof allLessons>();
for (const l of allLessons) {
const k = l.parentId ?? null;
if (!byParent.has(k)) byParent.set(k, []);
byParent.get(k)!.push(l);
}
const readableIds = new Set<number>();
for (const l of ownerLessons) readableIds.add(l.id);
const stack: number[] = [];
for (const r of subRoots) stack.push(r.id);
for (const r of curatedRoots) stack.push(r.id);
while (stack.length) {
const cur = stack.pop()!;
if (readableIds.has(cur)) continue;
readableIds.add(cur);
for (const c of byParent.get(cur) ?? []) stack.push(c.id);
}
if (readableIds.size === 0) return createEmptyDueSession(db, userId);
const cardRows = db.select({ id: cards.id, lessonId: cards.lessonId }).from(cards)
.where(inArray(cards.lessonId, Array.from(readableIds))).all();
const cardIds = cardRows.map((r) => r.id);
if (cardIds.length === 0) return createEmptyDueSession(db, userId);
const due = db.select({ cardId: cardProgress.cardId, direction: cardProgress.direction, box: cardProgress.box })
.from(cardProgress)
.where(and(
eq(cardProgress.userId, userId),
inArray(cardProgress.cardId, cardIds),
sql`${cardProgress.nextDueAt} <= ${now}`,
))
.all();
due.sort((a, b) => a.box - b.box);
const queue: QueueItem[] = due.map((d) => ({ cardId: d.cardId, direction: d.direction }));
const sessionLessonId = cardRows[0]?.lessonId ?? ownerLessons[0]?.id ?? Array.from(readableIds)[0]!;
const [row] = db.insert(sessions).values({
lessonId: sessionLessonId,
userId,
queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }),
}).returning().all();
return { session: rowToSession(row!), queue };
}
function createEmptyDueSession(db: Db, userId: number): StartedSession {
const anyLesson = db.select({ id: lessons.id }).from(lessons).where(eq(lessons.ownerId, userId)).get()
?? db.select({ id: lessons.id }).from(lessons).get();
if (!anyLesson) {
throw new ApiError(409, 'NO_LESSONS', 'No lessons exist for this user yet');
}
const [row] = db.insert(sessions).values({
lessonId: anyLesson.id,
userId,
queueSnapshot: JSON.stringify({ remaining: [], index: 0 }),
}).returning().all();
return { session: rowToSession(row!), queue: [] };
}
function rowToSession(r: typeof sessions.$inferSelect): SessionRow {
return {
id: r.id,