133 lines
4.9 KiB
TypeScript
133 lines
4.9 KiB
TypeScript
import { eq, inArray, isNull, sql } from 'drizzle-orm';
|
|
import type { Db } from '../db/client.js';
|
|
import { cards, lessons } from '../db/schema.js';
|
|
import { ApiError } from '../lib/errors.js';
|
|
import type {
|
|
Lesson,
|
|
LessonTreeNode,
|
|
LessonCreateInput,
|
|
LessonUpdateInput,
|
|
LessonMoveInput,
|
|
} 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,
|
|
};
|
|
}
|
|
|
|
async function nextPosition(db: Db, parentId: number | null): Promise<number> {
|
|
const rows = parentId == null
|
|
? db.select({ pos: lessons.position }).from(lessons).where(isNull(lessons.parentId)).all()
|
|
: db.select({ pos: lessons.position }).from(lessons).where(eq(lessons.parentId, parentId)).all();
|
|
return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1;
|
|
}
|
|
|
|
export async function createLesson(db: Db, input: LessonCreateInput): Promise<Lesson> {
|
|
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');
|
|
}
|
|
const position = await nextPosition(db, parentId);
|
|
const [row] = db.insert(lessons).values({
|
|
name: input.name,
|
|
parentId,
|
|
description: input.description ?? null,
|
|
bidirectional: input.bidirectional ?? false,
|
|
position,
|
|
}).returning().all();
|
|
return rowToLesson(row!);
|
|
}
|
|
|
|
export async function updateLesson(db: Db, id: number, input: LessonUpdateInput): Promise<Lesson> {
|
|
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
|
if (!existing) throw ApiError.notFound('Lesson');
|
|
const [row] = db.update(lessons).set({
|
|
...(input.name !== undefined && { name: input.name }),
|
|
...(input.description !== undefined && { description: input.description }),
|
|
...(input.bidirectional !== undefined && { bidirectional: input.bidirectional }),
|
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
}).where(eq(lessons.id, id)).returning().all();
|
|
return rowToLesson(row!);
|
|
}
|
|
|
|
export async function deleteLesson(db: Db, id: number): Promise<void> {
|
|
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
|
if (!existing) throw ApiError.notFound('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<Lesson> {
|
|
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
|
if (!existing) throw ApiError.notFound('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');
|
|
let cursor: number | null = input.parentId;
|
|
while (cursor !== null) {
|
|
if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant');
|
|
const row = db.select({ parentId: lessons.parentId }).from(lessons).where(eq(lessons.id, cursor)).get();
|
|
cursor = row?.parentId ?? null;
|
|
}
|
|
}
|
|
const [row] = db.update(lessons).set({
|
|
parentId: input.parentId,
|
|
position: input.position,
|
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
}).where(eq(lessons.id, id)).returning().all();
|
|
return rowToLesson(row!);
|
|
}
|
|
|
|
export async function getLessonTree(db: Db): Promise<LessonTreeNode[]> {
|
|
const all = db.select().from(lessons).orderBy(lessons.position).all();
|
|
const counts = db
|
|
.select({ lessonId: cards.lessonId, count: sql<number>`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<number, LessonTreeNode>();
|
|
for (const r of all) {
|
|
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);
|
|
}
|
|
return roots;
|
|
}
|
|
|
|
export async function getDescendantLessonIds(db: Db, rootId: number): Promise<number[]> {
|
|
const all = db.select({ id: lessons.id, parentId: lessons.parentId }).from(lessons).all();
|
|
const byParent = new Map<number | null, number[]>();
|
|
for (const r of all) {
|
|
const key = r.parentId ?? null;
|
|
if (!byParent.has(key)) byParent.set(key, []);
|
|
byParent.get(key)!.push(r.id);
|
|
}
|
|
const result: number[] = [rootId];
|
|
const stack = [rootId];
|
|
while (stack.length) {
|
|
const cur = stack.pop()!;
|
|
for (const child of byParent.get(cur) ?? []) {
|
|
result.push(child);
|
|
stack.push(child);
|
|
}
|
|
}
|
|
return result;
|
|
}
|