# CommunicationBundle — diagnostic préalable et modèle proposé

Date du diagnostic : 28 juillet 2026

Statut : **proposition soumise à validation humaine — aucune migration créée ou exécutée**

## 1. Périmètre et méthode

Ce document couvre les étapes 1 et 2 demandées :

1. inspection de l'application existante ;
2. proposition d'un modèle de données additif.

Il ne crée pas encore `CommunicationBundle`, ne modifie aucune entité existante et
ne touche ni à la base de données ni à `update.sql`. La suite ne doit commencer
qu'après validation de ce diagnostic et du modèle.

Le lot 1 reste le socle éditorial : idées, posts, variantes `generic` et
`linkedin`, campagnes, modèles, médias, calendrier, validation, programmation
interne, publication manuelle et historique. La publication LinkedIn réelle,
les métriques et l'IA restent respectivement dans les lots 2 et 3.

## 2. Diagnostic de l'existant

### 2.1 Architecture Symfony

- Le projet utilise PHP `>= 8.2`, Symfony `7.1`, Doctrine ORM `3.3`,
  DoctrineBundle `2.13`, Symfony UX Turbo et Stimulus.
- Les bundles métier sont dans `src/Bundles`, sous le namespace
  `App\Bundle\...`.
- Les bundles activés sont déclarés dans `config/bundles.php`.
- Les routes de chaque bundle sont importées explicitement dans
  `config/routes.yaml`.
- Les services sont configurés par bundle dans `Config/services.yaml`.
- Le menu est décrit dans `Config/menu.yaml`.
- Les templates de bundle étendent le layout Admin et utilisent les namespaces
  Twig tels que `@Commercial/...`.
- AssetMapper est utilisé. Les contrôleurs Stimulus et feuilles CSS sont
  importés explicitement dans `assets/bootstrap.js` et `assets/app.js`.
- Il n'existe actuellement aucune classe ou table Communication.

Conséquence : `CommunicationBundle` devra fournir son Bundle, son extension DI,
ses services, ses routes, son menu, ses templates et ses imports d'assets selon
ces conventions. Aucun `FormType` ne sera créé.

### 2.2 Société active et isolation multi-société

Le service de référence est
`App\Bundle\AdminBundle\Service\CurrentCompanyProvider`. Il s'appuie sur la
session et `User::canAccessCompany()`, tout en excluant une société supprimée.

`Societe` est une entité historique volumineuse avec de nombreuses associations
inverses. Pour limiter le couplage et respecter l'interdiction de modifier une
entité existante sans nécessité, les nouvelles entités Communication porteront
leurs relations `ManyToOne` vers `Societe`, sans ajouter de collection inverse à
`Societe`.

Règles proposées :

- toute requête métier reçoit explicitement la société active ;
- les repositories n'exposent pas de `findOneByIdtoken()` non contextualisé ;
- la signature standard sera `findOneForCompanyByIdtoken(Societe $company,
  string $idtoken)` ;
- les managers revalident l'appartenance de toutes les relations ;
- les Voters vérifient à la fois le droit fonctionnel et la société active ;
- aucune relation inter-société n'est acceptée, même si un identifiant valide
  est soumis manuellement.

### 2.3 Utilisateurs, rôles, permissions et Voters

`User` porte un tableau de rôles, une relation vers les rôles applicatifs et la
méthode `canAccessCompany()`. La sécurité Symfony repose actuellement surtout
sur le tableau de rôles et `role_hierarchy`.

Les entités `Role` et `Permission` existent, mais les Voters et le menu ne
consomment pas encore réellement les permissions métier stockées en base. Le
menu sait filtrer par rôles et abonnement, pas par attribut de Voter.

Proposition :

- ajouter les rôles `ROLE_COMMUNICATION_MANAGER`,
  `ROLE_COMMUNICATION_EDITOR` et `ROLE_COMMUNICATION_REVIEWER` à la hiérarchie ;
- définir les attributs `COMMUNICATION_*` demandés dans des Voters ;
- faire des Voters la source de vérité des actions ;
- étendre minimalement `MenuService` pour accepter
  `permissions.attributes: [COMMUNICATION_VIEW]` ;
- conserver également `required_bundle: CommunicationBundle` dans le menu ;
- interdire toute action POST au rôle lecture seule ;
- réserver la publication immédiate et la gestion des comptes au manager ou
  administrateur.

Cette extension est préférable à l'introduction d'un second moteur de
permissions.

### 2.4 Activation du bundle par abonnement

`SubscriptionAccessService` sait vérifier l'activation d'un bundle par société
et `RequiresBundle` permet de protéger un contrôleur. Le menu sait utiliser
`required_bundle`.

Anomalie observée : `SubscriptionAccessSubscriber` importe
`App\Bundle\AppBundle\Entity\User`, qui n'existe pas, alors que l'utilisateur
réel est dans `AdminBundle`. La vérification d'accès reste exploitable, mais la
branche de journalisation de l'utilisateur est incohérente.

Avant d'exposer Communication en production, il faudra :

