# CalendarBundle — diagnostic et migrations additives proposées

Date du diagnostic : 5 août 2026.

## Décision d'architecture

Le projet ne contient pas de bundle Symfony autonome nommé `CalendarBundle`. Le domaine calendrier existe déjà, de manière substantielle, sous `App\Bundle\CommercialBundle\Calendar` et est exposé sous `/commercial/calendar`.

Il ne faut ni créer `AgendaBundle`, ni créer `GoogleCalendarBundle`, ni dupliquer ce domaine. L'évolution proposée conserve ce sous-domaine et en fait le moteur central d'agenda. Un déplacement de namespace ou une extraction en bundle autonome serait une opération de renommage à risque, sans bénéfice fonctionnel immédiat, et n'est donc pas proposé.

## Inventaire existant

### Entités et relations

| Entité existante | Éléments réutilisables | Limites constatées |
|---|---|---|
| `CalendarEvent` | société, `publicId`, propriétaire, assigné, créateur/modificateur, dates immuables, journée entière, fuseau, statut, visibilité, transparence, priorité, rappels, soft delete, métadonnées et données de synchronisation Google | aucun calendrier interne, `owner` obligatoire, type métier confondu avec la source, pas d'organisateur, pas de liens métier explicites, pas de token de tâche, pas de récurrence ni d'archivage métier |
| `CalendarEventAttendee` | participant interne ou externe, statut de réponse, facultatif | pas de politique d'invitation distincte |
| `CalendarEventReminder` | méthode, délai et date d'envoi | suffisant pour le premier lot |
| `CalendarIntegrationAccount` | société + utilisateur + fournisseur, tokens chiffrés, scopes, statut et erreurs | uniquement une connexion salarié ; pas de type `company`, de `authorizedBy`, de sujet Google ni d'identifiant public |
| `CalendarSource` | calendrier Google, compte, rôle d'accès, couleur, fuseau, sens de sync, sync token et dates de sync | ce n'est pas un calendrier Capsule interne ; il n'est relié à aucun calendrier interne et impose un utilisateur |
| `CalendarWebhookChannel` | channel/resource/token hash, expiration et statut | manque le dernier numéro de message et la date de renouvellement |
| `CalendarConflict` | snapshots local/distant, résolution et historique | couvre les conflits de synchronisation Google, pas les collisions de disponibilité |
| `CalendarSyncJob` | outbox Doctrine idempotente avec tentative, délai, déduplication et statistiques | coexiste avec Messenger ; la responsabilité des deux files doit être clarifiée |
| `CalendarSyncLog` | journal technique de synchronisation filtré par société | ne remplace pas `PlatformAuditTrail` pour l'audit métier |

Il n'existe actuellement aucune entité équivalente à un calendrier interne société/salarié, une permission entre collègues, une demande de rendez-vous ou des préférences d'affichage d'agenda.

### Services existants à conserver

- `CurrentCompanyProvider` détermine la société active et vérifie `User::canAccessCompany()`.
- `UserRepository::findActiveStaffBySociete()` fournit les salariés actifs (`userType=staff`, non supprimés et non archivés), y compris les deux variantes de rattachement société actuellement utilisées.
- `CalendarEventManager`, `CalendarEventValidator` et `CalendarEventRepository` apportent une base métier et un filtrage tenant.
- `CalendarTokenCipher` chiffre les tokens avec Sodium et refuse la clé de développement en production.
- Les services Google couvrent déjà OAuth, appels HTTP, mapping, synchronisation initiale/incrémentale, sync token, repli après HTTP 410, webhooks, watch channels, outbox et conflits.
- Messenger est installé et route déjà `SyncGoogleCalendar` et `SyncCalendarEventToGoogle` vers le transport asynchrone.
- `PlatformAuditTrail` existe, masque les clés sensibles et doit devenir l'audit métier du calendrier.
- Un centre de notifications et un diffuseur temps réel existent et doivent être réutilisés.

