# Spec — Actualités & Newsletter (pilotage console admin)

**Date** : 2026-06-16
**Projet** : ALDANA / Kinetic Silence
**Statut** : validé pour planification
**Issu de** : panel d'experts (architecte-php, bdd-mysql, securite-php, frontend-vanilla, deploiement-mutualise)

## 1. Objectif

Permettre à l'administratrice du studio de publier des **actualités** (brèves) sur la
vitrine et de gérer une **newsletter** (composition + envoi), entièrement depuis la
console admin, sans intervention technique.

Deux modules **découplés** :

1. **Actualités** — brèves courtes (date + image + titre + corps), listées sur
   `/actualites` et en bloc « dernières actus » sur l'accueil. Pas de page individuelle
   par brève en V1.
2. **Newsletter** — campagnes composées en admin, envoyées **par lots via cron** pour
   respecter les quotas SMTP du mutualisé. Abonnés via formulaire public (double opt-in
   RGPD) **et** clients existants consentants (`t_users.Data_Commercial`).

## 2. Décisions arbitrées (entrées utilisateur)

| Sujet | Décision |
|---|---|
| Format actus | Brèves courtes, **corps HTML riche** purifié via **HTMLPurifier** |
| Envoi newsletter | **Interne par lots via cron** (modèle `ReminderService`) |
| Abonnés | **Public (formulaire + double opt-in)** + clients via `Data_Commercial` |
| Lien actu↔news | **Découplés** (2 modules indépendants) |
| Stockage tokens | **Hashés au repos** (SHA-256), confirm TTL 48h + usage unique, unsub durable |
| File d'envoi | Table `t_newsletter_sends`, idempotence par statut de ligne |
| Clients existants | **Pas de seed** : UNION au moment de l'enqueue, exclusion des `unsubscribed` |
| Désinscription | GET → page de confirmation → POST+CSRF ; + en-tête `List-Unsubscribe` |
| Transport prod | **SMTP mutualisé** OVH/Hostinger (`MAIL_DSN`, config-only) |

## 3. Conventions imposées (rappel CLAUDE.md)

- `declare(strict_types=1);`, PSR-12, indentation 4 espaces.
- `htmlspecialchars($x ?? '', ENT_QUOTES, 'UTF-8')` systématique (helper `e()`).
- Tables `t_`, colonnes snake_case, PK `*_ID INT UNSIGNED AUTO_INCREMENT`, `utf8mb4_unicode_ci`.
- PDO via `Database::getInstance()->getConnection()`, requêtes préparées.
- **CSRF sur tout POST** (sauf webhooks). Role admin = 29.
- Migrations idempotentes (`CREATE TABLE IF NOT EXISTS`), **appliquées à la main** en prod
  (runner cassé sur PREPARE/EXECUTE).

---

## 4. Module ACTUALITÉS

### 4.1 Schéma — migration `038_create_news.sql`

```sql
CREATE TABLE IF NOT EXISTS t_news (
    news_ID       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    title         VARCHAR(200) NOT NULL,
    slug          VARCHAR(200) NOT NULL UNIQUE,
    body          MEDIUMTEXT NULL,            -- HTML purifié à l'enregistrement
    image_url     VARCHAR(500) NULL,
    status        ENUM('draft','published') NOT NULL DEFAULT 'draft',
    published_at  DATETIME NULL,
    display_date  DATE NULL,                  -- date éditoriale affichée
    position      SMALLINT UNSIGNED NOT NULL DEFAULT 0,
    is_active     TINYINT(1) NOT NULL DEFAULT 1,   -- soft-deactivate
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_news_published (status, is_active, display_date),
    INDEX idx_news_position (position)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

### 4.2 Fichiers

**Créer**
- `src/Repositories/NewsRepository.php`
- `src/Controllers/Admin/NewsController.php` (réutilise `HandlesPhotoUpload`)
- `views/pages/admin/news/index.php`
- `views/pages/admin/news/form.php`
- `views/pages/public/actualites.php`
- `views/partials/public/news-latest.php` (bloc accueil)

**Modifier**
- `config/routes.php` (routes admin + `/actualites`)
- `views/partials/admin/sidebar.php` (groupe **Configuration**, après « Site & contenu »)
- `src/Controllers/PublicController.php` (`actualites()` + injection dans `home()`)
- `views/pages/public/home.php` (include `news-latest.php`)
- `views/partials/navbar.php` (lien `/actualites`)
- `public/assets/css/aldana.css` (`.news-grid`, `.news-card*`)

### 4.3 NewsRepository — API

```php
public function listAll(): array                          // admin, tous statuts
public function findByID(int $id): ?array
public function listPublished(): array                    // /actualites : published + active
public function listLatestPublished(int $limit = 3): array // home
public function create(array $d): int
public function update(int $id, array $d): void
public function deactivate(int $id): void
public function activate(int $id): void
public function updateImage(int $id, ?string $url): void
```
> `LIMIT` paramétré : caster `(int)` validé puis concaténer, ou `bindValue(PARAM_INT)` selon `ATTR_EMULATE_PREPARES`.

### 4.4 NewsController — actions (calqué EventsController)

`index`, `form`, `save`, `deactivate`, `activate`. Validation `save()` :
- `title` requis ; `display_date` `^\d{4}-\d{2}-\d{2}$` si fournie ; `status` whitelisté.
- **`body` purifié via HTMLPurifier** avant persistance (whitelist : p, a[href], strong, em,
  ul/ol/li, h3/h4, br ; suppression script/on*/javascript:).
- Image via `resolvePhotoField('news', ...)` (champs `image_file`/`image_url`/`image_delete`).

### 4.5 Routes

```php
'GET /actualites'                  => ['App\Controllers\PublicController', 'actualites'],

