# Datamodel en migraties

Modellen: `models.py` (SQLAlchemy 2.0, declarative). Migraties: `database.py`
(`init_db()` = `create_all` + `_migreer()`), **geen Alembic**.

## Tabellen

- **Woordenlijst** (`woordenlijsten`) — naam, `slug` (privacy-vriendelijke
  oefen-URL), `bron_taal`/`doel_taal` (bijv. `nl-NL`/`en-US`), `maker_leerling_id`,
  `is_public`, `description`, `thumbnail_url`, `aantal_geoefend` (hoe vaak de
  set gestart is, over alle gebruikers samen), `soort` (`"woordjes"`, standaard,
  of `"rollenspel"`: een vaste catalog met gespreksbeurten).
- **Woord** (`woorden`) — `bron_woord`, `doel_woord`, `bron_alt`, `doel_alt`
  (alternatieven gescheiden door `|`), `positie`, `moeilijk`, `rol`
  (`"leerling"`, standaard, of `"tegenstander"`; alleen zinvol bij een
  rollenspel). `lijst_id = NULL` betekent "uit de lijst verwijderd, voortgang
  bewaard".
- **Leerling** (`leerlingen`) — één tabel voor studenten én docenten; `rol`
  (`student`/`docent`), `email`, `wachtwoord_hash`, `goedgekeurd` (alleen
  uitgezet bij een geblokkeerd account), `geverifieerd`,
  `gekoppelde_providers`, `anonieme_id`, `azure_modus`, `docent_aangevraagd`,
  `avatar_url`, `sessie_versie` (hoogt op bij wachtwoord-/e-mailwijziging, zodat
  oudere sessies vervallen).
- **AuthToken** (`auth_tokens`) — eenmalige tokens met `soort`
  (`verificatie`, `reset`, `email-wijziging`, `verwijderen`) + `payload`.
- **Klas** (`klassen`) + **KlasLeerling** (`klas_leerlingen`, unieke koppeling);
  `Klas.docent_id` is de eigenaar (NULL = alleen beheerder, bijv. na het
  verwijderen van de docent).
- **SrsVoortgang** (`srs_voortgang`) — per (leerling, woord): `niveau`, `streak`,
  `gemasterd`, `herhalingen_nog`, `volgende_herhaling`, `beheerst_op`,
  `laatst_geoefend`, `sessie_id`. De volledige SRS-staat-uitwisseling tussen
  online en offline (`POST /api/sync/voortgang-staat` en de offline-tegenhanger)
  voegt deze rijen samen en gebruikt **geen nieuwe tabellen**.
- **Oefensessie** (`oefensessies`) — een aaneengesloten oefenblok; `beëindigd_op`
  wordt gezet bij het afsluiten (batch-unlock).
- **Deellink** (`deellinks`) — `token`, `lijst_id`, `aantal_gebruikt`, `is_actief`,
  `modus` (nullable; de vaste oefenmodus waarin de link opent, `None` =
  standaard slimme route).
- **LijstDeelname** (`lijst_deelnames`) — welke leerling (incl. gast) een
  gedeelde set gebruikt.
- **Oefenpoging** (`oefenpogingen`) — elke poging: `modus` (spreken/spellen/
  meerkeuze/flitskaarten), scores, `is_correct`, `aarzeling_niveau`,
  `herkende_tekst`, `beoordelings_engine` (lokaal/azure/offline), `reactietijd_ms`.
- **OfflineWachtrij** (`offline_wachtrij`) — offline geoefende resultaten die nog
  naar de server moeten: `uuid`, `server_woord_id`, `modus`, `score`,
  `is_correct`, `geoefend_op`. Alleen gevuld in offline-modus.
- **SyncResultaat** (`sync_resultaten`) — server-side administratie van verwerkte
  offline-resultaten; per leerling uniek op `uuid`, zodat een herhaalde sync
  niets dubbel verwerkt.
- **SyncSetStart** (`sync_set_starts`) — server-side administratie van verwerkte
  offline set-starts; per leerling uniek op `uuid`, zodat een herhaalde sync de
  teller niet nog eens verhoogt.