- corriger cet import dans un changement séparé et testé ;
- décider si Communication est activé automatiquement pour les abonnements
  existants ou uniquement via une nouvelle ligne d'abonnement ;
- protéger toutes les routes du bundle avec `RequiresBundle`.

### 2.5 Notifications

Une façade de notifications existe dans `AppBundle` :

- `NotificationService` ;
- repositories et contrôleurs ;
- extension Twig ;
- publication temps réel.

Cependant elle référence `App\Bundle\AppBundle\Entity\Notification` et
`App\Bundle\AppBundle\Entity\User`, entités absentes du mapping actuel.
`RoutineNotificationManager` constate déjà cette indisponibilité et fonctionne
en no-op.

Conclusion : le système global n'est pas opérationnel dans l'état inspecté. Le
lot 1 ne doit pas créer une seconde table de notifications.

Proposition :

- créer dans Communication un adaptateur
  `CommunicationNotificationManager` vers le service global ;
- le rendre désactivable et sans erreur tant que l'infrastructure globale n'est
  pas réparée ;
- réactiver les notifications Communication après restauration et migration
  séparée des entités globales ;
- tester que l'absence du service n'empêche ni la validation ni la
  programmation.

La réparation du centre global de notifications est un prérequis distinct à
faire valider.

### 2.6 Fichiers, documents et stockage

Il n'existe pas d'entité générique `Document`, `PieceJointe` ou `Media`
réutilisable pour une médiathèque éditoriale.

Les stockages existants sont spécialisés :

- justificatifs de dépenses sous `var/uploads` ;
- logos clients sous `public/uploads` ;
- pièces jointes d'email liées à `EmailMessage`.

`App\Bundles\AppBundle\Service\FileManagerService` n'est pas réutilisable en
l'état :

- namespace différent des conventions actives (`App\Bundles`) ;
- service non auto-enregistré ;
- paramètre de répertoire non configuré ;
- dépendances vers plusieurs entités historiques absentes ;
- contrat trop large et validations hétérogènes.

Créer une abstraction étroite dans Communication n'est donc pas un doublon d'un
service global fonctionnel :

```text
CommunicationMediaStorageInterface
└── LocalCommunicationMediaStorage
```

Stockage proposé :

```text
var/uploads/communication/{companyId}/{year}/{month}/{randomFilename}
```

Les téléchargements passeront par un contrôleur autorisé. Les fichiers ne
recevront pas d'URL publique permanente.

Mesures obligatoires :

- nom aléatoire non dérivé du nom client ;
- MIME réel via `finfo`/`UploadedFile`, et contrôle d'image si applicable ;
- liste blanche par catégorie ;
- taille configurable par type ;
- SHA-256 pour la détection de doublons ;
- chemin relatif seulement en base ;
- protection contre traversée de chemin ;
- archivage logique si le média est référencé dans un historique publié.

Limite d'infrastructure détectée :

- `upload_max_filesize = 2M` ;
- `post_max_size = 8M` ;
- `max_file_uploads = 20`.

Ces valeurs empêchent une vraie prise en charge des vidéos et de nombreux PDF.
Le lot 1 peut prendre en charge images et petits documents dans les limites
réelles. Les limites PHP/proxy devront être relevées et documentées avant
d'activer les vidéos. Les miniatures ne seront promises que si GD/Imagick et
l'infrastructure de traitement sont validés.

### 2.7 Calendrier existant

Le calendrier du `CommercialBundle` fournit déjà :

- événements multi-société ;
- sources et comptes ;
- filtres de période ;
- Voter ;
- synchronisation Google ;
- tâches Messenger, jobs, logs, conflits et audit ;
- chiffrement des jetons avec Sodium.

`CommunicationPublication` doit néanmoins rester l'agrégat métier source de
vérité. Un simple `CalendarEvent` ne porte ni snapshots éditoriaux, ni
validation, ni idempotence de publication.

Proposition :

- les vues mois/semaine/jour/liste lisent directement les repositories
  Communication ;
- `CommunicationCalendarQuery` normalise idées, posts et publications en
  éléments de calendrier ;
- un projecteur facultatif pourra plus tard créer un `CalendarEvent` de type
  `communication_publication` pour le calendrier global ;
- aucun événement Commercial ne sera nécessaire au fonctionnement du lot 1.

### 2.8 Messenger, commandes et programmation

`symfony/doctrine-messenger` est installé. Un transport asynchrone existe, avec
trois tentatives configurées. Le calendrier apporte des exemples de messages,
handlers, outbox et commandes `#[AsCommand]`.

Le processeur d'outbox Calendar ne réalise toutefois pas un verrouillage
concurrent suffisamment fort pour une publication sociale irréversible.
Symfony Lock n'est pas installé.

Pour Communication :

- lot 1 : programmation interne, échéances et publication manuelle, sans appel
  externe obligatoire ;
