242 lines
8.6 KiB
TypeScript
242 lines
8.6 KiB
TypeScript
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<T>(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<StartedSession> {
|
|
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<string, typeof progressRows[number]>();
|
|
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<QueueItem | null> {
|
|
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<void> {
|
|
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<SessionRow> {
|
|
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<SessionRow> {
|
|
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<SessionRow | null> {
|
|
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 };
|
|
}
|