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) enshared. Geenunlisted. - 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_idvoor een "Geforkt van X" badge. - Bestaande data migreert naar de oudste sysadmin (geen drop-and-reseed; user accounts blijven).
- Per-user voortgang:
card_progress,sessionsenattemptsworden 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_idautomatisch 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
visibilityheeft twee waarden:private(default) enshared.- 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 = truewordt automatisch ookvisibility = '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/subscribecreëert een rij inlesson_subscriptions.DELETE /api/lessons/:id/subscribeverwijdert 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/forkdupliceert 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 = currentUservisibility = 'private'is_curated = falsesource_lesson_id = oorspronkelijke id
- Kaarten worden 1-op-1 gekopieerd (inclusief question/answer/hint/position).
card_progresswordt 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
sharedvoor deze user (parent_id IS NULLof 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, daarnacreatedAt DESC. - Filters:
qzoekt opnameendescription(LIKE, lowercase),curated=truefiltert 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_progresskrijgtuser_id. Composite uniqueness op(card_id, direction, user_id).sessionskrijgtuser_id.attemptserft user via session (geen kolom nodig).- Sessie-engine queries (
startSession,recordAttempt,getNextItem) filteren opuser_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📥 Geabonneerden 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-treefork.ts: subtree copy, parent_id rewrite, card duplication, source_lesson_id, fork-of-ownmarketplace.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:
- A registreert, deelt "Spaans"
- B registreert, vindt "Spaans" in marketplace, abonneert
- B oefent, ziet eigen stats
- 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:
- Voegt de nieuwe kolommen toe.
- Voor
lessonszonderowner_id: zetowner_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. - Voor
sessions.user_idencard_progress.user_id: zelfde fallback naar oudste sysadmin. - Indien
userstabel 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)