From a85d4afa4fea65752873aeb3c126b6824daf3638 Mon Sep 17 00:00:00 2001 From: Bert Hausmans Date: Wed, 20 May 2026 23:46:29 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20spec=20for=20sub-project=20B=20?= =?UTF-8?q?=E2=80=94=20ownership=20&=20sharing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...2026-05-20-ownership-and-sharing-design.md | 298 ++++++++++++++++++ 1 file changed, 298 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-20-ownership-and-sharing-design.md diff --git a/docs/superpowers/specs/2026-05-20-ownership-and-sharing-design.md b/docs/superpowers/specs/2026-05-20-ownership-and-sharing-design.md new file mode 100644 index 0000000..651eb7e --- /dev/null +++ b/docs/superpowers/specs/2026-05-20-ownership-and-sharing-design.md @@ -0,0 +1,298 @@ +# Sub-project B — Ownership & Sharing + +**Datum:** 2026-05-20 +**Status:** Draft, ready for review +**Parent app:** Flashcard webapplicatie +**Voorgaande spec:** `2026-05-20-auth-and-roles-design.md` (sub-project A, opgeleverd) +**Scope:** Tweede van drie subprojecten in de multi-user uitbreiding (A→B→C). + +--- + +## 1. Doel + +Eigenaarschap, visibility en sharing op lessen invoeren, plus een minimale marketplace voor het vinden van gedeelde content. Sub-project A leverde auth + rollen op; sub-project B maakt de app daadwerkelijk multi-tenant. Per-user statistieken volgen automatisch uit het toevoegen van `user_id` aan voortgangsdata. + +In B blijft de bestaande UI grotendeels staan; rijke marktplaats-UX, app-brede search en de les-detailpagina komen in sub-project C. + +## 2. Uitgangspunten + +- **Visibility-niveaus:** `private` (default) en `shared`. Geen `unlisted`. +- **Sharing-model:** hybride. Een gedeelde les in de marketplace kan worden **geabonneerd** (live link, read-only) of **geforkt** (kopie, eigen aanpassingen). +- **Curated:** sysadmin kan een gedeelde les markeren als `is_curated`. Curated lessen zijn zichtbaar voor iedereen zonder expliciete abonnering. +- **Subtree-gedrag:** sharing/abonnering/forks zijn implicit subtree-based. Wie een root-les forkt, krijgt de hele subtree gekopieerd. +- **Forks zijn losgekoppeld:** geen pull-updates van origineel. Wel een `source_lesson_id` voor een "Geforkt van X" badge. +- **Bestaande data migreert** naar de oudste sysadmin (geen drop-and-reseed; user accounts blijven). +- **Per-user voortgang:** `card_progress`, `sessions` en `attempts` worden per-user. +- **Edit-rechten op gedeelde lessen:** alleen de eigenaar. Geen co-owners. + +## 3. Functionele eisen + +### 3.1 Eigenaarschap + +- Iedere les heeft een `owner_id`. +- Bij creatie wordt `owner_id` automatisch gezet op de huidige user. +- Eigenaar kan zijn lessen aanpassen en verwijderen. +- Niet-eigenaars kunnen een gedeelde les wel lezen maar niet wijzigen. + +### 3.2 Visibility + +- `visibility` heeft twee waarden: `private` (default) en `shared`. +- Eigenaar wisselt visibility via `PATCH /api/lessons/:id/visibility`. +- Sublessen erven niet automatisch de visibility; iedere les heeft een eigen vlag. Wel: in de marketplace tonen we alleen **roots** binnen de scope van de gebruiker (zie 3.6). + +### 3.3 Curated content + +- `is_curated: boolean` (default false). +- Alleen sysadmin kan de vlag zetten via `PATCH /api/admin/lessons/:id/curated`. +- Als `is_curated = true` wordt automatisch ook `visibility = 'shared'` afgedwongen. +- Curated lessen zijn zichtbaar voor iedereen zonder dat de gebruiker hoeft te abonneren. De permission-check kijkt direct naar de vlag. +- In de marketplace en in de eigen-lessen-lijst krijgen curated lessen een ⭐ badge. + +### 3.4 Abonnering (subscribe) + +- `POST /api/lessons/:id/subscribe` creëert een rij in `lesson_subscriptions`. +- `DELETE /api/lessons/:id/subscribe` verwijdert die rij. +- Abonnementen zijn idempotent: dubbel POST geeft 200 (geen 409). +- Een geabonneerde les verschijnt in de eigen-lessen-lijst van de subscriber met badge `📥 Geabonneerd`. +- Subscriber kan oefenen, voortgang wordt per-user bijgehouden, maar kan de inhoud niet wijzigen. +- Owner edits propagaten direct (kaarten worden live geserveerd). +- Owner-deletes cascaden via FK; de subscription-rij verdwijnt. + +### 3.5 Fork + +- `POST /api/lessons/:id/fork` dupliceert de les + hele subtree + kaarten. +- Vereist: gebruiker mag de bronles lezen. +- Eigen lessen forken is toegestaan (kopie binnen eigen library). +- De forked root verliest zijn parent (wordt nieuwe root in user's library). +- Alle nieuwe lessen krijgen: + - `owner_id = currentUser` + - `visibility = 'private'` + - `is_curated = false` + - `source_lesson_id = oorspronkelijke id` +- Kaarten worden 1-op-1 gekopieerd (inclusief question/answer/hint/position). +- `card_progress` wordt NIET gekopieerd; fork begint fris. +- Operatie loopt in één transactie. + +### 3.6 Marketplace + +- `GET /api/marketplace/lessons?q=&curated=&limit=&offset=` retourneert gedeelde lessen. +- Alleen **roots** worden getoond. Een les is een marketplace-root als: + - `visibility = 'shared'`, EN + - de directe parent is niet ook `shared` voor deze user (`parent_id IS NULL` of parent niet shared/curated voor caller). +- Velden per rij: `id`, `name`, `description`, `ownerDisplayName`, `totalCards` (recursief over subtree), `subscribersCount`, `isCurated`, `isFork` (`source_lesson_id != null`), `createdAt`. +- Sortering: curated eerst, daarna `subscribersCount DESC`, daarna `createdAt DESC`. +- Filters: `q` zoekt op `name` en `description` (LIKE, lowercase), `curated=true` filtert alleen curated. +- Paginatie via `limit` (default 50, max 100) + `offset`. +- Eigen lessen worden NIET getoond in de marketplace (de gebruiker heeft ze al). + +### 3.7 Per-user voortgang + +- `card_progress` krijgt `user_id`. Composite uniqueness op `(card_id, direction, user_id)`. +- `sessions` krijgt `user_id`. +- `attempts` erft user via session (geen kolom nodig). +- Sessie-engine queries (`startSession`, `recordAttempt`, `getNextItem`) filteren op `user_id = currentUser`. +- Stats endpoints filteren op currentUser. + +### 3.8 Permissions helper + +Geconsolideerd in `services/permissions.ts`: + +```ts +canEditLesson(userId, lessonId): boolean + // lesson.owner_id === userId + +canReadLesson(userId, lessonId): boolean + // Walk ancestors of lessonId (incl. self). + // Return true if any ancestor: + // - is owned by userId, OR + // - has an active subscription by userId, OR + // - is shared + curated. +``` + +Belangrijk: een gebruiker die abonneert op de root `Spaans` ziet automatisch de sublessen `Begroetingen`, `Werkwoorden`, etc. Permission-check via ancestor-walk maakt dat correct werken. + +Sysadmin krijgt **geen** impliciete read-access op andermans content. Wel kan sysadmin curated zetten op gedeelde lessen. + +### 3.9 Bestaande UI-aanpassingen + +In B alleen het minimum: + +- **Header**: nieuwe link `Marketplace 🛍️`. +- **Lessen-boom** (Admin & Dashboard): badges per les: `🔒 Privé` / `🌍 Gedeeld` / `⭐ Curated`. Bij geabonneerde lessen ook `📥 Geabonneerd` en de eigenaar-naam. +- **AdminLessonPage**: visibility-toggle (Privé / Gedeeld) bovenin. Voor sysadmin: extra knop "Markeer als curated". Bij geabonneerde lessen (niet-owner): CRUD-acties zijn disabled met hint "Geabonneerd op X — verwijder abonnement of fork om aan te passen". + +Geen nieuwe les-detailpagina, geen visueel rijke marktplaats — dat is sub-project C. + +## 4. Datamodel + +```ts +// Toevoegingen aan bestaande lessons tabel: +lessons { + // ... bestaande velden ... + owner_id: integer NOT NULL fk → users.id ON DELETE CASCADE + visibility: text ('private' | 'shared') NOT NULL default 'private' + is_curated: integer (boolean) NOT NULL default false + source_lesson_id: integer fk → lessons.id ON DELETE SET NULL +} +INDEX lessons (owner_id) +INDEX lessons (visibility, is_curated) + +// Toevoeging aan card_progress: +card_progress { + // ... bestaande velden ... + user_id: integer NOT NULL fk → users.id ON DELETE CASCADE +} +// Composite uniqueness: (card_id, direction, user_id) +INDEX card_progress (user_id, next_due_at) + +// Toevoeging aan sessions: +sessions { + // ... bestaande velden ... + user_id: integer NOT NULL fk → users.id ON DELETE CASCADE +} +INDEX sessions (user_id, status) + +// Nieuwe tabel: +lesson_subscriptions { + id: integer pk autoIncrement + user_id: integer fk → users.id ON DELETE CASCADE NOT NULL + lesson_id: integer fk → lessons.id ON DELETE CASCADE NOT NULL + created_at: integer not null default unixepoch() + UNIQUE (user_id, lesson_id) +} +INDEX lesson_subscriptions (user_id) +INDEX lesson_subscriptions (lesson_id) +``` + +`attempts.user_id` wordt **niet** toegevoegd; per-user filtering loopt via `attempts.session_id → sessions.user_id`. + +## 5. API-overzicht + +### Aangepast (existing — nu user-scoped via middleware): + +``` +GET /api/lessons/tree → filtert op canReadLesson voor elke node +POST /api/lessons → zet owner_id = currentUser +PATCH /api/lessons/:id → canEditLesson check +DELETE /api/lessons/:id → canEditLesson check +POST /api/lessons/:id/move → canEditLesson check +GET /api/lessons/:id/cards → canReadLesson op les +POST /api/lessons/:id/cards → canEditLesson check +PATCH /api/cards/:id → canEditLesson op les van de kaart +DELETE /api/cards/:id → canEditLesson op les van de kaart +GET /api/cards/:id → canReadLesson op les +POST /api/lessons/:id/cards/import → canEditLesson +GET /api/lessons/:id/cards/export → canReadLesson +POST /api/sessions → canReadLesson op lesson; user_id = currentUser +GET /api/sessions/active → filter op user_id +GET /api/sessions/:id → eigenaar-check +GET /api/sessions/:id/next → eigenaar-check +POST /api/sessions/:id/attempts → eigenaar-check +POST /api/sessions/:id/end → eigenaar-check +POST /api/sessions/:id/abandon → eigenaar-check +GET /api/stats/overview → filter op currentUser +GET /api/stats/lessons/:id → canReadLesson + filter op currentUser +GET /api/stats/cards/:id → canReadLesson + filter op currentUser +GET /api/stats/heatmap → filter op currentUser +``` + +### Nieuw: + +``` +PATCH /api/lessons/:id/visibility { visibility: 'private' | 'shared' } + // alleen eigenaar; bij visibility=private + // wordt is_curated geforceerd naar false en + // is_curated mag niet true zijn als visibility=private + +GET /api/marketplace/lessons ?q=&curated=&limit=&offset= + +POST /api/lessons/:id/subscribe → 201 (of 200 als al geabonneerd) +DELETE /api/lessons/:id/subscribe → 204 +GET /api/me/subscriptions → lijst van { lessonId, name, ownerDisplayName, subscribedAt } + +POST /api/lessons/:id/fork → 201 + nieuw lesson object + +PATCH /api/admin/lessons/:id/curated { isCurated: boolean } + // sysadmin-only; bij isCurated=true wordt + // visibility automatisch op 'shared' gezet +``` + +### Foutcodes (nieuw): + +| Code | Status | Wanneer | +|---|---|---| +| `FORBIDDEN_LESSON` | 403 | canRead/canEdit faalt | +| `CANNOT_CURATE_PRIVATE` | 409 | sysadmin probeert is_curated=true op private les zonder visibility-promotie te accepteren | +| `ALREADY_SUBSCRIBED` | (200) | dubbel POST subscribe — niet als fout, status 200 met bestaande row | + +## 6. Marketplace-rootselectie (precisie) + +Een gedeelde les `L` verschijnt in de marketplace voor user `U` als: + +``` +L.visibility = 'shared' +AND L.owner_id != U.id // niet je eigen content +AND ( + L.parent_id IS NULL + OR NOT canReadLesson(U, L.parent_id) // parent niet bereikbaar voor U +) +``` + +Dit voorkomt duplicates: als je geabonneerd bent op een root, zie je sublessen niet ook nog in de marketplace. + +## 7. UI-componenten + +| Component | Locatie | Verandering | +|---|---|---| +| `Layout.tsx` | header | + `Marketplace 🛍️` link | +| `LessonTree.tsx` | admin / dashboard | + visibility/curated/subscribed badges per node | +| `AdminLessonPage.tsx` | admin lesson detail | + visibility toggle + curated toggle (sysadmin) + readonly mode voor subscribers | +| `MarketplacePage.tsx` (nieuw) | /marketplace | grid van les-cards met "Abonneer" / "Forken" knoppen, filter `q` + `Alleen ⭐ officieel`, paginatie | +| `Subscriptions` (sublijst) | dashboard | sectie "Geabonneerde lessen" naast eigen lessen | + +## 8. Tests + +### Backend unit +- `permissions.ts`: owner, ancestor-owned, ancestor-subscribed, curated, fully-private, deep-tree +- `fork.ts`: subtree copy, parent_id rewrite, card duplication, source_lesson_id, fork-of-own +- `marketplace.ts`: root selection, q-filter, curated filter, pagination, sorting + +### Backend integration +- User A maakt private les; user B krijgt 403 op read/edit +- User A deelt les; user B vindt in marketplace +- User B abonneert; B kan oefenen; A's voortgang en B's voortgang zijn losgekoppeld +- User B forkt; B's kopie is editable, los van A +- Sysadmin curated; user C ziet curated les zonder abonneren +- Owner private maakt: subscribers verliezen toegang (subscription cascade-deletes wanneer owner permanently delete; bij visibility-flip naar private blijft subscription bestaan maar canRead faalt → frontend toont melding) + +### E2E +- Twee users via Mailpit-flow: + 1. A registreert, deelt "Spaans" + 2. B registreert, vindt "Spaans" in marketplace, abonneert + 3. B oefent, ziet eigen stats + 4. A voegt kaart toe; B ziet de nieuwe kaart bij volgende sessie +- Fork-flow: C forkt B's geabonneerde fork van A (dubbel forken werkt) + +## 9. Migratie van A → B + +Geen drop-and-reseed. De migratie-runner: + +1. Voegt de nieuwe kolommen toe. +2. Voor `lessons` zonder `owner_id`: zet `owner_id = (SELECT id FROM users WHERE role='sysadmin' AND is_active=true ORDER BY id ASC LIMIT 1)`. Indien geen sysadmin bestaat: migratie faalt met duidelijke error. +3. Voor `sessions.user_id` en `card_progress.user_id`: zelfde fallback naar oudste sysadmin. +4. Indien `users` tabel volledig leeg is: skipping toewijzing veilig (er is toch geen data om te migreren behalve verse seed). + +Migratie-runner krijgt extra TypeScript pre-step die deze SQL-fixups uitvoert na de Drizzle ALTER TABLE. + +## 10. Out of scope (komt in C) + +- Les-detailpagina met preview van kaarten + stats + sublessen + start-knop +- App-brede search +- Visuele dashboards (charts, heatmaps detail) +- Admin-lessenbeheer-UI uitbreiding (rijkere tabel, bulk-acties) +- Marketplace categorieën / featured / recommendations / detail-pagina per gedeelde les +- Pull-updates van origineel naar fork +- Co-ownership / collaborators +- Sharing met specifieke users (alleen public sharing in B) +- Reviews / ratings op marketplace items +- Privé delen via link (unlisted)