299 lines
14 KiB
Markdown
299 lines
14 KiB
Markdown
# 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)
|