- lot 2 : message `PublishCommunicationPublication` sur le transport async ;
- acquisition atomique SQL `scheduled/queued -> publishing` ;
- `idempotencyKey` unique et vérification de l'identifiant externe ;
- snapshots créés avant la mise en file ;
- retries seulement pour erreurs temporaires ;
- aucune boucle infinie.

L'ajout de Symfony Lock pourra être proposé séparément. L'unicité et la
transition atomique en base restent obligatoires.

### 2.9 OAuth et secrets

L'intégration Google Calendar donne un modèle sain à reprendre :

- `state` aléatoire stocké en session et comparé avec `hash_equals` ;
- callback serveur ;
- échange du code côté serveur ;
- stockage chiffré par Sodium ;
- scopes réellement accordés ;
- journal d'audit nettoyé.

Le lot 2 LinkedIn réutilisera ces principes derrière
`SocialNetworkProviderInterface`, sans dépendance LinkedIn dans le cœur.
Il faudra utiliser exclusivement les API officielles et vérifier leur
documentation actuelle au moment du lot 2. Aucun endpoint, scope ou numéro de
version LinkedIn ne doit être figé pendant le lot 1.

La clé de chiffrement Communication devra être séparée ou gérée par un service
de secrets commun explicitement validé. Jamais de token dans Twig, logs,
snapshots, exceptions utilisateur ou messages Messenger.

### 2.10 IA

Il n'existe pas de `AiProviderInterface` global. `ProspectionBundle` contient
une passerelle et des providers OpenAI/Gemini propres à la prospection. Les
coupler directement à Communication créerait une dépendance métier inversée.

Proposition :

- aucune génération IA obligatoire dans le lot 1 ;
- au lot 3, extraire une interface IA générique dans un socle partagé, avec des
  adaptateurs vers les providers existants, ou valider un port Communication
  dédié ;
- conserver la règle « brouillon uniquement » ;
- aucune planification ou publication automatique d'un résultat IA.

### 2.11 Prospection, Routine, Projet, Client et Produit

`ProspectionBundle` contient déjà `ProspectionContentItem`, un contenu planifié
simple. Il chevauche le futur calendrier éditorial, sans variantes, validation,
médias réutilisables ni historique robuste.

Plan de coexistence proposé :

- ne pas supprimer ni modifier sa table ;
- geler cette fonctionnalité pour compatibilité ;
- fournir après le socle une conversion explicite vers
  `CommunicationIdea`/`CommunicationPost` ;
- mémoriser `sourceType = prospection_content` et le token source ;
- ne rediriger son menu vers Communication qu'après reprise contrôlée des
  données.

Les autres intégrations seront en lecture, derrière
`CommunicationSourceResolver` :

- `project` vers Projet ;
- `client` ou `prospect` vers Commercial ;
- `product` vers Produit ;
- `routine` vers Routine ;
- `campaign` vers Communication.

Le resolver reçoit toujours la société active, expose une vue minimale et
sanitisée, et n'ajoute pas de clé étrangère vers ces bundles. Communication ne
devient ni CRM ni gestionnaire de tâches.

`EmailBundle` possède des entités de messages, modèles et pièces jointes, mais
n'est pas enregistré comme bundle et ne fournit pas actuellement de service
opérationnel réutilisable.

### 2.12 Identifiants, catégories, tags et paramètres

- Il n'existe pas de générateur d'idtoken partagé. La convention courante est
  `bin2hex(random_bytes(24))`. Communication utilisera la même convention,
  centralisée dans un petit service interne ou trait commun.
- Les tags observés sont soit JSON, soit propres à un domaine. Ils ne
  constituent pas un système générique extensible.
- Les catégories produit/routine sont propres à leurs domaines.
- Communication doit donc posséder ses catégories et tags, tous deux isolés
  par société.
- `SocieteSettingsManager` et `SocietePreferencesManager` peuvent fournir
  locale, fuseau et signature par défaut.
- Le paramètre générique existant est trop court et non typé pour le workflow,
  les horaires et la bibliothèque de hashtags. Une entité
  `CommunicationSettings`, unique par société, est justifiée.

### 2.13 Audit et historique

Plusieurs bundles ont leurs propres logs métier. Le calendrier fournit un
logger qui nettoie les secrets. Il n'existe pas de journal transversal capable
de couvrir tout le workflow Communication.

Proposition :

- `CommunicationPublicationLog` pour l'historique détaillé et append-only
  d'une publication ;
- `CommunicationAuditLog` générique append-only pour idées, posts, campagnes,
  médias, comptes et paramètres ;
- métadonnées limitées à une liste blanche ;
- aucun contenu secret ni token ;
- décisions de validation également conservées dans
  `CommunicationApproval`.

### 2.14 Front, Bootstrap, Turbo et fonctionnement sans JavaScript

Le projet utilise déjà :

- layout Admin ;
- cartes Bootstrap ;
- formulaires HTML manuels ;
- modale Turbo globale `frame_modal` ;
- contrôleurs Stimulus importés explicitement.

Le lot 1 suivra ces conventions. Toute action restera disponible via un POST
HTML classique avec CSRF manuel. Stimulus apportera uniquement aperçu,
glisser-déposer, filtres et confort de médiathèque. Un échec JavaScript ne doit
pas bloquer le workflow.

