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