diff --git a/packages/backend/src/services/import.test.ts b/packages/backend/src/services/import.test.ts index f59f714..86c57a6 100644 --- a/packages/backend/src/services/import.test.ts +++ b/packages/backend/src/services/import.test.ts @@ -3,6 +3,7 @@ import * as XLSX from 'xlsx'; import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js'; import { importCardsFromBuffer, exportCardsToBuffer } from './import.js'; import { listCardsByLesson } from './cards.js'; +import { getLessonTree } from './lessons.js'; let env: ReturnType; let owner: Awaited>; @@ -50,6 +51,35 @@ describe('excel import', () => { expect(cards[0]!.answer).toBe('b'); }); + it('nests lesson_path under the started lesson, owned by the user, visible in the tree', async () => { + // Regression: previously auto-created lessons had no owner_id and were invisible, + // and lesson_path was resolved at root instead of under the started lesson. + const home = await createLessonOwnedBy(env.db, owner.id, { name: 'Nederlands' }); + const buf = buildXlsx([ + { question: 'aanvankelijk', answer: 'eerst', lesson_path: '/Thema: Schooltaal' }, + { question: 'achterhalen', answer: 'vinden', lesson_path: '/Thema: Schooltaal' }, + ]); + const result = await importCardsFromBuffer(env.db, owner.id, home.id, buf, { updateExisting: true, createMissingLessons: true }); + expect(result.inserted).toBe(2); + expect(result.errors).toHaveLength(0); + + const tree = await getLessonTree(env.db, owner.id); + expect(tree).toHaveLength(1); + expect(tree[0]!.name).toBe('Nederlands'); + const child = tree[0]!.children.find((c) => c.name === 'Thema: Schooltaal'); + expect(child).toBeTruthy(); + expect(child!.ownerId).toBe(owner.id); + expect(await listCardsByLesson(env.db, owner.id, child!.id)).toHaveLength(2); + }); + + it('refuses lesson_path when createMissingLessons is false and path does not exist', async () => { + const home = await createLessonOwnedBy(env.db, owner.id, { name: 'Home' }); + const buf = buildXlsx([{ question: 'q', answer: 'a', lesson_path: 'Nieuw' }]); + const result = await importCardsFromBuffer(env.db, owner.id, home.id, buf, { updateExisting: true, createMissingLessons: false }); + expect(result.inserted).toBe(0); + expect(result.errors).toHaveLength(1); + }); + it('exports a buffer that round-trips back via import', async () => { const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' }); await importCardsFromBuffer(env.db, owner.id, l.id, buildXlsx([{ question: 'q', answer: 'a' }]), { updateExisting: true, createMissingLessons: false }); diff --git a/packages/backend/src/services/import.ts b/packages/backend/src/services/import.ts index 849e25d..b73c9ff 100644 --- a/packages/backend/src/services/import.ts +++ b/packages/backend/src/services/import.ts @@ -1,7 +1,7 @@ -import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import * as XLSX from 'xlsx'; import type { Db } from '../db/client.js'; -import { cardProgress, cards, lessons } from '../db/schema.js'; +import { cards, lessons } from '../db/schema.js'; import { getDescendantLessonIds } from './lessons.js'; import { canEditLesson, canReadLesson } from './permissions.js'; import { ApiError } from '../lib/errors.js'; @@ -36,6 +36,7 @@ function nowSec() { return Math.floor(Date.now() / 1000); } async function resolveLesson( db: Db, + userId: number, defaultLessonId: number, lessonPath: string | undefined, createMissing: boolean @@ -43,14 +44,19 @@ async function resolveLesson( if (!lessonPath || lessonPath.trim() === '') return defaultLessonId; const parts = lessonPath.split('/').map((s) => s.trim()).filter(Boolean); if (parts.length === 0) return defaultLessonId; - let parentId: number | null = null; + // lesson_path is resolved RELATIVE to the lesson the import was started from: + // each segment becomes a sublesson under it (created on demand, owned by the user). + let parentId: number = defaultLessonId; for (const name of parts) { - const found: typeof lessons.$inferSelect | undefined = parentId === null - ? db.select().from(lessons).where(and(eq(lessons.name, name), isNull(lessons.parentId))).get() - : db.select().from(lessons).where(and(eq(lessons.name, name), eq(lessons.parentId, parentId))).get(); + const found = db.select().from(lessons) + .where(and(eq(lessons.name, name), eq(lessons.parentId, parentId), eq(lessons.ownerId, userId))) + .get(); if (found) { parentId = found.id; continue; } if (!createMissing) return null; - const [row] = db.insert(lessons).values({ name, parentId, position: 0 }).returning().all(); + const [row] = db.insert(lessons).values({ + name, parentId, position: 0, + ownerId: userId, visibility: 'private', isCurated: false, + }).returning().all(); parentId = row!.id; } return parentId; @@ -73,7 +79,7 @@ export async function importCardsFromBuffer( result.errors.push({ row: rowNum, message: 'question and answer are required' }); continue; } - const targetLessonId = await resolveLesson(db, defaultLessonId, row.lesson_path, opts.createMissingLessons); + const targetLessonId = await resolveLesson(db, userId, defaultLessonId, row.lesson_path, opts.createMissingLessons); if (targetLessonId === null) { result.errors.push({ row: rowNum, message: `lesson_path not found: ${row.lesson_path}` }); continue; @@ -87,12 +93,7 @@ export async function importCardsFromBuffer( } else { const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, targetLessonId)).all(); const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1; - const [inserted] = db.insert(cards).values({ lessonId: targetLessonId, question: q, answer: a, hint, position }).returning().all(); - db.insert(cardProgress).values({ cardId: inserted!.id, direction: 'forward', box: 1, nextDueAt: 0 }).run(); - const lesson = db.select().from(lessons).where(eq(lessons.id, targetLessonId)).get(); - if (lesson?.bidirectional) { - db.insert(cardProgress).values({ cardId: inserted!.id, direction: 'backward', box: 1, nextDueAt: 0 }).run(); - } + db.insert(cards).values({ lessonId: targetLessonId, question: q, answer: a, hint, position }).run(); result.inserted += 1; } }