diff --git a/packages/backend/src/routes/admin-lessons.ts b/packages/backend/src/routes/admin-lessons.ts new file mode 100644 index 0000000..30918d7 --- /dev/null +++ b/packages/backend/src/routes/admin-lessons.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { adminLessonCuratedSchema } from '@flashcard/shared'; +import type { Db } from '../db/client.js'; +import { setLessonCurated } from '../services/lessons.js'; + +export function adminLessonsRouter(db: Db): Router { + const r = Router(); + + r.patch('/:id/curated', async (req, res, next) => { + try { + const input = adminLessonCuratedSchema.parse(req.body); + res.json(await setLessonCurated(db, Number(req.params.id), input.isCurated)); + } catch (e) { next(e); } + }); + + return r; +} diff --git a/packages/backend/src/routes/cards.ts b/packages/backend/src/routes/cards.ts index c759910..d69e9ed 100644 --- a/packages/backend/src/routes/cards.ts +++ b/packages/backend/src/routes/cards.ts @@ -2,38 +2,39 @@ import { Router } from 'express'; import multer from 'multer'; import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared'; import type { Db } from '../db/client.js'; -import { ApiError } from '../lib/errors.js'; import { createCard, deleteCard, getCard, listCardsByLesson, updateCard } from '../services/cards.js'; +import { ApiError } from '../lib/errors.js'; import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js'; +const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); + export function cardsRouter(db: Db): Router { const r = Router({ mergeParams: true }); - const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); r.get('/lessons/:lessonId/cards', async (req, res, next) => { - try { res.json(await listCardsByLesson(db, Number(req.params.lessonId))); } catch (e) { next(e); } + try { res.json(await listCardsByLesson(db, req.user!.id, Number(req.params.lessonId))); } catch (e) { next(e); } }); r.post('/lessons/:lessonId/cards', async (req, res, next) => { try { const input = cardCreateSchema.parse(req.body); - res.status(201).json(await createCard(db, Number(req.params.lessonId), input)); + res.status(201).json(await createCard(db, req.user!.id, Number(req.params.lessonId), input)); } catch (e) { next(e); } }); r.get('/cards/:id', async (req, res, next) => { - try { res.json(await getCard(db, Number(req.params.id))); } catch (e) { next(e); } + try { res.json(await getCard(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } }); r.patch('/cards/:id', async (req, res, next) => { try { const input = cardUpdateSchema.parse(req.body); - res.json(await updateCard(db, Number(req.params.id), input)); + res.json(await updateCard(db, req.user!.id, Number(req.params.id), input)); } catch (e) { next(e); } }); r.delete('/cards/:id', async (req, res, next) => { - try { await deleteCard(db, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); } + try { await deleteCard(db, req.user!.id, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); } }); r.post('/lessons/:lessonId/cards/import', upload.single('file'), async (req, res, next) => { @@ -42,7 +43,8 @@ export function cardsRouter(db: Db): Router { const updateExisting = req.body.updateExisting !== 'false'; const createMissingLessons = req.body.createMissingLessons === 'true'; const result = await importCardsFromBuffer( - db, Number(req.params.lessonId), req.file.buffer, { updateExisting, createMissingLessons } + db, req.user!.id, Number(req.params.lessonId), req.file.buffer, + { updateExisting, createMissingLessons } ); res.json(result); } catch (e) { next(e); } @@ -51,7 +53,7 @@ export function cardsRouter(db: Db): Router { r.get('/lessons/:lessonId/cards/export', async (req, res, next) => { try { const includeDescendants = req.query.include_descendants === 'true'; - const buf = await exportCardsToBuffer(db, Number(req.params.lessonId), includeDescendants); + const buf = await exportCardsToBuffer(db, req.user!.id, Number(req.params.lessonId), includeDescendants); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`); res.send(buf); diff --git a/packages/backend/src/routes/lessons.ts b/packages/backend/src/routes/lessons.ts index bedfeac..4ce3259 100644 --- a/packages/backend/src/routes/lessons.ts +++ b/packages/backend/src/routes/lessons.ts @@ -1,35 +1,37 @@ import { Router } from 'express'; -import { lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema } from '@flashcard/shared'; +import { + lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema, lessonVisibilityUpdateSchema, +} from '@flashcard/shared'; import type { Db } from '../db/client.js'; import { - createLesson, deleteLesson, getLessonTree, moveLesson, updateLesson, + createLesson, deleteLesson, getLessonTree, moveLesson, setLessonVisibility, updateLesson, } from '../services/lessons.js'; +import { forkLesson } from '../services/fork.js'; export function lessonsRouter(db: Db): Router { const r = Router(); - r.get('/tree', async (_req, res, next) => { - try { res.json(await getLessonTree(db)); } catch (e) { next(e); } + r.get('/tree', async (req, res, next) => { + try { res.json(await getLessonTree(db, req.user!.id)); } catch (e) { next(e); } }); r.post('/', async (req, res, next) => { try { const input = lessonCreateSchema.parse(req.body); - res.status(201).json(await createLesson(db, input)); + res.status(201).json(await createLesson(db, req.user!.id, input)); } catch (e) { next(e); } }); r.patch('/:id', async (req, res, next) => { try { - const id = Number(req.params.id); const input = lessonUpdateSchema.parse(req.body); - res.json(await updateLesson(db, id, input)); + res.json(await updateLesson(db, req.user!.id, Number(req.params.id), input)); } catch (e) { next(e); } }); r.delete('/:id', async (req, res, next) => { try { - await deleteLesson(db, Number(req.params.id)); + await deleteLesson(db, req.user!.id, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); } }); @@ -37,7 +39,20 @@ export function lessonsRouter(db: Db): Router { r.post('/:id/move', async (req, res, next) => { try { const input = lessonMoveSchema.parse(req.body); - res.json(await moveLesson(db, Number(req.params.id), input)); + res.json(await moveLesson(db, req.user!.id, Number(req.params.id), input)); + } catch (e) { next(e); } + }); + + r.patch('/:id/visibility', async (req, res, next) => { + try { + const input = lessonVisibilityUpdateSchema.parse(req.body); + res.json(await setLessonVisibility(db, req.user!.id, Number(req.params.id), input.visibility)); + } catch (e) { next(e); } + }); + + r.post('/:id/fork', async (req, res, next) => { + try { + res.status(201).json(await forkLesson(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } }); diff --git a/packages/backend/src/routes/sessions.ts b/packages/backend/src/routes/sessions.ts index ebf2640..aca2345 100644 --- a/packages/backend/src/routes/sessions.ts +++ b/packages/backend/src/routes/sessions.ts @@ -13,17 +13,17 @@ export function sessionsRouter(db: Db): Router { r.post('/', async (req, res, next) => { try { const input = sessionStartSchema.parse(req.body); - res.status(201).json(await startSession(db, input)); + res.status(201).json(await startSession(db, req.user!.id, input)); } catch (e) { next(e); } }); - r.get('/active', async (_req, res, next) => { - try { res.json(await getActiveSession(db)); } catch (e) { next(e); } + r.get('/active', async (req, res, next) => { + try { res.json(await getActiveSession(db, req.user!.id)); } catch (e) { next(e); } }); r.get('/:id', async (req, res, next) => { try { - const state = await getSessionState(db, Number(req.params.id)); + const state = await getSessionState(db, req.user!.id, Number(req.params.id)); if (!state) throw ApiError.notFound('Session'); res.json(state); } catch (e) { next(e); } @@ -31,7 +31,7 @@ export function sessionsRouter(db: Db): Router { r.get('/:id/next', async (req, res, next) => { try { - const item = await getNextItem(db, Number(req.params.id)); + const item = await getNextItem(db, req.user!.id, Number(req.params.id)); if (!item) { res.json({ done: true }); return; } res.json({ done: false, item }); } catch (e) { next(e); } @@ -40,17 +40,17 @@ export function sessionsRouter(db: Db): Router { r.post('/:id/attempts', async (req, res, next) => { try { const input = attemptCreateSchema.parse(req.body); - await recordAttempt(db, Number(req.params.id), input); + await recordAttempt(db, req.user!.id, Number(req.params.id), input); res.status(204).end(); } catch (e) { next(e); } }); r.post('/:id/end', async (req, res, next) => { - try { res.json(await endSession(db, Number(req.params.id))); } catch (e) { next(e); } + try { res.json(await endSession(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } }); r.post('/:id/abandon', async (req, res, next) => { - try { res.json(await abandonSession(db, Number(req.params.id))); } catch (e) { next(e); } + try { res.json(await abandonSession(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } }); return r; diff --git a/packages/backend/src/routes/stats.ts b/packages/backend/src/routes/stats.ts index 537b00c..7cc232f 100644 --- a/packages/backend/src/routes/stats.ts +++ b/packages/backend/src/routes/stats.ts @@ -4,20 +4,22 @@ import { getCardStats, getHeatmap, getLessonStats, getOverview } from '../servic export function statsRouter(db: Db): Router { const r = Router(); - r.get('/overview', async (_req, res, next) => { - try { res.json(await getOverview(db)); } catch (e) { next(e); } + + r.get('/overview', async (req, res, next) => { + try { res.json(await getOverview(db, req.user!.id)); } catch (e) { next(e); } }); r.get('/lessons/:id', async (req, res, next) => { - try { res.json(await getLessonStats(db, Number(req.params.id))); } catch (e) { next(e); } + try { res.json(await getLessonStats(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } }); r.get('/cards/:id', async (req, res, next) => { - try { res.json(await getCardStats(db, Number(req.params.id))); } catch (e) { next(e); } + try { res.json(await getCardStats(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); } }); r.get('/heatmap', async (req, res, next) => { try { const weeks = Math.min(52, Math.max(1, Number(req.query.weeks ?? 12))); - res.json(await getHeatmap(db, weeks)); + res.json(await getHeatmap(db, req.user!.id, weeks)); } catch (e) { next(e); } }); + return r; } diff --git a/packages/backend/src/services/import.test.ts b/packages/backend/src/services/import.test.ts index a5e3fee..f59f714 100644 --- a/packages/backend/src/services/import.test.ts +++ b/packages/backend/src/services/import.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect, beforeEach } from 'vitest'; import * as XLSX from 'xlsx'; -import { makeTestDb } from '../tests/dbHelper.js'; -import { createLesson } from './lessons.js'; +import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js'; import { importCardsFromBuffer, exportCardsToBuffer } from './import.js'; import { listCardsByLesson } from './cards.js'; let env: ReturnType; -beforeEach(() => { env = makeTestDb(); }); +let owner: Awaited>; + +beforeEach(async () => { + env = makeTestDb(); + owner = await createUserDirect(env.db, { email: 'owner@test.local' }); +}); function buildXlsx(rows: Record[]): Buffer { const ws = XLSX.utils.json_to_sheet(rows); @@ -17,39 +21,39 @@ function buildXlsx(rows: Record[]): Buffer { describe('excel import', () => { it('imports cards into the target lesson when no lesson_path column present', async () => { - const l = await createLesson(env.db, { name: 'L' }); + const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); const buf = buildXlsx([ { question: 'q1', answer: 'a1' }, { question: 'q2', answer: 'a2', hint: 'tip' }, ]); - const result = await importCardsFromBuffer(env.db, l.id, buf, { updateExisting: true, createMissingLessons: false }); + const result = await importCardsFromBuffer(env.db, owner.id, l.id, buf, { updateExisting: true, createMissingLessons: false }); expect(result.inserted).toBe(2); expect(result.errors).toHaveLength(0); - expect(await listCardsByLesson(env.db, l.id)).toHaveLength(2); + expect(await listCardsByLesson(env.db, owner.id, l.id)).toHaveLength(2); }); it('creates intermediate lessons from lesson_path when allowed', async () => { - const root = await createLesson(env.db, { name: 'Spaans' }); + const root = await createLessonOwnedBy(env.db, owner.id, { name: 'Spaans' }); const buf = buildXlsx([ { question: 'hola', answer: 'hello', lesson_path: 'Spaans/Begroetingen' }, ]); - const result = await importCardsFromBuffer(env.db, root.id, buf, { updateExisting: true, createMissingLessons: true }); + const result = await importCardsFromBuffer(env.db, owner.id, root.id, buf, { updateExisting: true, createMissingLessons: true }); expect(result.inserted).toBe(1); }); it('updates an existing card on duplicate question in same lesson', async () => { - const l = await createLesson(env.db, { name: 'L' }); - await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); - const res = await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'b' }]), { updateExisting: true, createMissingLessons: false }); + const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); + await importCardsFromBuffer(env.db, owner.id, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); + const res = await importCardsFromBuffer(env.db, owner.id, l.id, buildXlsx([{ question: 'q', answer: 'b' }]), { updateExisting: true, createMissingLessons: false }); expect(res.updated).toBe(1); - const cards = await listCardsByLesson(env.db, l.id); + const cards = await listCardsByLesson(env.db, owner.id, l.id); expect(cards[0]!.answer).toBe('b'); }); it('exports a buffer that round-trips back via import', async () => { - const l = await createLesson(env.db, { name: 'L' }); - await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); - const buf = await exportCardsToBuffer(env.db, l.id, false); + const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); + await importCardsFromBuffer(env.db, owner.id, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); + const buf = await exportCardsToBuffer(env.db, owner.id, l.id, false); const wb = XLSX.read(buf, { type: 'buffer' }); const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]!]!); expect(rows).toHaveLength(1); diff --git a/packages/backend/src/services/import.ts b/packages/backend/src/services/import.ts index 80b50fd..849e25d 100644 --- a/packages/backend/src/services/import.ts +++ b/packages/backend/src/services/import.ts @@ -3,6 +3,8 @@ import * as XLSX from 'xlsx'; import type { Db } from '../db/client.js'; import { cardProgress, cards, lessons } from '../db/schema.js'; import { getDescendantLessonIds } from './lessons.js'; +import { canEditLesson, canReadLesson } from './permissions.js'; +import { ApiError } from '../lib/errors.js'; export interface ImportRow { question: string; @@ -55,8 +57,11 @@ async function resolveLesson( } export async function importCardsFromBuffer( - db: Db, defaultLessonId: number, buffer: Buffer, opts: ImportOptions + db: Db, userId: number, defaultLessonId: number, buffer: Buffer, opts: ImportOptions ): Promise { + if (!(await canEditLesson(db, userId, defaultLessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); + } const rows = parseSheet(buffer); const result: ImportResult = { inserted: 0, updated: 0, skipped: 0, errors: [] }; for (let i = 0; i < rows.length; i++) { @@ -94,7 +99,12 @@ export async function importCardsFromBuffer( return result; } -export async function exportCardsToBuffer(db: Db, lessonId: number, includeDescendants: boolean): Promise { +export async function exportCardsToBuffer( + db: Db, userId: number, lessonId: number, includeDescendants: boolean +): Promise { + if (!(await canReadLesson(db, userId, lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson'); + } const ids = includeDescendants ? await getDescendantLessonIds(db, lessonId) : [lessonId]; const lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all(); const pathById = buildPathMap(lessonRows);