# CLAUDE.md — mémoire projet pour Claude

Ce fichier condense l'essentiel pour qu'une future session Claude comprenne rapidement l'architecture, les conventions, et les pièges rencontrés.

---

## 1. Contexte produit

Wishlist de vinyles **personnelle** (une seule instance, un seul owner) avec partage par capability URLs. L'owner tient la liste, des viewers peuvent poser des « verrous » (réservations) pour signaler « je pense acheter celui-ci pour l'offrir ». Un **suivi de prix manuel** : l'owner saisit à la main les URLs et prix pour chaque source marchande qu'il a configurée.

**Volumétrie cible** : ≤ 100 albums. Pas d'optimisation DB nécessaire.

**Utilisateur** : r.cuny@capeos.fr. Prod : `https://vinyle.dpc.li` sur VPS Debian OVH. Dev local : `http://vinyle-dev.dpc.li`.

---

## 2. Stack technique

- **PHP 7.4 minimum, compatible jusqu'à PHP 8.x** — Docker dev : `php:8.4.16-apache`. Le code tourne aussi sur hébergement mutualisé PHP 7.4+.
- **Pas** de framework.
- **SQLite** via PDO (`data/vinyle.sqlite`, WAL + foreign_keys ON).
- **Front** : Bootstrap 5.3 + Bootstrap Icons + jQuery 3.7 (CDN). JS custom en **vanilla** dans `public/assets/js/app.js`.
- **Composer** : requiert juste PHP + extensions (pas de dépendance tierce utilisée au runtime). `composer install` reste supporté mais optionnel — le fallback PSR-4 dans [`public/index.php`](public/index.php) assure l'autoloading des classes `App\*`.
- **HTTP externe** : `HttpClient` via `stream_context_create + file_get_contents` (aucune extension PHP additionnelle requise).

---

## 3. Arborescence (après la grande simplification)

```
vinyle/
├── bin/                 init_db, add_share, add_test_album, refresh_covers, reset_owner
├── config/
│   ├── config.php       constantes + override depuis ENV
│   └── local.php        (gitignore) — surcharges locales
├── data/                SQLite (volume Docker)
├── docker/              Dockerfile, vhost, php.ini
├── public/              DocumentRoot Apache
│   ├── index.php        front controller
│   ├── .htaccess
│   ├── assets/          css + js
│   └── covers/          pochettes téléchargées {id}.jpg|png|webp|gif
└── src/
    ├── Router.php
    ├── Models/
    │   └── Database.php          PDO singleton
    ├── Controllers/
    │   ├── BootstrapController.php
    │   ├── WishlistController.php
    │   ├── AlbumController.php
    │   ├── SearchController.php
    │   ├── CoverController.php
    │   ├── LockController.php
    │   ├── AdminController.php           (gestion share_tokens)
    │   ├── SourcesController.php         (CRUD sources de prix)
    │   └── ManualPriceController.php     (saisie prix manuel)
    ├── Services/
    │   ├── Auth.php
    │   ├── View.php
    │   ├── HttpClient.php                (stream_context, pas de curl PHP)
    │   ├── MusicBrainzClient.php
    │   ├── ITunesClient.php
    │   ├── CoverArtArchiveClient.php
    │   ├── CoverResolver.php             (iTunes → CAA fallback)
    │   ├── CoverCache.php                (download + stockage local)
    │   └── helpers.php                   (e(), url(), generate_token())
    └── Views/           layout, wishlist, album, error_404, admin/{shares,sources}
```

Ce qui a été **supprimé** par rapport à des itérations précédentes :
- `src/Services/PriceSources/*` — 10 classes (DiscogsSource, AmazonSource, etc., + interface + registry + DTOs)
- `bin/update_prices.php` — cron
- `bin/cron_docker.sh` — wrapper cron prod
- Table `cron_runs`

Ces éléments existaient pour gérer un scraping automatique multi-sites — abandonné car ni Amazon (AWS WAF), ni Discogs (Cloudflare), ni Fnac (Akamai), ni Leclerc (SPA JS) ne se sont révélés suffisamment fiables ou pertinents (ex. lowest_price global ≠ prix réel ≥ VG+).

---

## 4. Schéma DB

6 tables. Cf [`bin/init_db.php`](bin/init_db.php).

- **`config`** (key, value) — stocke `owner_token`
- **`share_tokens`** (id, token, viewer_name, created_at) — liens nommés
- **`albums`** (id, mb_release_group_id UNIQUE, discogs_master_id, artist, title, year, cover_url, notes, added_at)
- **`locks`** (id, album_id FK CASCADE, share_token_id FK SET NULL, viewer_name_snapshot, is_owner_lock, locked_at)
- **`price_sources`** (id, code UNIQUE, label, **color** (hex), enabled, sort_order)
  - Administrable : l'owner peut créer/modifier/supprimer. Le `code` est un slug auto-généré à partir du label à la création.
