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 { ApiError } from '../lib/errors.js'; import type { AttemptCreateInput, QueueItem, SessionRow, SessionStartInput, } from '@flashcard/shared'; import { applyResult } from './leitner.js'; import { getDescendantLessonIds } from './lessons.js'; import { canReadLesson } from './permissions.js'; const REINSERT_OFFSET = 3; function nowSec() { return Math.floor(Date.now() / 1000); } function shuffleInPlace(arr: T[]): T[] { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j]!, arr[i]!]; } return arr; } export interface StartedSession { session: SessionRow; queue: QueueItem[]; } export async function startSession( db: Db, userId: number, input: SessionStartInput ): Promise { const lesson = db.select().from(lessons).where(eq(lessons.id, input.lessonId)).get(); if (!lesson) throw ApiError.notFound('Lesson'); if (!(await canReadLesson(db, userId, input.lessonId))) { throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot start a session for this lesson'); } const lessonIds = await getDescendantLessonIds(db, input.lessonId); const lessonRows = db.select().from(lessons).where(inArray(lessons.id, lessonIds)).all(); const bidirById = new Map(lessonRows.map((l) => [l.id, l.bidirectional])); const allCards = db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all(); const direction = input.direction ?? 'forward'; const candidates: QueueItem[] = []; for (const c of allCards) { const isBidi = bidirById.get(c.lessonId) === true; if (direction === 'forward' || direction === 'both') { candidates.push({ cardId: c.id, direction: 'forward' }); } if ((direction === 'backward' || direction === 'both') && isBidi) { candidates.push({ cardId: c.id, direction: 'backward' }); } } // Ensure per-user progress rows for each candidate for (const item of candidates) { const existing = db.select().from(cardProgress).where(and( eq(cardProgress.cardId, item.cardId), eq(cardProgress.direction, item.direction), eq(cardProgress.userId, userId), )).get(); if (!existing) { db.insert(cardProgress).values({ cardId: item.cardId, direction: item.direction, userId, box: 1, nextDueAt: 0, }).run(); } } const progressRows = allCards.length === 0 ? [] : db.select().from(cardProgress) .where(and( inArray(cardProgress.cardId, allCards.map((c) => c.id)), eq(cardProgress.userId, userId), )) .all(); const progByKey = new Map(); for (const p of progressRows) progByKey.set(`${p.cardId}:${p.direction}`, p); const now = nowSec(); const due: QueueItem[] = []; const future: QueueItem[] = []; for (const item of candidates) { const p = progByKey.get(`${item.cardId}:${item.direction}`)!; (p.nextDueAt <= now ? due : future).push(item); } const shuffle = input.shuffle ?? true; const sortByBox = (a: QueueItem, b: QueueItem) => { const pa = progByKey.get(`${a.cardId}:${a.direction}`)!; const pb = progByKey.get(`${b.cardId}:${b.direction}`)!; return pa.box - pb.box; }; if (shuffle) { shuffleInPlace(due); shuffleInPlace(future); } due.sort(sortByBox); future.sort(sortByBox); let queue: QueueItem[] = [...due, ...future]; const max = input.maxCards ?? null; if (max !== null) queue = queue.slice(0, max); const [row] = db.insert(sessions).values({ lessonId: input.lessonId, userId, queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }), }).returning().all(); return { session: rowToSession(row!), queue }; } function rowToSession(r: typeof sessions.$inferSelect): SessionRow { return { id: r.id, lessonId: r.lessonId, startedAt: r.startedAt, endedAt: r.endedAt ?? null, durationSeconds: r.durationSeconds ?? null, cardsShown: r.cardsShown, cardsCorrect: r.cardsCorrect, cardsIncorrect: r.cardsIncorrect, status: r.status, }; } interface QueueState { remaining: QueueItem[]; index: number; } function readQueue(snapshot: string | null | undefined): QueueState { if (!snapshot) return { remaining: [], index: 0 }; return JSON.parse(snapshot) as QueueState; } function assertSessionOwnership(sess: { userId: number | null } | undefined, userId: number) { if (!sess) throw ApiError.notFound('Session'); if (sess.userId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your session'); } export async function getNextItem(db: Db, userId: number, sessionId: number): Promise { const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get(); assertSessionOwnership(row, userId); if (row!.status !== 'active') return null; const state = readQueue(row!.queueSnapshot); return state.remaining[state.index] ?? null; } export async function recordAttempt( db: Db, userId: number, sessionId: number, input: AttemptCreateInput ): Promise { const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get(); assertSessionOwnership(sess, userId); if (sess!.status !== 'active') throw ApiError.validation('Session is not active'); const now = nowSec(); db.insert(attempts).values({ sessionId, cardId: input.cardId, direction: input.direction, result: input.result, timeToAnswerMs: input.timeToAnswerMs ?? null, }).run(); const prog = db.select().from(cardProgress).where(and( eq(cardProgress.cardId, input.cardId), eq(cardProgress.direction, input.direction), eq(cardProgress.userId, userId), )).get(); if (prog) { const delta = applyResult( { box: prog.box, correctCount: prog.correctCount, incorrectCount: prog.incorrectCount }, input.result, now ); db.update(cardProgress).set({ box: delta.box, correctCount: delta.correctCount, incorrectCount: delta.incorrectCount, nextDueAt: delta.nextDueAt, lastShownAt: delta.lastShownAt, }).where(and( eq(cardProgress.cardId, input.cardId), eq(cardProgress.direction, input.direction), eq(cardProgress.userId, userId), )).run(); } const state = readQueue(sess!.queueSnapshot); state.index += 1; if (input.result === 'incorrect') { const insertAt = Math.min(state.remaining.length, state.index + REINSERT_OFFSET); state.remaining.splice(insertAt, 0, { cardId: input.cardId, direction: input.direction }); } db.update(sessions).set({ queueSnapshot: JSON.stringify(state), cardsShown: sess!.cardsShown + 1, cardsCorrect: sess!.cardsCorrect + (input.result === 'correct' ? 1 : 0), cardsIncorrect: sess!.cardsIncorrect + (input.result === 'incorrect' ? 1 : 0), }).where(eq(sessions.id, sessionId)).run(); } export async function endSession(db: Db, userId: number, sessionId: number): Promise { const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get(); assertSessionOwnership(sess, userId); const endedAt = nowSec(); const duration = endedAt - sess!.startedAt; const [row] = db.update(sessions).set({ status: 'completed', endedAt, durationSeconds: duration, queueSnapshot: null, }).where(eq(sessions.id, sessionId)).returning().all(); return rowToSession(row!); } export async function abandonSession(db: Db, userId: number, sessionId: number): Promise { const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get(); assertSessionOwnership(sess, userId); const endedAt = nowSec(); const duration = endedAt - sess!.startedAt; const [row] = db.update(sessions).set({ status: 'abandoned', endedAt, durationSeconds: duration, }).where(eq(sessions.id, sessionId)).returning().all(); return rowToSession(row!); } export async function getActiveSession(db: Db, userId: number): Promise { const row = db.select().from(sessions) .where(and(eq(sessions.status, 'active'), eq(sessions.userId, userId))) .orderBy(sql`${sessions.startedAt} DESC`).get(); return row ? rowToSession(row) : null; } export async function getSessionState( db: Db, userId: number, sessionId: number ): Promise<{ session: SessionRow; queue: QueueItem[]; index: number } | null> { const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get(); if (!row) return null; if (row.userId !== userId) return null; const state = readQueue(row.queueSnapshot); return { session: rowToSession(row), queue: state.remaining, index: state.index }; }