diff --git a/packages/backend/src/services/stats.test.ts b/packages/backend/src/services/stats.test.ts index 99699c8..ac6652d 100644 --- a/packages/backend/src/services/stats.test.ts +++ b/packages/backend/src/services/stats.test.ts @@ -1,49 +1,54 @@ 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, recordAttempt, getNextItem, endSession } from './sessions.js'; import { getCardStats, getLessonStats, getOverview } from './stats.js'; +import type { UserRow } from '../db/schema.js'; let env: ReturnType; -beforeEach(() => { env = makeTestDb(); }); +let owner: UserRow; +beforeEach(async () => { + env = makeTestDb(); + owner = await createUserDirect(env.db, { email: 'owner@example.com' }); +}); describe('stats', () => { it('computes per-card attempts and box', async () => { - const l = await createLesson(env.db, { name: 'L' }); - const c = await createCard(env.db, l.id, { question: 'q', answer: 'a' }); - const s = await startSession(env.db, { lessonId: l.id, shuffle: false }); - const item = await getNextItem(env.db, s.session.id); - await recordAttempt(env.db, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' }); - await endSession(env.db, s.session.id); - const stats = await getCardStats(env.db, c.id); + const l = await createLesson(env.db, owner.id, { name: 'L' }); + const c = await createCard(env.db, owner.id, l.id, { question: 'q', answer: 'a' }); + const s = await startSession(env.db, owner.id, { lessonId: l.id, shuffle: false }); + const item = await getNextItem(env.db, owner.id, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: item!.cardId, direction: 'forward', result: 'correct' }); + await endSession(env.db, owner.id, s.session.id); + const stats = await getCardStats(env.db, owner.id, c.id); expect(stats.attempts).toBe(1); expect(stats.correct).toBe(1); expect(stats.box.forward).toBe(2); }); it('aggregates lesson stats with 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, child.id, { question: 'q', answer: 'a' }); - const s = await startSession(env.db, { lessonId: root.id, shuffle: false }); - const it = await getNextItem(env.db, s.session.id); - await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' }); - await endSession(env.db, s.session.id); - const ls = await getLessonStats(env.db, root.id); + 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, child.id, { question: 'q', answer: 'a' }); + const s = await startSession(env.db, owner.id, { lessonId: root.id, shuffle: false }); + const it = await getNextItem(env.db, owner.id, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' }); + await endSession(env.db, owner.id, s.session.id); + const ls = await getLessonStats(env.db, owner.id, root.id); expect(ls.totalCards).toBe(1); expect(ls.sessions).toBe(1); expect(ls.totalDurationSeconds).toBeGreaterThanOrEqual(0); }); it('overview returns streak >= 1 after a session today', async () => { - const l = await createLesson(env.db, { name: 'L' }); - await createCard(env.db, l.id, { question: 'q', answer: 'a' }); - const s = await startSession(env.db, { lessonId: l.id }); - const it = await getNextItem(env.db, s.session.id); - await recordAttempt(env.db, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' }); - await endSession(env.db, s.session.id); - const ov = await getOverview(env.db); + const l = await createLesson(env.db, owner.id, { name: 'L' }); + await createCard(env.db, owner.id, l.id, { question: 'q', answer: 'a' }); + const s = await startSession(env.db, owner.id, { lessonId: l.id }); + const it = await getNextItem(env.db, owner.id, s.session.id); + await recordAttempt(env.db, owner.id, s.session.id, { cardId: it!.cardId, direction: 'forward', result: 'correct' }); + await endSession(env.db, owner.id, s.session.id); + const ov = await getOverview(env.db, owner.id); expect(ov.totalSessions).toBe(1); expect(ov.streakDays).toBeGreaterThanOrEqual(1); }); diff --git a/packages/backend/src/services/stats.ts b/packages/backend/src/services/stats.ts index e5e4081..9c2d15b 100644 --- a/packages/backend/src/services/stats.ts +++ b/packages/backend/src/services/stats.ts @@ -3,6 +3,7 @@ import type { Db } from '../db/client.js'; import { attempts, cardProgress, cards, lessons, sessions } from '../db/schema.js'; import { ApiError } from '../lib/errors.js'; import { getDescendantLessonIds } from './lessons.js'; +import { canReadLesson } from './permissions.js'; const MIN_ATTEMPTS_FOR_SCORE = 3; @@ -17,26 +18,35 @@ export interface CardStats { history: { shownAt: number; result: 'correct' | 'incorrect'; direction: 'forward' | 'backward' }[]; } -export async function getCardStats(db: Db, cardId: number): Promise { +export async function getCardStats(db: Db, userId: number, cardId: number): Promise { const card = db.select().from(cards).where(eq(cards.id, cardId)).get(); if (!card) throw ApiError.notFound('Card'); - const prog = db.select().from(cardProgress).where(eq(cardProgress.cardId, cardId)).all(); - const history = db.select({ - shownAt: attempts.shownAt, result: attempts.result, direction: attempts.direction, - }).from(attempts).where(eq(attempts.cardId, cardId)).orderBy(desc(attempts.shownAt)).all(); + if (!(await canReadLesson(db, userId, card.lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this card'); + } + const prog = db.select().from(cardProgress) + .where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId))).all(); + const historyJoined = db.select({ + shownAt: attempts.shownAt, + result: attempts.result, + direction: attempts.direction, + }).from(attempts) + .innerJoin(sessions, eq(sessions.id, attempts.sessionId)) + .where(and(eq(attempts.cardId, cardId), eq(sessions.userId, userId))) + .orderBy(desc(attempts.shownAt)).all(); const forward = prog.find((p) => p.direction === 'forward'); const backward = prog.find((p) => p.direction === 'backward'); - const correct = history.filter((h) => h.result === 'correct').length; + const correct = historyJoined.filter((h) => h.result === 'correct').length; return { cardId, - attempts: history.length, + attempts: historyJoined.length, correct, - incorrect: history.length - correct, + incorrect: historyJoined.length - correct, box: { forward: forward?.box ?? 1, backward: backward?.box ?? null }, lastShownAt: forward?.lastShownAt ?? null, nextDueAt: forward?.nextDueAt ?? 0, - history, + history: historyJoined, }; } @@ -52,14 +62,17 @@ export interface LessonStats { incorrect: number; } -export async function getLessonStats(db: Db, lessonId: number): Promise { +export async function getLessonStats(db: Db, userId: number, lessonId: number): Promise { const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get(); if (!lesson) throw ApiError.notFound('Lesson'); + if (!(await canReadLesson(db, userId, lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson'); + } const ids = await getDescendantLessonIds(db, lessonId); const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all(); const cardIds = cardRows.map((c) => c.id); - const totalCards = cardIds.length; + let totalCards = cardIds.length; let mastered = 0; let attemptsTotal = 0; let correctTotal = 0; @@ -67,8 +80,16 @@ export async function getLessonStats(db: Db, lessonId: number): Promise 0) { - const prog = db.select().from(cardProgress).where(inArray(cardProgress.cardId, cardIds)).all(); - const att = db.select().from(attempts).where(inArray(attempts.cardId, cardIds)).all(); + const prog = db.select().from(cardProgress).where(and( + inArray(cardProgress.cardId, cardIds), + eq(cardProgress.userId, userId), + )).all(); + const att = db.select({ result: attempts.result }).from(attempts) + .innerJoin(sessions, eq(sessions.id, attempts.sessionId)) + .where(and( + inArray(attempts.cardId, cardIds), + eq(sessions.userId, userId), + )).all(); attemptsTotal = att.length; correctTotal = att.filter((a) => a.result === 'correct').length; const byCard = new Map(); @@ -91,7 +112,11 @@ export async function getLessonStats(db: Db, lessonId: number): Promise s + (r.duration ?? 0), 0); return { @@ -114,26 +139,30 @@ function dayKeyUTC(unixSec: number): string { return `${d.getUTCFullYear()}-${d.getUTCMonth()}-${d.getUTCDate()}`; } -export async function getOverview(db: Db): Promise { - const sessRows = db.select().from(sessions).where(eq(sessions.status, 'completed')).all(); +export async function getOverview(db: Db, userId: number): Promise { + const sessRows = db.select().from(sessions) + .where(and(eq(sessions.status, 'completed'), eq(sessions.userId, userId))).all(); const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.durationSeconds ?? 0), 0); - const totalAttempts = db.select({ c: sql`count(*)`.as('c') }).from(attempts).get()?.c ?? 0; + const totalAttempts = db.select({ c: sql`count(*)`.as('c') }) + .from(attempts) + .innerJoin(sessions, eq(sessions.id, attempts.sessionId)) + .where(eq(sessions.userId, userId)).get()?.c ?? 0; const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt))); let streak = 0; const cursor = new Date(); for (;;) { const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000)); - if (days.has(k)) { - streak += 1; - cursor.setUTCDate(cursor.getUTCDate() - 1); - } else break; + if (days.has(k)) { streak += 1; cursor.setUTCDate(cursor.getUTCDate() - 1); } + else break; } const recent = db.select({ id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt, durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect, - }).from(sessions).where(eq(sessions.status, 'completed')).orderBy(desc(sessions.startedAt)).limit(10).all(); + }).from(sessions) + .where(and(eq(sessions.status, 'completed'), eq(sessions.userId, userId))) + .orderBy(desc(sessions.startedAt)).limit(10).all(); return { totalSessions: sessRows.length, @@ -145,12 +174,17 @@ export async function getOverview(db: Db): Promise { } export interface HeatmapPoint { day: string; sessions: number; attempts: number; } -export async function getHeatmap(db: Db, weeks: number): Promise { +export async function getHeatmap(db: Db, userId: number, weeks: number): Promise { const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60; const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions) - .where(and(eq(sessions.status, 'completed'), sql`${sessions.startedAt} >= ${since}`)).all(); + .where(and( + eq(sessions.status, 'completed'), + eq(sessions.userId, userId), + sql`${sessions.startedAt} >= ${since}`, + )).all(); const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts) - .where(sql`${attempts.shownAt} >= ${since}`).all(); + .innerJoin(sessions, eq(sessions.id, attempts.sessionId)) + .where(and(eq(sessions.userId, userId), sql`${attempts.shownAt} >= ${since}`)).all(); const map = new Map(); for (const s of sessRows) { const k = dayKeyUTC(s.startedAt);