From 2d37aee32c7dedd405989311736bcc1cbe3c7c5e Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Thu, 21 May 2026 00:11:00 +0200 Subject: [PATCH] feat(cards): permission-aware CRUD --- packages/backend/src/services/cards.test.ts | 35 +++++++------ packages/backend/src/services/cards.ts | 54 ++++++++++++--------- 2 files changed, 51 insertions(+), 38 deletions(-) diff --git a/packages/backend/src/services/cards.test.ts b/packages/backend/src/services/cards.test.ts index 2801948..bf87429 100644 --- a/packages/backend/src/services/cards.test.ts +++ b/packages/backend/src/services/cards.test.ts @@ -1,38 +1,41 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { makeTestDb } from '../tests/dbHelper.js'; -import { createLesson } from './lessons.js'; +import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js'; import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js'; let env: ReturnType; -beforeEach(() => { env = makeTestDb(); }); +let owner: { id: number }; +beforeEach(async () => { + env = makeTestDb(); + owner = await createUserDirect(env.db, { email: 'owner@example.com' }); +}); 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' }); + const lesson = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); + const card = await createCard(env.db, owner.id, 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); + const list = await listCardsByLesson(env.db, owner.id, 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' }); + it('creates a card in a bidirectional lesson', async () => { + const lesson = await createLessonOwnedBy(env.db, owner.id, { name: 'L', bidirectional: true }); + const card = await createCard(env.db, owner.id, 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' }); + const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); + const c = await createCard(env.db, owner.id, l.id, { question: 'Q', answer: 'A' }); + const u = await updateCard(env.db, owner.id, 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); + const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); + const c = await createCard(env.db, owner.id, l.id, { question: 'Q', answer: 'A' }); + await deleteCard(env.db, owner.id, c.id); + expect(await listCardsByLesson(env.db, owner.id, l.id)).toHaveLength(0); }); }); diff --git a/packages/backend/src/services/cards.ts b/packages/backend/src/services/cards.ts index 5b7b430..7b362f5 100644 --- a/packages/backend/src/services/cards.ts +++ b/packages/backend/src/services/cards.ts @@ -1,8 +1,9 @@ -import { and, eq } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import type { Db } from '../db/client.js'; -import { cardProgress, cards, lessons } from '../db/schema.js'; +import { cards, lessons } from '../db/schema.js'; import { ApiError } from '../lib/errors.js'; -import type { Card, CardCreateInput, CardUpdateInput, Direction } from '@flashcard/shared'; +import type { Card, CardCreateInput, CardUpdateInput } from '@flashcard/shared'; +import { canEditLesson, canReadLesson } from './permissions.js'; function rowToCard(r: typeof cards.$inferSelect): Card { return { @@ -17,18 +18,12 @@ function rowToCard(r: typeof cards.$inferSelect): Card { }; } -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, userId: number, lessonId: number, input: CardCreateInput +): Promise { + if (!(await canEditLesson(db, userId, lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); } -} - -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(); @@ -40,24 +35,35 @@ export async function createCard(db: Db, lessonId: number, input: CardCreateInpu 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 { +export async function listCardsByLesson( + db: Db, userId: number, lessonId: number +): Promise { + if (!(await canReadLesson(db, userId, lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson'); + } 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 { +export async function getCard(db: Db, userId: number, id: number): Promise { const row = db.select().from(cards).where(eq(cards.id, id)).get(); if (!row) throw ApiError.notFound('Card'); + if (!(await canReadLesson(db, userId, row.lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this card'); + } return rowToCard(row); } -export async function updateCard(db: Db, id: number, input: CardUpdateInput): Promise { +export async function updateCard( + db: Db, userId: number, id: number, input: CardUpdateInput +): Promise { const existing = db.select().from(cards).where(eq(cards.id, id)).get(); if (!existing) throw ApiError.notFound('Card'); + if (!(await canEditLesson(db, userId, existing.lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); + } const [row] = db.update(cards).set({ ...(input.question !== undefined && { question: input.question }), ...(input.answer !== undefined && { answer: input.answer }), @@ -67,7 +73,11 @@ export async function updateCard(db: Db, id: number, input: CardUpdateInput): Pr 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'); +export async function deleteCard(db: Db, userId: number, id: number): Promise { + const existing = db.select().from(cards).where(eq(cards.id, id)).get(); + if (!existing) throw ApiError.notFound('Card'); + if (!(await canEditLesson(db, userId, existing.lessonId))) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); + } + db.delete(cards).where(eq(cards.id, id)).run(); }