feat(backend): lessons CRUD service and routes
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
45
packages/backend/src/routes/lessons.ts
Normal file
45
packages/backend/src/routes/lessons.ts
Normal file
@@ -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;
|
||||
}
|
||||
53
packages/backend/src/services/lessons.test.ts
Normal file
53
packages/backend/src/services/lessons.test.ts
Normal file
@@ -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<typeof makeTestDb>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
126
packages/backend/src/services/lessons.ts
Normal file
126
packages/backend/src/services/lessons.ts
Normal file
@@ -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<number> {
|
||||
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<Lesson> {
|
||||
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<Lesson> {
|
||||
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<void> {
|
||||
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<Lesson> {
|
||||
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<LessonTreeNode[]> {
|
||||
const all = db.select().from(lessons).orderBy(lessons.position).all();
|
||||
const counts = db
|
||||
.select({ lessonId: cards.lessonId, count: sql<number>`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<number, LessonTreeNode>();
|
||||
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<number[]> {
|
||||
const all = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all();
|
||||
const byParent = new Map<number | null, number[]>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user