# Diagnostic du moteur de calendrier

Date du diagnostic : 5 août 2026.

Ce document est le point de contrôle demandé avant toute migration. Aucune migration
n'a été générée ou exécutée pendant cet audit.

## Conclusion

Le calendrier ne vit pas dans un `CalendarBundle` autonome. Il est déjà implémenté
comme sous-domaine de `CommercialBundle`, dans
`src/Bundles/CommercialBundle/Calendar`. Il faut le faire évoluer à cet emplacement,
sans déplacer les classes ni créer un nouveau bundle : un déplacement préalable
augmenterait inutilement le risque sur les routes, les services, les templates et les
jobs déjà installés.

Le socle Google est significatif et doit être conservé. En revanche, le modèle ne
possède pas de calendrier interne représentant le calendrier société ou le calendrier
d'un salarié. L'événement est aujourd'hui directement rattaché à un propriétaire et,
éventuellement, à un calendrier Google. Les permissions entre collègues, les demandes
de rendez-vous, les préférences, la disponibilité consolidée et le calendrier société
n'existent pas.

Le lot 1 doit donc commencer par le calendrier interne et la sécurité. Il ne faut pas
commencer par réécrire la synchronisation Google.

## Inventaire réel

### Entités Calendar existantes

| Entité | Table | Réutilisation |
| --- | --- | --- |
| `CalendarEvent` | `calendar_event` | Événement Capsule et événement Google matérialisé |
| `CalendarEventAttendee` | `calendar_event_attendee` | Participants internes ou externes |
| `CalendarEventReminder` | `calendar_event_reminder` | Rappels Google/Capsule |
| `CalendarIntegrationAccount` | `calendar_integration_account` | Connexion OAuth Google d'un salarié |
| `CalendarSource` | `calendar_source` | Calendrier retourné par Google CalendarList |
| `CalendarWebhookChannel` | `calendar_webhook_channel` | Canal Google Push |
| `CalendarConflict` | `calendar_conflict` | Conflit local/distant |
| `CalendarSyncJob` | `calendar_sync_job` | Outbox Doctrine idempotente |
| `CalendarSyncLog` | `calendar_sync_log` | Journal technique Google |

Il n'existe aucune entité équivalente à un calendrier interne, une permission, une
demande de rendez-vous ou une préférence de vue.

### Relations et champs réutilisables

- `CalendarEvent.publicId` est l'idtoken public existant et ne doit pas être dupliqué.
- Tous les objets Calendar actuels portent une relation `societe`; les repositories
  d'affichage et les contrôleurs filtrent déjà la société active.
- `owner`, `assignedTo`, `createdBy` et `updatedBy` sont présents sur l'événement.
- Les dates, le mode journée entière, le fuseau, le lieu, la visibilité, la
  transparence, les rappels et les participants existent déjà.
- `sourceEntityType/sourceEntityId` permet un lien métier, mais l'identifiant entier
  n'est pas assez stable pour l'architecture de providers demandée.
- Les métadonnées Google (`googleEventId`, iCal UID, ETag, lien HTML et dates de
  synchronisation) sont actuellement stockées sur l'événement.
- `CalendarSource` contient déjà le rôle Google, la couleur, le fuseau, la direction,
  les choix import/export, le sync token et les dates de synchronisation.
- `CalendarIntegrationAccount` chiffre les jetons avec Sodium et ne les transmet pas
  aux templates.
- `CalendarWebhookChannel` stocke le hash du jeton de canal, jamais le jeton en clair.
- `PlatformAuditTrail` fournit déjà l'historique métier central et filtre les clés
  sensibles. Il doit remplacer un nouveau centre d'audit Calendar. `CalendarSyncLog`
  reste réservé au diagnostic technique de synchronisation.
- Symfony Messenger et un transport `async` sont installés. Deux messages Calendar
  sont déjà routés vers ce transport.
- Le fuseau société est déjà disponible dans le paramètre `societe_timezone`, avec
  `Europe/Paris` comme valeur de repli.

### Identité société/salarié

- La société active est obtenue par `CurrentCompanyProvider` à partir de la session.
- `User` possède une relation ManyToMany vers `Societe` et `canAccessCompany()`.
- Un utilisateur multi-sociétés est donc déjà représentable.
- Il n'existe pas de profil salarié Calendar distinct. Pour le calendrier, un salarié
  actif est un `User` actif, non supprimé, autorisé sur la société. Cette règle doit
  être centralisée dans `EmployeeCalendarManager`, pas répétée dans les contrôleurs.

## Fonctionnement existant

### Routes et interface

