From 5468b7c172b588b5b6dab57ad3147803289bca9f Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 20:51:42 +0200 Subject: [PATCH] feat(backend): cards CRUD service and routes --- packages/backend/src/app.ts | 2 + packages/backend/src/routes/cards.ts | 32 +++++++++ packages/backend/src/services/cards.test.ts | 38 +++++++++++ packages/backend/src/services/cards.ts | 73 +++++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 packages/backend/src/routes/cards.ts create mode 100644 packages/backend/src/services/cards.test.ts create mode 100644 packages/backend/src/services/cards.ts diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 1172c5f..4a9cb60 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -2,6 +2,7 @@ import express, { type Express, type NextFunction, type Request, type Response } import { ZodError } from 'zod'; import type { Db } from './db/client.js'; import { ApiError } from './lib/errors.js'; +import { cardsRouter } from './routes/cards.js'; import { lessonsRouter } from './routes/lessons.js'; export function createApp(db: Db): Express { @@ -10,6 +11,7 @@ export function createApp(db: Db): Express { app.get('/api/health', (_req, res) => res.json({ ok: true })); app.use('/api/lessons', lessonsRouter(db)); + app.use('/api', cardsRouter(db)); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { if (err instanceof ZodError) { diff --git a/packages/backend/src/routes/cards.ts b/packages/backend/src/routes/cards.ts new file mode 100644 index 0000000..887df32 --- /dev/null +++ b/packages/backend/src/routes/cards.ts @@ -0,0 +1,32 @@ +import { Router } from 'express'; +import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared'; +import type { Db } from '../db/client.js'; +import { createCard, deleteCard, listCardsByLesson, updateCard } from '../services/cards.js'; + +export function cardsRouter(db: Db): Router { + const r = Router({ mergeParams: true }); + + r.get('/lessons/:lessonId/cards', async (req, res, next) => { + try { res.json(await listCardsByLesson(db, 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)); + } 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)); + } 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); } + }); + + return r; +} diff --git a/packages/backend/src/services/cards.test.ts b/packages/backend/src/services/cards.test.ts new file mode 100644 index 0000000..2801948 --- /dev/null +++ b/packages/backend/src/services/cards.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { makeTestDb } from '../tests/dbHelper.js'; +import { createLesson } from './lessons.js'; +import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js'; + +let env: ReturnType; +beforeEach(() => { env = makeTestDb(); }); + +describe('cards service', () => { + it('creates a card and initializes forward progress', async () => { + const lesson = await createLesson(env.db, { name: 'L' }); + const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' }); + expect(card.lessonId).toBe(lesson.id); + expect(card.position).toBe(0); + const list = await listCardsByLesson(env.db, lesson.id); + expect(list).toHaveLength(1); + }); + + it('creates two progress rows when lesson is bidirectional', async () => { + const lesson = await createLesson(env.db, { name: 'L', bidirectional: true }); + const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' }); + expect(card.id).toBeGreaterThan(0); + }); + + it('updates a card', async () => { + const l = await createLesson(env.db, { name: 'L' }); + const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' }); + const u = await updateCard(env.db, c.id, { hint: 'tip' }); + expect(u.hint).toBe('tip'); + }); + + it('deletes a card', async () => { + const l = await createLesson(env.db, { name: 'L' }); + const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' }); + await deleteCard(env.db, c.id); + expect(await listCardsByLesson(env.db, l.id)).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/services/cards.ts b/packages/backend/src/services/cards.ts new file mode 100644 index 0000000..5b7b430 --- /dev/null +++ b/packages/backend/src/services/cards.ts @@ -0,0 +1,73 @@ +import { and, eq } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { cardProgress, cards, lessons } from '../db/schema.js'; +import { ApiError } from '../lib/errors.js'; +import type { Card, CardCreateInput, CardUpdateInput, Direction } from '@flashcard/shared'; + +function rowToCard(r: typeof cards.$inferSelect): Card { + return { + id: r.id, + lessonId: r.lessonId, + question: r.question, + answer: r.answer, + hint: r.hint ?? null, + position: r.position, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +function ensureProgress(db: Db, cardId: number, direction: Direction) { + const existing = db + .select() + .from(cardProgress) + .where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.direction, direction))) + .get(); + if (!existing) { + db.insert(cardProgress).values({ cardId, direction, box: 1, nextDueAt: 0 }).run(); + } +} + +export async function createCard(db: Db, lessonId: number, input: CardCreateInput): Promise { + const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get(); + if (!lesson) throw ApiError.notFound('Lesson'); + const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, lessonId)).all(); + const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1; + const [row] = db.insert(cards).values({ + lessonId, + question: input.question, + answer: input.answer, + hint: input.hint ?? null, + position, + }).returning().all(); + ensureProgress(db, row!.id, 'forward'); + if (lesson.bidirectional) ensureProgress(db, row!.id, 'backward'); + return rowToCard(row!); +} + +export async function listCardsByLesson(db: Db, lessonId: number): Promise { + return db.select().from(cards).where(eq(cards.lessonId, lessonId)).orderBy(cards.position).all().map(rowToCard); +} + +export async function getCard(db: Db, id: number): Promise { + const row = db.select().from(cards).where(eq(cards.id, id)).get(); + if (!row) throw ApiError.notFound('Card'); + return rowToCard(row); +} + +export async function updateCard(db: Db, id: number, input: CardUpdateInput): Promise { + const existing = db.select().from(cards).where(eq(cards.id, id)).get(); + if (!existing) throw ApiError.notFound('Card'); + const [row] = db.update(cards).set({ + ...(input.question !== undefined && { question: input.question }), + ...(input.answer !== undefined && { answer: input.answer }), + ...(input.hint !== undefined && { hint: input.hint }), + updatedAt: Math.floor(Date.now() / 1000), + }).where(eq(cards.id, id)).returning().all(); + return rowToCard(row!); +} + +export async function deleteCard(db: Db, id: number): Promise { + const r = db.delete(cards).where(eq(cards.id, id)).run(); + if (r.changes === 0) throw ApiError.notFound('Card'); +}