feat(backend): cards CRUD service and routes
This commit is contained in:
@@ -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) {
|
||||
|
||||
32
packages/backend/src/routes/cards.ts
Normal file
32
packages/backend/src/routes/cards.ts
Normal file
@@ -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;
|
||||
}
|
||||
38
packages/backend/src/services/cards.test.ts
Normal file
38
packages/backend/src/services/cards.test.ts
Normal file
@@ -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<typeof makeTestDb>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
73
packages/backend/src/services/cards.ts
Normal file
73
packages/backend/src/services/cards.ts
Normal file
@@ -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<Card> {
|
||||
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<Card[]> {
|
||||
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<Card> {
|
||||
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<Card> {
|
||||
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<void> {
|
||||
const r = db.delete(cards).where(eq(cards.id, id)).run();
|
||||
if (r.changes === 0) throw ApiError.notFound('Card');
|
||||
}
|
||||
Reference in New Issue
Block a user