### 2.15 État Doctrine et migrations

État observé :

- 19 migrations exécutées sur 20 disponibles ;
- `Version20260718103000` est signalée comme non exécutée alors que des
  migrations plus récentes le sont ;
- `doctrine:schema:validate --skip-sync` échoue déjà sur plusieurs associations
  existantes (`Avoir`, `Devis`, `Produit`, `FactureRecurrente`, `Source`,
  `CommercialPaymentTransaction`, `Client`).

Conséquences :

- un `doctrine:migrations:diff` brut n'est pas fiable actuellement ;
- aucune migration Communication ne doit être générée ou exécutée avant
  clarification de la migration en attente et de la baseline Doctrine ;
- la future migration devra être explicitement relue, strictement additive et
  limitée aux tables/index Communication ;
- `down()` ne devra pas être exécuté en production sans procédure de sauvegarde
  explicite ;
- `update.sql` ne sera complété qu'après validation de la migration finale.

Le worktree contient par ailleurs de nombreux changements et fichiers non
suivis appartenant aux travaux en cours. Ils ne doivent pas être écrasés.

## 3. Architecture métier proposée

### 3.1 Dépendances autorisées

```text
HTTP/Twig
   │
RequestMapper → DTO → Validator → Manager → Repository/Doctrine
                                      │
                                      ├── CurrentCompanyProvider
                                      ├── Voters / Security
                                      ├── MediaStorageInterface
                                      ├── Notification adapter
                                      ├── SourceResolver
                                      └── SocialNetworkProviderInterface
```

Le cœur Communication connaît les abstractions sociales, jamais LinkedIn
directement.

### 3.2 Enums du lot 1

Enums PHP backed-string proposés :

- `CommunicationIdeaStatus` ;
- `CommunicationPostStatus` ;
- `CommunicationVariantStatus` ;
- `CommunicationPublicationStatus` ;
- `CommunicationCampaignStatus` ;
- `CommunicationApprovalStatus` ;
- `CommunicationPlatform` (`generic`, `linkedin` au lot 1) ;
- `CommunicationObjective` ;
- `CommunicationPriority` ;
- `CommunicationMediaType` ;
- `CommunicationMediaRole` ;
- `CommunicationPublicationMode` (`api`, `manual`, `external`) ;
- `CommunicationAccountType` et `CommunicationAccountStatus`.

Les valeurs restent stockées en `VARCHAR`, pas en ENUM SQL, pour conserver des
migrations portables.

## 4. Modèle de données proposé

Conventions communes :

- clé primaire `INT` auto-générée, alignée sur les clés existantes de `societe`
  et `user` afin de conserver des clés étrangères compatibles ;
- `idtoken CHAR(48)` unique, créé par 24 octets aléatoires ;
- `company_id BIGINT NOT NULL`, sauf modèle global explicitement autorisé ;
- dates métier en `DATETIME_IMMUTABLE` UTC ;
- fuseau IANA conservé séparément pour l'affichage et la planification ;
- archivage logique par `archived_at` ;
- textes éditoriaux en `TEXT`/`LONGTEXT` selon le moteur ;
- JSON uniquement pour snapshots, capacités et petites listes sans relations ;
- clés étrangères et index nommés explicitement.

### 4.1 Tables du lot 1

#### `communication_category`

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `name VARCHAR(120)`, `slug VARCHAR(140)`, `color VARCHAR(16) NULL` ;
- `description TEXT NULL`, `active BOOLEAN` ;
- `created_at`, `updated_at`, `archived_at NULL`.

Contraintes/index :

- unique `(company_id, slug)` ;
- index `(company_id, active)`.

#### `communication_tag`

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `name VARCHAR(80)`, `slug VARCHAR(100)` ;
- `created_at`, `updated_at`, `archived_at NULL`.

Contraintes/index :

- unique `(company_id, slug)` ;
- index `company_id`.

#### `communication_campaign`

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `name VARCHAR(180)`, `description TEXT NULL` ;
- `objective VARCHAR(40)`, `status VARCHAR(30)` ;
- `start_date DATE_IMMUTABLE NULL`, `end_date DATE_IMMUTABLE NULL` ;
- `budget NUMERIC(14,2) NULL`, `target_audience TEXT NULL` ;
- `platforms JSON`, `owner_id NULL`, `created_by_id` ;
- `created_at`, `updated_at`, `archived_at NULL`.

Contraintes/index :

- index `(company_id, status)` ;
- index `(company_id, start_date, end_date)`.

Les compteurs et taux sont calculés par un service de lecture, jamais stockés.

#### `communication_idea`

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `title VARCHAR(200)`, `description TEXT NULL` ;
- `objective VARCHAR(40) NULL`, `category_id NULL` ;
- `source_type VARCHAR(40) NULL`, `source_token VARCHAR(80) NULL` ;
- `status VARCHAR(30)`, `priority VARCHAR(20)` ;
- `suggested_platform VARCHAR(30) NULL` ;
- `suggested_publish_at DATETIME_IMMUTABLE NULL` ;
- `assigned_to_id NULL`, `created_by_id` ;
- `converted_post_id NULL`, `converted_at NULL` ;
- `created_at`, `updated_at`, `archived_at NULL`.