### Interface et JavaScript

- Les vues mois, semaine, jour et liste existent, mais sont rendues par un contrôleur Stimulus maison.
- FullCalendar 6.1.9 est déjà livré dans `public/vendor/matdash/libs/fullcalendar`, mais n'est pas utilisé par l'agenda commercial actuel.
- Le formulaire d'événement est manuel et protégé par CSRF, mais il vit uniquement dans un `<dialog>` piloté en JavaScript et envoie du JSON. Il n'existe pas encore de parcours HTML utile sans JavaScript.
- Les templates emploient déjà les classes Capsule, mais la page de paramètres utilise encore plusieurs classes Bootstrap génériques qui pourront être harmonisées.

La première évolution doit conserver le rendu actuel. Le remplacement par FullCalendar n'est pas requis pour le Lot 1 ; il augmenterait inutilement le risque de régression. FullCalendar pourra être évalué séparément pour les récurrences et le glisser-déposer avancé.

### Tâches projetées

`CommercialTaskCalendarProjector` ne duplique pas les tâches, ce qui est correct. Il projette actuellement :

- `CommercialBundle\ProspectionTask` ;
- `ProspectionBundle\ProspectionActivity` ;
- `RoutineBundle\RoutineTask`.

Écarts :

- `ProjetBundle\Tache` n'est pas projetée ;
- le service interroge directement plusieurs repositories au lieu d'utiliser des providers tagués ;
- les identifiants techniques numériques sont exposés dans les identifiants d'items alors que les entités disposent souvent d'un `idtoken` ;
- les URLs vers les fiches source, le vrai mode toute la journée, le filtre des tâches terminées et la capacité explicite de report ne sont pas normalisés ;
- les managers peuvent actuellement voir les tâches de collègues sans grant `canViewTasks`.

La cible doit donc être `CalendarItemProviderInterface` + registre tagué, avec un provider dans chaque bundle source. Les tâches restent exclusivement dans leur table d'origine.

### Paramètres, commentaires et fichiers

- Des mécanismes de paramètres société existent, mais aucun réglage d'agenda dédié et aucun fuseau utilisateur/société fiable n'ont été trouvés. Le comportement actuel retombe sur `Europe/Paris`.
- Les commentaires sont propres aux projets/tâches ; aucun nouveau système de commentaires n'est nécessaire pour le Lot 1.
- Plusieurs stockages de fichiers locaux existent, mais l'agenda n'a pas de besoin de pièce jointe dans le périmètre demandé. Aucun stockage supplémentaire n'est proposé.
- Les repositories `UserPreferenceRepository`/`ParametreUserRepository` de l'ancien `AppBundle` référencent des modèles qui ne sont pas présents dans l'arborescence Doctrine active. Ils ne constituent pas une base assez sûre pour les préférences d'agenda.

## Problèmes de sécurité et de cohérence à corriger en priorité

1. `CalendarEventPresenter` révèle les détails privés lorsqu'un booléen `privileged` est fourni. Les rôles manager/admin ne doivent jamais contourner la confidentialité. Pour un tiers autorisé, un événement `private` ou `busy_only` doit être présenté comme « Occupé » sans description, lieu, participants, liens ni relation métier.
2. `CalendarVoter` ne connaît que `VIEW`, `EDIT` et `MANAGE_GOOGLE`. Il autorise largement les rôles commerciaux et permet aux managers/admins d'éditer les événements d'autrui. Des Voters par objet et par capacité sont nécessaires.
3. La liste des collègues provient de `Societe::getUsers()` dans la page alors que la convention fiable est `findActiveStaffBySociete()`.
4. La projection des tâches utilise également un statut privilégié au lieu de permissions explicites.
5. Les actions JSON n'ont pas d'alternative HTML sans JavaScript.
6. Les dates immuables sont bien utilisées, mais la base ne garantit pas à elle seule que toutes les valeurs reçues sont normalisées en UTC. La normalisation doit être centralisée dans le service métier, avec conservation du fuseau d'origine.
7. `CalendarAuditLogger` écrit uniquement dans `CalendarSyncLog`. Les mutations métier doivent aussi passer par `PlatformAuditTrail` ; `CalendarSyncLog` reste un journal technique.
8. Le service de notifications existant référence des namespaces `AppBundle` historiques qui ne correspondent pas tous aux entités Admin actives. Cette incompatibilité doit être résolue par un adaptateur ciblé avant de brancher les notifications calendrier, sans créer un second centre de notifications.
9. Le `state` OAuth actuel est une valeur de session simple. Il doit être aléatoire, à usage unique, expirant et lié à l'utilisateur, la société et au type de connexion.
10. Les variables Google existantes (`GOOGLE_CLIENT_ID`, etc.) doivent rester compatibles. Un renommage brutal vers `GOOGLE_CALENDAR_*` casserait les environnements déjà configurés.