- **`prices`** (id, album_id FK CASCADE, source_id FK CASCADE, price_eur, product_url, updated_at, UNIQUE(album_id, source_id))
  - Colonnes historiques `media_condition`, `sleeve_condition`, `is_available`, `is_manual`, `is_url_pinned` peuvent exister dans les bases migrées — **elles ne sont plus lues ni écrites** par le code.

### Règles métier
- Un verrou par (album, share_token_id) pour un viewer, un par album pour l'owner.
- Suppression d'un share_token → les verrous existants gardent le `viewer_name_snapshot`.
- Prix : une ligne ou rien. Prix **et/ou** URL (au moins l'un des deux), tout est optionnel ensuite.

---

## 5. JS client — `public/assets/js/app.js`

Vanilla, IIFE, commence par `console.info('[vinyle] app.js vN chargé')`. **À chaque modif, incrémente le numéro de version** (v13 → v14…) et synchronise le query param dans [`src/Views/layout.php`](src/Views/layout.php) (`app.js?v=N`) pour cache-buster les navigateurs.

Modules :
- `wireLocalFilter()` — filtre temps réel de la liste wishlist
- `wireAddModal()` — modale recherche + ajout (MusicBrainz → covers async)
- `fetchCover()` — fetch async pochette par résultat
- `wireDeleteAlbum()` — bouton retirer album
- `wireLockButton()` / `wireUnlockButtons()` — verrous
- `wireCopyButtons()` — copie URL (fallback `execCommand('copy')` pour HTTP)
- `wireManualPrice()` — modale saisie prix (URL + prix)
- `wireTooltips()` — init Bootstrap tooltips

### Piège récurrent : classes Bootstrap `!important`
`.d-flex` a `display: flex !important`. Pour masquer, **toujours utiliser `classList.toggle('d-none', …)`** — `.d-none` est déclarée après `.d-flex` dans Bootstrap et gagne la cascade.

---

## 6. Modèle de prix (actuel, stabilisé)

- Aucune automatisation. Zero cron, zero scraping.
- L'owner saisit **manuellement** : URL et/ou prix pour chaque (album × source).
- Rien d'autre. Pas de condition (M, NM, VG+…), pas de date de mise à jour affichée, pas de flag `is_manual` (tout est manuel par définition maintenant).

### Admin des sources
L'owner gère sa propre liste de sources avec **nom + couleur** via `/w/{owner}/admin/sources`. CRUD complet (create/update/toggle/delete). Le `code` interne est un slug auto-généré, les URLs REST l'utilisent (ex. `/admin/sources/bandcamp/delete`). Un `<input type="color">` natif est utilisé pour la couleur ; la lisibilité du foreground est calculée en PHP via la luminance ITU-R BT.601.

Les 5 sources seedées par défaut (Discogs, Amazon.fr, Fnac, iMusic.fr, E.Leclerc) ne sont qu'une base — l'owner peut les renommer, recolorier, en ajouter (Bandcamp, Boutique locale, eBay…) ou supprimer.

---

## 7. Pochettes d'album

Flow à l'ajout :
1. Client affiche les résultats MusicBrainz sans cover (placeholder spinner).
2. Pour chaque résultat, JS appelle `/api/cover?artist=…&title=…&mb_id=…` en parallèle.
3. `CoverResolver` essaie iTunes (Search API, normalisation fuzzy), fallback sur Cover Art Archive via `mb_release_group_id`.
4. À l'ajout définitif d'un album, `CoverCache::store(albumId, remoteUrl)` télécharge l'image dans `public/covers/{id}.{ext}` (détection magic bytes JPEG/PNG/WebP/GIF).
5. `albums.cover_url` est mis à jour vers `/covers/{id}.{ext}` (chemin servi en direct par Apache → vitesse instantanée).

`bin/refresh_covers.php` heale les fichiers manquants.

---

## 8. Conventions

- PHP `declare(strict_types=1);` dans tous les fichiers.
- Classes `final` par défaut.
- Commentaires : seulement pour expliquer le « pourquoi » non évident (ex. piège Bootstrap `!important`).
- Français côté UI / DB / docs utilisateur ; anglais côté code.
- Échappement HTML systématique via `e()` (helper).

### ⚠️ Compatibilité PHP 7.4
L'app doit tourner aussi sur PHP 7.4 (hébergement mutualisé possible). **Éviter** :
- `str_contains`, `str_starts_with`, `str_ends_with` — **sauf** si les polyfills de [`src/Services/helpers.php`](src/Services/helpers.php) sont en place (c'est le cas aujourd'hui, les fonctions sont définies avec `if (!function_exists())` comme garde).
- `catch (\Throwable)` sans variable → utiliser `catch (\Throwable $e)`.
- Union types `foo|bar` dans les signatures → passer en `@param` docblock ou retirer le type.
- Constructor property promotion (`public function __construct(private Foo $x)`) → déclarer les propriétés + assigner dans le body.
- `readonly` (PHP 8.1+), `enum` (PHP 8.1+), `match` (PHP 8), `?->` nullsafe (PHP 8), `never` (PHP 8.1+), `new` en argument par défaut (PHP 8.1+).

`config/config.php` charge `src/Services/helpers.php` pour que les polyfills soient dispos partout (y compris dans les scripts `bin/*` et l'autoloader fallback de `public/index.php`).

### ⚠️ Transactions PDO sur SQLite
Pour les sections qui ont besoin d'un `BEGIN IMMEDIATE` (ex. LockController qui sérialise les écritures concurrentes), utiliser `$pdo->exec('BEGIN IMMEDIATE')` + `$pdo->exec('COMMIT')` / `$pdo->exec('ROLLBACK')`. Mélanger avec `beginTransaction()` / `rollBack()` provoque en PHP 7.4 : `PDOException: There is no active transaction`. Tenir la cohérence : soit TOUT via méthodes PDO, soit TOUT via `exec()`.

### ⚠️ NE JAMAIS faire dans les tests locaux
Deux fois dans l'historique, j'ai écrasé les données utilisateur en faisant :
- `rm -f data/vinyle.sqlite*` → supprimait la base de l'utilisateur (volume Docker mappé sur le dossier).
- `rm -f public/covers/*.jpg` → supprimait les pochettes téléchargées.

**Règle** : pour mes tests, TOUJOURS utiliser :
```bash
export APP_DB_PATH=/tmp/vinyle_test.sqlite
export APP_COVERS_DIR=/tmp/test_covers
```
Et ne nettoyer QUE ces chemins `/tmp/`. **Jamais** `data/` ni `public/covers/`.

### Pattern de test end-to-end isolé
```bash
rm -rf /tmp/test_covers /tmp/vinyle_test.sqlite
mkdir -p /tmp/test_covers
export APP_DB_PATH=/tmp/vinyle_test.sqlite APP_COVERS_DIR=/tmp/test_covers
php bin/init_db.php > /dev/null

cat > /tmp/srv_router.php <<'PHP'
<?php
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$real = '/Users/ntamack/Projets/Vinyle/vinyle/public' . $path;
if ($path !== '/' && is_file($real)) return false;
require '/Users/ntamack/Projets/Vinyle/vinyle/public/index.php';
PHP
APP_DB_PATH=/tmp/vinyle_test.sqlite APP_COVERS_DIR=/tmp/test_covers \
  php -S 127.0.0.1:8765 -t public /tmp/srv_router.php > /dev/null 2>&1 &
SRV=$!; sleep 1
```

---

## 9. Historique des grandes décisions

| Phase / décision | Note |
|---|---|
| Phases 1-4 | Docker, router, auth, covers, verrous — stables |
| Phase 5-6 | Sources de prix multi-sites avec scraping auto — **abandonnée** |
| **Grande simplification** | Tout le scraping/cron retiré : MusicBrainz a été trop peu fiable (retours différents sans raison), Discogs (Cloudflare) + Amazon (WAF) + Fnac (Akamai) + Leclerc (SPA) impossibles à scraper proprement. iMusic marchait mais trop fragile seul. Décision : pas d'automatisation, tout manuel. |
| **Sources administrables** | Le user peut gérer sa propre liste (nom + couleur). Les 5 sources par défaut restent un exemple, pas une contrainte. |

---

## 10. Choses à ne PAS suggérer si non demandé

- Pas d'authentification par mot de passe (capability URLs = demande explicite).
- Pas d'historique de prix.
- Pas de scraping automatique ni de cron de mise à jour des prix.
- Pas de tests automatisés unitaires (scripts de smoke test manuel suffisent).
- Pas de migration vers un framework (PHP natif est voulu).
- Pas d'export/import, multi-wishlist, notifications.
- Pas de retour à la notion d'état (M, NM, VG+…) ni de date de dernière maj affichée — explicitement retirés.