Relations :

- tags via `communication_idea_tag`.

Contraintes/index :

- unique `converted_post_id` lorsqu'il est non null ;
- index `(company_id, status)` ;
- index `(company_id, suggested_publish_at)` ;
- index `(company_id, source_type, source_token)`.

La conversion crée un post dans une transaction, conserve l'idée et son lien,
puis passe l'idée à `converted`.

#### `communication_post`

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `internal_title VARCHAR(200)`, `brief TEXT NULL`,
  `master_content LONGTEXT NULL` ;
- `objective VARCHAR(40)`, `tone VARCHAR(80) NULL` ;
- `category_id NULL`, `campaign_id NULL` ;
- `source_type VARCHAR(40) NULL`, `source_token VARCHAR(80) NULL` ;
- `status VARCHAR(30)`, `priority VARCHAR(20)` ;
- `assigned_to_id NULL`, `created_by_id`, `updated_by_id NULL` ;
- `approved_by_id NULL`, `approved_at NULL` ;
- `rejected_by_id NULL`, `rejected_at NULL`,
  `rejection_reason TEXT NULL` ;
- `content_version INTEGER DEFAULT 1`,
  `current_content_hash CHAR(64)` ;
- `created_at`, `updated_at`, `archived_at NULL`.

Relations :

- tags via `communication_post_tag`.

Contraintes/index :

- index `(company_id, status)` ;
- index `(company_id, campaign_id)` ;
- index `(company_id, assigned_to_id)` ;
- index `(company_id, updated_at)`.

Le hash porte sur les champs éditoriaux significatifs et les médias. Une
modification significative incrémente la version et invalide l'approbation
active selon les paramètres.

#### `communication_post_variant`

Colonnes :

- `id`, `idtoken`, `company_id`, `post_id` ;
- `platform VARCHAR(30)`, `name VARCHAR(140)` ;
- `title VARCHAR(255) NULL`, `content LONGTEXT` ;
- `link_url TEXT NULL`, `call_to_action VARCHAR(255) NULL` ;
- `hashtags JSON`, `mentions JSON`, `first_comment TEXT NULL` ;
- `alt_text TEXT NULL`, `locale VARCHAR(16)` ;
- `status VARCHAR(30)`, `is_primary BOOLEAN` ;
- `content_version INTEGER DEFAULT 1`,
  `content_hash CHAR(64)` ;
- `created_by_id`, `updated_by_id NULL` ;
- `created_at`, `updated_at`, `locked_at NULL`, `archived_at NULL`.

Contraintes/index :

- index `(company_id, post_id)` ;
- index `(company_id, platform, status)` ;
- une seule variante principale active par `(post_id, platform)`, garantie par
  le manager et, si le moteur le permet proprement, par index conditionnel.

Une variante verrouillée est immutable ; elle peut seulement être dupliquée.

#### `communication_media_folder`

Colonnes :

- `id`, `idtoken`, `company_id`, `parent_id NULL` ;
- `name VARCHAR(140)`, `slug VARCHAR(160)` ;
- `created_by_id`, `created_at`, `updated_at`, `archived_at NULL`.

Contraintes/index :

- unique `(company_id, parent_id, slug)` si la sémantique des `NULL` du moteur
  est validée ; sinon unicité garantie par le manager ;
- index `(company_id, parent_id)`.

#### `communication_media`

Colonnes :

- `id`, `idtoken`, `company_id`, `folder_id NULL` ;
- `original_filename VARCHAR(255)`, `stored_filename VARCHAR(255)` ;
- `relative_path VARCHAR(500)`, `mime_type VARCHAR(120)` ;
- `media_type VARCHAR(30)`, `file_size BIGINT` ;
- `width INTEGER NULL`, `height INTEGER NULL`,
  `duration_seconds INTEGER NULL` ;
- `checksum CHAR(64)` ;
- `title VARCHAR(180) NULL`, `description TEXT NULL`,
  `alt_text TEXT NULL` ;
- `copyright_owner VARCHAR(180) NULL`, `usage_rights TEXT NULL`,
  `usage_expires_at DATE_IMMUTABLE NULL` ;
- `uploaded_by_id`, `created_at`, `updated_at`, `archived_at NULL`.

Relations :

- tags via `communication_media_tag`.

Contraintes/index :

- index `(company_id, media_type)` ;
- index `(company_id, folder_id)` ;
- index `(company_id, checksum)` ;
- index `(company_id, usage_expires_at)`.

Le checksum signale un doublon mais ne bloque pas nécessairement deux fiches
ayant des droits ou descriptions différents.

#### `communication_post_media`

Colonnes :

