diff --git a/docs/superpowers/plans/2026-05-20-ownership-and-sharing.md b/docs/superpowers/plans/2026-05-20-ownership-and-sharing.md new file mode 100644 index 0000000..42fe142 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-ownership-and-sharing.md @@ -0,0 +1,3160 @@ +# 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; +export type AdminLessonCuratedInput = z.infer; +export type MarketplaceQuery = z.infer; +``` + +- [ ] **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 { + 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 { + 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; +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 { + const path: AncestorRow[] = []; + let cursor: number | null = lessonId; + const seen = new Set(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + // 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(); + 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(); + 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`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(); + 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 { + 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 = { + 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 { + // 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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`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 { + 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(); + 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; +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 { + db.delete(lessonSubscriptions).where(and( + eq(lessonSubscriptions.userId, userId), + eq(lessonSubscriptions.lessonId, lessonId), + )).run(); +} + +export async function listSubscriptions(db: Db, userId: number): Promise { + 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; +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 { + 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(); + 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; +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 { + 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`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`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 { + 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 { + 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, 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, 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; +let app: ReturnType; +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('/lessons/tree'), + create: (input: LessonCreateInput) => api.post('/lessons', input), + update: (id: number, input: LessonUpdateInput) => api.patch(`/lessons/${id}`, input), + remove: (id: number) => api.delete(`/lessons/${id}`), + move: (id: number, input: LessonMoveInput) => api.post(`/lessons/${id}/move`, input), + setVisibility: (id: number, visibility: Visibility) => + api.patch(`/lessons/${id}/visibility`, { visibility }), + fork: (id: number) => api.post(`/lessons/${id}/fork`), + subscribe: (id: number) => api.post<{ ok: true }>(`/lessons/${id}/subscribe`), + unsubscribe: (id: number) => api.delete(`/lessons/${id}/subscribe`), + mySubscriptions: () => api.get('/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(`/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(`/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 `` 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 ( +
  • +
    + + + {n.name} + + {n.cardCount} + + + {visibilityBadge} + + {!isOwner && ( + + 📥 Geabonneerd + + )} + + {isOwner && ( +
    + + + +
    + )} +
    + {/* ... existing addingTo block + recursive children ... */} +
  • + ); +})} +``` + +Keep the existing `addingTo` block and recursive `` 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([]); + 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 ( +
    + + ← Terug naar lessen + + +
    +
    +

    + Kaartenbeheer + {!isOwner && 📥 Geabonneerd} + {isCurated && ⭐ Curated} +

    +

    {cards.length} {cards.length === 1 ? 'kaart' : 'kaarten'} in deze les

    +
    +
    + {isOwner ? ( + <> + + {user?.role === 'sysadmin' && visibility === 'shared' && ( + + )} + + 📤 Exporteer + Start oefenen → + + ) : ( + <> + + {node && node.ownerId !== user?.id && ( + + )} + Start oefenen → + + )} +
    +
    + + {!isOwner && ( +
    + Je bent geabonneerd op deze les en kunt kaarten alleen bekijken. Klik op 🍴 Fork voor een eigen, bewerkbare kopie. +
    + )} + +
    + +
    + + {showImport && setShowImport(false)} onDone={refresh} />} +
    + ); +} +``` + +- [ ] **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 `` with `{!readOnly && ( ... )}` and the delete button with `{!readOnly && }`. Add `disabled={readOnly}` to each `` 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({ rows: [], total: 0 }); + const [q, setQ] = useState(''); + const [curatedOnly, setCuratedOnly] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
    +
    +

    Marketplace 🛍️

    +

    Vind trainingen van andere gebruikers en officiële beheerderscontent.

    +
    + +
    + setQ(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && refresh()} + /> + + +
    + + {error &&

    {error}

    } + + {data.rows.length === 0 && !busy ? ( +
    Geen lessen gevonden.
    + ) : ( +
      + {data.rows.map((l, i) => ( + +
      + {l.isCurated && ⭐ Curated} + {l.isFork && 🍴 Fork} +
      +

      {l.name}

      +

      {l.description ?? geen beschrijving}

      +
      + door {l.ownerDisplayName} + {l.totalCards} kaarten · {l.subscribersCount} abonnees +
      +
      + + +
      +
      + ))} +
    + )} +
    + ); +} +``` + +- [ ] **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([]); +useEffect(() => { lessonsApi.mySubscriptions().then(setSubs).catch(() => {}); }, []); + +// inside the returned JSX, after the lessons section: +{subs.length > 0 && ( +
    +

    Geabonneerde lessen

    +
      + {subs.map((s) => ( +
    • +
      +
      {s.name}
      +
      door {s.ownerDisplayName}
      +
      + Oefenen → +
    • + ))} +
    +
    +)} +``` + +- [ ] **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: }, +``` + +- [ ] **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 { + 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?**