feat(cards): permission-aware CRUD
This commit is contained in:
@@ -1,38 +1,41 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { makeTestDb } from '../tests/dbHelper.js';
|
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
|
||||||
import { createLesson } from './lessons.js';
|
|
||||||
import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js';
|
import { createCard, listCardsByLesson, updateCard, deleteCard } from './cards.js';
|
||||||
|
|
||||||
let env: ReturnType<typeof makeTestDb>;
|
let env: ReturnType<typeof makeTestDb>;
|
||||||
beforeEach(() => { env = makeTestDb(); });
|
let owner: { id: number };
|
||||||
|
beforeEach(async () => {
|
||||||
|
env = makeTestDb();
|
||||||
|
owner = await createUserDirect(env.db, { email: 'owner@example.com' });
|
||||||
|
});
|
||||||
|
|
||||||
describe('cards service', () => {
|
describe('cards service', () => {
|
||||||
it('creates a card and initializes forward progress', async () => {
|
it('creates a card and initializes forward progress', async () => {
|
||||||
const lesson = await createLesson(env.db, { name: 'L' });
|
const lesson = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
|
||||||
const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' });
|
const card = await createCard(env.db, owner.id, lesson.id, { question: 'Q', answer: 'A' });
|
||||||
expect(card.lessonId).toBe(lesson.id);
|
expect(card.lessonId).toBe(lesson.id);
|
||||||
expect(card.position).toBe(0);
|
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);
|
expect(list).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates two progress rows when lesson is bidirectional', async () => {
|
it('creates a card in a bidirectional lesson', async () => {
|
||||||
const lesson = await createLesson(env.db, { name: 'L', bidirectional: true });
|
const lesson = await createLessonOwnedBy(env.db, owner.id, { name: 'L', bidirectional: true });
|
||||||
const card = await createCard(env.db, lesson.id, { question: 'Q', answer: 'A' });
|
const card = await createCard(env.db, owner.id, lesson.id, { question: 'Q', answer: 'A' });
|
||||||
expect(card.id).toBeGreaterThan(0);
|
expect(card.id).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates a card', async () => {
|
it('updates a card', async () => {
|
||||||
const l = await createLesson(env.db, { name: 'L' });
|
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
|
||||||
const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' });
|
const c = await createCard(env.db, owner.id, l.id, { question: 'Q', answer: 'A' });
|
||||||
const u = await updateCard(env.db, c.id, { hint: 'tip' });
|
const u = await updateCard(env.db, owner.id, c.id, { hint: 'tip' });
|
||||||
expect(u.hint).toBe('tip');
|
expect(u.hint).toBe('tip');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes a card', async () => {
|
it('deletes a card', async () => {
|
||||||
const l = await createLesson(env.db, { name: 'L' });
|
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
|
||||||
const c = await createCard(env.db, l.id, { question: 'Q', answer: 'A' });
|
const c = await createCard(env.db, owner.id, l.id, { question: 'Q', answer: 'A' });
|
||||||
await deleteCard(env.db, c.id);
|
await deleteCard(env.db, owner.id, c.id);
|
||||||
expect(await listCardsByLesson(env.db, l.id)).toHaveLength(0);
|
expect(await listCardsByLesson(env.db, owner.id, l.id)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { and, eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import type { Db } from '../db/client.js';
|
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 { 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 {
|
function rowToCard(r: typeof cards.$inferSelect): Card {
|
||||||
return {
|
return {
|
||||||
@@ -17,18 +18,12 @@ function rowToCard(r: typeof cards.$inferSelect): Card {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureProgress(db: Db, cardId: number, direction: Direction) {
|
export async function createCard(
|
||||||
const existing = db
|
db: Db, userId: number, lessonId: number, input: CardCreateInput
|
||||||
.select()
|
): Promise<Card> {
|
||||||
.from(cardProgress)
|
if (!(await canEditLesson(db, userId, lessonId))) {
|
||||||
.where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.direction, direction)))
|
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
|
||||||
.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();
|
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||||
if (!lesson) throw ApiError.notFound('Lesson');
|
if (!lesson) throw ApiError.notFound('Lesson');
|
||||||
const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, lessonId)).all();
|
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,
|
hint: input.hint ?? null,
|
||||||
position,
|
position,
|
||||||
}).returning().all();
|
}).returning().all();
|
||||||
ensureProgress(db, row!.id, 'forward');
|
|
||||||
if (lesson.bidirectional) ensureProgress(db, row!.id, 'backward');
|
|
||||||
return rowToCard(row!);
|
return rowToCard(row!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listCardsByLesson(db: Db, lessonId: number): Promise<Card[]> {
|
export async function listCardsByLesson(
|
||||||
|
db: Db, userId: number, lessonId: number
|
||||||
|
): Promise<Card[]> {
|
||||||
|
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);
|
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> {
|
export async function getCard(db: Db, userId: number, id: number): Promise<Card> {
|
||||||
const row = db.select().from(cards).where(eq(cards.id, id)).get();
|
const row = db.select().from(cards).where(eq(cards.id, id)).get();
|
||||||
if (!row) throw ApiError.notFound('Card');
|
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);
|
return rowToCard(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateCard(db: Db, id: number, input: CardUpdateInput): Promise<Card> {
|
export async function updateCard(
|
||||||
|
db: Db, userId: number, id: number, input: CardUpdateInput
|
||||||
|
): Promise<Card> {
|
||||||
const existing = db.select().from(cards).where(eq(cards.id, id)).get();
|
const existing = db.select().from(cards).where(eq(cards.id, id)).get();
|
||||||
if (!existing) throw ApiError.notFound('Card');
|
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({
|
const [row] = db.update(cards).set({
|
||||||
...(input.question !== undefined && { question: input.question }),
|
...(input.question !== undefined && { question: input.question }),
|
||||||
...(input.answer !== undefined && { answer: input.answer }),
|
...(input.answer !== undefined && { answer: input.answer }),
|
||||||
@@ -67,7 +73,11 @@ export async function updateCard(db: Db, id: number, input: CardUpdateInput): Pr
|
|||||||
return rowToCard(row!);
|
return rowToCard(row!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteCard(db: Db, id: number): Promise<void> {
|
export async function deleteCard(db: Db, userId: number, id: number): Promise<void> {
|
||||||
const r = db.delete(cards).where(eq(cards.id, id)).run();
|
const existing = db.select().from(cards).where(eq(cards.id, id)).get();
|
||||||
if (r.changes === 0) throw ApiError.notFound('Card');
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user