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:
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user