feat(sessions): per-user sessions and progress

This commit is contained in:
2026-05-21 00:13:15 +02:00
parent 2d37aee32c
commit a0c11d8e21
2 changed files with 101 additions and 71 deletions

View File

@@ -7,6 +7,7 @@ import type {
} from '@flashcard/shared';
import { applyResult } from './leitner.js';
import { getDescendantLessonIds } from './lessons.js';
import { canReadLesson } from './permissions.js';
const REINSERT_OFFSET = 3;
@@ -25,17 +26,20 @@ export interface StartedSession {
queue: QueueItem[];
}
export async function startSession(db: Db, input: SessionStartInput): Promise<StartedSession> {
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 = lessonIds.length === 0
? []
: db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all();
const allCards = db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all();
const direction = input.direction ?? 'forward';
const candidates: QueueItem[] = [];
@@ -49,19 +53,29 @@ export async function startSession(db: Db, input: SessionStartInput): Promise<St
}
}
// 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)))
.get();
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, box: 1, nextDueAt: 0,
cardId: item.cardId, direction: item.direction,
userId, box: 1, nextDueAt: 0,
}).run();
}
}
const progressRows = allCards.length === 0
? []
: db.select().from(cardProgress).where(inArray(cardProgress.cardId, allCards.map((c) => c.id))).all();
: 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);
@@ -88,10 +102,11 @@ export async function startSession(db: Db, input: SessionStartInput): Promise<St
let queue: QueueItem[] = [...due, ...future];
const max = input.maxCards ?? null;
if (max !== null && max !== undefined) queue = queue.slice(0, max);
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();
@@ -118,22 +133,25 @@ function readQueue(snapshot: string | null | undefined): QueueState {
return JSON.parse(snapshot) as QueueState;
}
export async function getNextItem(db: Db, sessionId: number): Promise<QueueItem | null> {
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();
if (!row) throw ApiError.notFound('Session');
if (row.status !== 'active') return null;
const state = readQueue(row.queueSnapshot);
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,
sessionId: number,
input: AttemptCreateInput
db: Db, userId: number, sessionId: number, input: AttemptCreateInput
): Promise<void> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
if (sess.status !== 'active') throw ApiError.validation('Session is not active');
assertSessionOwnership(sess, userId);
if (sess!.status !== 'active') throw ApiError.validation('Session is not active');
const now = nowSec();
db.insert(attempts).values({
@@ -144,9 +162,11 @@ export async function recordAttempt(
timeToAnswerMs: input.timeToAnswerMs ?? null,
}).run();
const prog = db.select().from(cardProgress)
.where(and(eq(cardProgress.cardId, input.cardId), eq(cardProgress.direction, input.direction)))
.get();
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 },
@@ -159,10 +179,14 @@ export async function recordAttempt(
incorrectCount: delta.incorrectCount,
nextDueAt: delta.nextDueAt,
lastShownAt: delta.lastShownAt,
}).where(and(eq(cardProgress.cardId, input.cardId), eq(cardProgress.direction, input.direction))).run();
}).where(and(
eq(cardProgress.cardId, input.cardId),
eq(cardProgress.direction, input.direction),
eq(cardProgress.userId, userId),
)).run();
}
const state = readQueue(sess.queueSnapshot);
const state = readQueue(sess!.queueSnapshot);
state.index += 1;
if (input.result === 'incorrect') {
const insertAt = Math.min(state.remaining.length, state.index + REINSERT_OFFSET);
@@ -171,45 +195,47 @@ export async function recordAttempt(
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),
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, sessionId: number): Promise<SessionRow> {
export async function endSession(db: Db, userId: number, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
assertSessionOwnership(sess, userId);
const endedAt = nowSec();
const duration = endedAt - sess.startedAt;
const duration = endedAt - sess!.startedAt;
const [row] = db.update(sessions).set({
status: 'completed',
endedAt,
durationSeconds: duration,
queueSnapshot: null,
status: 'completed', endedAt, durationSeconds: duration, queueSnapshot: null,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row!);
}
export async function abandonSession(db: Db, sessionId: number): Promise<SessionRow> {
export async function abandonSession(db: Db, userId: number, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!sess) throw ApiError.notFound('Session');
assertSessionOwnership(sess, userId);
const endedAt = nowSec();
const duration = endedAt - sess.startedAt;
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): Promise<SessionRow | null> {
const row = db.select().from(sessions).where(eq(sessions.status, 'active')).orderBy(sql`${sessions.startedAt} DESC`).get();
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, sessionId: number): Promise<{ session: SessionRow; queue: QueueItem[]; index: number } | 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 };
}