fix(import): set owner on auto-created lessons + nest lesson_path under started lesson

Two bugs surfaced by Excel import on a lesson with a lesson_path column:
1. resolveLesson created lessons without owner_id, so after the ownership
   model (sub-project B) they never appeared in getLessonTree — import
   reported success but nothing was visible.
2. lesson_path was resolved at the root; cards landed in new root lessons
   instead of under the lesson the import was started from.

Now: auto-created lessons get ownerId + visibility 'private'; lesson_path is
resolved relative to the started lesson (each segment a sublesson). Also drop
the stale eager card_progress insert (progress is per-user and lazy since B).
This commit is contained in:
2026-05-21 07:45:49 +02:00
parent 2890e19953
commit f5000d3c58
2 changed files with 45 additions and 14 deletions

View File

@@ -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<typeof makeTestDb>;
let owner: Awaited<ReturnType<typeof createUserDirect>>;
@@ -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 });

View File

@@ -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;
}
}