feat(sessions): startDueSession + POST /api/sessions/due
This commit is contained in:
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user