Le module expose 20 routes sous `/commercial/calendar` et le webhook public
`/webhooks/google/calendar`. Les opérations mutantes existantes vérifient un CSRF
manuel. L'OAuth est une redirection GET protégée par un `state` de session.

L'interface utilise un contrôleur Stimulus maison. FullCalendar n'est pas installé ni
utilisé : les vues mois, semaine, jour et liste sont rendues en JavaScript dans
`commercial_calendar_controller.js`.

Le HTML serveur ne contient aucun agenda ni aucune liste d'événements. Sans
JavaScript, la page n'a donc pas de fonctionnement utile. Les formulaires de création
et de modification sont également enfermés dans un `<dialog>` et envoient uniquement
du JSON. Le lot 1 devra ajouter une liste/agenda serveur et des routes HTML POST/GET,
puis laisser Stimulus améliorer cette base.

### Projection des tâches

`CommercialTaskCalendarProjector` projette directement :

- `ProspectionTask` ;
- `ProspectionActivity` ;
- `RoutineTask`.

Il ne crée pas de copie en base, ce qui est conforme au principe de source de vérité.
Il doit cependant être remplacé progressivement par un registre de providers tagués :

- `ProjectTaskCalendarProvider` pour `ProjetBundle\Entity\Tache` ;
- `RoutineTaskCalendarProvider` ;
- `ProspectionTaskCalendarProvider` ;
- un provider d'activités commerciales si cette couche est conservée.

Écarts actuels : les tâches Projet sont absentes, les tâches sans heure sont forcées à
09:00 au lieu d'être affichées sur la journée, les tâches terminées ne sont pas masquées
par défaut, les URL sources manquent, les affectations multiples ne sont pas toutes
prises en compte et aucune permission `canViewTasks` ne protège les tâches d'un
collègue.

### Google Calendar

Acquis à conserver :

- Authorization Code Flow côté serveur avec `state` aléatoire ;
- scopes configurables et scopes de repli limités ;
- chiffrement authentifié des jetons avec `sodium_crypto_secretbox` ;
- renouvellement du jeton d'accès et état `needs_reauth` ;
- import initial paginé et import incrémental par sync token ;
- reprise par synchronisation complète après HTTP 410 ;
- création/mise à jour distante avec ETag ;
- annulation distante matérialisée sans suppression physique locale ;
- participants sortants, rappels, journée entière et transparence ;
- conflits local/distant et écran de résolution ;
- Google Push avec token de canal haché ;
- renouvellement des canaux, outbox et commandes planifiables ;
- Messenger disponible pour les traitements asynchrones.

Écarts :

- uniquement une connexion Google de type salarié ;
- `CalendarIntegrationAccount.user` est obligatoire et la contrainte unique actuelle
  empêche une connexion société distincte effectuée par le même utilisateur ;
- CalendarList n'est pas paginée et ne possède pas de sync token propre ;
- un `CalendarSource` n'est relié à aucun calendrier interne ;
- le lien Google/événement est embarqué dans `CalendarEvent`, ce qui empêche plusieurs
  liens propres et complique l'anti-duplication du calendrier société ;
- les participants entrants, l'organisateur, Google Meet et les règles de récurrence
  ne sont pas importés ;
- `singleEvents=true` aplatit actuellement les récurrences ;
- aucune opération Google `delete` dédiée, aucun FreeBusy ;
- le webhook ne contrôle ni l'expiration ni le numéro monotone
  `X-Goog-Message-Number` ;
- le callback affiche le message brut de certaines exceptions et l'outbox conserve le
  message brut des erreurs : ils doivent passer par un classificateur expurgé ;
- l'outbox et Messenger offrent deux chemins d'exécution concurrents. Un seul chemin
  doit devenir canonique, l'outbox restant la garantie transactionnelle.

## Incompatibilités et risques

### Confidentialité critique

`CalendarEventRepository::findForRange()` charge les événements d'une société avant
toute règle de permission. `CalendarEventPresenter` montre ensuite le détail privé aux
utilisateurs déclarés « privilégiés ». Un manager peut donc voir le détail d'un
événement privé, contrairement à la règle demandée. Le filtre de visibilité doit être
calculé côté service/repository et le presenter ne doit recevoir qu'un niveau d'accès
explicite (`none`, `busy_only`, `details`).

Le voter actuel mélange accès au module, modification d'événement et gestion Google.
Il doit être conservé pour la compatibilité des attributs existants, puis complété par
des voters spécialisés.

### Modèle événement

`sourceType` mélange actuellement le type visuel (`appointment`) et l'origine métier
(`prospection_task`, `external_google`). Il faut ajouter `eventType` et `origin` sans
renommer ni supprimer `sourceType`.

