# 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?**