- `id`, `company_id`, `post_id`, `variant_id NULL`, `media_id` ;
- `position INTEGER`, `role VARCHAR(30)` ;
- `caption TEXT NULL`, `alt_text TEXT NULL` ;
- `created_at`.

Règles :

- `variant_id`, s'il est présent, doit appartenir à `post_id` ;
- toutes les relations doivent appartenir à `company_id` ;
- unique `(post_id, variant_id, media_id, position)` lorsque réalisable ;
- index `(company_id, post_id, position)` ;
- index `(company_id, variant_id, position)`.

Le post est toujours renseigné ; la variante précise une adaptation éventuelle.
Cela évite une association polymorphe sans intégrité référentielle.

#### `communication_template`

Colonnes :

- `id`, `idtoken`, `company_id NULL` pour un modèle global ;
- `name VARCHAR(180)`, `description TEXT NULL`, `category_id NULL` ;
- `platform VARCHAR(30)`, `objective VARCHAR(40) NULL`,
  `tone VARCHAR(80) NULL` ;
- `content_template LONGTEXT`, `title_template TEXT NULL` ;
- `default_hashtags JSON`, `available_variables JSON` ;
- `allow_empty_variables BOOLEAN`, `active BOOLEAN` ;
- `created_by_id NULL`, `created_at`, `updated_at`, `archived_at NULL`.

Contraintes/index :

- index `(company_id, active, platform)` ;
- unicité métier du nom actif par société contrôlée par le manager.

Les modèles globaux sont en lecture seule pour une société normale. La création
d'un post copie le rendu ; elle ne garde pas une dépendance mutable au modèle.

#### `communication_approval`

Colonnes :

- `id`, `idtoken`, `company_id`, `post_id` ;
- `requested_by_id`, `requested_at`, `reviewer_id NULL` ;
- `status VARCHAR(30)`, `comment TEXT NULL`, `reviewed_at NULL` ;
- `content_version INTEGER`, `content_hash CHAR(64)` ;
- `created_at`, `updated_at`.

Contraintes/index :

- index `(company_id, status, requested_at)` ;
- index `(company_id, reviewer_id, status)` ;
- index `(post_id, created_at)`.

Une décision n'est jamais écrasée. Une nouvelle demande crée une nouvelle ligne.

#### `communication_social_account`

Cette table est créée dès le socle pour représenter une cible manuelle ou
future connexion, mais aucun OAuth LinkedIn n'est activé au lot 1.

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `platform VARCHAR(30)`, `account_type VARCHAR(30)` ;
- `external_account_id VARCHAR(255) NULL`,
  `external_account_urn VARCHAR(500) NULL` ;
- `display_name VARCHAR(180)`, `username VARCHAR(180) NULL`,
  `avatar_url TEXT NULL` ;
- `connected_by_id NULL`, `linked_user_id NULL` ;
- `access_token_encrypted TEXT NULL`,
  `refresh_token_encrypted TEXT NULL` ;
- `token_expires_at NULL`, `scopes JSON`, `capabilities JSON` ;
- `status VARCHAR(30)`, `last_checked_at NULL`,
  `last_successful_call_at NULL`, `last_error_at NULL`,
  `last_error_message TEXT NULL` ;
- `created_at`, `updated_at`, `disconnected_at NULL`.

Contraintes/index :

- index `(company_id, platform, status)` ;
- index `(company_id, external_account_urn)` ;
- unicité d'un compte connecté à définir selon les capacités du moteur. Le
  manager garantit au minimum l'absence de doublon actif.

Les propriétés de jeton ne sont jamais exposées par un getter utilisé en vue,
une sérialisation ou `__toString()`.

#### `communication_publication`

Colonnes :

- `id`, `idtoken`, `company_id`, `post_id`, `variant_id` ;
- `social_account_id NULL` uniquement pour une publication manuelle externe ;
- `platform VARCHAR(30)`, `publication_mode VARCHAR(20)` ;
- `status VARCHAR(30)`, `scheduled_at NULL`, `timezone VARCHAR(64)` ;
- `queued_at NULL`, `processing_at NULL`, `published_at NULL`,
  `failed_at NULL`, `cancelled_at NULL` ;
- `external_post_id VARCHAR(255) NULL`,
  `external_post_urn VARCHAR(500) NULL`, `external_post_url TEXT NULL` ;
- `idempotency_key CHAR(64)` ;
- `retry_count INTEGER DEFAULT 0`, `max_retry_count INTEGER DEFAULT 3`,
  `next_retry_at NULL` ;
- `last_error_type VARCHAR(40) NULL`, `last_error_code VARCHAR(100) NULL`,
  `last_error_message TEXT NULL` ;
- `payload_snapshot JSON NULL`, `content_snapshot JSON NULL`,
  `media_snapshot JSON NULL`, `provider_response_snapshot JSON NULL` ;
- `manually_published_by_id NULL`, `manual_comment TEXT NULL` ;
- `created_by_id`, `created_at`, `updated_at`.

Contraintes/index :

