diff --git a/packages/backend/src/services/sessions.test.ts b/packages/backend/src/services/sessions.test.ts index 7c8115a..ac1ffe1 100644 --- a/packages/backend/src/services/sessions.test.ts +++ b/packages/backend/src/services/sessions.test.ts @@ -1,72 +1,76 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { makeTestDb } from '../tests/dbHelper.js'; +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'; let env: ReturnType; -beforeEach(() => { env = makeTestDb(); }); +let owner: Awaited>; +beforeEach(async () => { + env = makeTestDb(); + owner = await createUserDirect(env.db, { email: 'owner@example.com' }); +}); async function seedLesson(name = 'L', cards = 3, bidi = false) { - const lesson = await createLesson(env.db, { name, bidirectional: bidi }); + const lesson = await createLesson(env.db, owner.id, { name, bidirectional: bidi }); for (let i = 0; i < cards; i++) { - await createCard(env.db, lesson.id, { question: `q${i}`, answer: `a${i}` }); + await createCard(env.db, owner.id, lesson.id, { question: `q${i}`, answer: `a${i}` }); } return lesson; } describe('session engine', () => { it('starts a session and queues all cards from lesson + descendants', async () => { - const root = await createLesson(env.db, { name: 'R' }); - const child = await createLesson(env.db, { name: 'C', parentId: root.id }); - await createCard(env.db, root.id, { question: 'r1', answer: 'a' }); - await createCard(env.db, child.id, { question: 'c1', answer: 'a' }); - const s = await startSession(env.db, { lessonId: root.id, shuffle: false }); + const root = await createLesson(env.db, owner.id, { name: 'R' }); + const child = await createLesson(env.db, owner.id, { name: 'C', parentId: root.id }); + await createCard(env.db, owner.id, root.id, { question: 'r1', answer: 'a' }); + await createCard(env.db, owner.id, child.id, { question: 'c1', answer: 'a' }); + const s = await startSession(env.db, owner.id, { lessonId: root.id, shuffle: false }); expect(s.queue).toHaveLength(2); }); it('returns items in queue order and marks done when exhausted', async () => { const l = await seedLesson('L', 2); - const s = await startSession(env.db, { lessonId: l.id, shuffle: false }); - const a = await getNextItem(env.db, s.session.id); + const s = await startSession(env.db, owner.id, { lessonId: l.id, shuffle: false }); + const a = await getNextItem(env.db, owner.id, s.session.id); expect(a).not.toBeNull(); - await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' }); - const b = await getNextItem(env.db, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' }); + const b = await getNextItem(env.db, owner.id, s.session.id); expect(b).not.toBeNull(); - await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'correct' }); - const done = await getNextItem(env.db, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'correct' }); + const done = await getNextItem(env.db, owner.id, s.session.id); expect(done).toBeNull(); }); it('reinserts an incorrect card later in the same session', async () => { const l = await seedLesson('L', 4); - const s = await startSession(env.db, { lessonId: l.id, shuffle: false }); - const first = await getNextItem(env.db, s.session.id); - await recordAttempt(env.db, s.session.id, { cardId: first!.cardId, direction: 'forward', result: 'incorrect' }); + const s = await startSession(env.db, owner.id, { lessonId: l.id, shuffle: false }); + const first = await getNextItem(env.db, owner.id, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: first!.cardId, direction: 'forward', result: 'incorrect' }); const seen: number[] = [first!.cardId]; for (let i = 0; i < 4; i++) { - const nx = await getNextItem(env.db, s.session.id); + const nx = await getNextItem(env.db, owner.id, s.session.id); if (!nx) break; seen.push(nx.cardId); - await recordAttempt(env.db, s.session.id, { cardId: nx.cardId, direction: 'forward', result: 'correct' }); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: nx.cardId, direction: 'forward', result: 'correct' }); } expect(seen.filter((c) => c === first!.cardId).length).toBeGreaterThanOrEqual(2); }); it('respects maxCards limit', async () => { const l = await seedLesson('L', 10); - const s = await startSession(env.db, { lessonId: l.id, shuffle: false, maxCards: 3 }); + const s = await startSession(env.db, owner.id, { lessonId: l.id, shuffle: false, maxCards: 3 }); expect(s.queue).toHaveLength(3); }); it('tracks counters on session and ends with duration', async () => { const l = await seedLesson('L', 2); - const s = await startSession(env.db, { lessonId: l.id, shuffle: false }); - const a = await getNextItem(env.db, s.session.id); - await recordAttempt(env.db, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' }); - const b = await getNextItem(env.db, s.session.id); - await recordAttempt(env.db, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'incorrect' }); - const ended = await endSession(env.db, s.session.id); + const s = await startSession(env.db, owner.id, { lessonId: l.id, shuffle: false }); + const a = await getNextItem(env.db, owner.id, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: a!.cardId, direction: 'forward', result: 'correct' }); + const b = await getNextItem(env.db, owner.id, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: b!.cardId, direction: 'forward', result: 'incorrect' }); + const ended = await endSession(env.db, owner.id, s.session.id); expect(ended.cardsCorrect).toBe(1); expect(ended.cardsIncorrect).toBe(1); expect(ended.cardsShown).toBe(2); @@ -76,11 +80,11 @@ describe('session engine', () => { it('returns active session if one exists', async () => { const l = await seedLesson('L', 1); - const s = await startSession(env.db, { lessonId: l.id }); - const active = await getActiveSession(env.db); + const s = await startSession(env.db, owner.id, { lessonId: l.id }); + const active = await getActiveSession(env.db, owner.id); expect(active?.id).toBe(s.session.id); - await endSession(env.db, s.session.id); - const after = await getActiveSession(env.db); + await endSession(env.db, owner.id, s.session.id); + const after = await getActiveSession(env.db, owner.id); expect(after).toBeNull(); }); }); diff --git a/packages/backend/src/services/sessions.ts b/packages/backend/src/services/sessions.ts index b4d7581..c9d23c7 100644 --- a/packages/backend/src/services/sessions.ts +++ b/packages/backend/src/services/sessions.ts @@ -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 { +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 = 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 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(); 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 { +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(); - 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 { 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 { +export async function endSession(db: Db, userId: number, sessionId: number): Promise { 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 { +export async function abandonSession(db: Db, userId: number, sessionId: number): Promise { 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 { - 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 { + 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 }; }