diff --git a/packages/backend/src/services/lessons.test.ts b/packages/backend/src/services/lessons.test.ts index 4bc3bb9..488426e 100644 --- a/packages/backend/src/services/lessons.test.ts +++ b/packages/backend/src/services/lessons.test.ts @@ -1,24 +1,26 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { makeTestDb } from '../tests/dbHelper.js'; +import { makeTestDb, createUserDirect } from '../tests/dbHelper.js'; import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js'; let env: ReturnType; -beforeEach(() => { +let owner: { id: number }; +beforeEach(async () => { env = makeTestDb(); + owner = await createUserDirect(env.db, { email: 'owner@example.com' }); }); describe('lessons service', () => { it('creates a root lesson', async () => { - const lesson = await createLesson(env.db, { name: 'Spaans' }); + const lesson = await createLesson(env.db, owner.id, { 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); + const root = await createLesson(env.db, owner.id, { name: 'A' }); + const child = await createLesson(env.db, owner.id, { name: 'B', parentId: root.id }); + const tree = await getLessonTree(env.db, owner.id); expect(tree).toHaveLength(1); expect(tree[0]!.id).toBe(root.id); expect(tree[0]!.children).toHaveLength(1); @@ -27,27 +29,27 @@ describe('lessons service', () => { }); 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 }); + const l = await createLesson(env.db, owner.id, { name: 'X' }); + const updated = await updateLesson(env.db, owner.id, 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 a = await createLesson(env.db, owner.id, { name: 'A' }); + const b = await createLesson(env.db, owner.id, { name: 'B' }); + const c = await createLesson(env.db, owner.id, { name: 'C', parentId: a.id }); + await moveLesson(env.db, owner.id, c.id, { parentId: b.id, position: 0 }); + const tree = await getLessonTree(env.db, owner.id); 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); + const a = await createLesson(env.db, owner.id, { name: 'A' }); + await createLesson(env.db, owner.id, { name: 'B', parentId: a.id }); + await deleteLesson(env.db, owner.id, a.id); + const tree = await getLessonTree(env.db, owner.id); expect(tree).toHaveLength(0); }); }); diff --git a/packages/backend/src/services/lessons.ts b/packages/backend/src/services/lessons.ts index cc963a3..67bda0b 100644 --- a/packages/backend/src/services/lessons.ts +++ b/packages/backend/src/services/lessons.ts @@ -1,6 +1,6 @@ import { eq, inArray, isNull, sql } from 'drizzle-orm'; import type { Db } from '../db/client.js'; -import { cards, lessons } from '../db/schema.js'; +import { cards, lessons, lessonSubscriptions } from '../db/schema.js'; import { ApiError } from '../lib/errors.js'; import type { Lesson, @@ -34,11 +34,18 @@ async function nextPosition(db: Db, parentId: number | null): Promise { return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1; } -export async function createLesson(db: Db, input: LessonCreateInput): Promise { +export async function createLesson( + db: Db, + userId: number, + input: LessonCreateInput +): Promise { 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'); + if (exists.ownerId !== userId) { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot create sublesson under a lesson you do not own'); + } } const position = await nextPosition(db, parentId); const [row] = db.insert(lessons).values({ @@ -47,13 +54,19 @@ export async function createLesson(db: Db, input: LessonCreateInput): Promise { +export async function updateLesson( + db: Db, userId: number, id: number, input: LessonUpdateInput +): Promise { const existing = db.select().from(lessons).where(eq(lessons.id, id)).get(); if (!existing) throw ApiError.notFound('Lesson'); + if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); const [row] = db.update(lessons).set({ ...(input.name !== undefined && { name: input.name }), ...(input.description !== undefined && { description: input.description }), @@ -63,19 +76,24 @@ export async function updateLesson(db: Db, id: number, input: LessonUpdateInput) return rowToLesson(row!); } -export async function deleteLesson(db: Db, id: number): Promise { +export async function deleteLesson(db: Db, userId: number, id: number): Promise { const existing = db.select().from(lessons).where(eq(lessons.id, id)).get(); if (!existing) throw ApiError.notFound('Lesson'); + if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); const ids = await getDescendantLessonIds(db, id); db.delete(lessons).where(inArray(lessons.id, ids)).run(); } -export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Promise { +export async function moveLesson( + db: Db, userId: number, id: number, input: LessonMoveInput +): Promise { const existing = db.select().from(lessons).where(eq(lessons.id, id)).get(); if (!existing) throw ApiError.notFound('Lesson'); + if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your 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'); + if (p.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Target parent is not yours'); let cursor: number | null = input.parentId; while (cursor !== null) { if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant'); @@ -91,23 +109,79 @@ export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Pr return rowToLesson(row!); } -export async function getLessonTree(db: Db): Promise { - const all = db.select().from(lessons).orderBy(lessons.position).all(); - const counts = db - .select({ lessonId: cards.lessonId, count: sql`count(*)`.as('count') }) - .from(cards) - .groupBy(cards.lessonId) +export async function getLessonTree(db: Db, userId: number): Promise { + const ownerLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all(); + const subscribedRoots = db.select({ + id: lessons.id, + parentId: lessons.parentId, + name: lessons.name, + description: lessons.description, + position: lessons.position, + bidirectional: lessons.bidirectional, + ownerId: lessons.ownerId, + visibility: lessons.visibility, + isCurated: lessons.isCurated, + sourceLessonId: lessons.sourceLessonId, + createdAt: lessons.createdAt, + updatedAt: lessons.updatedAt, + }).from(lessons) + .innerJoin(lessonSubscriptions, eq(lessonSubscriptions.lessonId, lessons.id)) + .where(eq(lessonSubscriptions.userId, userId)) .all(); + + const allLessons = db.select().from(lessons).all(); + const byId = new Map(allLessons.map((l) => [l.id, l])); + const byParent = new Map(); + for (const l of allLessons) { + const k = l.parentId ?? null; + if (!byParent.has(k)) byParent.set(k, []); + byParent.get(k)!.push(l); + } + + function gatherDescendants(rootId: number): typeof allLessons { + const out: typeof allLessons = []; + const stack = [rootId]; + while (stack.length) { + const cur = stack.pop()!; + const node = byId.get(cur); + if (node) out.push(node); + for (const child of byParent.get(cur) ?? []) stack.push(child.id); + } + return out; + } + + const visible = new Map(); + for (const l of ownerLessons) visible.set(l.id, l); + for (const sr of subscribedRoots) { + for (const d of gatherDescendants(sr.id)) visible.set(d.id, d); + } + for (const l of allLessons) { + if (l.visibility === 'shared' && l.isCurated) { + for (const d of gatherDescendants(l.id)) visible.set(d.id, d); + } + } + + const counts = db.select({ lessonId: cards.lessonId, count: sql`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(); - for (const r of all) { + for (const r of visible.values()) { 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); + if (n.parentId !== null && nodes.has(n.parentId)) { + nodes.get(n.parentId)!.children.push(n); + } else { + roots.push(n); + } } + function sortChildren(arr: LessonTreeNode[]) { + arr.sort((a, b) => a.position - b.position || a.id - b.id); + for (const c of arr) sortChildren(c.children); + } + sortChildren(roots); return roots; } @@ -130,3 +204,33 @@ export async function getDescendantLessonIds(db: Db, rootId: number): Promise { + const existing = db.select().from(lessons).where(eq(lessons.id, lessonId)).get(); + if (!existing) throw ApiError.notFound('Lesson'); + if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); + const patch: Record = { + visibility, + updatedAt: Math.floor(Date.now() / 1000), + }; + if (visibility === 'private') patch.isCurated = false; + const [row] = db.update(lessons).set(patch).where(eq(lessons.id, lessonId)).returning().all(); + return rowToLesson(row!); +} + +export async function setLessonCurated( + db: Db, lessonId: number, isCurated: boolean +): Promise { + // Caller (route) must ensure sysadmin. + const existing = db.select().from(lessons).where(eq(lessons.id, lessonId)).get(); + if (!existing) throw ApiError.notFound('Lesson'); + const patch: Record = { + isCurated, + updatedAt: Math.floor(Date.now() / 1000), + }; + if (isCurated && existing.visibility !== 'shared') patch.visibility = 'shared'; + const [row] = db.update(lessons).set(patch).where(eq(lessons.id, lessonId)).returning().all(); + return rowToLesson(row!); +}