# Piattaforma videocorsi Inlumia: design tecnico

Documento interno. Il perimetro concordato col cliente sta nel preventivo 19/2026 (prima versione, 15 giornate, più l'opzione filigrana). Qui ci sono le scelte tecniche.

## Stack

| Area | Scelta |
|---|---|
| Framework | SvelteKit 2 + Svelte 5, TypeScript, Tailwind 4 |
| Database | Turso (libSQL, regione EU), Drizzle ORM. In locale un file SQLite |
| Hosting | Vercel Pro sull'account di Inlumia (uso commerciale: l'Hobby non è ammesso) |
| Video | Bunny Stream: caricamento TUS dal browser, HLS con token firmati sulla CDN, player nostro con hls.js |
| Email di servizio | Resend, mittente `corsi@inlumia.it`, Reply-To `info@inlumia.it` |
| Email promozionali | systeme.io via API (contatti e tag) |
| Cron | Vercel Cron, una volta al giorno: avvisi di scadenza e sincronizzazione systeme.io |
| Dominio | `corsi.inlumia.it`, DNS su Cloudflare (dominio e posta su Register.it) |

Stesso impianto di Alleanza DVR, con due differenze: le **sessioni stanno nel database** (servono revoca, limite dispositivi e un video alla volta, che un JWT da solo non permette) e il **player è nostro** (serve per tracciare per secondi e per tenere la filigrana sopra al video anche a schermo intero, cosa impossibile con l'iframe di Bunny).

## Ruoli

- `admin`: pannello completo. Accesso con password + codice TOTP obbligatorio. Due persone: Cristiano Ballerani e Luis Perez.
- `corsista`: area personale. Password + codice email sui dispositivi nuovi.

Il ruolo editor previsto dalla specifica non serve: gestiscono in due e fanno tutto.

## Accesso e sessioni

- **Invito**: l'admin crea il corsista (nome, cognome, email, azienda, gruppi, data di attivazione). Parte un'email con un link a scadenza (7 giorni) per scegliere la password. Stesso meccanismo per il recupero password (link da 1 ora).
- **Dispositivo**: cookie `dispositivo` di lunga durata (1 anno) con un token casuale, di cui in tabella si salva solo l'hash. Un dispositivo è "verificato" dopo il codice via email (6 cifre, 10 minuti, 5 tentativi). Massimo **3 dispositivi** per corsista: al quarto il login chiede di toglierne uno, oppure di contattare Inlumia. Il corsista vede e rimuove i suoi dispositivi dall'area personale. Gli admin non hanno il codice email: hanno il TOTP a ogni accesso, con "ricorda questo dispositivo" per 30 giorni.
- **Sessione**: cookie `sessione` httpOnly con token casuale, in tabella l'hash SHA-256. Durata 30 giorni a scorrimento. Logout e rimozione dispositivo cancellano le sessioni collegate.
- **Tentativi**: dopo 5 password sbagliate per email in 15 minuti, blocco di 15 minuti.
- Password con bcrypt (12 round), minimo 10 caratteri.

## Abbonamento

- Tabella `abbonamenti` con i periodi (inizio, fine). L'attivazione crea un periodo di 365 giorni. Il rinnovo, quando Inlumia incassa di nuovo, crea un periodo che parte dal giorno dopo la fine del precedente se è ancora attivo, altrimenti da oggi.
- Proroga: sposta la fine del periodo corrente. Sospensione e riattivazione: flag sull'utente. Ogni operazione va in `abbonamento_eventi` con operatore e motivo.
- "Abbonato attivo" = utente non sospeso e oggi compreso in un periodo. È l'unica funzione che decide l'accesso ai video (`haAccesso(utente, oggi)`).
- Avvisi: email a 30 e 7 giorni dalla fine e il giorno della scadenza, registrati in `avvisi_inviati` per non ripeterli. Banner nell'area personale negli ultimi 30 giorni.

## Catalogo

`aree` → `corsi` → `lezioni`, ordinati con un campo `ordine`. Stati `bozza`, `pubblicato`, `archiviato` su corsi e lezioni (le aree sono solo visibili o no). Una lezione ha titolo, descrizione breve, copertina (miniatura di Bunny o caricata), durata (letta da Bunny) e il video.

**Copertine**: ridotte nel browser (massimo 1280 px, WebP) e salvate nel database nella tabella `immagini`, servite da `/img/[id]` con cache immutabile. Pesano 50-150 KB l'una: per qualche centinaio di immagini non serve uno spazio esterno.

Nel catalogo dei corsisti compaiono solo i corsi con almeno una lezione pubblicata e col video pronto, e solo le aree che ne contengono. Gli admin vedono anche le bozze, come anteprima.

"Corsi nuovi": pubblicati negli ultimi 30 giorni. Ricerca su titolo e descrizione di corsi e lezioni (LIKE, i volumi sono piccoli).

**Versioni del video**: ogni caricamento crea una riga in `video_versioni`. La lezione punta alla versione corrente. Sostituendo il video, il completamento già raggiunto resta (scelta di default del preventivo); l'avanzamento si registra sempre con la versione vista, così la fase 2 può decidere diversamente.

## Video

- **Caricamento**: dal pannello il browser carica direttamente su Bunny con TUS. Il server crea il video su Bunny e firma l'upload (`sha256(libraryId + apiKey + scadenza + videoId)`), la chiave API non arriva mai al browser. Bunny converte e un webhook (o un controllo periodico dal pannello) aggiorna stato e durata.
- **Riproduzione**: aprendo la pagina il player riceve solo durata, posizione, filigrana e copertina. L'URL HLS firmato (token di cartella) arriva **solo premendo play**, insieme alla presa della riproduzione, e solo se `haAccesso` è vero. Il token dura 3 minuti; ogni battito (15 s) ne rilascia uno nuovo a chi sta ancora guardando e hls.js riscrive ogni richiesta col token più recente (`xhrSetup`). Senza battito, o con l'abbonamento finito, entro 3 minuti il video smette di caricarsi. L'HLS nativo di Safari, dove il token non si può cambiare a metà, riceve 15 minuti. Buffer limitato a 60 s.
- Restrizione dei referrer al dominio, download MP4 disattivati nella libreria Bunny.
- **Configurazione della libreria Bunny "Inlumia Corsi" (id 798476, solo Francoforte), fatta il 03/11/2026:** riproduzione diretta spenta; domini ammessi `corsi.inlumia.it`, `localhost`, `localhost:5190` (Bunny confronta anche la porta); blocco degli accessi senza referer; token sull'embed e sulla CDN attivi; MP4 di riserva e conservazione degli originali spenti (col token di cartella sarebbero stati scaricabili). Gli originali li conserva Inlumia.
- **Firma dei token**: algoritmo "avanzato" HS256 (HMAC-SHA256), come la libreria ufficiale BunnyWay/BunnyCDN.TokenAuthentication. Nel percorso a cartella l'ordine è `bcdn_token=…&token_path=…&expires=…`. Verificato su un video vero: playlist e segmenti 200 col token, 403 senza token, con referer estraneo o con il token della miniatura.
- **Un video alla volta**: tabella `riproduzioni_attive` (una riga per utente). Premere play la prende per il proprio dispositivo; il player manda un battito ogni 15 secondi e, se un altro dispositivo l'ha presa, si ferma con un messaggio e il pulsante "Guarda qui".
- **Filigrana**: nome ed email del corsista in un livello sopra al video, semitrasparente (opacità bassa, come chiesto), che cambia posizione ogni 30-60 secondi. Il livello è dentro il contenitore che va a schermo intero.

## Tracciamento

- Il player raccoglie gli intervalli realmente riprodotti (da `timeupdate`, scartando salti superiori a 2 secondi) e li invia con il battito ogni 15 secondi, alla pausa, alla fine e alla chiusura della pagina (`sendBeacon`).
- Il server unisce gli intervalli in `visioni` (una riga per utente, lezione e versione): intervalli uniti in JSON, secondi unici visti, percentuale, ultima posizione, tempo totale di riproduzione (che comprende le ripetizioni), primo avvio, ultimo aggiornamento, data di completamento.
- Completata quando i secondi unici superano la soglia (90%, in `impostazioni`). La completata non torna indietro.
- Plausibilità: per ogni battito si accreditano al massimo `min(secondi dal battito precedente della stessa riproduzione, 40) × 2 + 5` secondi, misurati dal server; niente registrazione senza abbonamento o se la riproduzione è di un altro dispositivo.
- Più schede o riconnessioni non duplicano nulla: l'unione degli intervalli è idempotente.
- "Continua da dove eri rimasto": ultima lezione toccata con posizione; il player riparte da lì (se oltre il 95%, dall'inizio).

## Report

Interrogazioni su `visioni` e `abbonamenti`, filtri periodo/area/corso/gruppo/corsista, esportazione CSV con `;` e BOM come in Alleanza.

## systeme.io

Entrano solo i corsisti con il consenso alle email promozionali (campo nell'area personale, o spuntato dagli admin quando l'hanno raccolto nella vendita; si registrano data e testo accettato). Senza consenso il contatto non si crea, e a uno già esistente si tolgono i tag della piattaforma.

Il piano systeme.io di Inlumia ha un massimo di **10 tag** (lo dice l'API: "Please upgrade your plan to create more tags"). Il 5 novembre Cristiano ha scelto di usarne **uno solo**, `Corsi - iscritto`, liberando il posto con la cancellazione di "Colorificio Tognela". **Per le campagne ai corsisti Inlumia usa `Corsi - iscritto`.** Il codice per gli altri tag (`abbonamento attivo`, `in scadenza`, `abbonamento scaduto`, `gruppo <nome>`, `completato <corso>`) c'è ancora e si riattiva con `SYSTEME_TUTTI_I_TAG=1`, se passano a un piano superiore. Gli altri tag del contatto non si toccano. La sincronizzazione parte dopo ogni modifica del corsista nel pannello e ogni notte per tutti; nel job notturno, un errore uguale per più corsisti (per esempio il limite dei tag) va nel registro una volta sola.

## Job giornaliero

`/api/cron/giornaliero`, chiamato da Vercel Cron alle 6 UTC (`vercel.json`) con `Authorization: Bearer CRON_SECRET`: avvisi di scadenza (30 e 7 giorni, appena scaduto, una volta per periodo), sincronizzazione systeme.io, pulizia di sessioni, codici, link e dispositivi mai verificati scaduti.

## Registro operazioni

`log_operazioni`: operatore, azione, entità, id, dettaglio, data. Si scrive su tutte le operazioni del pannello.

## Struttura del codice

```
app/
  src/lib/server/db/schema.ts     schema Drizzle
  src/lib/server/auth/            sessioni, dispositivi, codici, totp, password, accesso a più passaggi
  src/lib/server/abbonamenti.ts   haAccesso, riepilogo, attivazione, rinnovo, proroga, sospensione
  src/lib/server/catalogo.ts      letture del catalogo per i corsisti
  src/lib/server/bunny.ts         API Bunny e firme dei token
  src/lib/server/video.ts         versioni dei video e stato da Bunny
  src/lib/server/riproduzione.ts  lezione riproducibile, un video alla volta, durata dei token
  src/lib/server/intervalli.ts    unione e validazione dei tratti visti (testato)
  src/lib/server/visioni.ts       registrazione dell'avanzamento, ripresa
  src/lib/server/report.ts, csv.ts  report del pannello ed esportazione (csv testato)
  src/lib/server/avvisi.ts, pulizia.ts, systeme.ts  job giornaliero
  src/lib/server/email/           invio Resend + modelli dei messaggi
  src/routes/(corsista)/          catalogo, corso, lezione, area personale
  src/routes/admin/               pannello: riepilogo, catalogo, corsisti, gruppi, report
  src/routes/accesso/             login, codice, TOTP, invito, recupero
  src/routes/api/                 player (avvia, battito), webhook Bunny, cron giornaliero
  scripts/seed.ts, crea-admin.ts  dati di prova; amministratori in produzione
```

## Tappe

1-7 fatte (accesso, catalogo, player, tracciamento, corsisti, report, email e systeme.io). Manca la 8: messa online, collaudo, guida. Stato e prossimi passi in `2026-11-04-piattaforma-videocorsi-implementation.md`.

## Registro errori

`log_errori`, aggiunto il 4 novembre su richiesta di Paolo: quando qualcuno segnala "mi ha dato un errore strano" si deve poter ricostruire cosa è successo.

- **Server**: `handleError` in `hooks.server.ts` registra ogni errore imprevisto (non i 404) con stack, percorso, metodo, utente e browser. La pagina `+error.svelte` mostra all'utente un **codice di 6 caratteri** da citare scrivendo a info@inlumia.it.
- **Browser**: `src/lib/errori-browser.ts` manda a `/api/errori` (aperta anche prima dell'accesso; al massimo 30 segnalazioni all'ora per utente, 300 in tutto dagli utenti collegati e 30 dagli anonimi; caratteri di controllo tolti prima di scrivere) le eccezioni della pagina, gli errori del player (hls.js, HLS nativo di Safari, risposte non 200 di `/api/player/avvia`) e i caricamenti di video e copertine falliti. Insieme all'errore arriva la **traccia** degli ultimi 20 passaggi della scheda (pagine aperte, link e pulsanti premuti, moduli inviati), tenuta in `sessionStorage`: solo nomi e testi dei pulsanti, mai i valori dei campi.
- **Servizi esterni**: risposte di errore di Resend (con oggetto e destinatario) e sincronizzazioni systeme.io fallite. Gli errori di Bunny lato server arrivano come errori del server.
- I token nei percorsi (`/accesso/invito/<token>`) e i parametri con nomi tipo token/codice/password vengono mascherati prima di scrivere.
- Si tengono 180 giorni (pulizia nel job giornaliero). Se è impostata `AVVISI_ERRORI_A`, il job di ogni mattina manda a quell'indirizzo il riepilogo degli errori delle 24 ore precedenti, raggruppati per messaggio.
- Lettura: `npx tsx scripts/errori.ts` (ultimi 7 giorni), `scripts/errori.ts 30`, `scripts/errori.ts K7M2QX` (un errore con dettaglio e traccia).
