feat(routes): thread userId through routes + visibility/fork/curated endpoints

This commit is contained in:
2026-05-21 00:26:49 +02:00
parent 181a757323
commit fb95ccd772
7 changed files with 98 additions and 48 deletions

View File

@@ -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;
}

View File

@@ -2,38 +2,39 @@ import { Router } from 'express';
import multer from 'multer'; import multer from 'multer';
import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared'; import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js'; import type { Db } from '../db/client.js';
import { ApiError } from '../lib/errors.js';
import { createCard, deleteCard, getCard, listCardsByLesson, updateCard } from '../services/cards.js'; import { createCard, deleteCard, getCard, listCardsByLesson, updateCard } from '../services/cards.js';
import { ApiError } from '../lib/errors.js';
import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.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 { export function cardsRouter(db: Db): Router {
const r = Router({ mergeParams: true }); 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) => { 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) => { r.post('/lessons/:lessonId/cards', async (req, res, next) => {
try { try {
const input = cardCreateSchema.parse(req.body); 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); } } catch (e) { next(e); }
}); });
r.get('/cards/:id', async (req, res, next) => { 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) => { r.patch('/cards/:id', async (req, res, next) => {
try { try {
const input = cardUpdateSchema.parse(req.body); 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); } } catch (e) { next(e); }
}); });
r.delete('/cards/:id', async (req, res, next) => { 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) => { 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 updateExisting = req.body.updateExisting !== 'false';
const createMissingLessons = req.body.createMissingLessons === 'true'; const createMissingLessons = req.body.createMissingLessons === 'true';
const result = await importCardsFromBuffer( 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); res.json(result);
} catch (e) { next(e); } } catch (e) { next(e); }
@@ -51,7 +53,7 @@ export function cardsRouter(db: Db): Router {
r.get('/lessons/:lessonId/cards/export', async (req, res, next) => { r.get('/lessons/:lessonId/cards/export', async (req, res, next) => {
try { try {
const includeDescendants = req.query.include_descendants === 'true'; 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-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`); res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`);
res.send(buf); res.send(buf);

View File

@@ -1,35 +1,37 @@
import { Router } from 'express'; 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 type { Db } from '../db/client.js';
import { import {
createLesson, deleteLesson, getLessonTree, moveLesson, updateLesson, createLesson, deleteLesson, getLessonTree, moveLesson, setLessonVisibility, updateLesson,
} from '../services/lessons.js'; } from '../services/lessons.js';
import { forkLesson } from '../services/fork.js';
export function lessonsRouter(db: Db): Router { export function lessonsRouter(db: Db): Router {
const r = Router(); const r = Router();
r.get('/tree', async (_req, res, next) => { r.get('/tree', async (req, res, next) => {
try { res.json(await getLessonTree(db)); } catch (e) { next(e); } try { res.json(await getLessonTree(db, req.user!.id)); } catch (e) { next(e); }
}); });
r.post('/', async (req, res, next) => { r.post('/', async (req, res, next) => {
try { try {
const input = lessonCreateSchema.parse(req.body); 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); } } catch (e) { next(e); }
}); });
r.patch('/:id', async (req, res, next) => { r.patch('/:id', async (req, res, next) => {
try { try {
const id = Number(req.params.id);
const input = lessonUpdateSchema.parse(req.body); 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); } } catch (e) { next(e); }
}); });
r.delete('/:id', async (req, res, next) => { r.delete('/:id', async (req, res, next) => {
try { try {
await deleteLesson(db, Number(req.params.id)); await deleteLesson(db, req.user!.id, Number(req.params.id));
res.status(204).end(); res.status(204).end();
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
@@ -37,7 +39,20 @@ export function lessonsRouter(db: Db): Router {
r.post('/:id/move', async (req, res, next) => { r.post('/:id/move', async (req, res, next) => {
try { try {
const input = lessonMoveSchema.parse(req.body); 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); } } catch (e) { next(e); }
}); });

View File

@@ -13,17 +13,17 @@ export function sessionsRouter(db: Db): Router {
r.post('/', async (req, res, next) => { r.post('/', async (req, res, next) => {
try { try {
const input = sessionStartSchema.parse(req.body); 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); } } catch (e) { next(e); }
}); });
r.get('/active', async (_req, res, next) => { r.get('/active', async (req, res, next) => {
try { res.json(await getActiveSession(db)); } catch (e) { next(e); } try { res.json(await getActiveSession(db, req.user!.id)); } catch (e) { next(e); }
}); });
r.get('/:id', async (req, res, next) => { r.get('/:id', async (req, res, next) => {
try { 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'); if (!state) throw ApiError.notFound('Session');
res.json(state); res.json(state);
} catch (e) { next(e); } } catch (e) { next(e); }
@@ -31,7 +31,7 @@ export function sessionsRouter(db: Db): Router {
r.get('/:id/next', async (req, res, next) => { r.get('/:id/next', async (req, res, next) => {
try { 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; } if (!item) { res.json({ done: true }); return; }
res.json({ done: false, item }); res.json({ done: false, item });
} catch (e) { next(e); } } catch (e) { next(e); }
@@ -40,17 +40,17 @@ export function sessionsRouter(db: Db): Router {
r.post('/:id/attempts', async (req, res, next) => { r.post('/:id/attempts', async (req, res, next) => {
try { try {
const input = attemptCreateSchema.parse(req.body); 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(); res.status(204).end();
} catch (e) { next(e); } } catch (e) { next(e); }
}); });
r.post('/:id/end', async (req, res, next) => { 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) => { 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; return r;

View File

@@ -4,20 +4,22 @@ import { getCardStats, getHeatmap, getLessonStats, getOverview } from '../servic
export function statsRouter(db: Db): Router { export function statsRouter(db: Db): Router {
const r = 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) => { 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) => { 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) => { r.get('/heatmap', async (req, res, next) => {
try { try {
const weeks = Math.min(52, Math.max(1, Number(req.query.weeks ?? 12))); 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); } } catch (e) { next(e); }
}); });
return r; return r;
} }

View File

@@ -1,12 +1,16 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from 'vitest';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { makeTestDb } from '../tests/dbHelper.js'; import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
import { createLesson } from './lessons.js';
import { importCardsFromBuffer, exportCardsToBuffer } from './import.js'; import { importCardsFromBuffer, exportCardsToBuffer } from './import.js';
import { listCardsByLesson } from './cards.js'; import { listCardsByLesson } from './cards.js';
let env: ReturnType<typeof makeTestDb>; let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); }); let owner: Awaited<ReturnType<typeof createUserDirect>>;
beforeEach(async () => {
env = makeTestDb();
owner = await createUserDirect(env.db, { email: 'owner@test.local' });
});
function buildXlsx(rows: Record<string, string>[]): Buffer { function buildXlsx(rows: Record<string, string>[]): Buffer {
const ws = XLSX.utils.json_to_sheet(rows); const ws = XLSX.utils.json_to_sheet(rows);
@@ -17,39 +21,39 @@ function buildXlsx(rows: Record<string, string>[]): Buffer {
describe('excel import', () => { describe('excel import', () => {
it('imports cards into the target lesson when no lesson_path column present', async () => { 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([ const buf = buildXlsx([
{ question: 'q1', answer: 'a1' }, { question: 'q1', answer: 'a1' },
{ question: 'q2', answer: 'a2', hint: 'tip' }, { 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.inserted).toBe(2);
expect(result.errors).toHaveLength(0); 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 () => { 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([ const buf = buildXlsx([
{ question: 'hola', answer: 'hello', lesson_path: 'Spaans/Begroetingen' }, { 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); expect(result.inserted).toBe(1);
}); });
it('updates an existing card on duplicate question in same lesson', async () => { it('updates an existing card on duplicate question in same lesson', async () => {
const l = await createLesson(env.db, { name: 'L' }); const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); await importCardsFromBuffer(env.db, owner.id, 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 res = await importCardsFromBuffer(env.db, owner.id, l.id, buildXlsx([{ question: 'q', answer: 'b' }]), { updateExisting: true, createMissingLessons: false });
expect(res.updated).toBe(1); 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'); expect(cards[0]!.answer).toBe('b');
}); });
it('exports a buffer that round-trips back via import', async () => { it('exports a buffer that round-trips back via import', async () => {
const l = await createLesson(env.db, { name: 'L' }); const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
await importCardsFromBuffer(env.db, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); await importCardsFromBuffer(env.db, owner.id, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false });
const buf = await exportCardsToBuffer(env.db, l.id, false); const buf = await exportCardsToBuffer(env.db, owner.id, l.id, false);
const wb = XLSX.read(buf, { type: 'buffer' }); const wb = XLSX.read(buf, { type: 'buffer' });
const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]!]!); const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]!]!);
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);

View File

@@ -3,6 +3,8 @@ import * as XLSX from 'xlsx';
import type { Db } from '../db/client.js'; import type { Db } from '../db/client.js';
import { cardProgress, cards, lessons } from '../db/schema.js'; import { cardProgress, cards, lessons } from '../db/schema.js';
import { getDescendantLessonIds } from './lessons.js'; import { getDescendantLessonIds } from './lessons.js';
import { canEditLesson, canReadLesson } from './permissions.js';
import { ApiError } from '../lib/errors.js';
export interface ImportRow { export interface ImportRow {
question: string; question: string;
@@ -55,8 +57,11 @@ async function resolveLesson(
} }
export async function importCardsFromBuffer( export async function importCardsFromBuffer(
db: Db, defaultLessonId: number, buffer: Buffer, opts: ImportOptions db: Db, userId: number, defaultLessonId: number, buffer: Buffer, opts: ImportOptions
): Promise<ImportResult> { ): Promise<ImportResult> {
if (!(await canEditLesson(db, userId, defaultLessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
}
const rows = parseSheet(buffer); const rows = parseSheet(buffer);
const result: ImportResult = { inserted: 0, updated: 0, skipped: 0, errors: [] }; const result: ImportResult = { inserted: 0, updated: 0, skipped: 0, errors: [] };
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
@@ -94,7 +99,12 @@ export async function importCardsFromBuffer(
return result; return result;
} }
export async function exportCardsToBuffer(db: Db, lessonId: number, includeDescendants: boolean): Promise<Buffer> { export async function exportCardsToBuffer(
db: Db, userId: number, lessonId: number, includeDescendants: boolean
): Promise<Buffer> {
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 ids = includeDescendants ? await getDescendantLessonIds(db, lessonId) : [lessonId];
const lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all(); const lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all();
const pathById = buildPathMap(lessonRows); const pathById = buildPathMap(lessonRows);