## Modèle additif proposé

### Lot 1 — nouvelles tables

#### `calendar_internal`

Nom d'entité proposé : `Calendar` dans le sous-domaine Calendar, nom de table explicite pour éviter toute ambiguïté avec les sources Google.

Colonnes :

- `id`, `idtoken` (48, unique) ;
- `societe_id` non nul ;
- `owner_id` nullable, uniquement pour le type `employee` ;
- `type` : `company` ou `employee` ;
- `scope_key` non nul : `company` ou `employee:<userId>` ;
- `name`, `color`, `timezone` ;
- `active`, `created_at`, `updated_at`, `archived_at` nullable.

`scope_key` permet une contrainte portable et fiable `UNIQUE(societe_id, scope_key)`. Une simple unicité avec `owner_id NULL` ne garantit pas un seul calendrier société sous MySQL.

#### `calendar_permission`

Colonnes :

- `id`, `idtoken`, `societe_id`, `calendar_id`, `granted_to_user_id`, `created_by_id` ;
- `visibility_level` (`none`, `busy_only`, `details`) ;
- `booking_level` (`none`, `request`, `direct`) ;
- `can_view_tasks`, `can_edit_own_created_events` ;
- `starts_at`, `ends_at`, `created_at`, `updated_at`, `revoked_at` ;
- `active_slot` nullable, égal à `1` pour le grant actif puis mis à `NULL` lors de la révocation.

Contrainte proposée : `UNIQUE(calendar_id, granted_to_user_id, active_slot)`. Le `NULL` autorise l'historique de plusieurs grants révoqués tout en interdisant deux grants actifs.

#### `calendar_booking_request`

Colonnes :

- `id`, `idtoken`, `societe_id`, `target_calendar_id` ;
- `requested_for_id`, `requested_by_id`, `decision_by_id` nullable, `created_event_id` nullable ;
- `title`, `description`, `starts_at`, `ends_at`, `timezone`, `location` ;
- `visibility`, `blocks_availability`, `status` ;
- `client_id`, `prospect_id`, `project_id` nullable ;
- `decision_at`, `decision_comment`, `created_at`, `updated_at`, `expires_at` nullable.

`created_event_id` reçoit une contrainte unique afin qu'une seconde acceptation ne puisse pas matérialiser deux fois la même demande. Le manager devra en plus accepter la demande dans une transaction avec verrou pessimiste.

#### `calendar_view_preference`

Colonnes :

- `id`, `societe_id`, `user_id` ;
- `visible_layers`, `visible_colleague_calendars`, `color_preferences` en JSON ;
- `default_view`, `default_date_range`, `show_completed_tasks`, `show_weekends`, `display_timezone`, `updated_at`.

Contrainte : `UNIQUE(societe_id, user_id)`. Les préférences ne sont jamais utilisées comme autorisation.

### Lot 1 — colonnes additives sur `calendar_event`

