# Spec — Cours vidéo (VOD) : catalogue, dépôt admin, achat daté

- **Date** : 2026-06-12
- **Projet** : ALDANA / Kinetic Silence
- **Statut** : validé (design), prêt pour plan d'implémentation
- **Phase** : extension P5/P6 (paiement + back-office)

## 1. Objectif

Permettre :
1. Côté **admin** : déposer des cours en **vidéo** (lien externe YouTube/Vimeo) via une UX simple, catégorisés par **pratique**.
2. Côté **client** : un **catalogue** où choisir librement des vidéos, les **acheter à l'unité**, et les **visionner** tant que l'accès n'est pas périmé.
3. Gérer la **date d'achat** et la **date de péremption** par achat.

## 2. Décisions de cadrage (issues du brainstorming)

| Sujet | Décision |
|---|---|
| Hébergement vidéo | **Lien externe** (YouTube non répertorié / Vimeo). Pas d'upload de fichier vidéo (contrainte hébergement mutualisé). |
| Durée de validité | **Par vidéo**, définie dans la fiche produit par l'admin (champ `access_days`). |
| Modèle d'achat | **À l'unité** uniquement. Pas de lien avec les forfaits/packs (`plan_inclusions`). |
| Protection | Application-level : l'URL n'est jamais exposée sauf accès valide et non périmé. La vidéo elle-même n'est pas protégée chez l'hébergeur. |
| Catégorisation | Par **pratique** (`t_practices`). |
| Paiement | Réutilise le tunnel existant (`CheckoutController`, mode simulation / Stripe) et `t_payments`. |

## 3. Architecture

Module Vidéo **dédié**, calqué sur les patterns existants (Controller / Service / Repository, soft-deactivate + slug auto). Aucune modification du modèle de paiement hormis un nouveau type d'opération.