- **SyncStand** (`sync_stand`) — lokale cursor voor online→offline: per leerling
  `laatste_gebeurtenis_id` en `laatste_sync_op`.
- **OfflineGebeurtenis** (`offline_gebeurtenissen`) — lokaal toegepaste
  serverpogingen, uniek op `server_poging_id`, zodat herhaald importeren de
  SRS-ladder niet dubbel doorloopt.
- **OfflineSetStart** (`offline_set_starts`) — lokale wachtrij met set-starts die
  nog naar de server moeten: `uuid` (uniek), `lijst_id`, `slug`, `geoefend_op`.
  Alleen gevuld in offline-modus; de uuid houdt het uploaden idempotent.
- **OfflineImport** (`offline_imports`) — per geïmporteerde set-zip
  (`bestandsnaam` als primaire sleutel) de `sha256` van de inhoud, zodat een
  ongewijzigde zip bij een volgende offline-start niet opnieuw wordt ingelezen.
- **OfflineSetVersie** (`offline_set_versies`) — per (leerling, slug) de
  inhoudsversie van een geïmporteerde set: `server_id`, `inhouds_hash`,
  `aantal_woorden`, `bijgewerkt_op` en `gecontroleerd_op`; uniek op
  (leerling, slug). De host vergelijkt deze met de online `inhouds_hash` om te
  zien of de lokale kopie bijgewerkt moet worden.
- **AppInstelling** (`app_instellingen`) — systeemwijde schakelaars in één rij;
  kolommen `gesprek_aan` en `uitspraak_uit` (beide bool, standaard `false`). De
  beheerder zet de gespreksmodus en de uitspraakserver (engine + TTS-dienst op
  de engine-host, bij een storing of zonder internet op die host) aan/uit via
  de beheer-UI (niet via `.env`). De leesroute van `uitspraak_uit` zit op het
  hete pad (elke voorlees- en beoordeelactie) en cachet de waarde daarom 5
  seconden per proces (`uitspraak_status.py`).
- **GesprekDraaiboek** (`gesprek_draaiboeken`) — per set (`lijst_id`) het
  concept voor de gespreksmodus: `systeem_prompt` (de instructie waarmee de AI
  het gesprek voert), `doelwoorden` (JSON-snapshot van alle setwoorden incl.
  alternatieven, dat tijdens het gesprek achter de prompt wordt gezet),
  `actief`, `maker_leerling_id`, `aangemaakt_op`, `bijgewerkt_op`. De oudere
  kolommen `titel`, `situatie`, `rollen` en `beurten` bestaan nog in het model
  maar worden niet meer gevuld of gebruikt.
- **GesprekSessie** (`gesprek_sessies`) — één gespeeld gesprek: `draaiboek_id`,
  `lijst_id`, `leerling_id`, `status` (`bezig`/`klaar`), `gestart_op`,
  `afgerond_op`, `transcript` (JSON-string met de heen-en-weer). Heeft een
  `berichten`-relatie naar `GesprekBericht`.
- **GesprekBericht** (`gesprek_berichten`) — één chatbericht in een gesprek:
  `sessie_id`, `rol` (`assistant`/`user`), `bron` (`start`/`spraak`/`tekst`/
  `hint`), `tekst`, `gebruikte_woord_ids` (JSON-string met de setwoorden die in
  deze leerlingbeurt voorkomen) en `aangemaakt_op`. Het oude `GesprekKeuze`
  (`gesprek_keuzes`) bestaat niet meer in de code.

## Alternatieven

`parse_alternatieven(primair, alt)` in `models.py` geeft de lijst acceptabele
antwoorden (primair eerst); `alt` is een `|`-gescheiden lijst. In de
woordenlijst-editor worden synoniemen met `/` gesplitst (`ontleed_paar` in
`routers/woorden.py`). `vouw_haakjes_varianten(tekst)` in `models.py` splitst
daarnaast een optioneel deel tussen haakjes in twee varianten
(`l'appli(cation)` → `l'application` en `l'appli`); de spreekoefening past dit
toe in `_spreek_referenties`. De haakjes zelf gaan nooit naar de engine.

