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

14 KiB

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:

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

// 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)