'GET /admin/actualites'            => ['App\Controllers\Admin\NewsController', 'index',      ['middleware' => 'admin']],
'GET /admin/actualites/nouveau'    => ['App\Controllers\Admin\NewsController', 'form',       ['middleware' => 'admin']],
'GET /admin/actualites/edit'       => ['App\Controllers\Admin\NewsController', 'form',       ['middleware' => 'admin']],
'POST /admin/actualites/save'      => ['App\Controllers\Admin\NewsController', 'save',       ['middleware' => 'admin']],
'POST /admin/actualites/deactivate'=> ['App\Controllers\Admin\NewsController', 'deactivate', ['middleware' => 'admin']],
'POST /admin/actualites/activate'  => ['App\Controllers\Admin\NewsController', 'activate',   ['middleware' => 'admin']],
```

### 4.6 Front public

- **`.news-grid`** (`repeat(auto-fill, minmax(18rem,1fr))`) + **`.news-card`** (calqué
  `.video-card`, palette `var(--card)/--border`, hover `scale(1.04)`). Réutilisé liste + accueil.
- Cartes en `<article>` + `.reveal` (IntersectionObserver déjà global) + `<time datetime>`.
- Bloc accueil inséré entre la section « Aurane » et le CTA final, titre + bouton « Toutes
  les actualités ».
- Corps de brève rendu **après purification au stockage** (donc affiché tel quel, déjà sûr).

---

## 5. Module NEWSLETTER

### 5.1 Schémas

**`039_create_newsletter_subscribers.sql`**
```sql
CREATE TABLE IF NOT EXISTS t_newsletter_subscribers (
    subscriber_ID       INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email               VARCHAR(255) NOT NULL,
    status              ENUM('pending','confirmed','unsubscribed') NOT NULL DEFAULT 'pending',
    confirm_token_hash  CHAR(64) NULL,        -- sha256(token brut)
    confirm_expires_at  DATETIME NULL,        -- TTL 48h
    unsub_token_hash    CHAR(64) NULL,        -- sha256(token brut), durable
    source              ENUM('public','client') NOT NULL DEFAULT 'public',
    user_ID             INT UNSIGNED NULL,
    consent_ip          VARCHAR(45) NULL,     -- preuve RGPD
    consent_user_agent  VARCHAR(255) NULL,
    confirmed_at        DATETIME NULL,
    unsubscribed_at     DATETIME NULL,
    created_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at          TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_newsletter_email (email),
    INDEX idx_newsletter_status (status),
    INDEX idx_confirm_token (confirm_token_hash),
    INDEX idx_unsub_token (unsub_token_hash),
    CONSTRAINT fk_newsletter_sub_user
        FOREIGN KEY (user_ID) REFERENCES t_users (user_ID)
        ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
> **À vérifier avant exécution** : type exact de `t_users.user_ID` (INT vs BIGINT UNSIGNED)
> pour éviter errno 150. Aligner en conséquence.

**`040_create_newsletter_campaigns.sql`**
```sql
CREATE TABLE IF NOT EXISTS t_newsletter_campaigns (
    campaign_ID   INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    subject       VARCHAR(255) NOT NULL,
    body_html     MEDIUMTEXT NOT NULL,        -- HTML purifié à l'enregistrement
    status        ENUM('draft','queued','sending','sent') NOT NULL DEFAULT 'draft',
    total_count   INT UNSIGNED NOT NULL DEFAULT 0,
    sent_count    INT UNSIGNED NOT NULL DEFAULT 0,
    failed_count  INT UNSIGNED NOT NULL DEFAULT 0,
    queued_at     DATETIME NULL,
    sent_at       DATETIME NULL,
    created_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at    TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_campaign_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**`041_create_newsletter_sends.sql`** (la file d'envoi)
```sql
CREATE TABLE IF NOT EXISTS t_newsletter_sends (
    send_ID         INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    campaign_ID     INT UNSIGNED NOT NULL,
    subscriber_ID   INT UNSIGNED NULL,
    email           VARCHAR(255) NOT NULL,    -- snapshot figé
    full_name       VARCHAR(200) NULL,
    unsub_token_hash CHAR(64) NULL,           -- pour le lien désinscription de CET envoi
    status          ENUM('pending','sending','sent','failed','skipped') NOT NULL DEFAULT 'pending',
    attempts        TINYINT UNSIGNED NOT NULL DEFAULT 0,
    error_message   VARCHAR(500) NULL,
    sent_at         DATETIME NULL,
    created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_send_campaign_subscriber (campaign_ID, subscriber_ID),
    INDEX idx_send_pickup (campaign_ID, status),
    CONSTRAINT fk_send_campaign
        FOREIGN KEY (campaign_ID) REFERENCES t_newsletter_campaigns (campaign_ID)
        ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT fk_send_subscriber
        FOREIGN KEY (subscriber_ID) REFERENCES t_newsletter_subscribers (subscriber_ID)
        ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```
> **Désinscription — approche unique retenue** : chaque envoi (`t_newsletter_sends`) porte
> son propre `unsub_token_hash`, donc le lien de désinscription fonctionne pour tout
> destinataire, **y compris les clients sans ligne subscriber dédiée**. Cliquer ce lien
> écrit dans `t_newsletter_subscribers` une ligne `unsubscribed` (créée si absente, par
> email). Le snapshot d'enqueue **exclut systématiquement tout email présent en
> `unsubscribed`**. Cela couvre clients + publics sans jamais toucher `Data_Commercial`
> (qui reste la preuve de consentement initial côté compte).

### 5.2 Fichiers

**Créer**
- `src/Repositories/NewsletterSubscriberRepository.php`
- `src/Repositories/NewsletterCampaignRepository.php`
- `src/Services/NewsletterService.php` (injection optionnelle des deps, comme `ReminderService`)
- `src/Controllers/Admin/NewsletterController.php`
- `src/Controllers/NewsletterController.php` (public)
- `bin/send-newsletter.php` (cron worker, calqué `send-reminders.php`)
- `views/pages/admin/newsletter/index.php` (campagnes + KPI abonnés)
- `views/pages/admin/newsletter/form.php` (composer/éditer)
- `views/pages/admin/newsletter/show.php` (détail + progression + bouton enqueue + test)
- `views/pages/admin/newsletter/subscribers.php`
- `views/pages/public/newsletter-confirmation.php` (états opt-in/désinscription paramétrés)
- `views/partials/public/newsletter-form.php` (footer)

**Modifier**
- `config/routes.php`
- `views/partials/admin/sidebar.php` (groupe **Activité**, après « Paiements »)
- `src/Services/EmailService.php` (2 méthodes + propagation d'exception + `List-Unsubscribe`)
- `views/partials/footer.php` (include `newsletter-form.php`, 4e colonne)
- `public/assets/css/aldana.css` (`.footer-newsletter*`, `.nl-hp` honeypot)
- `config/deploy_manifest.php` (déclarer les 4 tables pour `DeployCheck`/`health.php`)
- `.env.example` (rappel `MAIL_DSN` réel en prod)

### 5.3 Repositories — API

```php
// NewsletterSubscriberRepository
findByEmail(string $email): ?array
findByConfirmTokenHash(string $hash): ?array
findByUnsubTokenHash(string $hash): ?array
createPending(string $email, string $confirmHash, \DateTimeInterface $expires, string $source, ?int $userID, string $ip, string $ua): int
confirm(int $id): void                       // status=confirmed, confirmed_at=NOW(), confirm_token_hash=NULL
unsubscribeByEmail(string $email): void
reactivatePending(int $id, string $confirmHash, \DateTimeInterface $expires): void
listConfirmed(): array                        // pour snapshot
listForAdmin(?string $status = null): array
countByStatus(): array

// NewsletterCampaignRepository
listAll(): array
findByID(int $id): ?array
create(array $d): int                         // draft
update(int $id, array $d): void               // refus si != draft (garde-fou service)
markQueued(int $id, int $total): void
markSending(int $id): void
markSent(int $id): void
refreshCounters(int $id): void                // recalcul COUNT depuis t_newsletter_sends
// file
insertSend(int $campaignID, ?int $subscriberID, string $email, ?string $name, string $unsubHash): void // INSERT IGNORE
fetchPendingBatch(int $campaignID, int $limit): array
claimSend(int $sendID): bool                  // UPDATE ... SET status='sending' WHERE send_ID=? AND status='pending'
markSendSent(int $sendID): void
markSendFailed(int $sendID, string $err): void
markSendSkipped(int $sendID): void
countPending(int $campaignID): int
stats(int $campaignID): array                 // {pending,sent,failed,skipped}
findSendByUnsubTokenHash(string $hash): ?array
requeueStale(int $minutes = 30): int          // 'sending' bloqués -> 'pending'
```

### 5.4 NewsletterService — logique métier

```php
__construct(?SubscriberRepo, ?CampaignRepo, ?EmailService)   // injection testable

// public / opt-in
subscribe(string $email, string $ip, string $ua): array      // pending+token, email confirmation ; gère réabo & déjà-confirmé ; message NEUTRE
confirmByToken(string $rawToken): bool                        // vérifie hash + TTL + usage unique
unsubscribeByToken(string $rawToken): bool                   // idempotent

// file
enqueue(int $campaignID): array                              // snapshot UNION (confirmed ∪ users Data_Commercial NOT NULL) − unsubscribed ; campagne queued ; refus si != draft
processQueue(int $batchSize): array                          // CRON : requeueStale, claim batch, envoie, re-vérifie unsubscribed -> skipped, marque sent/failed ; clôt en 'sent'
sendTest(int $campaignID, string $toEmail): void            // envoi unique, ne touche pas la file
```

Garde-fous : try/catch par destinataire (le lot continue), `error_log` sur échec,
`batch_size` lu via `SettingsService::get('newsletter_batch_size', '40')`, pause
`usleep` 200–500 ms entre envois.

### 5.5 EmailService — ajouts

```php
sendNewsletterConfirmation(string $to, string $confirmUrl): void   // double opt-in, template inline FR
sendNewsletterCampaign(string $to, string $subject, string $bodyHtml, string $unsubscribeUrl): void
```
- Pied « Se désinscrire » **obligatoire** + en-tête `List-Unsubscribe: <url>` &
  `List-Unsubscribe-Post: List-Unsubscribe=One-Click`.
- **Variante propageant les exceptions** (la file doit pouvoir marquer `failed`).
- Ne pas loguer l'URL/token en clair (expurger le log mail pour la newsletter).

### 5.6 Routes

```php
// Public
'POST /newsletter/inscription'   => ['App\Controllers\NewsletterController', 'subscribe'],
'GET /newsletter/confirmer'      => ['App\Controllers\NewsletterController', 'confirm'],        // ?token=
'GET /newsletter/desinscription' => ['App\Controllers\NewsletterController', 'unsubscribeForm'],// ?token= -> page confirm
'POST /newsletter/desinscription'=> ['App\Controllers\NewsletterController', 'unsubscribe'],    // CSRF, exécute

// Admin
'GET /admin/newsletter'            => ['App\Controllers\Admin\NewsletterController', 'index',       ['middleware' => 'admin']],
'GET /admin/newsletter/nouvelle'   => ['App\Controllers\Admin\NewsletterController', 'form',        ['middleware' => 'admin']],
'GET /admin/newsletter/edit'       => ['App\Controllers\Admin\NewsletterController', 'form',        ['middleware' => 'admin']],
'GET /admin/newsletter/voir'       => ['App\Controllers\Admin\NewsletterController', 'show',        ['middleware' => 'admin']],
'POST /admin/newsletter/save'      => ['App\Controllers\Admin\NewsletterController', 'save',        ['middleware' => 'admin']],
'POST /admin/newsletter/enqueue'   => ['App\Controllers\Admin\NewsletterController', 'enqueue',     ['middleware' => 'admin']],
'POST /admin/newsletter/send-test' => ['App\Controllers\Admin\NewsletterController', 'sendTest',    ['middleware' => 'admin']],
'GET /admin/newsletter/abonnes'    => ['App\Controllers\Admin\NewsletterController', 'subscribers', ['middleware' => 'admin']],
```

### 5.7 bin/send-newsletter.php

Calqué `bin/send-reminders.php` : autoload + Dotenv + timezone, **verrou `flock`**
anti-chevauchement, lit `newsletter_batch_size`, appelle `processQueue()`, log horodaté
STDOUT/STDERR, exit 0/1. Cron suggéré `*/15 * * * *` (PHP 8.4 OVH), redirection
`storage/logs/cron-newsletter.log`.

### 5.8 Front public

- **Formulaire footer** (4e colonne) : `type=email` + label `.visually-hidden`, honeypot
  `name="website"` en `.nl-hp` (hors écran, `tabindex=-1`, `aria-hidden`), `csrf_field()`,
  message `role="status"` « Vérifiez votre boîte mail » après POST.
- **Page confirmation/désinscription** : vue unique paramétrée par `$state`
  (`confirmed`/`already`/`invalid`/`unsubscribed`), centrée `.error-page`.

---

## 6. Sécurité & RGPD (bloquants)

1. Tokens `bin2hex(random_bytes(32))`, **stockés hashés** SHA-256 ; confirm TTL 48h +
   usage unique ; comparaison par lookup de hash (jamais `==`).
2. CSRF sur tout POST (inscription, désinscription, actions admin).
3. Anti-spam public : honeypot + rate-limit IP (5 / 15 min) ; email validé serveur +
   normalisé minuscules.
4. **Anti-énumération** : message identique quel que soit l'état de l'email.
5. Double opt-in obligatoire (CNIL) ; **preuve** `confirmed_at` + IP + UA.
6. Désinscription : GET → page confirm → POST+CSRF ; idempotent ; `List-Unsubscribe`
   one-click exempté CSRF (vient du MUA). Toujours possible sans être connecté.
7. **HTMLPurifier** sur `t_news.body` ET `t_newsletter_campaigns.body_html` à
   l'enregistrement ; aperçu admin du body en `<iframe sandbox>` (pas d'injection DOM).
8. Log mail expurgé des tokens. `EmailService::send()` actuel avale les exceptions →
   variante propageante pour la file.
9. Export CSV abonnés (si ajouté) : neutraliser l'injection de formules (`= + - @`).

## 7. Ops / déploiement

- Migrations **038 → 041** dans l'ordre, **à la main** (phpMyAdmin/mysql) sur `aldana`
  (local) puis `aldanadb` (prod), **avant tout test** (pattern « table manquante = 500 »).
- Vérifier le type de `t_users.user_ID` avant 039/041 (FK).
- Déclarer les 4 tables dans `config/deploy_manifest.php`.
- Backup BDD prod avant migration.
- `MAIL_DSN` prod = SMTP mutualisé réel (pas `null://null`), `MAIL_FROM` sur
  `@aldanayoga.com`.
- Cron CLI déclaré (PHP 8.4, `*/15`).
- **DNS délivrabilité avant 1er envoi** : SPF (un seul TXT apex `include` du SMTP), DKIM
  (sélecteur fournisseur), DMARC `p=none`+`rua` puis durcir. Test mail-tester ≥ 9/10.
- Lot conservateur **40 / run** (SMTP mutualisé, quotas ~200-500/j) ; pause inter-emails.

## 8. Tests (TDD)

**Actus** : `NewsRepository` round-trip + `listLatestPublished` exclut draft/inactive +
ordre/limit ; `NewsController::save` (titre vide, date mal formée, status hors whitelist,
**body purifié** retire `<script>`).

**Newsletter (cœur)** :
- `subscribe` : nouvel email → pending+token+email ; déjà confirmé → pas de doublon,
  message neutre ; unsubscribed → réactivation pending.
- `confirmByToken` : valide → confirmed + hash effacé ; expiré/invalide → false.
- `enqueue` : snapshot = union dédupliquée, exclut unsubscribed ; campagne queued ;
  refus si != draft.
- `processQueue` : n'envoie que `batchSize` ; 2 passages ne renvoient pas un `sent` ;
  envoi qui throw → `failed`, le lot continue ; file vide → campagne `sent` ; devenu
  unsubscribed après enqueue → `skipped`.
- `sendTest` : 1 envoi, 0 ligne file, statut campagne inchangé.
- Repos : `insertSend` 2× même couple → 1 ligne (UNIQUE) ; `fetchPendingBatch` respecte
  LIMIT et exclut `sent` ; `requeueStale` rebascule les `sending` anciens.
- Public : token bidon → page neutre, pas de 500.

Mocks via injection constructeur (comme `ReminderService`).

## 9. Hors périmètre V1 (YAGNI)

- Page individuelle par brève (`/actualites/{slug}`).
- A/B testing, segmentation d'audience, statistiques d'ouverture/clic.
- Relais transactionnel externe (Brevo/SES) — bascule `MAIL_DSN` ultérieure sans code.
- Éditeur WYSIWYG riche (textarea + HTMLPurifier suffit en V1).
- Purge automatique des `pending` non confirmés (à planifier plus tard via cron).
```