- `calendar_id` nullable pendant la reprise, puis obligatoire au niveau métier ;
- `organizer_id` nullable ;
- `event_type` nullable pendant la reprise (`meeting`, `appointment`, `company_event`, `absence`, `time_block`, `deadline`, `external`, `other`) ;
- `origin` nullable pendant la reprise (`capsule`, `google`, `company`) ;
- `blocks_availability` nullable pendant la reprise ;
- `client_id`, `prospect_id`, `project_id` nullable ;
- `source_task_type`, `source_task_token` nullable ;
- `recurrence_rule` nullable ;
- `archived_at` nullable.

Les champs existants `public_id`, `owner_id`, `created_by_id`, `source_type`, `source_entity_type`, `source_entity_id`, `transparency` et les colonnes Google sont conservés. `public_id` devient l'identifiant public de l'événement ; aucune seconde colonne `idtoken` n'est nécessaire.

Pour respecter une migration strictement additive, `owner_id` reste initialement obligatoire. Un événement société aura son calendrier interne comme autorité et conservera temporairement le créateur dans `owner_id` pour compatibilité. Rendre ensuite `owner_id` nullable serait une relaxation de contrainte non destructive, mais elle devra faire l'objet d'une validation séparée.

### Compléments Google proposés après le Lot 1

Les structures Google existantes doivent être complétées, pas recréées.

Sur `calendar_integration_account` :

- `public_id`, `connection_type`, `connection_key` ;
- `authorized_by_id`, `google_subject`, `display_name` ;
- `last_successful_call_at`, `last_error_code`.

Contrainte cible : `UNIQUE(societe_id, provider, connection_key)`, où la clé vaut `company` ou `employee:<userId>`. La colonne historique `user_id` reste en place ; pour une connexion société elle référence provisoirement l'auteur OAuth, ce qui évite une modification de nullabilité dans la première migration.

Sur `calendar_source` :

- `public_id`, `local_calendar_id` ;
- `selected_for_sync`, `default_for_creation` ;
- `calendar_list_sync_token`, `last_error_at`, `last_error_message`, `archived_at`.

Sur `calendar_webhook_channel` :

- `last_message_number`, `renewed_at`.

Les identifiants Google déjà présents dans `CalendarEvent`, l'unicité `(calendar_source_id, google_event_id)` et `CalendarConflict` constituent un équivalent suffisant de `GoogleCalendarEventLink` et `GoogleCalendarSyncConflict` pour les Lots 2 et 3 actuels. Créer immédiatement deux nouvelles tables dupliquerait l'état de synchronisation. Une table de liens distincte ne sera proposée que si un même événement Capsule doit réellement être publié vers plusieurs ressources Google.

## Index proposés

### Lot 1

- `calendar_internal(societe_id, type, active)` ;
- `calendar_internal(owner_id, active)` ;
- `calendar_event(societe_id, calendar_id, starts_at, ends_at)` ;
- `calendar_event(calendar_id, status, starts_at)` ;
- `calendar_event(owner_id, status, starts_at)` ;
- `calendar_event(source_task_type, source_task_token)` ;
- `calendar_permission(societe_id, granted_to_user_id, revoked_at)` ;
- `calendar_permission(calendar_id, granted_to_user_id, active_slot)` unique ;
- `calendar_booking_request(societe_id, requested_for_id, status, starts_at)` ;
- `calendar_booking_request(target_calendar_id, status, starts_at)` ;
- `calendar_view_preference(societe_id, user_id)` unique.

L'index existant `calendar_event(societe_id, starts_at, ends_at)` est conservé.

### Google

- `calendar_integration_account(societe_id, connection_type, status)` ;
- `calendar_integration_account(societe_id, provider, connection_key)` unique ;
- `calendar_source(local_calendar_id, sync_enabled, archived_at)` ;
- `calendar_webhook_channel(status, expires_at)` ;
- `calendar_webhook_channel(channel_id)` reste unique.

## Reprise de données proposée