`owner_id` est obligatoire. Un événement du calendrier société devra continuer à
recevoir un propriétaire technique pendant la transition, mais sa sécurité et son
affichage seront déterminés par son calendrier interne `company`. Rendre cette colonne
nullable pourra être proposé plus tard, séparément, mais n'est pas nécessaire au lot 1.

Les dates existantes ne garantissent pas qu'elles ont historiquement été enregistrées
en UTC. Une conversion aveugle serait destructive. La reprise devra détecter et
rapporter les valeurs, puis normaliser uniquement les nouvelles écritures et les
données dont le fuseau source est déterminable.

### Connexion Google société : décision requise

La contrainte `uniq_calendar_account_user_provider(societe_id, user_id, provider)`
interdit à un administrateur possédant déjà une connexion salarié de porter aussi la
connexion société. Ajouter seulement `connection_type` ne suffit pas.

La solution recommandée est de faire évoluer la contrainte en unicité logique par
type de connexion. Cela nécessite de remplacer un index unique existant ; aucune
donnée ni colonne n'est supprimée, mais l'opération n'est pas strictement « ajout
uniquement ». Elle doit donc recevoir une validation humaine explicite avant le lot 2.
Créer une seconde table de connexion uniquement pour contourner l'index dupliquerait
le modèle et n'est pas recommandé.

### État général Doctrine

Les 31 tests unitaires Calendar existants passent (54 assertions). La syntaxe des trois
templates Calendar est valide et les routes sont bien enregistrées.

La validation globale Doctrine échoue toutefois sur des associations sans rapport avec
Calendar (`Avoir`, `Produit`, `FactureRecurrente`, `Source` et
`CommercialPaymentTransaction`). Ces défauts préexistants peuvent parasiter un
`doctrine:migrations:diff`; la migration Calendar devra être écrite et revue de façon
ciblée, sans inclure de corrections étrangères au module.

Le centre de notifications contient par ailleurs des références à
`App\Bundle\AppBundle\Entity\User` et `Notification`, alors que ce répertoire d'entités
est vide dans l'arborescence auditée. Il ne faut pas créer un second centre de
notifications dans Calendar, mais l'intégration aux notifications devra être précédée
de la remise en cohérence de ce composant ou de l'identification de son implémentation
réelle.

## Proposition de schéma

Cette proposition réutilise toutes les tables existantes. Les noms définitifs seront
confirmés avant génération.

### Nouvelles tables du lot 1

#### `calendar_internal`

- `id`, `idtoken`, `societe_id`, `owner_id` nullable ;
- `identity_key` (`company` ou `employee:<user-id>`) ;
- `type` (`company`, `employee`), `name`, `timezone`, `active` ;
- `created_at`, `updated_at`, `archived_at` nullable.

Index :

- unique `idtoken` ;
- unique `(societe_id, identity_key)` ;
- `(societe_id, type, active)` ;
- `(societe_id, owner_id, active)`.

`identity_key` rend la commande de création idempotente et garantit un seul calendrier
logique par société ou couple société/utilisateur, y compris lorsque `owner_id` est
NULL pour le calendrier société.

#### `calendar_permission`

- `id`, `idtoken`, `societe_id`, `calendar_id`, `granted_to_user_id` ;
- `visibility_level`, `booking_level` ;
- `can_view_tasks`, `can_edit_own_created_events` ;
- `starts_at`, `ends_at`, `revoked_at` nullable ;
- `created_by_id`, `created_at`, `updated_at`.

Index : unique `(calendar_id, granted_to_user_id)`, plus
`(societe_id, granted_to_user_id, revoked_at)` et `(calendar_id, revoked_at)`.
Une nouvelle attribution réactive la ligne existante ; les changements restent dans
`PlatformAuditTrail`.

#### `calendar_booking_request`

- identité et tenant : `id`, `idtoken`, `societe_id` ;
- acteurs : `target_calendar_id`, `requested_for_id`, `requested_by_id` ;
- contenu : titre, description, début, fin, fuseau, lieu, visibilité,
  `blocks_availability` ;
- liens optionnels : `client_id`, `prospect_id`, `project_id` ;
- décision : statut, `decision_by_id`, `decision_at`, commentaire ;
- résultat : `created_event_id` nullable ;
- `created_at`, `updated_at`.

Index : unique `idtoken`, unique `created_event_id`,
`(societe_id, requested_for_id, status, start_at)` et
`(societe_id, requested_by_id, status)`.

#### `calendar_view_preference`

- `societe_id`, `user_id` ;
- couches visibles, calendriers collègues visibles, vue/plage par défaut ;
- tâches terminées, week-ends, fuseau d'affichage, couleurs ;
- `updated_at`.

Index : unique `(societe_id, user_id)`.

#### `calendar_google_event_link`

