feat(lessons): ownership-aware CRUD + tree filtering + visibility/curated
This commit is contained in:
@@ -1,24 +1,26 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { makeTestDb } from '../tests/dbHelper.js';
|
import { makeTestDb, createUserDirect } from '../tests/dbHelper.js';
|
||||||
import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js';
|
import { createLesson, getLessonTree, updateLesson, deleteLesson, moveLesson } from './lessons.js';
|
||||||
|
|
||||||
let env: ReturnType<typeof makeTestDb>;
|
let env: ReturnType<typeof makeTestDb>;
|
||||||
beforeEach(() => {
|
let owner: { id: number };
|
||||||
|
beforeEach(async () => {
|
||||||
env = makeTestDb();
|
env = makeTestDb();
|
||||||
|
owner = await createUserDirect(env.db, { email: 'owner@example.com' });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('lessons service', () => {
|
describe('lessons service', () => {
|
||||||
it('creates a root lesson', async () => {
|
it('creates a root lesson', async () => {
|
||||||
const lesson = await createLesson(env.db, { name: 'Spaans' });
|
const lesson = await createLesson(env.db, owner.id, { name: 'Spaans' });
|
||||||
expect(lesson.id).toBeGreaterThan(0);
|
expect(lesson.id).toBeGreaterThan(0);
|
||||||
expect(lesson.parentId).toBeNull();
|
expect(lesson.parentId).toBeNull();
|
||||||
expect(lesson.bidirectional).toBe(false);
|
expect(lesson.bidirectional).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('builds a tree with children and card counts', async () => {
|
it('builds a tree with children and card counts', async () => {
|
||||||
const root = await createLesson(env.db, { name: 'A' });
|
const root = await createLesson(env.db, owner.id, { name: 'A' });
|
||||||
const child = await createLesson(env.db, { name: 'B', parentId: root.id });
|
const child = await createLesson(env.db, owner.id, { name: 'B', parentId: root.id });
|
||||||
const tree = await getLessonTree(env.db);
|
const tree = await getLessonTree(env.db, owner.id);
|
||||||
expect(tree).toHaveLength(1);
|
expect(tree).toHaveLength(1);
|
||||||
expect(tree[0]!.id).toBe(root.id);
|
expect(tree[0]!.id).toBe(root.id);
|
||||||
expect(tree[0]!.children).toHaveLength(1);
|
expect(tree[0]!.children).toHaveLength(1);
|
||||||
@@ -27,27 +29,27 @@ describe('lessons service', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updates name and bidirectional flag', async () => {
|
it('updates name and bidirectional flag', async () => {
|
||||||
const l = await createLesson(env.db, { name: 'X' });
|
const l = await createLesson(env.db, owner.id, { name: 'X' });
|
||||||
const updated = await updateLesson(env.db, l.id, { name: 'Y', bidirectional: true });
|
const updated = await updateLesson(env.db, owner.id, l.id, { name: 'Y', bidirectional: true });
|
||||||
expect(updated.name).toBe('Y');
|
expect(updated.name).toBe('Y');
|
||||||
expect(updated.bidirectional).toBe(true);
|
expect(updated.bidirectional).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('moves a lesson to a new parent and position', async () => {
|
it('moves a lesson to a new parent and position', async () => {
|
||||||
const a = await createLesson(env.db, { name: 'A' });
|
const a = await createLesson(env.db, owner.id, { name: 'A' });
|
||||||
const b = await createLesson(env.db, { name: 'B' });
|
const b = await createLesson(env.db, owner.id, { name: 'B' });
|
||||||
const c = await createLesson(env.db, { name: 'C', parentId: a.id });
|
const c = await createLesson(env.db, owner.id, { name: 'C', parentId: a.id });
|
||||||
await moveLesson(env.db, c.id, { parentId: b.id, position: 0 });
|
await moveLesson(env.db, owner.id, c.id, { parentId: b.id, position: 0 });
|
||||||
const tree = await getLessonTree(env.db);
|
const tree = await getLessonTree(env.db, owner.id);
|
||||||
const bNode = tree.find((n) => n.id === b.id)!;
|
const bNode = tree.find((n) => n.id === b.id)!;
|
||||||
expect(bNode.children.map((cc) => cc.id)).toEqual([c.id]);
|
expect(bNode.children.map((cc) => cc.id)).toEqual([c.id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes a lesson and cascades to children', async () => {
|
it('deletes a lesson and cascades to children', async () => {
|
||||||
const a = await createLesson(env.db, { name: 'A' });
|
const a = await createLesson(env.db, owner.id, { name: 'A' });
|
||||||
await createLesson(env.db, { name: 'B', parentId: a.id });
|
await createLesson(env.db, owner.id, { name: 'B', parentId: a.id });
|
||||||
await deleteLesson(env.db, a.id);
|
await deleteLesson(env.db, owner.id, a.id);
|
||||||
const tree = await getLessonTree(env.db);
|
const tree = await getLessonTree(env.db, owner.id);
|
||||||
expect(tree).toHaveLength(0);
|
expect(tree).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { eq, inArray, isNull, sql } from 'drizzle-orm';
|
import { eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||||
import type { Db } from '../db/client.js';
|
import type { Db } from '../db/client.js';
|
||||||
import { cards, lessons } from '../db/schema.js';
|
import { cards, lessons, lessonSubscriptions } from '../db/schema.js';
|
||||||
import { ApiError } from '../lib/errors.js';
|
import { ApiError } from '../lib/errors.js';
|
||||||
import type {
|
import type {
|
||||||
Lesson,
|
Lesson,
|
||||||
@@ -34,11 +34,18 @@ async function nextPosition(db: Db, parentId: number | null): Promise<number> {
|
|||||||
return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1;
|
return rows.length === 0 ? 0 : Math.max(...rows.map((r) => r.pos)) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createLesson(db: Db, input: LessonCreateInput): Promise<Lesson> {
|
export async function createLesson(
|
||||||
|
db: Db,
|
||||||
|
userId: number,
|
||||||
|
input: LessonCreateInput
|
||||||
|
): Promise<Lesson> {
|
||||||
const parentId = input.parentId ?? null;
|
const parentId = input.parentId ?? null;
|
||||||
if (parentId !== null) {
|
if (parentId !== null) {
|
||||||
const exists = db.select().from(lessons).where(eq(lessons.id, parentId)).get();
|
const exists = db.select().from(lessons).where(eq(lessons.id, parentId)).get();
|
||||||
if (!exists) throw ApiError.notFound('Parent lesson');
|
if (!exists) throw ApiError.notFound('Parent lesson');
|
||||||
|
if (exists.ownerId !== userId) {
|
||||||
|
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot create sublesson under a lesson you do not own');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const position = await nextPosition(db, parentId);
|
const position = await nextPosition(db, parentId);
|
||||||
const [row] = db.insert(lessons).values({
|
const [row] = db.insert(lessons).values({
|
||||||
@@ -47,13 +54,19 @@ export async function createLesson(db: Db, input: LessonCreateInput): Promise<Le
|
|||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
bidirectional: input.bidirectional ?? false,
|
bidirectional: input.bidirectional ?? false,
|
||||||
position,
|
position,
|
||||||
|
ownerId: userId,
|
||||||
|
visibility: 'private',
|
||||||
|
isCurated: false,
|
||||||
}).returning().all();
|
}).returning().all();
|
||||||
return rowToLesson(row!);
|
return rowToLesson(row!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLesson(db: Db, id: number, input: LessonUpdateInput): Promise<Lesson> {
|
export async function updateLesson(
|
||||||
|
db: Db, userId: number, id: number, input: LessonUpdateInput
|
||||||
|
): Promise<Lesson> {
|
||||||
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
||||||
if (!existing) throw ApiError.notFound('Lesson');
|
if (!existing) throw ApiError.notFound('Lesson');
|
||||||
|
if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
|
||||||
const [row] = db.update(lessons).set({
|
const [row] = db.update(lessons).set({
|
||||||
...(input.name !== undefined && { name: input.name }),
|
...(input.name !== undefined && { name: input.name }),
|
||||||
...(input.description !== undefined && { description: input.description }),
|
...(input.description !== undefined && { description: input.description }),
|
||||||
@@ -63,19 +76,24 @@ export async function updateLesson(db: Db, id: number, input: LessonUpdateInput)
|
|||||||
return rowToLesson(row!);
|
return rowToLesson(row!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteLesson(db: Db, id: number): Promise<void> {
|
export async function deleteLesson(db: Db, userId: number, id: number): Promise<void> {
|
||||||
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
||||||
if (!existing) throw ApiError.notFound('Lesson');
|
if (!existing) throw ApiError.notFound('Lesson');
|
||||||
|
if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
|
||||||
const ids = await getDescendantLessonIds(db, id);
|
const ids = await getDescendantLessonIds(db, id);
|
||||||
db.delete(lessons).where(inArray(lessons.id, ids)).run();
|
db.delete(lessons).where(inArray(lessons.id, ids)).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Promise<Lesson> {
|
export async function moveLesson(
|
||||||
|
db: Db, userId: number, id: number, input: LessonMoveInput
|
||||||
|
): Promise<Lesson> {
|
||||||
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
|
||||||
if (!existing) throw ApiError.notFound('Lesson');
|
if (!existing) throw ApiError.notFound('Lesson');
|
||||||
|
if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
|
||||||
if (input.parentId !== null) {
|
if (input.parentId !== null) {
|
||||||
const p = db.select().from(lessons).where(eq(lessons.id, input.parentId)).get();
|
const p = db.select().from(lessons).where(eq(lessons.id, input.parentId)).get();
|
||||||
if (!p) throw ApiError.notFound('Parent lesson');
|
if (!p) throw ApiError.notFound('Parent lesson');
|
||||||
|
if (p.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Target parent is not yours');
|
||||||
let cursor: number | null = input.parentId;
|
let cursor: number | null = input.parentId;
|
||||||
while (cursor !== null) {
|
while (cursor !== null) {
|
||||||
if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant');
|
if (cursor === id) throw ApiError.validation('Cannot move lesson into its own descendant');
|
||||||
@@ -91,23 +109,79 @@ export async function moveLesson(db: Db, id: number, input: LessonMoveInput): Pr
|
|||||||
return rowToLesson(row!);
|
return rowToLesson(row!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLessonTree(db: Db): Promise<LessonTreeNode[]> {
|
export async function getLessonTree(db: Db, userId: number): Promise<LessonTreeNode[]> {
|
||||||
const all = db.select().from(lessons).orderBy(lessons.position).all();
|
const ownerLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all();
|
||||||
const counts = db
|
const subscribedRoots = db.select({
|
||||||
.select({ lessonId: cards.lessonId, count: sql<number>`count(*)`.as('count') })
|
id: lessons.id,
|
||||||
.from(cards)
|
parentId: lessons.parentId,
|
||||||
.groupBy(cards.lessonId)
|
name: lessons.name,
|
||||||
|
description: lessons.description,
|
||||||
|
position: lessons.position,
|
||||||
|
bidirectional: lessons.bidirectional,
|
||||||
|
ownerId: lessons.ownerId,
|
||||||
|
visibility: lessons.visibility,
|
||||||
|
isCurated: lessons.isCurated,
|
||||||
|
sourceLessonId: lessons.sourceLessonId,
|
||||||
|
createdAt: lessons.createdAt,
|
||||||
|
updatedAt: lessons.updatedAt,
|
||||||
|
}).from(lessons)
|
||||||
|
.innerJoin(lessonSubscriptions, eq(lessonSubscriptions.lessonId, lessons.id))
|
||||||
|
.where(eq(lessonSubscriptions.userId, userId))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
|
const allLessons = db.select().from(lessons).all();
|
||||||
|
const byId = new Map(allLessons.map((l) => [l.id, l]));
|
||||||
|
const byParent = new Map<number | null, typeof allLessons>();
|
||||||
|
for (const l of allLessons) {
|
||||||
|
const k = l.parentId ?? null;
|
||||||
|
if (!byParent.has(k)) byParent.set(k, []);
|
||||||
|
byParent.get(k)!.push(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gatherDescendants(rootId: number): typeof allLessons {
|
||||||
|
const out: typeof allLessons = [];
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length) {
|
||||||
|
const cur = stack.pop()!;
|
||||||
|
const node = byId.get(cur);
|
||||||
|
if (node) out.push(node);
|
||||||
|
for (const child of byParent.get(cur) ?? []) stack.push(child.id);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = new Map<number, typeof allLessons[number]>();
|
||||||
|
for (const l of ownerLessons) visible.set(l.id, l);
|
||||||
|
for (const sr of subscribedRoots) {
|
||||||
|
for (const d of gatherDescendants(sr.id)) visible.set(d.id, d);
|
||||||
|
}
|
||||||
|
for (const l of allLessons) {
|
||||||
|
if (l.visibility === 'shared' && l.isCurated) {
|
||||||
|
for (const d of gatherDescendants(l.id)) visible.set(d.id, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 countMap = new Map(counts.map((c) => [c.lessonId, Number(c.count)]));
|
||||||
|
|
||||||
const nodes = new Map<number, LessonTreeNode>();
|
const nodes = new Map<number, LessonTreeNode>();
|
||||||
for (const r of all) {
|
for (const r of visible.values()) {
|
||||||
nodes.set(r.id, { ...rowToLesson(r), children: [], cardCount: countMap.get(r.id) ?? 0 });
|
nodes.set(r.id, { ...rowToLesson(r), children: [], cardCount: countMap.get(r.id) ?? 0 });
|
||||||
}
|
}
|
||||||
const roots: LessonTreeNode[] = [];
|
const roots: LessonTreeNode[] = [];
|
||||||
for (const n of nodes.values()) {
|
for (const n of nodes.values()) {
|
||||||
if (n.parentId === null) roots.push(n);
|
if (n.parentId !== null && nodes.has(n.parentId)) {
|
||||||
else nodes.get(n.parentId)?.children.push(n);
|
nodes.get(n.parentId)!.children.push(n);
|
||||||
|
} else {
|
||||||
|
roots.push(n);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
function sortChildren(arr: LessonTreeNode[]) {
|
||||||
|
arr.sort((a, b) => a.position - b.position || a.id - b.id);
|
||||||
|
for (const c of arr) sortChildren(c.children);
|
||||||
|
}
|
||||||
|
sortChildren(roots);
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,3 +204,33 @@ export async function getDescendantLessonIds(db: Db, rootId: number): Promise<nu
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setLessonVisibility(
|
||||||
|
db: Db, userId: number, lessonId: number, visibility: 'private' | 'shared'
|
||||||
|
): Promise<Lesson> {
|
||||||
|
const existing = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||||
|
if (!existing) throw ApiError.notFound('Lesson');
|
||||||
|
if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
visibility,
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
if (visibility === 'private') patch.isCurated = false;
|
||||||
|
const [row] = db.update(lessons).set(patch).where(eq(lessons.id, lessonId)).returning().all();
|
||||||
|
return rowToLesson(row!);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLessonCurated(
|
||||||
|
db: Db, lessonId: number, isCurated: boolean
|
||||||
|
): Promise<Lesson> {
|
||||||
|
// Caller (route) must ensure sysadmin.
|
||||||
|
const existing = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
|
||||||
|
if (!existing) throw ApiError.notFound('Lesson');
|
||||||
|
const patch: Record<string, unknown> = {
|
||||||
|
isCurated,
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
if (isCurated && existing.visibility !== 'shared') patch.visibility = 'shared';
|
||||||
|
const [row] = db.update(lessons).set(patch).where(eq(lessons.id, lessonId)).returning().all();
|
||||||
|
return rowToLesson(row!);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user