diff --git a/packages/backend/src/services/fork.test.ts b/packages/backend/src/services/fork.test.ts new file mode 100644 index 0000000..4fe2611 --- /dev/null +++ b/packages/backend/src/services/fork.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js'; +import { createCard, listCardsByLesson } from './cards.js'; +import { lessons } from '../db/schema.js'; +import { forkLesson } from './fork.js'; + +let env: ReturnType; +beforeEach(() => { env = makeTestDb(); }); + +describe('fork', () => { + it('forks a single shared lesson with cards', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' }); + await createCard(env.db, o.id, l.id, { question: 'q1', answer: 'a1' }); + await createCard(env.db, o.id, l.id, { question: 'q2', answer: 'a2' }); + + const fork = await forkLesson(env.db, u.id, l.id); + expect(fork.ownerId).toBe(u.id); + expect(fork.visibility).toBe('private'); + expect(fork.sourceLessonId).toBe(l.id); + expect(fork.parentId).toBeNull(); + const forkCards = await listCardsByLesson(env.db, u.id, fork.id); + expect(forkCards).toHaveLength(2); + }); + + it('forks the whole subtree and rewrites parent_id', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' }); + const child = await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id }); + await createCard(env.db, o.id, child.id, { question: 'qc', answer: 'ac' }); + + const fork = await forkLesson(env.db, u.id, root.id); + const allUser = env.db.select().from(lessons).where(eq(lessons.ownerId, u.id)).all(); + expect(allUser).toHaveLength(2); + const forkChild = allUser.find((x) => x.id !== fork.id)!; + expect(forkChild.parentId).toBe(fork.id); + expect(forkChild.name).toBe('C'); + const childCards = await listCardsByLesson(env.db, u.id, forkChild.id); + expect(childCards).toHaveLength(1); + }); + + it('rejects forking a private lesson owned by someone else', async () => { + const o = await createUserDirect(env.db, { email: 'o@example.com' }); + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'private' }); + await expect(forkLesson(env.db, u.id, l.id)).rejects.toThrow(/forbid|private|cannot/i); + }); + + it('allows forking own lesson', async () => { + const u = await createUserDirect(env.db, { email: 'u@example.com' }); + const l = await createLessonOwnedBy(env.db, u.id, { name: 'L', visibility: 'private' }); + const fork = await forkLesson(env.db, u.id, l.id); + expect(fork.ownerId).toBe(u.id); + expect(fork.sourceLessonId).toBe(l.id); + }); +}); diff --git a/packages/backend/src/services/fork.ts b/packages/backend/src/services/fork.ts new file mode 100644 index 0000000..8c4c4ea --- /dev/null +++ b/packages/backend/src/services/fork.ts @@ -0,0 +1,84 @@ +import { eq, inArray } from 'drizzle-orm'; +import type { Db } from '../db/client.js'; +import { cards, lessons } from '../db/schema.js'; +import { getDescendantLessonIds } from './lessons.js'; +import { ApiError } from '../lib/errors.js'; +import type { Lesson } from '@flashcard/shared'; + +function rowToLesson(r: typeof lessons.$inferSelect): Lesson { + return { + id: r.id, + parentId: r.parentId ?? null, + name: r.name, + description: r.description ?? null, + position: r.position, + bidirectional: r.bidirectional, + ownerId: r.ownerId ?? null, + visibility: r.visibility, + isCurated: r.isCurated, + sourceLessonId: r.sourceLessonId ?? null, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; +} + +export async function forkLesson(db: Db, userId: number, lessonId: number): Promise { + const source = db.select().from(lessons).where(eq(lessons.id, lessonId)).get(); + if (!source) throw ApiError.notFound('Lesson'); + const isOwner = source.ownerId === userId; + if (!isOwner && source.visibility !== 'shared') { + throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot fork a private lesson you do not own'); + } + + const descendantIds = await getDescendantLessonIds(db, lessonId); + const sourceLessons = db.select().from(lessons).where(inArray(lessons.id, descendantIds)).all(); + const sourceCards = db.select().from(cards).where(inArray(cards.lessonId, descendantIds)).all(); + + // Sort by depth (parents before children). + const idSet = new Set(descendantIds); + function depth(l: typeof sourceLessons[number]): number { + let d = 0; + let cur: number | null = l.parentId ?? null; + while (cur !== null && idSet.has(cur)) { + d += 1; + const next = sourceLessons.find((x) => x.id === cur); + cur = next?.parentId ?? null; + } + return d; + } + const ordered = [...sourceLessons].sort((a, b) => depth(a) - depth(b)); + + const idMap = new Map(); + let newRoot: typeof sourceLessons[number] | null = null; + for (const L of ordered) { + const newParent = L.parentId !== null && idMap.has(L.parentId) ? idMap.get(L.parentId)! : null; + const [inserted] = db.insert(lessons).values({ + name: L.name, + description: L.description ?? null, + position: L.position, + bidirectional: L.bidirectional, + parentId: newParent, + ownerId: userId, + visibility: 'private', + isCurated: false, + sourceLessonId: L.id, + }).returning().all(); + idMap.set(L.id, inserted!.id); + if (L.id === source.id) newRoot = inserted!; + } + + for (const C of sourceCards) { + const newLessonId = idMap.get(C.lessonId); + if (!newLessonId) continue; + db.insert(cards).values({ + lessonId: newLessonId, + question: C.question, + answer: C.answer, + hint: C.hint ?? null, + position: C.position, + }).run(); + } + + if (!newRoot) throw new ApiError(500, 'INTERNAL', 'Fork failed: root not produced'); + return rowToLesson(newRoot); +}