121 KiB
Ownership & Sharing Implementation Plan (Sub-project B)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add per-user ownership, public sharing, subscribe/fork mechanics, and a minimal marketplace browser to the existing authenticated flashcard app.
Architecture: Add owner_id/visibility/is_curated/source_lesson_id columns to lessons, scope card_progress and sessions per-user, and introduce a lesson_subscriptions table. A new permissions service centralises read/edit checks via ancestor-walk. Marketplace lists shared root lessons filtered/paginated; fork duplicates the whole subtree; subscribe is a thin link table. Existing routes get a permission check inserted; new routes cover visibility toggle, subscribe/unsubscribe, fork, marketplace, and sysadmin curation.
Tech Stack: Drizzle ORM (SQLite), Express, Zod, React + Zustand, Vitest + supertest, Playwright + Mailpit. Reuses sub-project A's auth middleware (requireAuth, requireRole, verifyCsrf).
Spec: docs/superpowers/specs/2026-05-20-ownership-and-sharing-design.md
Pre-implementation note: The new ownership columns are nullable at the DB layer to keep the SQLite ALTER TABLE migration simple. Service code always populates them; a backfill step in migrate.ts assigns existing rows to the oldest sysadmin. The spec's "NOT NULL" semantic is enforced at the application layer.
File Structure
flashcard/
├── packages/
│ ├── shared/src/
│ │ ├── types.ts MODIFIED (+ Visibility, MarketplaceLesson, LessonSubscription)
│ │ └── schemas.ts MODIFIED (+ ownership/marketplace zod)
│ ├── backend/src/
│ │ ├── db/
│ │ │ ├── schema.ts MODIFIED (+ ownership fields, subscriptions table)
│ │ │ └── migrate.ts MODIFIED (post-migration backfill)
│ │ ├── services/
│ │ │ ├── permissions.ts NEW
│ │ │ ├── permissions.test.ts NEW
│ │ │ ├── lessons.ts MODIFIED (owner_id, visibility, tree-filter)
│ │ │ ├── cards.ts MODIFIED (perm checks)
│ │ │ ├── sessions.ts MODIFIED (user-scoped)
│ │ │ ├── stats.ts MODIFIED (user-scoped)
│ │ │ ├── fork.ts NEW
│ │ │ ├── fork.test.ts NEW
│ │ │ ├── subscriptions.ts NEW
│ │ │ ├── subscriptions.test.ts NEW
│ │ │ ├── marketplace.ts NEW
│ │ │ └── marketplace.test.ts NEW
│ │ ├── routes/
│ │ │ ├── lessons.ts MODIFIED (visibility, fork, perm checks)
│ │ │ ├── cards.ts MODIFIED (perm checks)
│ │ │ ├── sessions.ts MODIFIED (perm checks)
│ │ │ ├── stats.ts MODIFIED (user scope)
│ │ │ ├── subscriptions.ts NEW
│ │ │ ├── marketplace.ts NEW
│ │ │ └── admin-lessons.ts NEW (curated toggle)
│ │ ├── tests/
│ │ │ ├── dbHelper.ts MODIFIED (createLessonOwnedBy helper)
│ │ │ └── ownership.integration.test.ts NEW
│ │ └── app.ts MODIFIED (mount new routers)
│ └── frontend/src/
│ ├── api/
│ │ ├── lessons.ts MODIFIED (+ visibility/fork/subscribe)
│ │ ├── marketplace.ts NEW
│ │ └── admin-lessons.ts NEW (curated)
│ ├── stores/
│ │ └── lessonsStore.ts MODIFIED (badge data)
│ ├── pages/
│ │ ├── Marketplace.tsx NEW
│ │ ├── AdminLesson.tsx MODIFIED (visibility + readonly)
│ │ └── Dashboard.tsx MODIFIED (subscriptions section)
│ ├── components/
│ │ ├── LessonTree.tsx MODIFIED (badges + readonly)
│ │ └── Layout.tsx MODIFIED (Marketplace link)
│ └── router.tsx MODIFIED (+ /marketplace)
└── e2e/
└── ownership.spec.ts NEW (multi-user via Mailpit)
Task 1: Schema migration — ownership columns + subscriptions table
Files:
-
Modify:
packages/backend/src/db/schema.ts -
Generate:
packages/backend/drizzle/0002_*.sql -
Step 1: Add the new fields to existing tables in
schema.ts
Modify the lessons table definition to include the new columns. Inside the sqliteTable('lessons', { ... }) columns object, ADD after the existing bidirectional field:
ownerId: integer('owner_id').references(() => users.id, { onDelete: 'cascade' }),
visibility: text('visibility', { enum: ['private', 'shared'] }).notNull().default('private'),
isCurated: integer('is_curated', { mode: 'boolean' }).notNull().default(false),
sourceLessonId: integer('source_lesson_id').references((): AnyColumn => lessons.id, { onDelete: 'set null' }),
Note: self-reference on source_lesson_id requires the AnyColumn type from drizzle:
import type { AnyColumn } from 'drizzle-orm';
Add this import at the top of schema.ts if not already present.
Add indexes inside the second argument of the lessons table:
(t) => ({
ownerIdx: index('lessons_owner_idx').on(t.ownerId),
visibilityIdx: index('lessons_visibility_idx').on(t.visibility, t.isCurated),
})
If the existing lessons second-arg block does not exist (it currently has no indexes), add it.
- Step 2: Add
userIdtocardProgress
Inside the sqliteTable('card_progress', { ... }) columns, ADD after the existing nextDueAt:
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
Update the indexes block to add (t) => ({ ..., userIdx: index('card_progress_user_idx').on(t.userId, t.nextDueAt) }).
- Step 3: Add
userIdtosessions
Inside sqliteTable('sessions', { ... }):
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
Add to the indexes block:
userIdx: index('sessions_user_idx').on(t.userId, t.status),
- Step 4: Add new
lesson_subscriptionstable
After the authTokens table definition but before the type exports at the bottom of schema.ts, append:
export const lessonSubscriptions = sqliteTable(
'lesson_subscriptions',
{
id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
lessonId: integer('lesson_id').notNull().references(() => lessons.id, { onDelete: 'cascade' }),
createdAt: integer('created_at').notNull().default(sql`(unixepoch())`),
},
(t) => ({
userIdx: index('lesson_subscriptions_user_idx').on(t.userId),
lessonIdx: index('lesson_subscriptions_lesson_idx').on(t.lessonId),
userLessonUnique: uniqueIndex('lesson_subscriptions_user_lesson_unique').on(t.userId, t.lessonId),
})
);
export type LessonSubscriptionRow = typeof lessonSubscriptions.$inferSelect;
Add uniqueIndex to the top imports:
import { integer, sqliteTable, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
- Step 5: Generate migration
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/backend run db:generate
Expected: new file packages/backend/drizzle/0002_*.sql. Read the file to confirm:
-
ALTER TABLE statements adding columns to
lessons,card_progress,sessions(or rebuild migrations — drizzle decides based on column nullability) -
CREATE TABLE
lesson_subscriptions -
CREATE INDEX statements
-
Step 6: Apply migration to existing DB
DB_PATH=./data/flashcard.db npm -w @flashcard/backend run db:migrate
Expected: Migrations applied. without errors. The new columns exist with defaults; lesson_subscriptions is empty.
- Step 7: Typecheck
npm -w @flashcard/backend run typecheck
Must pass.
- Step 8: Commit
git add packages/backend/src/db/schema.ts packages/backend/drizzle
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(db): ownership columns and lesson_subscriptions table"
Task 2: Shared types & Zod schemas
Files:
-
Modify:
packages/shared/src/types.ts -
Modify:
packages/shared/src/schemas.ts -
Step 1: Extend
types.ts
Replace the existing Lesson interface to include ownership fields:
export type Visibility = 'private' | 'shared';
export interface Lesson {
id: number;
parentId: number | null;
name: string;
description: string | null;
position: number;
bidirectional: boolean;
ownerId: number | null;
visibility: Visibility;
isCurated: boolean;
sourceLessonId: number | null;
createdAt: number;
updatedAt: number;
}
Update LessonTreeNode (it extends Lesson, so it picks up the new fields automatically — verify it still reads children: LessonTreeNode[]; cardCount: number;).
Append new types at the end of the file:
export interface LessonAccess {
canEdit: boolean;
isOwner: boolean;
isSubscribed: boolean;
}
export interface MarketplaceLesson {
id: number;
name: string;
description: string | null;
ownerDisplayName: string;
totalCards: number;
subscribersCount: number;
isCurated: boolean;
isFork: boolean;
createdAt: number;
}
export interface SubscriptionEntry {
lessonId: number;
name: string;
ownerDisplayName: string;
subscribedAt: number;
}
- Step 2: Extend
schemas.ts
Append to packages/shared/src/schemas.ts:
export const lessonVisibilityUpdateSchema = z.object({
visibility: z.enum(['private', 'shared']),
});
export const adminLessonCuratedSchema = z.object({
isCurated: z.boolean(),
});
export const marketplaceQuerySchema = z.object({
q: z.string().trim().max(120).optional(),
curated: z.enum(['true', 'false']).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).optional(),
});
export type LessonVisibilityUpdateInput = z.infer<typeof lessonVisibilityUpdateSchema>;
export type AdminLessonCuratedInput = z.infer<typeof adminLessonCuratedSchema>;
export type MarketplaceQuery = z.infer<typeof marketplaceQuerySchema>;
- Step 3: Typecheck and commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/shared run typecheck
npm -w @flashcard/backend run typecheck
git add packages/shared/src/types.ts packages/shared/src/schemas.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(shared): ownership types and marketplace schemas"
The backend typecheck may now surface errors in services/lessons.ts (rowToLesson doesn't return the new fields). That's expected — Task 5 fixes it. For now, only commit if the SHARED typecheck passes; ignore backend errors until Task 5.
If the backend typecheck errors block other work, temporarily add the missing fields as null in rowToLesson to keep compilation green:
In packages/backend/src/services/lessons.ts, in rowToLesson, ensure the returned object has:
ownerId: r.ownerId ?? null,
visibility: r.visibility,
isCurated: r.isCurated,
sourceLessonId: r.sourceLessonId ?? null,
This is a minimal stub; full ownership logic comes in Task 5.
Task 3: Permissions service (TDD)
Files:
-
Create:
packages/backend/src/services/permissions.ts -
Create:
packages/backend/src/services/permissions.test.ts -
Modify:
packages/backend/src/tests/dbHelper.ts(addcreateLessonOwnedByhelper) -
Step 1: Extend
dbHelper.tswithcreateLessonOwnedBy
Append to packages/backend/src/tests/dbHelper.ts:
import { lessons, lessonSubscriptions } from '../db/schema.js';
import type { LessonRow } from '../db/schema.js';
export async function createLessonOwnedBy(
db: Db,
ownerId: number,
init: { name: string; parentId?: number | null; visibility?: 'private' | 'shared'; isCurated?: boolean; bidirectional?: boolean } = { name: 'Test lesson' }
): Promise<LessonRow> {
const [row] = db.insert(lessons).values({
name: init.name,
parentId: init.parentId ?? null,
ownerId,
visibility: init.visibility ?? 'private',
isCurated: init.isCurated ?? false,
bidirectional: init.bidirectional ?? false,
position: 0,
}).returning().all();
return row!;
}
export async function subscribeUserToLesson(db: Db, userId: number, lessonId: number): Promise<void> {
db.insert(lessonSubscriptions).values({ userId, lessonId }).run();
}
- Step 2: Write the failing tests
Create packages/backend/src/services/permissions.test.ts:
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb, createUserDirect, createLessonOwnedBy, subscribeUserToLesson } from '../tests/dbHelper.js';
import { canEditLesson, canReadLesson } from './permissions.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('permissions', () => {
it('owner can edit and read', async () => {
const u = await createUserDirect(env.db, { email: 'o@example.com' });
const l = await createLessonOwnedBy(env.db, u.id, { name: 'L' });
expect(await canEditLesson(env.db, u.id, l.id)).toBe(true);
expect(await canReadLesson(env.db, u.id, l.id)).toBe(true);
});
it('non-owner cannot edit a private lesson', async () => {
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
const other = await createUserDirect(env.db, { email: 'x@example.com' });
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L' });
expect(await canEditLesson(env.db, other.id, l.id)).toBe(false);
expect(await canReadLesson(env.db, other.id, l.id)).toBe(false);
});
it('subscriber can read but not edit', async () => {
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
const sub = await createUserDirect(env.db, { email: 's@example.com' });
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L', visibility: 'shared' });
await subscribeUserToLesson(env.db, sub.id, l.id);
expect(await canReadLesson(env.db, sub.id, l.id)).toBe(true);
expect(await canEditLesson(env.db, sub.id, l.id)).toBe(false);
});
it('subscriber gains read access to sublessons via ancestor', async () => {
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
const sub = await createUserDirect(env.db, { email: 's@example.com' });
const parent = await createLessonOwnedBy(env.db, owner.id, { name: 'P', visibility: 'shared' });
const child = await createLessonOwnedBy(env.db, owner.id, { name: 'C', parentId: parent.id });
await subscribeUserToLesson(env.db, sub.id, parent.id);
expect(await canReadLesson(env.db, sub.id, child.id)).toBe(true);
});
it('curated lesson is readable for everyone without subscription', async () => {
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
const other = await createUserDirect(env.db, { email: 'x@example.com' });
const l = await createLessonOwnedBy(env.db, owner.id, { name: 'L', visibility: 'shared', isCurated: true });
expect(await canReadLesson(env.db, other.id, l.id)).toBe(true);
expect(await canEditLesson(env.db, other.id, l.id)).toBe(false);
});
it('curated lesson grants read on descendants', async () => {
const owner = await createUserDirect(env.db, { email: 'o@example.com' });
const other = await createUserDirect(env.db, { email: 'x@example.com' });
const parent = await createLessonOwnedBy(env.db, owner.id, { name: 'P', visibility: 'shared', isCurated: true });
const child = await createLessonOwnedBy(env.db, owner.id, { name: 'C', parentId: parent.id });
expect(await canReadLesson(env.db, other.id, child.id)).toBe(true);
});
it('returns false for unknown lesson id', async () => {
const u = await createUserDirect(env.db, { email: 'u@example.com' });
expect(await canReadLesson(env.db, u.id, 9999)).toBe(false);
expect(await canEditLesson(env.db, u.id, 9999)).toBe(false);
});
});
- Step 3: Run — fail
npm -w @flashcard/backend test
Expected: failure (module ./permissions.js not found).
- Step 4: Implement
permissions.ts
import { and, eq, inArray } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { lessons, lessonSubscriptions } from '../db/schema.js';
interface AncestorRow {
id: number;
parentId: number | null;
ownerId: number | null;
visibility: 'private' | 'shared';
isCurated: boolean;
}
async function walkAncestors(db: Db, lessonId: number): Promise<AncestorRow[]> {
const path: AncestorRow[] = [];
let cursor: number | null = lessonId;
const seen = new Set<number>();
while (cursor !== null && !seen.has(cursor)) {
seen.add(cursor);
const row = db.select({
id: lessons.id,
parentId: lessons.parentId,
ownerId: lessons.ownerId,
visibility: lessons.visibility,
isCurated: lessons.isCurated,
}).from(lessons).where(eq(lessons.id, cursor)).get();
if (!row) break;
path.push({
id: row.id,
parentId: row.parentId ?? null,
ownerId: row.ownerId ?? null,
visibility: row.visibility,
isCurated: row.isCurated,
});
cursor = row.parentId ?? null;
}
return path;
}
export async function canEditLesson(db: Db, userId: number, lessonId: number): Promise<boolean> {
const row = db.select({ ownerId: lessons.ownerId }).from(lessons).where(eq(lessons.id, lessonId)).get();
if (!row) return false;
return row.ownerId === userId;
}
export async function canReadLesson(db: Db, userId: number, lessonId: number): Promise<boolean> {
const ancestors = await walkAncestors(db, lessonId);
if (ancestors.length === 0) return false;
// Owner / curated checks (cheap, no extra query)
for (const a of ancestors) {
if (a.ownerId === userId) return true;
if (a.isCurated && a.visibility === 'shared') return true;
}
// Subscription check across ancestor IDs
const ids = ancestors.map((a) => a.id);
const sub = db.select({ id: lessonSubscriptions.id })
.from(lessonSubscriptions)
.where(and(eq(lessonSubscriptions.userId, userId), inArray(lessonSubscriptions.lessonId, ids)))
.get();
return !!sub;
}
export async function getLessonAccessFlags(
db: Db, userId: number, lessonId: number
): Promise<{ canEdit: boolean; isOwner: boolean; isSubscribed: boolean }> {
const row = db.select({ ownerId: lessons.ownerId }).from(lessons).where(eq(lessons.id, lessonId)).get();
const isOwner = !!row && row.ownerId === userId;
const isSubscribed = !!db.select({ id: lessonSubscriptions.id })
.from(lessonSubscriptions)
.where(and(eq(lessonSubscriptions.userId, userId), eq(lessonSubscriptions.lessonId, lessonId)))
.get();
return { canEdit: isOwner, isOwner, isSubscribed };
}
- Step 5: Run — pass
npm -w @flashcard/backend test
Expected: 7 new permissions tests pass.
- Step 6: Commit
git add packages/backend/src/services/permissions.ts packages/backend/src/services/permissions.test.ts packages/backend/src/tests/dbHelper.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(perms): canRead/canEdit with ancestor walk + tests"
Task 4: Lessons service — ownership-aware CRUD
Files:
-
Modify:
packages/backend/src/services/lessons.ts -
Modify:
packages/backend/src/services/lessons.test.ts -
Step 1: Update
rowToLessonto include ownership fields
In services/lessons.ts, replace rowToLesson:
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,
};
}
- Step 2: Update
createLessonto requireuserId
Change the signature:
export async function createLesson(
db: Db,
userId: number,
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');
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 [row] = db.insert(lessons).values({
name: input.name,
parentId,
description: input.description ?? null,
bidirectional: input.bidirectional ?? false,
position,
ownerId: userId,
visibility: 'private',
isCurated: false,
}).returning().all();
return rowToLesson(row!);
}
- Step 3: Update
updateLesson,deleteLesson,moveLessonto enforce ownership
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();
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({
...(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, userId: number, id: number): Promise<void> {
const existing = db.select().from(lessons).where(eq(lessons.id, id)).get();
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);
db.delete(lessons).where(inArray(lessons.id, ids)).run();
}
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();
if (!existing) throw ApiError.notFound('Lesson');
if (existing.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your 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');
if (p.ownerId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Target parent is not yours');
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!);
}
- Step 4: Update
getLessonTreeto scope per user
Replace the full function:
export async function getLessonTree(db: Db, userId: number): Promise<LessonTreeNode[]> {
// Visible lessons: owner OR subscribed root in any ancestor OR curated shared.
// Strategy: pull all candidate lessons in one shot, then filter via canReadLesson.
const ownerLessons = db.select().from(lessons).where(eq(lessons.ownerId, userId)).all();
const subscribedRoots = db.select({
id: lessons.id,
parentId: lessons.parentId,
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();
// Descendants of subscribed roots & curated shared lessons
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);
}
}
// Card counts (per lesson)
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 visible.values()) {
nodes.set(r.id, { ...rowToLesson(r), children: [], cardCount: countMap.get(r.id) ?? 0 });
}
const roots: LessonTreeNode[] = [];
// Visible parent → child relation; if parent not visible, treat node as root.
for (const n of nodes.values()) {
if (n.parentId !== null && nodes.has(n.parentId)) {
nodes.get(n.parentId)!.children.push(n);
} else {
roots.push(n);
}
}
// Stable ordering by position then id
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;
}
Add the lessonSubscriptions import at the top of lessons.ts:
import { lessonSubscriptions } from '../db/schema.js';
- Step 5: Add
setLessonVisibilityandsetLessonCuratedhelpers
Append to lessons.ts:
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');
// Forcing private must also clear is_curated
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),
};
// Curated forces visibility=shared (spec 3.3)
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!);
}
- Step 6: Update existing
lessons.test.tsto passuserId
The existing tests call createLesson(env.db, { name: 'X' }) etc. They now need userId. Refactor each test to first create a user via createUserDirect, then pass that user's id.
Read the current packages/backend/src/services/lessons.test.ts. For each call, replace as follows:
createLesson(env.db, { name: 'A' })→createLesson(env.db, owner.id, { name: 'A' })updateLesson(env.db, l.id, {...})→updateLesson(env.db, owner.id, l.id, {...})deleteLesson(env.db, l.id)→deleteLesson(env.db, owner.id, l.id)moveLesson(env.db, l.id, ...)→moveLesson(env.db, owner.id, l.id, ...)getLessonTree(env.db)→getLessonTree(env.db, owner.id)
Add let owner: { id: number }; at the test file top-level and initialize in each beforeEach via owner = await createUserDirect(env.db, { email: 'owner@example.com' });.
- Step 7: Run tests — they should pass
npm -w @flashcard/backend test
Expected: lessons + permissions tests pass. Many integration/route-level tests may still break — Task 9 will fix those.
- Step 8: Commit
git add packages/backend/src/services/lessons.ts packages/backend/src/services/lessons.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(lessons): ownership-aware CRUD + tree filtering"
Task 5: Cards service — perm-aware
Files:
-
Modify:
packages/backend/src/services/cards.ts -
Modify:
packages/backend/src/services/cards.test.ts -
Step 1: Update signatures in
cards.ts
Replace these functions:
import { canEditLesson, canReadLesson } from './permissions.js';
export async function createCard(
db: Db, userId: number, lessonId: number, input: CardCreateInput
): Promise<Card> {
if (!(await canEditLesson(db, userId, lessonId))) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, lessonId)).all();
const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1;
const [row] = db.insert(cards).values({
lessonId,
question: input.question,
answer: input.answer,
hint: input.hint ?? null,
position,
}).returning().all();
// Progress rows are now per-user; initial owner progress is created lazily on first session use.
return rowToCard(row!);
}
export async function listCardsByLesson(db: Db, userId: number, lessonId: number): Promise<Card[]> {
if (!(await canReadLesson(db, userId, lessonId))) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson');
return db.select().from(cards).where(eq(cards.lessonId, lessonId)).orderBy(cards.position).all().map(rowToCard);
}
export async function getCard(db: Db, userId: number, id: number): Promise<Card> {
const row = db.select().from(cards).where(eq(cards.id, id)).get();
if (!row) throw ApiError.notFound('Card');
if (!(await canReadLesson(db, userId, row.lessonId))) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this card');
return rowToCard(row);
}
export async function updateCard(
db: Db, userId: number, id: number, input: CardUpdateInput
): Promise<Card> {
const existing = db.select().from(cards).where(eq(cards.id, id)).get();
if (!existing) throw ApiError.notFound('Card');
if (!(await canEditLesson(db, userId, existing.lessonId))) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
const [row] = db.update(cards).set({
...(input.question !== undefined && { question: input.question }),
...(input.answer !== undefined && { answer: input.answer }),
...(input.hint !== undefined && { hint: input.hint }),
updatedAt: Math.floor(Date.now() / 1000),
}).where(eq(cards.id, id)).returning().all();
return rowToCard(row!);
}
export async function deleteCard(db: Db, userId: number, id: number): Promise<void> {
const existing = db.select().from(cards).where(eq(cards.id, id)).get();
if (!existing) throw ApiError.notFound('Card');
if (!(await canEditLesson(db, userId, existing.lessonId))) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
db.delete(cards).where(eq(cards.id, id)).run();
}
Remove the old ensureProgress helper (no longer needed at card creation time; per-user progress is now created lazily in the session engine — see Task 6).
Also remove the legacy cardProgress import if it's no longer used.
- Step 2: Update
cards.test.tsto passuserId
For each call replace as follows:
createCard(env.db, l.id, {...})→createCard(env.db, owner.id, l.id, {...})updateCard(env.db, c.id, {...})→updateCard(env.db, owner.id, c.id, {...})deleteCard(env.db, c.id)→deleteCard(env.db, owner.id, c.id)listCardsByLesson(env.db, l.id)→listCardsByLesson(env.db, owner.id, l.id)
Add owner setup in beforeEach as in Task 4.
The existing test "creates two progress rows when lesson is bidirectional" is no longer relevant (progress is per-user, lazy). REPLACE that test with one that just verifies a bidi card is created:
it('creates a card in a bidirectional lesson', async () => {
const lesson = await createLessonOwnedBy(env.db, owner.id, { name: 'L', bidirectional: true });
const card = await createCard(env.db, owner.id, lesson.id, { question: 'Q', answer: 'A' });
expect(card.id).toBeGreaterThan(0);
});
Add import { createLessonOwnedBy } from '../tests/dbHelper.js'; at the top of the test file.
- Step 3: Run tests
npm -w @flashcard/backend test
Expected: cards + permissions + lessons tests pass.
- Step 4: Commit
git add packages/backend/src/services/cards.ts packages/backend/src/services/cards.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(cards): permission-aware CRUD"
Task 6: Sessions service — per-user
Files:
-
Modify:
packages/backend/src/services/sessions.ts -
Modify:
packages/backend/src/services/sessions.test.ts -
Step 1: Update
startSessionto takeuserIdand seed per-user progress lazily
Replace the function:
import { canReadLesson } from './permissions.js';
export async function startSession(
db: Db, userId: number, input: SessionStartInput
): Promise<StartedSession> {
const lesson = db.select().from(lessons).where(eq(lessons.id, input.lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
if (!(await canReadLesson(db, userId, input.lessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot start a session for this lesson');
}
const lessonIds = await getDescendantLessonIds(db, input.lessonId);
const lessonRows = db.select().from(lessons).where(inArray(lessons.id, lessonIds)).all();
const bidirById = new Map(lessonRows.map((l) => [l.id, l.bidirectional]));
const allCards = db.select().from(cards).where(inArray(cards.lessonId, lessonIds)).all();
const direction = input.direction ?? 'forward';
const candidates: QueueItem[] = [];
for (const c of allCards) {
const isBidi = bidirById.get(c.lessonId) === true;
if (direction === 'forward' || direction === 'both') {
candidates.push({ cardId: c.id, direction: 'forward' });
}
if ((direction === 'backward' || direction === 'both') && isBidi) {
candidates.push({ cardId: c.id, direction: 'backward' });
}
}
// Ensure per-user progress rows for each candidate
for (const item of candidates) {
const existing = db.select().from(cardProgress).where(and(
eq(cardProgress.cardId, item.cardId),
eq(cardProgress.direction, item.direction),
eq(cardProgress.userId, userId),
)).get();
if (!existing) {
db.insert(cardProgress).values({
cardId: item.cardId, direction: item.direction,
userId, box: 1, nextDueAt: 0,
}).run();
}
}
const progressRows = allCards.length === 0
? []
: db.select().from(cardProgress)
.where(and(
inArray(cardProgress.cardId, allCards.map((c) => c.id)),
eq(cardProgress.userId, userId),
))
.all();
const progByKey = new Map<string, typeof progressRows[number]>();
for (const p of progressRows) progByKey.set(`${p.cardId}:${p.direction}`, p);
const now = nowSec();
const due: QueueItem[] = [];
const future: QueueItem[] = [];
for (const item of candidates) {
const p = progByKey.get(`${item.cardId}:${item.direction}`)!;
(p.nextDueAt <= now ? due : future).push(item);
}
const shuffle = input.shuffle ?? true;
const sortByBox = (a: QueueItem, b: QueueItem) => {
const pa = progByKey.get(`${a.cardId}:${a.direction}`)!;
const pb = progByKey.get(`${b.cardId}:${b.direction}`)!;
return pa.box - pb.box;
};
if (shuffle) {
shuffleInPlace(due);
shuffleInPlace(future);
}
due.sort(sortByBox);
future.sort(sortByBox);
let queue: QueueItem[] = [...due, ...future];
const max = input.maxCards ?? null;
if (max !== null) queue = queue.slice(0, max);
const [row] = db.insert(sessions).values({
lessonId: input.lessonId,
userId,
queueSnapshot: JSON.stringify({ remaining: queue, index: 0 }),
}).returning().all();
return { session: rowToSession(row!), queue };
}
- Step 2: Update
recordAttempt,getNextItem,getActiveSession,getSessionState,endSession,abandonSessionto takeuserIdand assert ownership
For each of these, fetch the session and assert sess.userId === userId before proceeding:
function assertSessionOwnership(sess: { userId: number | null } | undefined, userId: number) {
if (!sess) throw ApiError.notFound('Session');
if (sess.userId !== userId) throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your session');
}
Then in each function:
export async function getNextItem(db: Db, userId: number, sessionId: number): Promise<QueueItem | null> {
const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
assertSessionOwnership(row, userId);
if (row!.status !== 'active') return null;
const state = readQueue(row!.queueSnapshot);
return state.remaining[state.index] ?? null;
}
export async function recordAttempt(
db: Db, userId: number, sessionId: number, input: AttemptCreateInput
): Promise<void> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
assertSessionOwnership(sess, userId);
if (sess!.status !== 'active') throw ApiError.validation('Session is not active');
const now = nowSec();
db.insert(attempts).values({
sessionId,
cardId: input.cardId,
direction: input.direction,
result: input.result,
timeToAnswerMs: input.timeToAnswerMs ?? null,
}).run();
const prog = db.select().from(cardProgress).where(and(
eq(cardProgress.cardId, input.cardId),
eq(cardProgress.direction, input.direction),
eq(cardProgress.userId, userId),
)).get();
if (prog) {
const delta = applyResult(
{ box: prog.box, correctCount: prog.correctCount, incorrectCount: prog.incorrectCount },
input.result,
now
);
db.update(cardProgress).set({
box: delta.box,
correctCount: delta.correctCount,
incorrectCount: delta.incorrectCount,
nextDueAt: delta.nextDueAt,
lastShownAt: delta.lastShownAt,
}).where(and(
eq(cardProgress.cardId, input.cardId),
eq(cardProgress.direction, input.direction),
eq(cardProgress.userId, userId),
)).run();
}
const state = readQueue(sess!.queueSnapshot);
state.index += 1;
if (input.result === 'incorrect') {
const insertAt = Math.min(state.remaining.length, state.index + REINSERT_OFFSET);
state.remaining.splice(insertAt, 0, { cardId: input.cardId, direction: input.direction });
}
db.update(sessions).set({
queueSnapshot: JSON.stringify(state),
cardsShown: sess!.cardsShown + 1,
cardsCorrect: sess!.cardsCorrect + (input.result === 'correct' ? 1 : 0),
cardsIncorrect: sess!.cardsIncorrect + (input.result === 'incorrect' ? 1 : 0),
}).where(eq(sessions.id, sessionId)).run();
}
export async function endSession(db: Db, userId: number, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
assertSessionOwnership(sess, userId);
const endedAt = nowSec();
const duration = endedAt - sess!.startedAt;
const [row] = db.update(sessions).set({
status: 'completed', endedAt, durationSeconds: duration, queueSnapshot: null,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row!);
}
export async function abandonSession(db: Db, userId: number, sessionId: number): Promise<SessionRow> {
const sess = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
assertSessionOwnership(sess, userId);
const endedAt = nowSec();
const duration = endedAt - sess!.startedAt;
const [row] = db.update(sessions).set({
status: 'abandoned', endedAt, durationSeconds: duration,
}).where(eq(sessions.id, sessionId)).returning().all();
return rowToSession(row!);
}
export async function getActiveSession(db: Db, userId: number): Promise<SessionRow | null> {
const row = db.select().from(sessions)
.where(and(eq(sessions.status, 'active'), eq(sessions.userId, userId)))
.orderBy(sql`${sessions.startedAt} DESC`).get();
return row ? rowToSession(row) : null;
}
export async function getSessionState(
db: Db, userId: number, sessionId: number
): Promise<{ session: SessionRow; queue: QueueItem[]; index: number } | null> {
const row = db.select().from(sessions).where(eq(sessions.id, sessionId)).get();
if (!row) return null;
if (row.userId !== userId) return null;
const state = readQueue(row.queueSnapshot);
return { session: rowToSession(row), queue: state.remaining, index: state.index };
}
- Step 3: Update
sessions.test.ts
Add owner setup. Update all calls similarly:
startSession(env.db, {...})→startSession(env.db, owner.id, {...})getNextItem(env.db, s.session.id)→getNextItem(env.db, owner.id, s.session.id)recordAttempt(env.db, s.session.id, {...})→recordAttempt(env.db, owner.id, s.session.id, {...})endSession(env.db, s.session.id)→endSession(env.db, owner.id, s.session.id)getActiveSession(env.db)→getActiveSession(env.db, owner.id)
Also: createLesson(env.db, ...) → createLesson(env.db, owner.id, ...), createCard(env.db, l.id, ...) → createCard(env.db, owner.id, l.id, ...).
- Step 4: Run tests
npm -w @flashcard/backend test
Expected: pass.
- Step 5: Commit
git add packages/backend/src/services/sessions.ts packages/backend/src/services/sessions.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(sessions): per-user sessions and progress"
Task 7: Stats service — per-user
Files:
-
Modify:
packages/backend/src/services/stats.ts -
Modify:
packages/backend/src/services/stats.test.ts -
Step 1: Update all stats functions to take
userIdand filter
Update signatures and queries:
import { canReadLesson } from './permissions.js';
export async function getCardStats(db: Db, userId: number, cardId: number): Promise<CardStats> {
const card = db.select().from(cards).where(eq(cards.id, cardId)).get();
if (!card) throw ApiError.notFound('Card');
if (!(await canReadLesson(db, userId, card.lessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this card');
}
const prog = db.select().from(cardProgress)
.where(and(eq(cardProgress.cardId, cardId), eq(cardProgress.userId, userId))).all();
// attempts are scoped via session.user_id; join via session ownership
const history = db.select({
shownAt: attempts.shownAt, result: attempts.result, direction: attempts.direction,
}).from(attempts)
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
.where(and(eq(attempts.cardId, cardId), eq(sessions.userId, userId)))
.orderBy(desc(attempts.shownAt)).all();
const forward = prog.find((p) => p.direction === 'forward');
const backward = prog.find((p) => p.direction === 'backward');
const correct = history.filter((h) => h.result === 'correct').length;
return {
cardId,
attempts: history.length,
correct,
incorrect: history.length - correct,
box: { forward: forward?.box ?? 1, backward: backward?.box ?? null },
lastShownAt: forward?.lastShownAt ?? null,
nextDueAt: forward?.nextDueAt ?? 0,
history,
};
}
export async function getLessonStats(db: Db, userId: number, lessonId: number): Promise<LessonStats> {
const lesson = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!lesson) throw ApiError.notFound('Lesson');
if (!(await canReadLesson(db, userId, lessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson');
}
const ids = await getDescendantLessonIds(db, lessonId);
const cardRows = db.select({ id: cards.id }).from(cards).where(inArray(cards.lessonId, ids)).all();
const cardIds = cardRows.map((c) => c.id);
let totalCards = cardIds.length;
let mastered = 0;
let attemptsTotal = 0;
let correctTotal = 0;
let score = 0;
let countedForScore = 0;
if (cardIds.length > 0) {
const prog = db.select().from(cardProgress).where(and(
inArray(cardProgress.cardId, cardIds),
eq(cardProgress.userId, userId),
)).all();
const att = db.select().from(attempts)
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
.where(and(
inArray(attempts.cardId, cardIds),
eq(sessions.userId, userId),
)).all();
attemptsTotal = att.length;
correctTotal = att.filter((a) => a.attempts.result === 'correct').length;
const byCard = new Map<number, typeof prog[number][]>();
for (const p of prog) {
if (!byCard.has(p.cardId)) byCard.set(p.cardId, []);
byCard.get(p.cardId)!.push(p);
}
for (const id of cardIds) {
const ps = byCard.get(id) ?? [];
if (ps.some((p) => p.box >= 4)) mastered += 1;
const total = ps.reduce((s, p) => s + p.correctCount + p.incorrectCount, 0);
const correct = ps.reduce((s, p) => s + p.correctCount, 0);
if (total >= MIN_ATTEMPTS_FOR_SCORE) {
score += correct / total;
countedForScore += 1;
}
}
score = countedForScore === 0 ? 0 : score / countedForScore;
}
const sessRows = db.select({
id: sessions.id, duration: sessions.durationSeconds,
}).from(sessions).where(and(
inArray(sessions.lessonId, ids),
eq(sessions.status, 'completed'),
eq(sessions.userId, userId),
)).all();
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.duration ?? 0), 0);
return {
lessonId, totalCards, mastered, score,
sessions: sessRows.length, totalDurationSeconds,
attempts: attemptsTotal, correct: correctTotal, incorrect: attemptsTotal - correctTotal,
};
}
export async function getOverview(db: Db, userId: number): Promise<Overview> {
const sessRows = db.select().from(sessions)
.where(and(eq(sessions.status, 'completed'), eq(sessions.userId, userId))).all();
const totalDurationSeconds = sessRows.reduce((s, r) => s + (r.durationSeconds ?? 0), 0);
const totalAttempts = db.select({ c: sql<number>`count(*)`.as('c') })
.from(attempts)
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
.where(eq(sessions.userId, userId)).get()?.c ?? 0;
const days = new Set(sessRows.map((s) => dayKeyUTC(s.startedAt)));
let streak = 0;
const cursor = new Date();
for (;;) {
const k = dayKeyUTC(Math.floor(cursor.getTime() / 1000));
if (days.has(k)) { streak += 1; cursor.setUTCDate(cursor.getUTCDate() - 1); }
else break;
}
const recent = db.select({
id: sessions.id, lessonId: sessions.lessonId, startedAt: sessions.startedAt,
durationSeconds: sessions.durationSeconds, cardsShown: sessions.cardsShown, cardsCorrect: sessions.cardsCorrect,
}).from(sessions)
.where(and(eq(sessions.status, 'completed'), eq(sessions.userId, userId)))
.orderBy(desc(sessions.startedAt)).limit(10).all();
return {
totalSessions: sessRows.length,
totalDurationSeconds,
totalAttempts: Number(totalAttempts),
streakDays: streak,
recentSessions: recent.map((r) => ({ ...r, durationSeconds: r.durationSeconds ?? null })),
};
}
export async function getHeatmap(db: Db, userId: number, weeks: number): Promise<HeatmapPoint[]> {
const since = Math.floor(Date.now() / 1000) - weeks * 7 * 24 * 60 * 60;
const sessRows = db.select({ startedAt: sessions.startedAt }).from(sessions)
.where(and(
eq(sessions.status, 'completed'),
eq(sessions.userId, userId),
sql`${sessions.startedAt} >= ${since}`,
)).all();
const attRows = db.select({ shownAt: attempts.shownAt }).from(attempts)
.innerJoin(sessions, eq(sessions.id, attempts.sessionId))
.where(and(eq(sessions.userId, userId), sql`${attempts.shownAt} >= ${since}`)).all();
const map = new Map<string, { sessions: number; attempts: number }>();
for (const s of sessRows) {
const k = dayKeyUTC(s.startedAt);
const m = map.get(k) ?? { sessions: 0, attempts: 0 };
m.sessions += 1; map.set(k, m);
}
for (const a of attRows) {
const k = dayKeyUTC(a.shownAt);
const m = map.get(k) ?? { sessions: 0, attempts: 0 };
m.attempts += 1; map.set(k, m);
}
return Array.from(map.entries()).map(([day, v]) => ({ day, ...v }));
}
- Step 2: Update
stats.test.ts
Add owner setup. Update all calls:
getCardStats(env.db, c.id)→getCardStats(env.db, owner.id, c.id)getLessonStats(env.db, l.id)→getLessonStats(env.db, owner.id, l.id)getOverview(env.db)→getOverview(env.db, owner.id)
And ensure all createLesson/createCard/startSession/etc. calls pass owner.id.
- Step 3: Run tests
npm -w @flashcard/backend test
Expected: pass.
- Step 4: Commit
git add packages/backend/src/services/stats.ts packages/backend/src/services/stats.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(stats): per-user filtering across all aggregations"
Task 8: Subscriptions service + routes (TDD)
Files:
-
Create:
packages/backend/src/services/subscriptions.ts -
Create:
packages/backend/src/services/subscriptions.test.ts -
Create:
packages/backend/src/routes/subscriptions.ts -
Step 1: Write failing tests
// packages/backend/src/services/subscriptions.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
import { subscribe, unsubscribe, listSubscriptions } from './subscriptions.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('subscriptions', () => {
it('subscribes a user to a shared lesson', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
const r = await subscribe(env.db, u.id, l.id);
expect(r.created).toBe(true);
const list = await listSubscriptions(env.db, u.id);
expect(list.find((s) => s.lessonId === l.id)).toBeTruthy();
});
it('refuses to subscribe to a private lesson', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'private' });
await expect(subscribe(env.db, u.id, l.id)).rejects.toThrow(/private|forbidden/i);
});
it('idempotent: second subscribe returns created=false', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
await subscribe(env.db, u.id, l.id);
const r = await subscribe(env.db, u.id, l.id);
expect(r.created).toBe(false);
});
it('unsubscribe removes the row', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
await subscribe(env.db, u.id, l.id);
await unsubscribe(env.db, u.id, l.id);
const list = await listSubscriptions(env.db, u.id);
expect(list).toHaveLength(0);
});
});
- Step 2: Run — fail
npm -w @flashcard/backend test
- Step 3: Implement
subscriptions.ts
import { and, eq } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { lessons, lessonSubscriptions, users } from '../db/schema.js';
import { ApiError } from '../lib/errors.js';
import type { SubscriptionEntry } from '@flashcard/shared';
export async function subscribe(db: Db, userId: number, lessonId: number): Promise<{ created: boolean }> {
const l = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!l) throw ApiError.notFound('Lesson');
if (l.ownerId === userId) {
throw new ApiError(409, 'CANNOT_SUBSCRIBE_OWN', 'Cannot subscribe to your own lesson');
}
if (l.visibility !== 'shared') {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Lesson is not shared');
}
const existing = db.select().from(lessonSubscriptions).where(and(
eq(lessonSubscriptions.userId, userId),
eq(lessonSubscriptions.lessonId, lessonId),
)).get();
if (existing) return { created: false };
db.insert(lessonSubscriptions).values({ userId, lessonId }).run();
return { created: true };
}
export async function unsubscribe(db: Db, userId: number, lessonId: number): Promise<void> {
db.delete(lessonSubscriptions).where(and(
eq(lessonSubscriptions.userId, userId),
eq(lessonSubscriptions.lessonId, lessonId),
)).run();
}
export async function listSubscriptions(db: Db, userId: number): Promise<SubscriptionEntry[]> {
const rows = db.select({
lessonId: lessonSubscriptions.lessonId,
subscribedAt: lessonSubscriptions.createdAt,
name: lessons.name,
ownerDisplayName: users.displayName,
}).from(lessonSubscriptions)
.innerJoin(lessons, eq(lessons.id, lessonSubscriptions.lessonId))
.leftJoin(users, eq(users.id, lessons.ownerId))
.where(eq(lessonSubscriptions.userId, userId))
.all();
return rows.map((r) => ({
lessonId: r.lessonId,
name: r.name,
ownerDisplayName: r.ownerDisplayName ?? '—',
subscribedAt: r.subscribedAt,
}));
}
- Step 4: Implement
routes/subscriptions.ts
import { Router } from 'express';
import type { Db } from '../db/client.js';
import { subscribe, unsubscribe, listSubscriptions } from '../services/subscriptions.js';
export function subscriptionsRouter(db: Db): Router {
const r = Router();
r.post('/lessons/:id/subscribe', async (req, res, next) => {
try {
const result = await subscribe(db, req.user!.id, Number(req.params.id));
res.status(result.created ? 201 : 200).json({ ok: true });
} catch (e) { next(e); }
});
r.delete('/lessons/:id/subscribe', async (req, res, next) => {
try {
await unsubscribe(db, req.user!.id, Number(req.params.id));
res.status(204).end();
} catch (e) { next(e); }
});
r.get('/me/subscriptions', async (req, res, next) => {
try {
res.json(await listSubscriptions(db, req.user!.id));
} catch (e) { next(e); }
});
return r;
}
- Step 5: Run tests + commit
npm -w @flashcard/backend test
git add packages/backend/src/services/subscriptions.ts packages/backend/src/services/subscriptions.test.ts packages/backend/src/routes/subscriptions.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(subs): subscribe/unsubscribe/list service + routes"
Task 9: Fork service + route (TDD)
Files:
-
Create:
packages/backend/src/services/fork.ts -
Create:
packages/backend/src/services/fork.test.ts -
Step 1: Write failing tests
import { describe, it, expect, beforeEach } from 'vitest';
import { eq } from 'drizzle-orm';
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
import { createCard, listCardsByLesson } from './cards.js';
import { lessons } from '../db/schema.js';
import { forkLesson } from './fork.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('fork', () => {
it('forks a single shared lesson with cards', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'shared' });
await createCard(env.db, o.id, l.id, { question: 'q1', answer: 'a1' });
await createCard(env.db, o.id, l.id, { question: 'q2', answer: 'a2' });
const fork = await forkLesson(env.db, u.id, l.id);
expect(fork.ownerId).toBe(u.id);
expect(fork.visibility).toBe('private');
expect(fork.sourceLessonId).toBe(l.id);
expect(fork.parentId).toBeNull();
const forkCards = await listCardsByLesson(env.db, u.id, fork.id);
expect(forkCards).toHaveLength(2);
});
it('forks the whole subtree and rewrites parent_id', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' });
const child = await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id });
await createCard(env.db, o.id, child.id, { question: 'qc', answer: 'ac' });
const fork = await forkLesson(env.db, u.id, root.id);
const allUser = env.db.select().from(lessons).where(eq(lessons.ownerId, u.id)).all();
expect(allUser).toHaveLength(2);
const forkChild = allUser.find((x) => x.id !== fork.id)!;
expect(forkChild.parentId).toBe(fork.id);
expect(forkChild.name).toBe('C');
const childCards = await listCardsByLesson(env.db, u.id, forkChild.id);
expect(childCards).toHaveLength(1);
});
it('rejects forking a private lesson owned by someone else', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, o.id, { name: 'L', visibility: 'private' });
await expect(forkLesson(env.db, u.id, l.id)).rejects.toThrow(/forbid|private/i);
});
it('allows forking own lesson', async () => {
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const l = await createLessonOwnedBy(env.db, u.id, { name: 'L', visibility: 'private' });
const fork = await forkLesson(env.db, u.id, l.id);
expect(fork.ownerId).toBe(u.id);
expect(fork.sourceLessonId).toBe(l.id);
});
});
- Step 2: Implement
fork.ts
import { eq, inArray } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { cards, lessons } from '../db/schema.js';
import { canReadLesson } from './permissions.js';
import { getDescendantLessonIds } from './lessons.js';
import { ApiError } from '../lib/errors.js';
import type { Lesson } 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,
};
}
export async function forkLesson(db: Db, userId: number, lessonId: number): Promise<Lesson> {
const source = db.select().from(lessons).where(eq(lessons.id, lessonId)).get();
if (!source) throw ApiError.notFound('Lesson');
if (!(await canReadLesson(db, userId, lessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot fork a lesson you cannot read');
}
const descendantIds = await getDescendantLessonIds(db, lessonId);
const sourceLessons = db.select().from(lessons).where(inArray(lessons.id, descendantIds)).all();
const sourceCards = db.select().from(cards).where(inArray(cards.lessonId, descendantIds)).all();
// Sort by depth so parents are inserted before children. Compute depth via parent walk.
const idSet = new Set(descendantIds);
function depth(l: typeof sourceLessons[number]): number {
let d = 0;
let cur: number | null = l.parentId ?? null;
while (cur !== null && idSet.has(cur)) {
d += 1;
const next = sourceLessons.find((x) => x.id === cur);
cur = next?.parentId ?? null;
}
return d;
}
const ordered = [...sourceLessons].sort((a, b) => depth(a) - depth(b));
const idMap = new Map<number, number>();
let newRoot: typeof sourceLessons[number] | null = null;
for (const L of ordered) {
const newParent = L.parentId !== null && idMap.has(L.parentId) ? idMap.get(L.parentId)! : null;
const [inserted] = db.insert(lessons).values({
name: L.name,
description: L.description ?? null,
position: L.position,
bidirectional: L.bidirectional,
parentId: newParent,
ownerId: userId,
visibility: 'private',
isCurated: false,
sourceLessonId: L.id,
}).returning().all();
idMap.set(L.id, inserted!.id);
if (L.id === source.id) newRoot = inserted!;
}
for (const C of sourceCards) {
const newLessonId = idMap.get(C.lessonId);
if (!newLessonId) continue;
db.insert(cards).values({
lessonId: newLessonId,
question: C.question,
answer: C.answer,
hint: C.hint ?? null,
position: C.position,
}).run();
}
if (!newRoot) throw new ApiError(500, 'INTERNAL', 'Fork failed: root not produced');
return rowToLesson(newRoot);
}
- Step 3: Run tests + commit
npm -w @flashcard/backend test
git add packages/backend/src/services/fork.ts packages/backend/src/services/fork.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(fork): subtree fork service + tests"
Task 10: Marketplace service + route (TDD)
Files:
-
Create:
packages/backend/src/services/marketplace.ts -
Create:
packages/backend/src/services/marketplace.test.ts -
Create:
packages/backend/src/routes/marketplace.ts -
Step 1: Write failing tests
import { describe, it, expect, beforeEach } from 'vitest';
import { makeTestDb, createUserDirect, createLessonOwnedBy } from '../tests/dbHelper.js';
import { createCard } from '../services/cards.js';
import { listMarketplaceLessons } from './marketplace.js';
let env: ReturnType<typeof makeTestDb>;
beforeEach(() => { env = makeTestDb(); });
describe('marketplace', () => {
it('lists shared roots from other users', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com', displayName: 'Owner' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
await createLessonOwnedBy(env.db, o.id, { name: 'A', visibility: 'shared' });
await createLessonOwnedBy(env.db, o.id, { name: 'B', visibility: 'private' }); // hidden
const r = await listMarketplaceLessons(env.db, u.id, {});
expect(r.rows).toHaveLength(1);
expect(r.rows[0]!.name).toBe('A');
expect(r.rows[0]!.ownerDisplayName).toBe('Owner');
});
it('excludes own lessons', async () => {
const u = await createUserDirect(env.db, { email: 'u@example.com' });
await createLessonOwnedBy(env.db, u.id, { name: 'Mine', visibility: 'shared' });
const r = await listMarketplaceLessons(env.db, u.id, {});
expect(r.rows).toHaveLength(0);
});
it('excludes children of shared roots', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' });
await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id, visibility: 'shared' });
const r = await listMarketplaceLessons(env.db, u.id, {});
expect(r.rows.find((x) => x.name === 'C')).toBeUndefined();
});
it('counts cards recursively over subtree', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
const root = await createLessonOwnedBy(env.db, o.id, { name: 'R', visibility: 'shared' });
const child = await createLessonOwnedBy(env.db, o.id, { name: 'C', parentId: root.id });
await createCard(env.db, o.id, root.id, { question: 'q1', answer: 'a' });
await createCard(env.db, o.id, child.id, { question: 'q2', answer: 'a' });
const r = await listMarketplaceLessons(env.db, u.id, {});
expect(r.rows[0]!.totalCards).toBe(2);
});
it('curated filter and sorting', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
await createLessonOwnedBy(env.db, o.id, { name: 'Plain', visibility: 'shared' });
await createLessonOwnedBy(env.db, o.id, { name: 'Star', visibility: 'shared', isCurated: true });
const r = await listMarketplaceLessons(env.db, u.id, {});
// curated first
expect(r.rows[0]!.name).toBe('Star');
const curated = await listMarketplaceLessons(env.db, u.id, { curated: 'true' });
expect(curated.rows).toHaveLength(1);
expect(curated.rows[0]!.name).toBe('Star');
});
it('q filter is case-insensitive on name', async () => {
const o = await createUserDirect(env.db, { email: 'o@example.com' });
const u = await createUserDirect(env.db, { email: 'u@example.com' });
await createLessonOwnedBy(env.db, o.id, { name: 'Spaans', visibility: 'shared' });
await createLessonOwnedBy(env.db, o.id, { name: 'Frans', visibility: 'shared' });
const r = await listMarketplaceLessons(env.db, u.id, { q: 'span' });
expect(r.rows).toHaveLength(1);
expect(r.rows[0]!.name).toBe('Spaans');
});
});
- Step 2: Implement
marketplace.ts
import { and, eq, inArray, ne, sql } from 'drizzle-orm';
import type { Db } from '../db/client.js';
import { cards, lessons, lessonSubscriptions, users } from '../db/schema.js';
import type { MarketplaceLesson, MarketplaceQuery } from '@flashcard/shared';
export interface MarketplaceResult { rows: MarketplaceLesson[]; total: number; }
export async function listMarketplaceLessons(
db: Db, userId: number, params: MarketplaceQuery
): Promise<MarketplaceResult> {
const allShared = db.select().from(lessons).where(eq(lessons.visibility, 'shared')).all();
const byId = new Map(allShared.map((l) => [l.id, l]));
// A lesson is a marketplace-root if its parent is NOT also a shared lesson.
// (We don't check subscriptions/curated of the *user* here — marketplace shows
// roots within the shared graph.)
const sharedIds = new Set(allShared.map((l) => l.id));
// Build candidate list
let candidates = allShared.filter((l) =>
l.ownerId !== userId
&& (l.parentId === null || !sharedIds.has(l.parentId))
);
if (params.curated === 'true') {
candidates = candidates.filter((l) => l.isCurated === true);
}
if (params.q && params.q.trim() !== '') {
const q = params.q.trim().toLowerCase();
candidates = candidates.filter((l) =>
l.name.toLowerCase().includes(q)
|| (l.description ?? '').toLowerCase().includes(q)
);
}
// Sort: curated first, then subscribersCount desc, then createdAt desc.
const subsCount = db.select({ lessonId: lessonSubscriptions.lessonId, c: sql<number>`count(*)`.as('c') })
.from(lessonSubscriptions).groupBy(lessonSubscriptions.lessonId).all();
const subsByLesson = new Map(subsCount.map((s) => [s.lessonId, Number(s.c)]));
candidates.sort((a, b) => {
if (a.isCurated !== b.isCurated) return a.isCurated ? -1 : 1;
const sa = subsByLesson.get(a.id) ?? 0;
const sb = subsByLesson.get(b.id) ?? 0;
if (sa !== sb) return sb - sa;
return b.createdAt - a.createdAt;
});
const total = candidates.length;
const offset = params.offset ?? 0;
const limit = params.limit ?? 50;
const page = candidates.slice(offset, offset + limit);
// For each candidate, compute totalCards over its subtree.
// Gather descendants for the page only (cheap enough for SQLite at small scale).
const ownerIds = Array.from(new Set(page.map((l) => l.ownerId).filter((id): id is number => id !== null && id !== undefined)));
const ownersRows = ownerIds.length === 0 ? [] :
db.select({ id: users.id, displayName: users.displayName })
.from(users).where(inArray(users.id, ownerIds)).all();
const ownerMap = new Map(ownersRows.map((u) => [u.id, u.displayName]));
const rows: MarketplaceLesson[] = [];
for (const l of page) {
// BFS subtree
const descendantIds: number[] = [l.id];
const queue = [l.id];
while (queue.length) {
const cur = queue.shift()!;
const children = db.select({ id: lessons.id })
.from(lessons).where(eq(lessons.parentId, cur)).all();
for (const c of children) {
descendantIds.push(c.id);
queue.push(c.id);
}
}
const cardCountRow = db.select({ c: sql<number>`count(*)`.as('c') })
.from(cards).where(inArray(cards.lessonId, descendantIds)).get();
rows.push({
id: l.id,
name: l.name,
description: l.description ?? null,
ownerDisplayName: ownerMap.get(l.ownerId ?? -1) ?? '—',
totalCards: Number(cardCountRow?.c ?? 0),
subscribersCount: subsByLesson.get(l.id) ?? 0,
isCurated: l.isCurated,
isFork: l.sourceLessonId !== null && l.sourceLessonId !== undefined,
createdAt: l.createdAt,
});
}
return { rows, total };
}
- Step 3: Implement
routes/marketplace.ts
import { Router } from 'express';
import { marketplaceQuerySchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import { listMarketplaceLessons } from '../services/marketplace.js';
export function marketplaceRouter(db: Db): Router {
const r = Router();
r.get('/lessons', async (req, res, next) => {
try {
const params = marketplaceQuerySchema.parse(req.query);
res.json(await listMarketplaceLessons(db, req.user!.id, params));
} catch (e) { next(e); }
});
return r;
}
- Step 4: Run tests + commit
npm -w @flashcard/backend test
git add packages/backend/src/services/marketplace.ts packages/backend/src/services/marketplace.test.ts packages/backend/src/routes/marketplace.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(marketplace): list shared roots with filters + sort + pagination"
Task 11: Routes — update existing + add new
Files:
-
Modify:
packages/backend/src/routes/lessons.ts -
Modify:
packages/backend/src/routes/cards.ts -
Modify:
packages/backend/src/routes/sessions.ts -
Modify:
packages/backend/src/routes/stats.ts -
Create:
packages/backend/src/routes/admin-lessons.ts -
Step 1: Update
routes/lessons.ts
Replace contents:
import { Router } from 'express';
import {
lessonCreateSchema, lessonMoveSchema, lessonUpdateSchema, lessonVisibilityUpdateSchema,
} from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
createLesson, deleteLesson, getLessonTree, moveLesson, setLessonVisibility, updateLesson,
} from '../services/lessons.js';
import { forkLesson } from '../services/fork.js';
export function lessonsRouter(db: Db): Router {
const r = Router();
r.get('/tree', async (req, res, next) => {
try { res.json(await getLessonTree(db, req.user!.id)); } catch (e) { next(e); }
});
r.post('/', async (req, res, next) => {
try {
const input = lessonCreateSchema.parse(req.body);
res.status(201).json(await createLesson(db, req.user!.id, input));
} catch (e) { next(e); }
});
r.patch('/:id', async (req, res, next) => {
try {
const input = lessonUpdateSchema.parse(req.body);
res.json(await updateLesson(db, req.user!.id, Number(req.params.id), input));
} catch (e) { next(e); }
});
r.delete('/:id', async (req, res, next) => {
try {
await deleteLesson(db, req.user!.id, Number(req.params.id));
res.status(204).end();
} catch (e) { next(e); }
});
r.post('/:id/move', async (req, res, next) => {
try {
const input = lessonMoveSchema.parse(req.body);
res.json(await moveLesson(db, req.user!.id, Number(req.params.id), input));
} catch (e) { next(e); }
});
r.patch('/:id/visibility', async (req, res, next) => {
try {
const input = lessonVisibilityUpdateSchema.parse(req.body);
res.json(await setLessonVisibility(db, req.user!.id, Number(req.params.id), input.visibility));
} catch (e) { next(e); }
});
r.post('/:id/fork', async (req, res, next) => {
try {
res.status(201).json(await forkLesson(db, req.user!.id, Number(req.params.id)));
} catch (e) { next(e); }
});
return r;
}
- Step 2: Update
routes/cards.ts
Replace existing handlers to thread req.user!.id:
import { Router } from 'express';
import multer from 'multer';
import { cardCreateSchema, cardUpdateSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import { createCard, deleteCard, getCard, listCardsByLesson, updateCard } from '../services/cards.js';
import { ApiError } from '../lib/errors.js';
import { exportCardsToBuffer, importCardsFromBuffer } from '../services/import.js';
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } });
export function cardsRouter(db: Db): Router {
const r = Router({ mergeParams: true });
r.get('/lessons/:lessonId/cards', async (req, res, next) => {
try { res.json(await listCardsByLesson(db, req.user!.id, Number(req.params.lessonId))); } catch (e) { next(e); }
});
r.post('/lessons/:lessonId/cards', async (req, res, next) => {
try {
const input = cardCreateSchema.parse(req.body);
res.status(201).json(await createCard(db, req.user!.id, Number(req.params.lessonId), input));
} catch (e) { next(e); }
});
r.get('/cards/:id', async (req, res, next) => {
try { res.json(await getCard(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); }
});
r.patch('/cards/:id', async (req, res, next) => {
try {
const input = cardUpdateSchema.parse(req.body);
res.json(await updateCard(db, req.user!.id, Number(req.params.id), input));
} catch (e) { next(e); }
});
r.delete('/cards/:id', async (req, res, next) => {
try { await deleteCard(db, req.user!.id, Number(req.params.id)); res.status(204).end(); } catch (e) { next(e); }
});
r.post('/lessons/:lessonId/cards/import', upload.single('file'), async (req, res, next) => {
try {
if (!req.file) throw ApiError.validation('file is required');
const updateExisting = req.body.updateExisting !== 'false';
const createMissingLessons = req.body.createMissingLessons === 'true';
const result = await importCardsFromBuffer(
db, req.user!.id, Number(req.params.lessonId), req.file.buffer,
{ updateExisting, createMissingLessons }
);
res.json(result);
} catch (e) { next(e); }
});
r.get('/lessons/:lessonId/cards/export', async (req, res, next) => {
try {
const includeDescendants = req.query.include_descendants === 'true';
const buf = await exportCardsToBuffer(db, req.user!.id, Number(req.params.lessonId), includeDescendants);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="cards-lesson-${req.params.lessonId}.xlsx"`);
res.send(buf);
} catch (e) { next(e); }
});
return r;
}
This requires importCardsFromBuffer and exportCardsToBuffer to accept userId. Update packages/backend/src/services/import.ts:
import { canEditLesson, canReadLesson } from './permissions.js';
export async function importCardsFromBuffer(
db: Db, userId: number, defaultLessonId: number, buffer: Buffer, opts: ImportOptions
): Promise<ImportResult> {
if (!(await canEditLesson(db, userId, defaultLessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson');
}
// ... existing implementation unchanged ...
}
export async function exportCardsToBuffer(
db: Db, userId: number, lessonId: number, includeDescendants: boolean
): Promise<Buffer> {
if (!(await canReadLesson(db, userId, lessonId))) {
throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson');
}
// ... existing implementation unchanged ...
}
Add the missing imports (canEditLesson, canReadLesson, ApiError).
Also update packages/backend/src/services/import.test.ts to pass a userId (via createUserDirect + createLessonOwnedBy).
- Step 3: Update
routes/sessions.ts
import { Router } from 'express';
import { attemptCreateSchema, sessionStartSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import {
abandonSession, endSession, getActiveSession, getNextItem, getSessionState,
recordAttempt, startSession,
} from '../services/sessions.js';
import { ApiError } from '../lib/errors.js';
export function sessionsRouter(db: Db): Router {
const r = Router();
r.post('/', async (req, res, next) => {
try {
const input = sessionStartSchema.parse(req.body);
res.status(201).json(await startSession(db, req.user!.id, input));
} catch (e) { next(e); }
});
r.get('/active', async (req, res, next) => {
try { res.json(await getActiveSession(db, req.user!.id)); } catch (e) { next(e); }
});
r.get('/:id', async (req, res, next) => {
try {
const state = await getSessionState(db, req.user!.id, Number(req.params.id));
if (!state) throw ApiError.notFound('Session');
res.json(state);
} catch (e) { next(e); }
});
r.get('/:id/next', async (req, res, next) => {
try {
const item = await getNextItem(db, req.user!.id, Number(req.params.id));
if (!item) { res.json({ done: true }); return; }
res.json({ done: false, item });
} catch (e) { next(e); }
});
r.post('/:id/attempts', async (req, res, next) => {
try {
const input = attemptCreateSchema.parse(req.body);
await recordAttempt(db, req.user!.id, Number(req.params.id), input);
res.status(204).end();
} catch (e) { next(e); }
});
r.post('/:id/end', async (req, res, next) => {
try { res.json(await endSession(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); }
});
r.post('/:id/abandon', async (req, res, next) => {
try { res.json(await abandonSession(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); }
});
return r;
}
- Step 4: Update
routes/stats.ts
import { Router } from 'express';
import type { Db } from '../db/client.js';
import { getCardStats, getHeatmap, getLessonStats, getOverview } from '../services/stats.js';
export function statsRouter(db: Db): Router {
const r = Router();
r.get('/overview', async (req, res, next) => {
try { res.json(await getOverview(db, req.user!.id)); } catch (e) { next(e); }
});
r.get('/lessons/:id', async (req, res, next) => {
try { res.json(await getLessonStats(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); }
});
r.get('/cards/:id', async (req, res, next) => {
try { res.json(await getCardStats(db, req.user!.id, Number(req.params.id))); } catch (e) { next(e); }
});
r.get('/heatmap', async (req, res, next) => {
try {
const weeks = Math.min(52, Math.max(1, Number(req.query.weeks ?? 12)));
res.json(await getHeatmap(db, req.user!.id, weeks));
} catch (e) { next(e); }
});
return r;
}
- Step 5: Create
routes/admin-lessons.ts
import { Router } from 'express';
import { adminLessonCuratedSchema } from '@flashcard/shared';
import type { Db } from '../db/client.js';
import { setLessonCurated } from '../services/lessons.js';
export function adminLessonsRouter(db: Db): Router {
const r = Router();
r.patch('/:id/curated', async (req, res, next) => {
try {
const input = adminLessonCuratedSchema.parse(req.body);
res.json(await setLessonCurated(db, Number(req.params.id), input.isCurated));
} catch (e) { next(e); }
});
return r;
}
- Step 6: Typecheck + commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/backend run typecheck
git add packages/backend/src/routes/ packages/backend/src/services/import.ts packages/backend/src/services/import.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(routes): thread user id through all routes + new visibility/fork/curated endpoints"
Task 12: Wire new routers + migration backfill
Files:
-
Modify:
packages/backend/src/app.ts -
Modify:
packages/backend/src/db/migrate.ts -
Step 1: Update
app.tsto mount new routers
Add imports near the top:
import { subscriptionsRouter } from './routes/subscriptions.js';
import { marketplaceRouter } from './routes/marketplace.js';
import { adminLessonsRouter } from './routes/admin-lessons.js';
Mount them inside createApp. After the existing /api/admin/users mount, add:
app.use('/api/admin/lessons', requireAuth, requireRole('sysadmin'), verifyCsrf, adminLessonsRouter(db));
app.use('/api', requireAuth, verifyCsrf, subscriptionsRouter(db));
app.use('/api/marketplace', requireAuth, marketplaceRouter(db));
The subscriptionsRouter registers paths like /lessons/:id/subscribe and /me/subscriptions — they live under /api to match the cardsRouter pattern.
- Step 2: Add backfill step to
migrate.ts
Replace packages/backend/src/db/migrate.ts with:
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import { resolve } from 'node:path';
import { sql } from 'drizzle-orm';
import { createDb } from './client.js';
const { db, sqlite } = createDb();
migrate(db, { migrationsFolder: resolve(import.meta.dirname, '../../drizzle') });
// Post-migration backfill: assign orphan rows to the oldest active sysadmin.
// This makes ownership migration idempotent and safe on fresh DBs (no-op if no users).
function backfill() {
const sysadmin = sqlite.prepare(`
SELECT id FROM users
WHERE role='sysadmin' AND is_active=1
ORDER BY id ASC LIMIT 1
`).get() as { id: number } | undefined;
if (!sysadmin) {
console.log('Backfill: no active sysadmin yet; skipping (this is normal on a fresh DB).');
return;
}
const uid = sysadmin.id;
const r1 = sqlite.prepare(`UPDATE lessons SET owner_id = ? WHERE owner_id IS NULL`).run(uid);
const r2 = sqlite.prepare(`UPDATE sessions SET user_id = ? WHERE user_id IS NULL`).run(uid);
const r3 = sqlite.prepare(`UPDATE card_progress SET user_id = ? WHERE user_id IS NULL`).run(uid);
if (r1.changes || r2.changes || r3.changes) {
console.log(`Backfill: assigned ${r1.changes} lessons, ${r2.changes} sessions, ${r3.changes} card_progress rows to sysadmin id=${uid}.`);
}
}
backfill();
sqlite.close();
console.log('Migrations applied.');
- Step 3: Run migration + typecheck + tests
DB_PATH=./data/flashcard.db npm -w @flashcard/backend run db:migrate
npm -w @flashcard/backend run typecheck
NODE_ENV=test npm -w @flashcard/backend test 2>&1 | tail -10
Expected: migration logs zero or no backfills (depending on state), typecheck clean, tests pass.
- Step 4: Commit
git add packages/backend/src/app.ts packages/backend/src/db/migrate.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(app): mount sharing routes + post-migration backfill"
Task 13: Ownership integration tests
Files:
-
Create:
packages/backend/src/tests/ownership.integration.test.ts -
Step 1: Write comprehensive multi-user integration tests
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { createApp } from '../app.js';
import { makeTestDb, createUserDirect } from './dbHelper.js';
import { hashPassword } from '../services/auth/passwords.js';
import { setMailerForTests, type Mailer } from '../services/auth/email.js';
class StubMailer implements Mailer { async send() {} }
async function login(app: ReturnType<typeof createApp>, email: string, password = 'secretpass') {
const r = await request(app).post('/api/auth/login').send({ email, password });
if (r.status !== 200) throw new Error(`login failed: ${r.status}`);
const cookies = r.headers['set-cookie'] as unknown as string[];
const csrf = cookies.find((c) => c.startsWith('flashcard_csrf='))!.split(';')[0]!.split('=')[1]!;
return { cookies, csrf };
}
async function makeActiveUser(env: ReturnType<typeof makeTestDb>, email: string, role: 'user'|'sysadmin' = 'user') {
return createUserDirect(env.db, {
email, role, isActive: true,
passwordHash: await hashPassword('secretpass'),
emailVerifiedAt: Math.floor(Date.now() / 1000),
});
}
let env: ReturnType<typeof makeTestDb>;
let app: ReturnType<typeof createApp>;
beforeEach(async () => {
env = makeTestDb();
setMailerForTests(new StubMailer());
app = createApp(env.db);
});
describe('ownership & sharing integration', () => {
it('user B cannot read user A private lesson', async () => {
await makeActiveUser(env, 'a@example.com');
await makeActiveUser(env, 'b@example.com');
const aAuth = await login(app, 'a@example.com');
const newL = await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ name: 'Privé' });
expect(newL.status).toBe(201);
const bAuth = await login(app, 'b@example.com');
const tree = await request(app).get('/api/lessons/tree').set('Cookie', bAuth.cookies);
expect(tree.status).toBe(200);
expect(tree.body).toHaveLength(0);
const card = await request(app).get(`/api/cards/9999`).set('Cookie', bAuth.cookies);
expect(card.status).toBe(404);
});
it('B finds A shared lesson in marketplace and subscribes', async () => {
await makeActiveUser(env, 'a@example.com');
await makeActiveUser(env, 'b@example.com');
const aAuth = await login(app, 'a@example.com');
const created = await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ name: 'Spaans' });
const aLesson = created.body;
await request(app).patch(`/api/lessons/${aLesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ visibility: 'shared' });
const bAuth = await login(app, 'b@example.com');
const mk = await request(app).get('/api/marketplace/lessons').set('Cookie', bAuth.cookies);
expect(mk.status).toBe(200);
expect(mk.body.rows.find((r: { name: string }) => r.name === 'Spaans')).toBeTruthy();
const sub = await request(app).post(`/api/lessons/${aLesson.id}/subscribe`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf);
expect(sub.status).toBe(201);
const tree = await request(app).get('/api/lessons/tree').set('Cookie', bAuth.cookies);
expect(tree.body.find((n: { id: number }) => n.id === aLesson.id)).toBeTruthy();
});
it('B forks an A shared lesson and edits independently', async () => {
await makeActiveUser(env, 'a@example.com');
await makeActiveUser(env, 'b@example.com');
const aAuth = await login(app, 'a@example.com');
const aLesson = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ name: 'Spaans' })).body;
await request(app).patch(`/api/lessons/${aLesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ visibility: 'shared' });
await request(app).post(`/api/lessons/${aLesson.id}/cards`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ question: 'hola', answer: 'hello' });
const bAuth = await login(app, 'b@example.com');
const fork = await request(app).post(`/api/lessons/${aLesson.id}/fork`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf);
expect(fork.status).toBe(201);
expect(fork.body.ownerId).not.toBeNull();
expect(fork.body.sourceLessonId).toBe(aLesson.id);
expect(fork.body.visibility).toBe('private');
// B can edit the forked card freely
const bCards = (await request(app).get(`/api/lessons/${fork.body.id}/cards`).set('Cookie', bAuth.cookies)).body;
expect(bCards).toHaveLength(1);
const editR = await request(app).patch(`/api/cards/${bCards[0].id}`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf)
.send({ answer: 'BUENOS' });
expect(editR.status).toBe(200);
// A's original is unaffected
const aCards = (await request(app).get(`/api/lessons/${aLesson.id}/cards`).set('Cookie', aAuth.cookies)).body;
expect(aCards[0].answer).toBe('hello');
});
it('sysadmin can mark lesson as curated; all users see it without subscribing', async () => {
await makeActiveUser(env, 'admin@example.com', 'sysadmin');
await makeActiveUser(env, 'user@example.com');
const adminAuth = await login(app, 'admin@example.com');
const lesson = (await request(app).post('/api/lessons').set('Cookie', adminAuth.cookies).set('x-csrf-token', adminAuth.csrf)
.send({ name: 'Officieel' })).body;
await request(app).patch(`/api/admin/lessons/${lesson.id}/curated`).set('Cookie', adminAuth.cookies).set('x-csrf-token', adminAuth.csrf)
.send({ isCurated: true });
const uAuth = await login(app, 'user@example.com');
const tree = await request(app).get('/api/lessons/tree').set('Cookie', uAuth.cookies);
expect(tree.body.find((n: { id: number }) => n.id === lesson.id)).toBeTruthy();
});
it('regular user cannot mark lesson as curated', async () => {
await makeActiveUser(env, 'u@example.com');
const uAuth = await login(app, 'u@example.com');
const lesson = (await request(app).post('/api/lessons').set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf)
.send({ name: 'X' })).body;
const r = await request(app).patch(`/api/admin/lessons/${lesson.id}/curated`).set('Cookie', uAuth.cookies).set('x-csrf-token', uAuth.csrf)
.send({ isCurated: true });
expect(r.status).toBe(403);
});
it('subscribers cannot edit but can practice; per-user progress is isolated', async () => {
await makeActiveUser(env, 'a@example.com');
await makeActiveUser(env, 'b@example.com');
const aAuth = await login(app, 'a@example.com');
const lesson = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ name: 'L' })).body;
await request(app).patch(`/api/lessons/${lesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ visibility: 'shared' });
const card = (await request(app).post(`/api/lessons/${lesson.id}/cards`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ question: 'q', answer: 'a' })).body;
const bAuth = await login(app, 'b@example.com');
await request(app).post(`/api/lessons/${lesson.id}/subscribe`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf);
// B cannot edit
const bad = await request(app).patch(`/api/cards/${card.id}`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf)
.send({ answer: 'X' });
expect(bad.status).toBe(403);
// B starts session and records an attempt — stats are scoped to B
const sess = (await request(app).post('/api/sessions').set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf)
.send({ lessonId: lesson.id, shuffle: false })).body;
const next = (await request(app).get(`/api/sessions/${sess.session.id}/next`).set('Cookie', bAuth.cookies)).body;
await request(app).post(`/api/sessions/${sess.session.id}/attempts`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf)
.send({ cardId: next.item.cardId, direction: 'forward', result: 'correct' });
await request(app).post(`/api/sessions/${sess.session.id}/end`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf);
// A's overview has 0 sessions; B's has 1
const aOv = (await request(app).get('/api/stats/overview').set('Cookie', aAuth.cookies)).body;
const bOv = (await request(app).get('/api/stats/overview').set('Cookie', bAuth.cookies)).body;
expect(aOv.totalSessions).toBe(0);
expect(bOv.totalSessions).toBe(1);
});
it('flipping visibility back to private clears is_curated and revokes subscriber read', async () => {
await makeActiveUser(env, 'a@example.com', 'sysadmin');
await makeActiveUser(env, 'b@example.com');
const aAuth = await login(app, 'a@example.com');
const lesson = (await request(app).post('/api/lessons').set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ name: 'L' })).body;
await request(app).patch(`/api/admin/lessons/${lesson.id}/curated`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ isCurated: true });
const bAuth = await login(app, 'b@example.com');
await request(app).post(`/api/lessons/${lesson.id}/subscribe`).set('Cookie', bAuth.cookies).set('x-csrf-token', bAuth.csrf);
// back to private
const flip = await request(app).patch(`/api/lessons/${lesson.id}/visibility`).set('Cookie', aAuth.cookies).set('x-csrf-token', aAuth.csrf)
.send({ visibility: 'private' });
expect(flip.body.isCurated).toBe(false);
// B can no longer see it in their tree (subscription still exists, but canRead now requires owner/shared+curated)
const tree = await request(app).get('/api/lessons/tree').set('Cookie', bAuth.cookies);
expect(tree.body.find((n: { id: number }) => n.id === lesson.id)).toBeUndefined();
});
});
- Step 2: Run + commit
NODE_ENV=test npm -w @flashcard/backend test 2>&1 | tail -10
git add packages/backend/src/tests/ownership.integration.test.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "test(ownership): multi-user integration coverage"
Task 14: Frontend API modules
Files:
-
Modify:
packages/frontend/src/api/lessons.ts -
Create:
packages/frontend/src/api/marketplace.ts -
Create:
packages/frontend/src/api/admin-lessons.ts -
Step 1: Extend
api/lessons.ts
Replace contents:
import type {
Lesson, LessonCreateInput, LessonMoveInput, LessonTreeNode, LessonUpdateInput,
Visibility, SubscriptionEntry,
} from '@flashcard/shared';
import { api } from './client.js';
export const lessonsApi = {
tree: () => api.get<LessonTreeNode[]>('/lessons/tree'),
create: (input: LessonCreateInput) => api.post<Lesson>('/lessons', input),
update: (id: number, input: LessonUpdateInput) => api.patch<Lesson>(`/lessons/${id}`, input),
remove: (id: number) => api.delete<void>(`/lessons/${id}`),
move: (id: number, input: LessonMoveInput) => api.post<Lesson>(`/lessons/${id}/move`, input),
setVisibility: (id: number, visibility: Visibility) =>
api.patch<Lesson>(`/lessons/${id}/visibility`, { visibility }),
fork: (id: number) => api.post<Lesson>(`/lessons/${id}/fork`),
subscribe: (id: number) => api.post<{ ok: true }>(`/lessons/${id}/subscribe`),
unsubscribe: (id: number) => api.delete<void>(`/lessons/${id}/subscribe`),
mySubscriptions: () => api.get<SubscriptionEntry[]>('/me/subscriptions'),
};
- Step 2: Create
api/marketplace.ts
import type { MarketplaceLesson } from '@flashcard/shared';
import { api } from './client.js';
export interface MarketplaceListResponse { rows: MarketplaceLesson[]; total: number; }
export const marketplaceApi = {
list: (params: { q?: string; curated?: boolean; limit?: number; offset?: number } = {}) => {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.curated !== undefined) qs.set('curated', String(params.curated));
if (params.limit !== undefined) qs.set('limit', String(params.limit));
if (params.offset !== undefined) qs.set('offset', String(params.offset));
const s = qs.toString();
return api.get<MarketplaceListResponse>(`/marketplace/lessons${s ? '?' + s : ''}`);
},
};
- Step 3: Create
api/admin-lessons.ts
import type { Lesson } from '@flashcard/shared';
import { api } from './client.js';
export const adminLessonsApi = {
setCurated: (id: number, isCurated: boolean) =>
api.patch<Lesson>(`/admin/lessons/${id}/curated`, { isCurated }),
};
- Step 4: Typecheck + commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/frontend run typecheck
git add packages/frontend/src/api/
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): API for visibility/fork/subscribe/marketplace/curated"
Task 15: LessonTree badges + Layout marketplace link
Files:
-
Modify:
packages/frontend/src/components/LessonTree.tsx -
Modify:
packages/frontend/src/components/Layout.tsx -
Step 1: Update
LessonTree.tsxto show badges and respect ownership
Read the current file. Modify the tree row to include badges and to disable actions for non-owners. After <Link to={...}> block, add badge spans. Replace the action buttons with a guard that hides them when the user is not the owner.
Add a helper at the top of the file:
import { useAuth } from '../stores/authStore.js';
Inside the component, fetch the current user:
const currentUserId = useAuth((s) => s.user?.id);
Then in the row:
{nodes.map((n) => {
const isOwner = n.ownerId === currentUserId;
const visibilityBadge =
n.isCurated ? '⭐ Curated'
: n.visibility === 'shared' ? '🌍 Gedeeld'
: '🔒 Privé';
return (
<li key={n.id} style={{ paddingLeft: depth * 20 }}>
<div className="group flex items-center gap-2 rounded-2xl px-3 py-2 transition hover:bg-brand-50/70 dark:hover:bg-slate-800/60">
<span className={`h-2 w-2 rounded-full ${depth === 0 ? 'bg-brand-500' : 'bg-brand-300'}`} />
<Link to={`/admin/lessons/${n.id}`} className="flex-1 truncate font-medium text-slate-800 dark:text-slate-100">
{n.name}
<span className="ml-2 rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700 dark:bg-brand-900/30 dark:text-brand-200">
{n.cardCount}
</span>
<span className="ml-2 rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{visibilityBadge}
</span>
{!isOwner && (
<span className="ml-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">
📥 Geabonneerd
</span>
)}
</Link>
{isOwner && (
<div className="flex items-center gap-1 opacity-0 transition group-hover:opacity-100">
<button className="rounded-lg px-2 py-1 text-xs font-medium text-brand-700 hover:bg-brand-100 dark:text-brand-200 dark:hover:bg-brand-900/40" onClick={() => setAddingTo(n.id)}>+ subles</button>
<button className="rounded-lg px-2 py-1 text-xs font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" onClick={() => rename(n.id, n.name)}>rename</button>
<button className="rounded-lg px-2 py-1 text-xs font-medium text-danger-600 hover:bg-danger-50 dark:hover:bg-danger-400/10" onClick={() => remove(n.id)}>delete</button>
</div>
)}
</div>
{/* ... existing addingTo block + recursive children ... */}
</li>
);
})}
Keep the existing addingTo block and recursive <LessonTree> call.
- Step 2: Update
Layout.tsxto add the Marketplace link
In the navItems array, replace with:
const navItems = [
{ to: '/', label: 'Dashboard', end: true },
{ to: '/admin', label: 'Lessen' },
{ to: '/marketplace', label: 'Marketplace 🛍️' },
{ to: '/stats', label: 'Stats' },
];
- Step 3: Typecheck + commit
npm -w @flashcard/frontend run typecheck
git add packages/frontend/src/components/LessonTree.tsx packages/frontend/src/components/Layout.tsx
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): lesson badges + marketplace nav link"
Task 16: AdminLessonPage — visibility toggle + readonly + curated
Files:
-
Modify:
packages/frontend/src/pages/AdminLesson.tsx -
Step 1: Replace contents of
AdminLesson.tsx
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import type { Card, Lesson } from '@flashcard/shared';
import { cardsApi } from '../api/cards.js';
import { lessonsApi } from '../api/lessons.js';
import { adminLessonsApi } from '../api/admin-lessons.js';
import { useAuth } from '../stores/authStore.js';
import { useLessons } from '../stores/lessonsStore.js';
import { CardTable } from '../components/CardTable.js';
import { ImportDialog } from '../components/ImportDialog.js';
import { ApiClientError } from '../api/client.js';
function findLesson(tree: { id: number; children: typeof tree }[], id: number): { id: number; children: typeof tree } | null {
for (const n of tree) {
if (n.id === id) return n;
const found = findLesson(n.children, id);
if (found) return found;
}
return null;
}
export function AdminLessonPage() {
const { id } = useParams();
const lessonId = Number(id);
const user = useAuth((s) => s.user);
const { tree, refresh: refreshTree } = useLessons();
const [cards, setCards] = useState<Card[]>([]);
const [showImport, setShowImport] = useState(false);
const [busy, setBusy] = useState(false);
// Find the lesson within the cached tree to derive ownership/visibility flags.
const node = findLesson(tree as unknown as { id: number; children: never[] }[], lessonId) as unknown as Lesson | null;
const isOwner = node?.ownerId === user?.id;
const visibility = node?.visibility ?? 'private';
const isCurated = node?.isCurated ?? false;
async function refresh() {
try { setCards(await cardsApi.list(lessonId)); }
catch (e) { if (e instanceof ApiClientError && e.status === 403) setCards([]); else throw e; }
}
useEffect(() => { refresh(); refreshTree(); }, [lessonId]);
async function toggleVisibility() {
setBusy(true);
try {
const next = visibility === 'shared' ? 'private' : 'shared';
await lessonsApi.setVisibility(lessonId, next);
await refreshTree();
} finally { setBusy(false); }
}
async function toggleCurated() {
if (!user || user.role !== 'sysadmin') return;
setBusy(true);
try {
await adminLessonsApi.setCurated(lessonId, !isCurated);
await refreshTree();
} finally { setBusy(false); }
}
async function forkThis() {
setBusy(true);
try {
const fork = await lessonsApi.fork(lessonId);
await refreshTree();
window.location.href = `/admin/lessons/${fork.id}`;
} finally { setBusy(false); }
}
async function unsubscribeThis() {
setBusy(true);
try {
await lessonsApi.unsubscribe(lessonId);
await refreshTree();
window.location.href = '/admin';
} finally { setBusy(false); }
}
return (
<div className="mx-auto max-w-5xl space-y-6">
<Link to="/admin" className="inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:text-brand-700 dark:text-brand-300">
← Terug naar lessen
</Link>
<header className="surface flex flex-col gap-3 p-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="font-display text-2xl font-bold">
Kaartenbeheer
{!isOwner && <span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-200">📥 Geabonneerd</span>}
{isCurated && <span className="ml-2 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-700">⭐ Curated</span>}
</h1>
<p className="mt-1 text-xs text-slate-500">{cards.length} {cards.length === 1 ? 'kaart' : 'kaarten'} in deze les</p>
</div>
<div className="flex flex-wrap gap-2">
{isOwner ? (
<>
<button className="btn-ghost" onClick={toggleVisibility} disabled={busy}>
{visibility === 'shared' ? '🔒 Maak privé' : '🌍 Deel publiek'}
</button>
{user?.role === 'sysadmin' && visibility === 'shared' && (
<button className="btn-ghost" onClick={toggleCurated} disabled={busy}>
{isCurated ? '☆ Verwijder curated' : '⭐ Markeer als curated'}
</button>
)}
<button className="btn-ghost" onClick={() => setShowImport(true)}>📥 Importeer</button>
<a className="btn-ghost" href={cardsApi.exportUrl(lessonId, false)}>📤 Exporteer</a>
<Link to={`/practice/${lessonId}/setup`} className="btn-success">Start oefenen →</Link>
</>
) : (
<>
<button className="btn-ghost" onClick={forkThis} disabled={busy}>🍴 Fork</button>
{node && node.ownerId !== user?.id && (
<button className="btn-ghost" onClick={unsubscribeThis} disabled={busy}>Abonnement opzeggen</button>
)}
<Link to={`/practice/${lessonId}/setup`} className="btn-success">Start oefenen →</Link>
</>
)}
</div>
</header>
{!isOwner && (
<div className="rounded-2xl bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">
Je bent geabonneerd op deze les en kunt kaarten alleen bekijken. Klik op <strong>🍴 Fork</strong> voor een eigen, bewerkbare kopie.
</div>
)}
<div className="surface overflow-hidden p-1">
<CardTable lessonId={lessonId} cards={cards} onChange={refresh} readOnly={!isOwner} />
</div>
{showImport && <ImportDialog lessonId={lessonId} onClose={() => setShowImport(false)} onDone={refresh} />}
</div>
);
}
- Step 2: Add
readOnlyprop toCardTable
In packages/frontend/src/components/CardTable.tsx, add readOnly?: boolean to the props and disable all editing when set. In the row, when readOnly skip the onBlur handlers (disabled on inputs) and hide the + and ✕ buttons:
export function CardTable({ lessonId, cards, onChange, readOnly = false }: { lessonId: number; cards: Card[]; onChange: () => void; readOnly?: boolean; }) {
// ... existing setup
// For each input: add disabled={readOnly}
// Hide draft row + delete button when readOnly
}
Concretely: wrap the trailing draft <tr> with {!readOnly && (<tr> ... </tr>)} and the delete button with {!readOnly && <button ...>x</button>}. Add disabled={readOnly} to each <input> in the existing rows. Add a small visual hint at the bottom when readOnly that says "Alleen lezen — fork om aan te passen".
- Step 3: Typecheck + commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/frontend run typecheck
git add packages/frontend/src/pages/AdminLesson.tsx packages/frontend/src/components/CardTable.tsx
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(admin): visibility toggle, fork/unsubscribe, readonly mode for subscribers"
Task 17: MarketplacePage
Files:
-
Create:
packages/frontend/src/pages/Marketplace.tsx -
Step 1: Create the page
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion';
import { marketplaceApi, type MarketplaceListResponse } from '../api/marketplace.js';
import { lessonsApi } from '../api/lessons.js';
import { ApiClientError } from '../api/client.js';
export function MarketplacePage() {
const [data, setData] = useState<MarketplaceListResponse>({ rows: [], total: 0 });
const [q, setQ] = useState('');
const [curatedOnly, setCuratedOnly] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const navigate = useNavigate();
async function refresh() {
setBusy(true); setError(null);
try {
const r = await marketplaceApi.list({ q: q.trim() || undefined, curated: curatedOnly ? true : undefined });
setData(r);
} catch (e) {
setError(e instanceof ApiClientError ? e.message : 'Kon marketplace niet laden.');
} finally { setBusy(false); }
}
useEffect(() => { refresh(); }, [curatedOnly]);
async function subscribe(id: number) {
try { await lessonsApi.subscribe(id); await refresh(); }
catch (e) { alert(e instanceof ApiClientError ? e.message : 'Abonneren mislukt'); }
}
async function fork(id: number) {
try {
const f = await lessonsApi.fork(id);
navigate(`/admin/lessons/${f.id}`);
}
catch (e) { alert(e instanceof ApiClientError ? e.message : 'Forken mislukt'); }
}
return (
<div className="space-y-6">
<header>
<h1 className="font-display text-3xl font-bold">Marketplace 🛍️</h1>
<p className="text-sm text-slate-500">Vind trainingen van andere gebruikers en officiële beheerderscontent.</p>
</header>
<div className="surface flex flex-col gap-2 p-4 sm:flex-row">
<input
className="input-field flex-1"
placeholder="Zoek op naam of beschrijving…"
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && refresh()}
/>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" className="h-4 w-4 rounded accent-brand-600" checked={curatedOnly} onChange={(e) => setCuratedOnly(e.target.checked)} />
⭐ Alleen officieel
</label>
<button className="btn-primary shrink-0" onClick={refresh} disabled={busy}>Zoek</button>
</div>
{error && <p className="text-sm text-danger-700">{error}</p>}
{data.rows.length === 0 && !busy ? (
<div className="surface p-12 text-center text-slate-500">Geen lessen gevonden.</div>
) : (
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{data.rows.map((l, i) => (
<motion.li
key={l.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.02 }}
className="surface flex flex-col p-5"
>
<div className="mb-2 flex items-center gap-2">
{l.isCurated && <span className="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-semibold text-yellow-800">⭐ Curated</span>}
{l.isFork && <span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">🍴 Fork</span>}
</div>
<h2 className="font-display text-lg font-bold">{l.name}</h2>
<p className="mt-1 line-clamp-2 text-sm text-slate-500">{l.description ?? <span className="italic">geen beschrijving</span>}</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span>door {l.ownerDisplayName}</span>
<span>{l.totalCards} kaarten · {l.subscribersCount} abonnees</span>
</div>
<div className="mt-4 flex gap-2">
<button className="btn-primary flex-1" onClick={() => subscribe(l.id)}>Abonneer</button>
<button className="btn-ghost flex-1" onClick={() => fork(l.id)}>🍴 Fork</button>
</div>
</motion.li>
))}
</ul>
)}
</div>
);
}
- Step 2: Typecheck + commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/frontend run typecheck
git add packages/frontend/src/pages/Marketplace.tsx
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): marketplace page with subscribe + fork"
Task 18: Dashboard subscriptions section
Files:
-
Modify:
packages/frontend/src/pages/Dashboard.tsx -
Step 1: Add a subscriptions section to the dashboard
Read the current dashboard. Above the "Recente sessies" section but below the "Lessen" section, insert:
import { lessonsApi } from '../api/lessons.js';
import type { SubscriptionEntry } from '@flashcard/shared';
// inside DashboardPage:
const [subs, setSubs] = useState<SubscriptionEntry[]>([]);
useEffect(() => { lessonsApi.mySubscriptions().then(setSubs).catch(() => {}); }, []);
// inside the returned JSX, after the lessons section:
{subs.length > 0 && (
<section>
<h2 className="mb-3 font-display text-xl font-bold">Geabonneerde lessen</h2>
<ul className="grid gap-3 sm:grid-cols-2">
{subs.map((s) => (
<li key={s.lessonId} className="surface flex items-center justify-between gap-3 p-4">
<div className="min-w-0">
<div className="truncate font-semibold">{s.name}</div>
<div className="text-xs text-slate-500">door {s.ownerDisplayName}</div>
</div>
<Link to={`/practice/${s.lessonId}/setup`} className="btn-success shrink-0 px-4 py-2">Oefenen →</Link>
</li>
))}
</ul>
</section>
)}
- Step 2: Typecheck + commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/frontend run typecheck
git add packages/frontend/src/pages/Dashboard.tsx
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(dashboard): subscriptions section"
Task 19: Router — add /marketplace
Files:
-
Modify:
packages/frontend/src/router.tsx -
Step 1: Add Marketplace lazy import + route
After the other lazyPage declarations, add:
const Marketplace = lazyPage(() => import('./pages/Marketplace.js'), 'MarketplacePage');
Inside the authenticated children block, after path: 'profile', add:
{ path: 'marketplace', element: <Marketplace /> },
- Step 2: Typecheck + build + commit
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/frontend run typecheck
npm -w @flashcard/frontend run build
git add packages/frontend/src/router.tsx
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): marketplace route"
Task 20: E2E — multi-user sharing & fork
Files:
-
Create:
e2e/ownership.spec.ts -
Step 1: Create the spec
import { test, expect } from '@playwright/test';
async function fetchVerifyLink(email: string): Promise<string> {
for (let i = 0; i < 30; i++) {
const res = await fetch('http://localhost:8025/api/v1/messages?limit=30');
const data = await res.json() as { messages: { ID: string; To: { Address: string }[] }[] };
const msg = data.messages.find((m) => m.To.some((t) => t.Address === email));
if (msg) {
const body = await (await fetch(`http://localhost:8025/api/v1/message/${msg.ID}`)).json() as { Text: string };
const m = body.Text.match(/https?:\/\/[^\s]+verify-email\?token=[^\s]+/);
if (m) return m[0];
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error('no verify link for ' + email);
}
async function registerAndVerify(page: import('@playwright/test').Page, name: string, email: string, password: string) {
await page.goto('/register');
await page.getByLabel(/Naam/).fill(name);
await page.getByLabel(/E-mailadres/).fill(email);
await page.getByLabel(/Wachtwoord/).fill(password);
await page.getByRole('button', { name: /Account aanmaken/ }).click();
await expect(page.getByText(/bevestigingsmail/i)).toBeVisible({ timeout: 10_000 });
const link = await fetchVerifyLink(email);
await page.goto(link);
await expect(page.getByRole('link', { name: 'Naar inloggen' })).toBeVisible({ timeout: 10_000 });
}
async function loginAs(page: import('@playwright/test').Page, email: string, password: string) {
await page.goto('/login');
await page.getByLabel(/E-mailadres/).fill(email);
await page.getByLabel(/Wachtwoord/).fill(password);
await page.getByRole('button', { name: 'Inloggen' }).click();
await expect(page.getByRole('button', { name: 'Account menu' })).toBeVisible({ timeout: 15_000 });
}
async function logout(page: import('@playwright/test').Page) {
await page.getByRole('button', { name: 'Account menu' }).click();
await page.getByRole('button', { name: 'Uitloggen' }).click();
await expect(page).toHaveURL(/\/login/);
}
test('user A shares lesson, B subscribes from marketplace and practices', async ({ page }) => {
const aEmail = `alice+${Date.now()}@example.com`;
const bEmail = `bob+${Date.now()}@example.com`;
const pw = 'secretpass';
// A registers + creates + shares lesson + adds card
await registerAndVerify(page, 'Alice', aEmail, pw);
await loginAs(page, aEmail, pw);
await page.goto('/admin');
await page.getByPlaceholder(/Nieuwe wortel-les/).fill('Spaans-test');
await page.getByRole('button', { name: /Toevoegen/ }).first().click();
await page.getByRole('link', { name: /Spaans-test/ }).first().click();
await page.getByPlaceholder('Nieuwe vraag').fill('hola');
await page.getByPlaceholder('Antwoord').fill('hello');
await page.getByRole('button', { name: 'Kaart toevoegen' }).click();
await page.getByRole('button', { name: /Deel publiek/ }).click();
await expect(page.getByRole('button', { name: /Maak privé/ })).toBeVisible();
await logout(page);
// B registers, finds in marketplace, subscribes, practices
await registerAndVerify(page, 'Bob', bEmail, pw);
await loginAs(page, bEmail, pw);
await page.goto('/marketplace');
await expect(page.getByRole('heading', { name: 'Spaans-test' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Abonneer' }).first().click();
// Now visible in admin tree as subscribed
await page.goto('/admin');
await expect(page.getByText(/📥 Geabonneerd/).first()).toBeVisible();
// Practice it
await page.getByRole('link', { name: /Spaans-test/ }).first().click();
await page.getByRole('link', { name: /Start oefenen/ }).click();
await page.getByRole('button', { name: /Start sessie/ }).click();
await page.getByRole('button', { name: 'Toon antwoord' }).click();
await page.getByRole('button', { name: /Goed/ }).click();
await expect(page.getByText(/Sessie klaar/)).toBeVisible({ timeout: 8_000 });
});
- Step 2: Run E2E
Start Mailpit + kill old servers + run:
cd /Users/berthausmans/Documents/Development/flashcard
docker compose up -d mailpit
lsof -ti tcp:3000 tcp:5173 2>/dev/null | xargs kill -9 2>/dev/null
rm -f packages/backend/data/e2e.db data/e2e.db
sleep 2
npm run e2e 2>&1 | tail -15
Expected: all 3 E2E tests pass (auth + smoke + ownership).
- Step 3: Commit
git add e2e/ownership.spec.ts
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "test(e2e): multi-user sharing + subscribe + practice flow"
Self-review
Spec coverage:
| Spec section | Implemented in task |
|---|---|
| 3.1 Eigenaarschap | 1, 4, 5, 6, 7 |
| 3.2 Visibility | 1, 4, 11, 16 |
| 3.3 Curated | 4, 11, 16 |
| 3.4 Subscribe / unsubscribe | 8, 11, 17 |
| 3.5 Fork | 9, 11, 16, 17 |
| 3.6 Marketplace | 10, 11, 17 |
| 3.7 Per-user voortgang | 1, 6, 7 |
| 3.8 Permissions helper | 3 |
| 3.9 UI-aanpassingen | 15, 16, 17, 18, 19 |
| Datamodel | 1 |
| API-overzicht | 8, 9, 10, 11, 12 |
| Migratie A → B | 12 |
| Tests | 3, 4, 5, 6, 7, 8, 9, 10, 13, 20 |
All spec sections covered.
Placeholders: scanned for "TBD", "TODO", "fill in details", "similar to" — none. All code blocks are complete.
Type consistency:
Lessontype extended in Task 2 withownerId,visibility,isCurated,sourceLessonId— used consistently in Tasks 4-7, 9, 14.canEditLesson(db, userId, lessonId)/canReadLesson(db, userId, lessonId)— same signature used everywhere.- Service method signatures all now take
userIdas a first business arg — consistent across tasks 4-9.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-05-20-ownership-and-sharing.md. Two execution options:
1. Subagent-Driven (recommended) — fresh subagent per task, review between tasks, fast iteration
2. Inline Execution — execute tasks in this session using executing-plans, batch execution with checkpoints
Which approach?