Files
flashcards/docs/superpowers/plans/2026-05-20-ownership-and-sharing.md

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 userId to cardProgress

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 userId to sessions

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_subscriptions table

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 (add createLessonOwnedBy helper)

  • Step 1: Extend dbHelper.ts with createLessonOwnedBy

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 rowToLesson to 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 createLesson to require userId

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, moveLesson to 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 getLessonTree to 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 setLessonVisibility and setLessonCurated helpers

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.ts to pass userId

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.ts to pass userId

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 startSession to take userId and 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, abandonSession to take userId and 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 userId and 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.ts to 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"

Files:

  • Modify: packages/frontend/src/components/LessonTree.tsx

  • Modify: packages/frontend/src/components/Layout.tsx

  • Step 1: Update LessonTree.tsx to 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.tsx to 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 readOnly prop to CardTable

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:

  • Lesson type extended in Task 2 with ownerId, 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 userId as 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?