## Migraties (`database.py:_migreer()`)

- Nieuwe kolommen worden **zowel** in `models.py` **als** in `_migreer()`
  toegevoegd.
- Nieuwe tabellen worden in beide takken met `CREATE TABLE IF NOT EXISTS`
  aangemaakt, inclusief de unieke indexen (bijv. `sync_set_starts` uniek per
  `leerling_id` + `uuid`). Een gloednieuwe tabel heeft géén `_migreer()`-regel
  nodig: `init_db()` doet eerst `Base.metadata.create_all`, dat ontbrekende
  tabellen aanmaakt. `gesprek_sessies` en `gesprek_berichten` staan daarom
  (nog) niet in `_migreer()`. `gesprek_draaiboeken` en `app_instellingen`
  staan er inmiddels wél in met kolommigraties: de later toegevoegde kolommen
  `systeem_prompt` en `doelwoorden` (`gesprek_draaiboeken`) en `uitspraak_uit`
  (`app_instellingen`; PG `BOOLEAN NOT NULL DEFAULT false`, SQLite
  `BOOLEAN NOT NULL DEFAULT 0`) worden in beide takken bijgewerkt (PG
  `ADD COLUMN IF NOT EXISTS`, SQLite via `PRAGMA table_info`). Zodra een
  **bestaande** tabel een kolom krijgt, moet die wél in beide `_migreer()`-takken.
  Zo worden de rollenspel-kolommen `woordenlijsten.soort` (PG
  `ADD COLUMN IF NOT EXISTS soort VARCHAR NOT NULL DEFAULT 'woordjes'`, SQLite
  `ALTER TABLE … soort TEXT NOT NULL DEFAULT 'woordjes'`) en `woorden.rol` in
  beide takken toegevoegd. Ook `deellinks.modus` staat in beide takken (PG
  `ALTER TABLE deellinks ADD COLUMN IF NOT EXISTS modus VARCHAR`, SQLite
  `ALTER TABLE deellinks ADD COLUMN modus TEXT`).
- `_migreer()` heeft aparte takken voor **PostgreSQL** (`ALTER TABLE … ADD COLUMN
  IF NOT EXISTS`) en **SQLite** (`PRAGMA table_info`).
- Bijzondere SQLite-migratie: omdat SQLite `NOT NULL` niet via `ALTER TABLE`
  kan wijzigen, wordt de `woorden`-tabel éénmalig opnieuw opgebouwd met
  `lijst_id` nullable (`PRAGMA foreign_keys = OFF` tijdens de rebuild); de
  herbouw neemt ook `rol` mee.

### Indexen voor de hotpaths

`_migreer()` legt in zowel PostgreSQL als SQLite expliciete indexen aan voor de
oefen-, dashboard- en sync-query's. Het gaat om `woorden(lijst_id)`,
`woorden(server_woord_id)`, `woordenlijsten(maker_leerling_id)`,
`oefenpogingen(leerling_id)`, `oefenpogingen(woord_id)`,
`oefenpogingen(leerling_id, woord_id)`,
`oefenpogingen(leerling_id, modus, aangemaakt_op)`,
`srs_voortgang(sessie_id)`, `oefensessies(leerling_id)`,
`oefensessies(lijst_id)`, `oefensessies(beëindigd_op)`, `deellinks(lijst_id)`,
`deellinks(maker_leerling_id)`, `lijst_deelnames(leerling_id)`,
`klas_leerlingen(leerling_id)`, `auth_tokens(leerling_id)`,
`webauthn_credentials(leerling_id)` en `favorieten(lijst_id)`. De indexen
worden met `IF NOT EXISTS` toegevoegd en zijn dus ook van toepassing op
bestaande databases.

## Tijd

- Timestamps zijn **naïve UTC** (PG connect-arg `-c timezone=utc`); in code
  interpreteren met `.replace(tzinfo=timezone.utc)` — zie `_naar_utc` in
  `auth.py`/`instellingen.py`.