À introduire au lot 2 avant la reprise Google :

- `societe_id`, `calendar_event_id`, `calendar_source_id` ;
- Google event ID, iCal UID, ETag, lien HTML, statut, visibilité,
  transparence, ID récurrent et début original ;
- dates Google, dernière synchronisation, hashes des payloads ;
- statut de sync, origine, `remote_deleted_at`.

Index : unique `(calendar_source_id, google_event_id)`, unique
`(calendar_event_id, calendar_source_id)`, et
`(societe_id, sync_status, last_synced_at)`.

Les colonnes Google historiques de `calendar_event` restent en place pour la
compatibilité, mais le lien devient la source canonique après reprise contrôlée.

### Colonnes additives sur les tables existantes

`calendar_event` :

- `internal_calendar_id` nullable ;
- `organizer_id`, `event_type`, `origin`, `blocks_availability` ;
- `client_id`, `prospect_id`, `project_id` nullable ;
- `source_task_type`, `source_task_token`, `recurrence_rule` nullable ;
- `archived_at` nullable.

Index : `(societe_id, internal_calendar_id, starts_at, ends_at)`,
`(societe_id, owner_id, starts_at)`,
`(source_task_type, source_task_token)` et
`(societe_id, status, blocks_availability, starts_at, ends_at)`.

`calendar_integration_account` (lot 2) :

- `idtoken`, `connection_type`, `authorized_by_id`, `display_name` ;
- `last_successful_call_at`, `last_error_code`.

`calendar_source` (lot 2) :

- `idtoken`, `internal_calendar_id` ;
- `selected_for_sync`, `default_for_creation`, `read_only`, `color_id` ;
- `calendar_list_sync_token`, `last_error_at`, `last_error_message`,
  `archived_at`.

`calendar_webhook_channel` (lot 3) :

- `last_message_number`, `renewed_at`.

`calendar_conflict` peut être réutilisée. Un `event_link_id` nullable pourra être ajouté
au lot 3 pour identifier précisément le lien Google concerné, sans supprimer les
relations actuelles.

## Reprises idempotentes proposées

Aucune de ces commandes ne doit être lancée avant validation et sauvegarde :

```bash
php bin/console app:calendar:ensure-employee-calendars --dry-run
php bin/console app:calendar:backfill-events --dry-run
php bin/console app:calendar:backfill-google-event-links --dry-run
php bin/console app:calendar:audit-integrity
```

- `ensure-employee-calendars` crée le calendrier société manquant et un calendrier par
  salarié actif, avec options `--company`, `--user` et `--dry-run`.
- `backfill-events` rattache chaque événement existant au calendrier salarié de son
  propriétaire. Les événements communs ne sont jamais devinés : ils sont rapportés
  pour arbitrage.
- `backfill-google-event-links` copie de façon idempotente les métadonnées Google
  existantes vers la nouvelle table de liens.
- `audit-integrity` détecte les relations hors tenant, doublons, calendriers manquants,
  événements sans calendrier et dates dont le fuseau est ambigu, sans écriture.

## Ordre d'implémentation proposé

1. Lot 1A : calendrier interne, commande d'initialisation, rattachement des événements,
   services tenant et tests d'isolation.
2. Lot 1B : permissions, confidentialité, demandes, notifications/audit, disponibilité
   et conflits.
3. Lot 1C : providers de tâches, préférences, vue consolidée et fallback HTML complet.
4. Lot 2 : extension des comptes/sources Google, lien événement Google, CalendarList
   et synchronisation en lecture.
5. Lot 3 : écriture bidirectionnelle, participants entrants, Meet, FreeBusy, webhook
   renforcé, conflits et file asynchrone canonique.
6. Lot 4 : récurrences avancées, créneaux, équipes, ressources et iCal.

Chaque lot doit inclure des tests unitaires, repository/tenant, fonctionnels avec et
sans JavaScript, sécurité/confidentialité, CSRF, synchronisation et idempotence des
commandes. `update.sql` ne sera complété qu'avec les SQL validés et rejouables du lot
concerné.

## Validation attendue

Avant de coder le lot 1, valider :

1. le maintien du sous-domaine dans `CommercialBundle/Calendar` ;
2. le nom de table `calendar_internal` et la clé logique `identity_key` ;
3. la création des quatre tables du lot 1 et les colonnes additives de
   `calendar_event` ;
4. l'usage de `PlatformAuditTrail` plutôt qu'une nouvelle table d'historique ;
5. la règle « utilisateur actif de la société = salarié Calendar » ;
6. pour le lot 2, le remplacement contrôlé de l'index unique des comptes Google afin
   d'autoriser une connexion `employee` et une connexion `company` distinctes.
