From 8af8ad54fa83ea037ab9487ae6934f7369882ea1 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 20:47:43 +0200 Subject: [PATCH] feat(backend): lessons CRUD service and routes --- packages/backend/src/app.ts | 19 +-- packages/backend/src/index.ts | 4 +- packages/backend/src/routes/lessons.ts | 45 +++++++ packages/backend/src/services/lessons.test.ts | 53 ++++++++ packages/backend/src/services/lessons.ts | 126 ++++++++++++++++++ 5 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 packages/backend/src/routes/lessons.ts create mode 100644 packages/backend/src/services/lessons.test.ts create mode 100644 packages/backend/src/services/lessons.ts diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 753be3b..1172c5f 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,28 +1,23 @@ import express, { type Express, type NextFunction, type Request, type Response } from 'express'; import { ZodError } from 'zod'; +import type { Db } from './db/client.js'; import { ApiError } from './lib/errors.js'; +import { lessonsRouter } from './routes/lessons.js'; -export function createApp(): Express { +export function createApp(db: Db): Express { const app = express(); app.use(express.json({ limit: '5mb' })); - app.get('/api/health', (_req, res) => { - res.json({ ok: true }); - }); - - // Routes mounted in later tasks. + app.get('/api/health', (_req, res) => res.json({ ok: true })); + app.use('/api/lessons', lessonsRouter(db)); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof ZodError) { - res.status(400).json({ - error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() }, - }); + res.status(400).json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid input', details: err.flatten() } }); return; } if (err instanceof ApiError) { - res.status(err.status).json({ - error: { code: err.code, message: err.message, details: err.details }, - }); + res.status(err.status).json({ error: { code: err.code, message: err.message, details: err.details } }); return; } console.error(err); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7289aec..08e794b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -1,7 +1,9 @@ import { createApp } from './app.js'; +import { createDb } from './db/client.js'; const PORT = Number(process.env.PORT ?? 3000); -const app = createApp(); +const { db } = createDb(); +const app = createApp(db); app.listen(PORT, () => { console.log(`Backend listening on http://localhost:${PORT}`); }); diff --git a/packages/backend/src/routes/lessons.ts b/packages/backend/src/routes/lessons.ts new file mode 100644 index 0000000..bedfeac --- /dev/null +++ b/packages/backend/src/routes/lessons.ts @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema } from '@flashcard/shared'; +import type { Db } from '../db/client.js'; +import { + createLesson, deleteLesson, getLessonTree, moveLesson, updateLesson, +} from '../services/lessons.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.post('/', async (req, res, next) => { + try { + const input = lessonCreateSchema.parse(req.body); + res.status(201).json(await createLesson(db, 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)); + } catch (e) { next(e); } + }); + + r.delete('/:id', async (req, res, next) => { + try { + await deleteLesson(db, Number(req.params.id)); + res.status(204).end(); + } catch (e) { next(e); } + }); + + 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)); + } catch (e) { next(e); } + }); + + return r; +} diff --git a/packages/backend/src/services/lessons.test.ts b/packages/backend/src/services/lessons.test.ts new file mode 100644 index 0000000..4bc3bb9 --- /dev/null +++ b/packages/backend/src/services/lessons.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeTestDb } from '../tests/dbHelper.js'; +import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js'; + +let env: ReturnType; +beforeEach(() => { + env = makeTestDb(); +}); + +describe('lessons service', () => { + it('creates a root lesson', async () => { + const lesson = await createLesson(env.db, { name: 'Spaans' }); + expect(lesson.id).toBeGreaterThan(0); + expect(lesson.parentId).toBeNull(); + expect(lesson.bidirectional).toBe(false); + }); + + it('builds a tree with children and card counts', async () => { + const root = await createLesson(env.db, { name: 'A' }); + const child = await createLesson(env.db, { name: 'B', parentId: root.id }); + const tree = await getLessonTree(env.db); + expect(tree).toHaveLength(1); + expect(tree[0]!.id).toBe(root.id); + expect(tree[0]!.children).toHaveLength(1); + expect(tree[0]!.children[0]!.id).toBe(child.id); + expect(tree[0]!.cardCount).toBe(0); + }); + + it('updates name and bidirectional flag', async () => { + const l = await createLesson(env.db, { name: 'X' }); + const updated = await updateLesson(env.db, l.id, { name: 'Y', bidirectional: true }); + expect(updated.name).toBe('Y'); + expect(updated.bidirectional).toBe(true); + }); + + it('moves a lesson to a new parent and position', async () => { + const a = await createLesson(env.db, { name: 'A' }); + const b = await createLesson(env.db, { name: 'B' }); + const c = await createLesson(env.db, { name: 'C', parentId: a.id }); + await moveLesson(env.db, c.id, { parentId: b.id, position: 0 }); + const tree = await getLessonTree(env.db); + const bNode = tree.find((n) => n.id === b.id)!; + expect(bNode.children.map((cc) => cc.id)).toEqual([c.id]); + }); + + it('deletes a lesson and cascades to children', async () => { + const a = await createLesson(env.db, { name: 'A' }); + await createLesson(env.db, { name: 'B', parentId: a.id }); + await deleteLesson(env.db, a.id); + const tree = await getLessonTree(env.db); + expect(tree).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/services/lessons.ts b/packages/backend/src/services/lessons.ts new file mode 100644 index 0000000..d5a03fe --- /dev/null +++ b/packages/backend/src/services/lessons.ts @@ -0,0 +1,126 @@ +import { eq, isNull, sql } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { cards, lessons } from '../db/schema.js'; +import { ApiError } from '../lib/errors.js'; +import type { + Lesson, + LessonTreeNode, + LessonCreateInput, + LessonUpdateInput, + LessonMoveInput, +} from '@flashcard/shared'; + +function rowToLesson(r: typeof lessons.$inferSelect): Lesson { + return { + id: r.id, + parentId: r.parentId ?? null, + name: r.name, + description: r.description ?? null, + position: r.position, + bidirectional: r.bidirectional, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +async function nextPosition(db: Db, parentId: number | null): Promise { + const rows = parentId == null + ? db.select({ pos: lessons.position }).from(lessons).where(isNull(lessons.parentId)).all() + : db.select({ pos: lessons.position }).from(lessons).where(eq(lessons.parentId, parentId)).all(); + return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1; +} + +export async function createLesson(db: Db, input: LessonCreateInput): Promise { + const parentId = input.parentId ?? null; + if (parentId !== null) { + const exists = db.select().from(lessons).where(eq(lessons.id, parentId)).get(); + if (!exists) throw ApiError.notFound('Parent lesson'); + } + const position = await nextPosition(db, parentId); + const [row] = db.insert(lessons).values({ + name: input.name, + parentId, + description: input.description ?? null, + bidirectional: input.bidirectional ?? false, + position, + }).returning().all(); + return rowToLesson(row!); +} + +export async function updateLesson(db: Db, id: number, input: LessonUpdateInput): Promise { + const existing = db.select().from(lessons).where(eq(lessons.id, id)).get(); + if (!existing) throw ApiError.notFound('Lesson'); + const [row] = db.update(lessons).set({ + ...(input.name !== undefined && { name: input.name }), + ...(input.description !== undefined && { description: input.description }), + ...(input.bidirectional !== undefined && { bidirectional: input.bidirectional }), + updatedAt: Math.floor(Date.now() / 1000), + }).where(eq(lessons.id, id)).returning().all(); + return rowToLesson(row!); +} + +export async function deleteLesson(db: Db, id: number): Promise { + const r = db.delete(lessons).where(eq(lessons.id, id)).run(); + if (r.changes === 0) throw ApiError.notFound('Lesson'); +} + +export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Promise { + const existing = db.select().from(lessons).where(eq(lessons.id, id)).get(); + if (!existing) throw ApiError.notFound('Lesson'); + if (input.parentId !== null) { + const p = db.select().from(lessons).where(eq(lessons.id, input.parentId)).get(); + if (!p) throw ApiError.notFound('Parent lesson'); + let cursor: number | null = input.parentId; + while (cursor !== null) { + if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant'); + const row = db.select({ parentId: lessons.parentId }).from(lessons).where(eq(lessons.id, cursor)).get(); + cursor = row?.parentId ?? null; + } + } + const [row] = db.update(lessons).set({ + parentId: input.parentId, + position: input.position, + updatedAt: Math.floor(Date.now() / 1000), + }).where(eq(lessons.id, id)).returning().all(); + return rowToLesson(row!); +} + +export async function getLessonTree(db: Db): Promise { + const all = db.select().from(lessons).orderBy(lessons.position).all(); + const counts = db + .select({ lessonId: cards.lessonId, count: sql`count(*)`.as('count') }) + .from(cards) + .groupBy(cards.lessonId) + .all(); + const countMap = new Map(counts.map((c) => [c.lessonId, Number(c.count)])); + const nodes = new Map(); + for (const r of all) { + nodes.set(r.id, { ...rowToLesson(r), children: [], cardCount: countMap.get(r.id) ?? 0 }); + } + const roots: LessonTreeNode[] = []; + for (const n of nodes.values()) { + if (n.parentId === null) roots.push(n); + else nodes.get(n.parentId)?.children.push(n); + } + return roots; +} + +export async function getDescendantLessonIds(db: Db, rootId: number): Promise { + const all = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all(); + const byParent = new Map(); + for (const r of all) { + const key = r.parentId ?? null; + if (!byParent.has(key)) byParent.set(key, []); + byParent.get(key)!.push(r.id); + } + const result: number[] = [rootId]; + const stack = [rootId]; + while (stack.length) { + const cur = stack.pop()!; + for (const child of byParent.get(cur) ?? []) { + result.push(child); + stack.push(child); + } + } + return result; +}