- unique `idempotency_key` ;
- index `(company_id, status, scheduled_at)` ;
- index `(company_id, social_account_id, status)` ;
- index `(company_id, post_id)` ;
- index `(company_id, next_retry_at)`.

Les snapshots sont créés avant le passage en file et ne contiennent aucun
secret. Une publication publiée n'est jamais supprimée ni modifiée
rétroactivement.

#### `communication_publication_log`

Colonnes :

- `id`, `company_id`, `publication_id`, `event_type VARCHAR(50)` ;
- `status_before VARCHAR(30) NULL`, `status_after VARCHAR(30) NULL` ;
- `user_id NULL`, `message TEXT NULL`, `error_code VARCHAR(100) NULL` ;
- `metadata JSON`, `created_at`.

Contraintes/index :

- index `(company_id, publication_id, created_at)` ;
- index `(company_id, event_type, created_at)`.

Table append-only au niveau applicatif.

#### `communication_audit_log`

Colonnes :

- `id`, `company_id` ;
- `target_type VARCHAR(50)`, `target_idtoken CHAR(48)` ;
- `event_type VARCHAR(50)`, `user_id NULL` ;
- `message TEXT NULL`, `metadata JSON`, `created_at`.

Contraintes/index :

- index `(company_id, target_type, target_idtoken, created_at)` ;
- index `(company_id, event_type, created_at)`.

Cette table couvre les événements hors publication : idée, campagne, média,
compte et paramètres.

#### `communication_settings`

Colonnes :

- `id`, `idtoken`, `company_id` ;
- `approval_required BOOLEAN DEFAULT TRUE` ;
- `direct_publication_allowed BOOLEAN DEFAULT FALSE` ;
- `author_self_approval_allowed BOOLEAN DEFAULT FALSE` ;
- `edit_after_approval_allowed BOOLEAN DEFAULT FALSE` ;
- `minimum_days_before_publication INTEGER DEFAULT 0` ;
- `timezone VARCHAR(64)`, `publication_hours JSON` ;
- `enabled_platforms JSON`, `ai_enabled BOOLEAN DEFAULT FALSE` ;
- `automatic_publication_enabled BOOLEAN DEFAULT FALSE` au lot 1 ;
- `retries_enabled BOOLEAN DEFAULT TRUE`,
  `max_retry_count INTEGER DEFAULT 3` ;
- `sync_frequency_minutes INTEGER DEFAULT 60` ;
- `notifications_enabled BOOLEAN DEFAULT TRUE` ;
- `hashtag_library JSON`, `default_signature TEXT NULL`,
  `default_cta TEXT NULL`, `default_tone VARCHAR(80) NULL`,
  `default_locale VARCHAR(16)` ;
- `created_at`, `updated_at`, `updated_by_id NULL`.

Contraintes/index :

- unique `company_id`.

Les rôles autorisés à valider restent pilotés par les Voters et la hiérarchie,
pas par une liste libre non sécurisée en JSON.

### 4.2 Tables différées

#### Lot 2

Pas de nouvelle table indispensable si `communication_social_account`,
`communication_publication` et ses logs sont validés au lot 1. Le code OAuth et
provider sera ajouté seulement après vérification de l'API officielle.

#### Lot 3 — `communication_publication_metric`

Colonnes proposées :

- `id`, `company_id`, `publication_id`, `captured_at` ;
- métriques numériques nullables ;
- `engagement_rate NUMERIC(10,4) NULL` ;
- `external_payload_snapshot JSON NULL`.

Contraintes :

- unique `(publication_id, captured_at)` ;
- index `(company_id, captured_at)`.

Cette table ne doit pas être créée au lot 1 sans validation explicite.

## 5. États et invariants

### 5.1 Post

```text
draft → writing → to_review → approved → scheduled
                   │             │
                   └→ changes_requested → writing

scheduled → partially_published → published
         └→ cancelled
```

L'archivage est une action explicite. Une modification significative après
approbation crée une nouvelle version et invalide l'approbation courante.

### 5.2 Publication

```text
draft → pending_approval → approved → scheduled → queued → publishing
                                                        ├→ published
                                                        ├→ failed
                                                        └→ retry_scheduled
```

Transitions atomiques, validées dans le manager. Une erreur
`authentication`, `permission` ou `validation` n'est pas retentée
automatiquement.

### 5.3 Invariants essentiels

- variante, post, compte, média, campagne et utilisateur appartiennent à la
  même société ;
- la plateforme du compte correspond à celle de la variante ;
- une publication validée référence exactement le hash approuvé ;
- une publication programmée possède date, fuseau et snapshots ;
- une publication API possède un compte social connecté et capable ;
- une publication manuelle possède date réelle et auteur ;
- une variante verrouillée et les snapshots publiés sont immuables ;
- aucune suppression physique d'un média historique ;
- aucune donnée IA n'est publiée sans validation humaine.

## 6. Services du premier incrément

Après validation du modèle, l'ordre d'implémentation proposé est :

