feat(routes): thread userId through routes + visibility/fork/curated endpoints
This commit is contained in:
17
packages/backend/src/routes/admin-lessons.ts
Normal file
17
packages/backend/src/routes/admin-lessons.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
const ws = XLSX.utils.json_to_sheet(rows);
|
||||
@@ -17,39 +21,39 @@ function buildXlsx(rows: Record<string, string>[]): 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);
|
||||
|
||||
@@ -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<ImportResult> {
|
||||
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<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 lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all();
|
||||
const pathById = buildPathMap(lessonRows);
|
||||
|
||||
Reference in New Issue
Block a user