diff --git a/packages/backend/src/routes/sessions.ts b/packages/backend/src/routes/sessions.ts index aca2345..90320fa 100644 --- a/packages/backend/src/routes/sessions.ts +++ b/packages/backend/src/routes/sessions.ts @@ -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); } }); diff --git a/packages/backend/src/services/sessions.test.ts b/packages/backend/src/services/sessions.test.ts index ac1ffe1..a8a4b69 100644 --- a/packages/backend/src/services/sessions.test.ts +++ b/packages/backend/src/services/sessions.test.ts @@ -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; let owner: Awaited>; @@ -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); + }); +}); diff --git a/packages/backend/src/services/sessions.ts b/packages/backend/src/services/sessions.ts index c9d23c7..5395ae0 100644 --- a/packages/backend/src/services/sessions.ts +++ b/packages/backend/src/services/sessions.ts @@ -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 { + 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(); + 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(); + 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,