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

3161 lines
121 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```ts
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:
```ts
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:
```ts
(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`:
```ts
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', { ... })`:
```ts
userId: integer('user_id').references(() => users.id, { onDelete: 'cascade' }),
```
Add to the indexes block:
```ts
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:
```ts
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:
```ts
import { integer, sqliteTable, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core';
```
- [ ] **Step 5: Generate migration**
```bash
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**
```bash
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**
```bash
npm -w @flashcard/backend run typecheck
```
Must pass.
- [ ] **Step 8: Commit**
```bash
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:
```ts
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:
```ts
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`:
```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**
```bash
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:
```ts
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`:
```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`:
```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**
```bash
npm -w @flashcard/backend test
```
Expected: failure (module `./permissions.js` not found).
- [ ] **Step 4: Implement `permissions.ts`**
```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**
```bash
npm -w @flashcard/backend test
```
Expected: 7 new permissions tests pass.
- [ ] **Step 6: Commit**
```bash
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`:
```ts
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:
```ts
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**
```ts
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:
```ts
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`:
```ts
import { lessonSubscriptions } from '../db/schema.js';
```
- [ ] **Step 5: Add `setLessonVisibility` and `setLessonCurated` helpers**
Append to `lessons.ts`:
```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**
```bash
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**
```bash
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:
```ts
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:
```ts
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**
```bash
npm -w @flashcard/backend test
```
Expected: cards + permissions + lessons tests pass.
- [ ] **Step 4: Commit**
```bash
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:
```ts
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:
```ts
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:
```ts
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**
```bash
npm -w @flashcard/backend test
```
Expected: pass.
- [ ] **Step 5: Commit**
```bash
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:
```ts
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**
```bash
npm -w @flashcard/backend test
```
Expected: pass.
- [ ] **Step 4: Commit**
```bash
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**
```ts
// 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**
```bash
npm -w @flashcard/backend test
```
- [ ] **Step 3: Implement `subscriptions.ts`**
```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`**
```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**
```bash
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**
```ts
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`**
```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**
```bash
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**
```ts
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`**
```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`**
```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**
```bash
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:
```ts
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`:
```ts
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`:
```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`**
```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`**
```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`**
```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**
```bash
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:
```ts
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:
```ts
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:
```ts
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**
```bash
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**
```bash
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**
```ts
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**
```bash
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:
```ts
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`**
```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`**
```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**
```bash
cd /Users/berthausmans/Documents/Development/flashcard
npm -w @flashcard/frontend run typecheck
git add packages/frontend/src/api/
git -c commit.gpgsign=false -c user.email=bert@hausmans.nl -c user.name="Bert Hausmans" commit -m "feat(frontend): API for visibility/fork/subscribe/marketplace/curated"
```
---
## Task 15: LessonTree badges + Layout marketplace link
**Files:**
- Modify: `packages/frontend/src/components/LessonTree.tsx`
- Modify: `packages/frontend/src/components/Layout.tsx`
- [ ] **Step 1: Update `LessonTree.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:
```tsx
import { useAuth } from '../stores/authStore.js';
```
Inside the component, fetch the current user:
```tsx
const currentUserId = useAuth((s) => s.user?.id);
```
Then in the row:
```tsx
{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:
```ts
const navItems = [
{ to: '/', label: 'Dashboard', end: true },
{ to: '/admin', label: 'Lessen' },
{ to: '/marketplace', label: 'Marketplace 🛍️' },
{ to: '/stats', label: 'Stats' },
];
```
- [ ] **Step 3: Typecheck + commit**
```bash
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`**
```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:
```tsx
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**
```bash
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**
```tsx
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**
```bash
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:
```tsx
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**
```bash
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:
```ts
const Marketplace = lazyPage(() => import('./pages/Marketplace.js'), 'MarketplacePage');
```
Inside the authenticated children block, after `path: 'profile'`, add:
```tsx
{ path: 'marketplace', element: <Marketplace /> },
```
- [ ] **Step 2: Typecheck + build + commit**
```bash
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**
```ts
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:
```bash
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**
```bash
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?**