Une vidéo n'est :
- ni une `t_classes` (live/visio synchrone),
- ni un `t_subscription_plans` (forfait — éviter d'y mêler URL/miniature/durée vidéo).

## 4. Modèle de données

### 4.1 Nouvelle table `t_videos` (catalogue)

| Colonne | Type | Notes |
|---|---|---|
| `video_ID` | INT UNSIGNED AUTO_INCREMENT PK | |
| `practice_ID` | INT UNSIGNED NULL, FK → `t_practices` | catégorisation |
| `teacher_ID` | INT UNSIGNED NULL, FK → `t_teachers` | optionnel |
| `title` | VARCHAR(150) NOT NULL | |
| `slug` | VARCHAR(160) NOT NULL UNIQUE | auto-généré |
| `description` | TEXT NULL | |
| `provider` | ENUM('youtube','vimeo','other') NOT NULL DEFAULT 'youtube' | |
| `video_url` | VARCHAR(500) NOT NULL | lien externe / embed |
| `thumbnail_path` | VARCHAR(255) NULL | miniature uploadée (`ImageUploadService` + WebP) |
| `duration_min` | SMALLINT UNSIGNED NULL | durée vidéo (info) |
| `price_cents` | INT UNSIGNED NOT NULL | |
| `access_days` | SMALLINT UNSIGNED NOT NULL DEFAULT 45 | durée de validité après achat |
| `display_order` | SMALLINT UNSIGNED NOT NULL DEFAULT 100 | |
| `active` | TINYINT(1) NOT NULL DEFAULT 1 | soft-deactivate |
| `created_at` / `updated_at` | TIMESTAMP | |

Index : `idx_practice (practice_ID)`, `idx_active_order (active, display_order)`.

### 4.2 Nouvelle table `t_video_access` (achats datés)

| Colonne | Type | Notes |
|---|---|---|
| `access_ID` | BIGINT UNSIGNED AUTO_INCREMENT PK | |
| `user_ID` | INT UNSIGNED NOT NULL, FK → `t_users` | |
| `video_ID` | INT UNSIGNED NOT NULL, FK → `t_videos` | |
| `payment_ID` | INT UNSIGNED NULL, FK → `t_payments` | trace / reçu |
| `purchased_at` | DATETIME NOT NULL | date d'achat |
| `expires_at` | DATETIME NOT NULL | = `purchased_at + access_days`, **figée à l'achat** |
| `status` | ENUM('active','expired','refunded') NOT NULL DEFAULT 'active' | |
| `created_at` | TIMESTAMP | |

Index : `idx_user_video (user_ID, video_ID)`, `idx_expires (expires_at)`.
Le rachat après expiration crée une **nouvelle ligne** (pas de contrainte d'unicité sur `(user_ID, video_ID)`).

### 4.3 Modification `t_payments`

Ajouter `'video_purchase'` à l'ENUM `type`. Migration idempotente (vérif via `INFORMATION_SCHEMA`).

### 4.4 Dépréciation

Désactiver (pas supprimer) le tarif générique « Vidéo » (`slug = 'video'`) de `t_subscription_plans` une fois le module en place — redondant avec le prix par vidéo.

### 4.5 Expiration

Gérée **paresseusement** : les requêtes filtrent `expires_at > NOW()` et `status = 'active'`. Pas de cron. Optionnel ultérieur : tâche de passage en `status='expired'` pour la lisibilité admin.

## 5. Back-office (admin)

- Entrée sidebar **« Vidéos »**.
- **Liste** (`views/pages/admin/videos/index.php`) : vignette, titre, pratique, prix, durée d'accès, statut ; filtre par pratique ; Modifier / Désactiver.
- **Formulaire** (`views/pages/admin/videos/form.php`) : Titre · Pratique (select) · Prof (optionnel) · Hébergeur · URL vidéo · Miniature (upload) · Description · Durée vidéo (min) · Prix (€) · Durée de validité (jours) · Ordre · Actif.
- `App\Controllers\Admin\VideosController` (index, form, save, toggle) sur le modèle de `PacksController` / `ClassesController`.
- `App\Repositories\VideoRepository` : CRUD, `listForAdmin`, `listActive`, `listByPractice`, `findBySlug`, `findActiveById`, slug auto.

## 6. Front client

### 6.1 Catalogue public `/cours-video`
- Lien navbar (à côté de PLANNING / TARIFS).
- Grille de vignettes, filtres par pratique, titre / durée / prix.
- Bouton **« Acheter »**, ou **« Regarder »** si accès actif valide.
- Lecteur **absent** du HTML.

### 6.2 Achat
- `POST /checkout/start` étendu pour accepter `video_id` (en plus de `plan_id`).
- Crée `t_payments` type `video_purchase`.
- Mode simulation **ou** Stripe (réutilisation existante).
- Au succès : création `t_video_access` (`purchased_at = NOW()`, `expires_at = NOW() + access_days`), transactionnel.

### 6.3 Visionnage `/cours-video/{slug}` (middleware `auth`)
- Le serveur vérifie `VideoAccessRepository->hasActiveAccess(user_ID, video_ID)` ET `expires_at > NOW()`.
- Si OK → iframe avec `video_url`.
- Sinon → CTA d'achat / « Accès expiré — racheter ».
- L'URL n'est rendue **que** dans le cas valide.

### 6.4 « Mes vidéos » `/mon-compte/videos`
- Vidéos actives avec date d'expiration (« Accès jusqu'au … »).
- Périmées grisées + bouton racheter.

## 7. Routes (`config/routes.php`)

```
GET  /cours-video                         PublicVideosController@catalog
GET  /cours-video/{slug}                  PublicVideosController@watch        (auth)
POST /checkout/start                       CheckoutController@start            (étendu video_id)
GET  /mon-compte/videos                    AccountController@videos            (auth)
GET  /admin/videos                         Admin\VideosController@index        (admin)
GET  /admin/videos/nouveau                 Admin\VideosController@form         (admin)
GET  /admin/videos/modifier?id=            Admin\VideosController@form         (admin)
POST /admin/videos/save                    Admin\VideosController@save         (admin)
POST /admin/videos/toggle                  Admin\VideosController@toggle       (admin)
```

## 8. Sécurité (conventions ALDANA)

- CSRF sur tout POST (achat + admin).
- `htmlspecialchars($v ?? '', ENT_QUOTES, 'UTF-8')` sur toute sortie.
- Validation `filter_var($url, FILTER_VALIDATE_URL)` + restriction des domaines selon `provider` (youtube.com/youtu.be, vimeo.com).
- Middleware `auth` sur visionnage et « Mes vidéos » ; `admin` sur le back-office.
- Achat transactionnel (paiement confirmé → création accès).
- `declare(strict_types=1);` partout, PSR-12.

## 9. Migrations à produire

- `026_create_videos.sql` — table `t_videos` (idempotente).
- `027_create_video_access.sql` — table `t_video_access` (idempotente).
- `028_alter_payments_add_video_type.sql` — ENUM `type` += `video_purchase` (idempotente).
- (dernière migration existante = `025`).

**Rappel critique** : exécuter les migrations en **prod** avant de tester (pattern récurrent « Erreur serveur » = table/colonne manquante).

## 10. Hors scope (YAGNI)

- Upload/transcodage de fichiers vidéo.
- Inclusion des vidéos dans les forfaits (`plan_inclusions`).
- Comptage de visionnages / DRM / signed URLs.
- Catégorisation autre que par pratique.

## 11. Critères de succès

- L'admin dépose une vidéo (URL + miniature + pratique + prix + durée) en une page.
- Le catalogue public liste et filtre les vidéos par pratique.
- Un client achète une vidéo (simulation et Stripe), `t_video_access` créée avec `expires_at` correct.
- Avant péremption : le lecteur s'affiche ; l'URL n'apparaît pas pour un non-acheteur.
- Après péremption : lecteur masqué, message « Accès expiré » + rachat possible.
- « Mes vidéos » affiche dates d'expiration et état.