1. enums, DTO, RequestMappers et Validators ;
2. entités du lot 1 ;
3. migration additive présentée en diff, sans exécution ;
4. repositories contextualisés société ;
5. managers de workflow, médias, calendrier et historique ;
6. Voters et intégration menu/abonnement ;
7. contrôleurs fins et formulaires Twig manuels avec CSRF ;
8. vues tableau de bord, idées, posts, campagnes, modèles, médias et calendrier ;
9. programmation interne et publication manuelle ;
10. adaptateur de notifications ;
11. tests.

Services structurants :

- `CommunicationIdeaManager` ;
- `CommunicationPostManager` ;
- `CommunicationApprovalManager` ;
- `CommunicationPublicationManager` ;
- `CommunicationCampaignManager` ;
- `CommunicationTemplateManager` ;
- `CommunicationMediaManager` ;
- `CommunicationCalendarQuery` ;
- `CommunicationVariableResolver` ;
- `CommunicationSourceResolver` ;
- `CommunicationAuditLogger` ;
- `CommunicationNotificationManager` ;
- `CommunicationMediaStorageInterface` ;
- `SocialNetworkProviderInterface` et `FakeSocialNetworkProvider`.

Le faux provider sert aux tests et à l'architecture. Il ne doit pas simuler une
publication réelle pour un utilisateur en production.

## 7. Plan de tests

### Tests unitaires

- mapping Request → DTO ;
- validations de chaque commande ;
- transitions de statuts ;
- invalidation d'approbation par hash/version ;
- résolution de variables ;
- classification des erreurs provider ;
- calcul des plages mois/semaine et fuseaux ;
- contrôle MIME/taille/chemin du stockage ;
- création de snapshots sans secrets ;
- idempotence de la mise en file.

### Tests fonctionnels

- CSRF obligatoire sur chaque POST ;
- formulaires utilisables sans JavaScript ;
- CRUD et archivage dans la société active ;
- transformation idée → post transactionnelle ;
- soumission, approbation, rejet et demande de modification ;
- interdiction de programmer un contenu non validé ;
- publication manuelle et historique ;
- accès selon rôles et lecture seule ;
- menu absent si bundle inactif ou droit absent.

### Tests multi-société

- token de société A introuvable depuis B ;
- relations forgées entre sociétés rejetées ;
- média, compte, campagne et utilisateur d'une autre société rejetés ;
- repositories toujours filtrés ;
- callback OAuth lié à l'utilisateur et la société ayant initié le flux au lot
  2.

### Tests différés LinkedIn

- faux provider ;
- state absent/invalide/expiré ;
- scopes insuffisants ;
- chiffrement et absence de secrets dans logs/erreurs ;
- verrouillage concurrent et idempotence ;
- retries selon classe d'erreur.

## 8. Risques et décisions à valider

### Bloquants avant migration

1. Clarifier la migration Doctrine en attente
   `Version20260718103000`.
2. Stabiliser ou au minimum documenter la baseline des associations Doctrine
   actuellement invalides.
3. Valider la stratégie de stockage privée propre à Communication.
4. Valider la création des tables du lot 1 listées ci-dessus.

### Bloquants avant notifications

5. Réparer ou remplacer officiellement les entités manquantes du système global
   de notifications, sans créer un second centre.

### Décisions produit

6. Confirmer l'activation de Communication dans les abonnements existants.
7. Confirmer les rôles et la matrice précise des permissions.
8. Confirmer qu'un compte social « manuel » peut être créé au lot 1 sans OAuth.
9. Confirmer la coexistence temporaire avec `ProspectionContentItem`.
10. Confirmer les limites de fichiers et l'activation différée de la vidéo.

### Décisions de modèle

11. Valider `CommunicationSettings` en table dédiée.
12. Valider catégories/tags propres à Communication.
13. Valider les deux historiques complémentaires :
    `CommunicationPublicationLog` et `CommunicationAuditLog`.
14. Valider que les métriques restent hors migration lot 1.

## 9. Proposition de découpage des migrations

Après validation humaine uniquement :

- **Migration A — socle taxonomie et contenu** : catégories, tags, campagnes,
  idées, posts, variantes, modèles et tables de jointure ;
- **Migration B — médias** : dossiers, médias, tags média et rattachements ;
- **Migration C — workflow** : approbations, comptes sociaux, publications,
  logs et paramètres ;
- **Migration D — métriques (lot 3)** : snapshots de statistiques.

Chaque migration sera additive, présentée avant exécution et accompagnée :

- du SQL généré ;
- de la liste exacte des clés étrangères ;
- des index ;
- d'une vérification qu'aucune table/colonne existante n'est altérée ;
- d'une mise à jour correspondante de `update.sql` seulement après validation.

## 10. Recommandation

Le modèle est compatible avec les frontières métier demandées et les
conventions du projet, mais il ne faut pas encore générer de migration.

La prochaine étape recommandée, après validation explicite de ce document, est
de créer uniquement les enums, DTO, RequestMappers et Validators du lot 1,
ainsi que leurs tests unitaires. Les entités et migrations viendront ensuite
dans une proposition séparée, conformément à l'ordre imposé.