Aucun SQL de reprise opaque ne doit être placé directement dans une migration longue. Les commandes suivantes seront idempotentes, filtrables et dotées de `--dry-run` :

1. `app:calendar:ensure-employee-calendars [--company=] [--user=] [--dry-run]`
   - crée le calendrier société manquant ;
   - crée un calendrier pour chaque salarié actif ;
   - ne crée aucune connexion Google.
2. `app:calendar:backfill-events [--company=] [--dry-run]`
   - rattache chaque événement historique au calendrier de son propriétaire ;
   - mappe `source_type` vers `event_type` et `origin` ;
   - mappe `transparency=busy` vers `blocks_availability=true` ;
   - conserve tous les champs historiques et signale les lignes non résolues.
3. `app:calendar:backfill-google-connections [--company=] [--dry-run]`, au Lot 2
   - initialise `connection_type=employee` et les clés de connexion ;
   - relie les `CalendarSource` au calendrier salarié correspondant ;
   - détecte, sans fusion automatique, les calendriers Google partagés potentiellement dupliqués.

Chaque commande produit des compteurs `scanned/created/updated/skipped/errors`, utilise des transactions par lots raisonnables et écrit l'opération dans `PlatformAuditTrail` sans token ni contenu privé.

## Ordre d'implémentation après validation

1. Migration additive Lot 1 et ajout miroir dans `update.sql`, sans exécution automatique.
2. Entités/repositories/managers : calendrier interne, permissions, demandes et préférences.
3. Commandes de création/reprise, puis validation en `--dry-run`.
4. Voters granulaires et correctif immédiat de confidentialité.
5. Providers tagués Projet, Routine, Prospection et Commercial.
6. disponibilité/conflits et workflows direct/request/time block.
7. routes et formulaires Twig manuels avec CSRF, réponses HTML sans JavaScript, puis enrichissement Stimulus.
8. notifications via l'infrastructure existante et audit via `PlatformAuditTrail`.
9. tests unitaires, fonctionnels et d'isolation tenant du Lot 1.
10. seulement ensuite : compléments Google, OAuth durci, connexion société, FreeBusy, webhooks Messenger et tests Google.

## Risques de régression et mesures

- **Confidentialité** : risque actuel élevé. Ajouter des tests qui prouvent qu'aucun rôle administratif ne reçoit les détails privés d'un tiers.
- **Multi-société** : toutes les recherches par `idtoken` doivent inclure la société active ; ajouter des tests croisés entre deux sociétés.
- **Données historiques** : garder les nouvelles FK nullables jusqu'à la fin de la reprise et refuser de finaliser si des événements restent non rattachés.
- **Google** : ne pas déplacer les colonnes existantes vers de nouvelles tables ; préserver les variables d'environnement et les sync tokens.
- **Double traitement asynchrone** : documenter clairement que Messenger transporte les messages et que `CalendarSyncJob` assure l'idempotence/reprise, ou simplifier ultérieurement après mesure.
- **Notifications** : traiter l'incompatibilité de namespaces par un adaptateur testé, sans dupliquer les entités de notification.
- **Sans JavaScript** : chaque mutation doit avoir une route POST HTML avec CSRF et redirection ; l'API JSON reste un enrichissement.
- **Performance** : plafonner les plages demandées, charger les couches en lots et conserver des index centrés sur société/calendrier/période.

## Validation attendue

Ce document est le point de contrôle préalable exigé. Aucune migration n'a été générée, aucun SQL n'a été exécuté et aucune entité n'a été modifiée.

La validation humaine doit confirmer :

1. le maintien du domaine sous `CommercialBundle\Calendar` ;
2. le nom de table `calendar_internal` ;
3. l'usage de `scope_key` et `active_slot` pour garantir les unicités sous MySQL ;
4. le maintien temporaire de `CalendarEvent.owner_id` obligatoire pour une migration strictement additive ;
5. le périmètre Lot 1 avant tout complément Google.
