Publication initiale de RelaisSMS : documentation et sources nettoyees

This commit is contained in:
2026-10-21 09:29:39 +02:00
commit ac186b03b1
158 changed files with 24818 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# afpma-sms-api
Il faut créer une interface weeb d’envoi de SMS à destination de personnes ou de groupes.
Dans un premier temps, il faut définir les champs nécessaires et les sources de données possibles. Il peut y avoir plusieurs sources potentielles de contacts.
La première source est Galette, le logiciel de gestion associative. À partir de la liste des adhérents, on récupère les informations de contact, notamment les numéros de téléphone. Un contact peut avoir plusieurs numéros de téléphone.
Une seconde source possible, c’est des contacts carddav (carnet d’adresse nextcloud par exemple)
On stockera un cache local dans une base de données. On récupérera également les groupes auxquels appartient l’adhérent. La notion de groupe est importante dans l’interface.
C’est sync donc si un utilisateur est supprimer dans la source on le supprime en base
Le sync ce fait via tâche cron mais aussi forcé via un bouton dans l'interface...
On peut cumuler ces sources
On peut ajouter des utilisateurs “locaux” (non distant, non sync…) donc en base sur chaque contact on doit se rappeler d’où ça vient…)
Les groupes peuvent être importés depuis la source, rester présents dans l’interface, et ne pas forcément être supprimables. Ils peuvent aussi être créés à la volée par l’utilisateur.
L’interface devra proposer un formulaire d’envoi de SMS. Ce formulaire devra permettre de choisir une destination, de saisir un texte, puis d’envoyer le SMS.
La destination pourra être un ou plusieurs contacts, un ou plusieurs groupes, qu’il s’agisse de groupes locaux ou de groupes issus de Galette.
Il faudra gérer le cas où un contact disparaît d’un groupe, ainsi que la mise à jour des informations importées.
Une autre fonctionnalité envisagée est la connexion à un agenda. L’idée serait de pouvoir déclencher un envoi ou un rappel lié à un événement, par exemple une notification quelques minutes avant un événement.
Il faudra conserver une trace des SMS envoyés par l’interface.
Il peut aussi être utile de prévoir une gestion de groupes favoris ou de contacts favoris, afin de les faire remonter plus facilement dans les listes.
Il s’agit d’une interface pour envoyer des SMS. L’envoi final ce fait via des API. Principalement ici compatible avec l’API OVH SMS dans un premier temps, mais il faudrait rester compatible avec d'autres... une sorte de gestion "plugin" / class...
À partir de ces besoins exprimés, il faut réaliser une sorte de cahier des charges Markdown.
+538
View File
@@ -0,0 +1,538 @@
# Cahier des charges - RelaisSMS
## 1. Objectif
RelaisSMS est une application web d'envoi de SMS permettant à des utilisateurs autorisés de sélectionner des contacts ou des groupes issus de plusieurs sources, de composer un message, de l'envoyer immédiatement ou de le programmer, puis de consulter l'historique des envois.
Le premier fournisseur cible est l'API SMS OVHcloud. L'architecture doit toutefois permettre d'ajouter d'autres fournisseurs sans réécrire l'interface ni la logique métier.
## 2. Périmètre fonctionnel
### 2.1 MVP
- Authentification des utilisateurs de l'interface.
- Tableau de bord d'envoi de SMS.
- Sélection de destinataires par contacts et par groupes.
- Gestion de contacts locaux.
- Synchronisation de contacts CardDAV/Nextcloud.
- Préparation de l'intégration Galette, sans la mélanger avec CardDAV/Nextcloud.
- Cache local des contacts, téléphones, groupes et appartenances.
- Déclenchement de synchronisation par tâche planifiée et par bouton manuel.
- Suppression locale des contacts synchronisés disparus de la source.
- Journal des SMS envoyés.
- Connecteur SMS OVHcloud.
- Abstraction permettant d'ajouter d'autres connecteurs SMS.
- Gestion des favoris contacts et groupes.
- Gestion manuelle d'un refus SMS sur les contacts.
- Programmation d'un envoi différé dans le temps.
### 2.2 V2
- Connexion à un agenda CalDAV ou autre source d'événements.
- Rappels SMS liés aux événements.
- Modèles de messages.
- Brouillons et réutilisation des derniers envois.
- Import/export CSV de contacts locaux.
- Statistiques détaillées par campagne, groupe, fournisseur et statut.
- Gestion fine des droits par rôle.
## 3. Utilisateurs et rôles
| Rôle | Droits principaux |
| --- | --- |
| Administrateur | Configurer les sources, fournisseurs SMS, utilisateurs, synchronisations et règles globales. |
| Opérateur | Envoyer des SMS, consulter contacts/groupes, lancer une synchronisation manuelle si autorisé. |
## 4. Sources de données
### 4.1 CardDAV/Nextcloud
La source CardDAV permet de synchroniser un carnet d'adresses, par exemple Nextcloud. C'est un système d'import/synchronisation distinct de Galette, avec son propre connecteur, ses propres identifiants distants et son propre nommage de source.
Données à récupérer :
- UID vCard.
- Nom affiché.
- Nom, prénom si présents.
- Numéros de téléphone et types.
- Groupes/catégories vCard si présents.
- ETag ou horodatage distant pour optimiser la synchronisation.
### 4.2 Galette
La source Galette fournit des adhérents et leurs informations de contact.
Galette n'est pas la source CardDAV/Nextcloud. Même si Galette peut exposer ou exporter des vCards, cette possibilité reste un détail d'implémentation du connecteur Galette et ne doit pas être nommée ni organisée comme une synchronisation CardDAV.
Données à récupérer :
- Identifiant distant stable.
- Nom, prénom, structure éventuelle.
- Numéros de téléphone, avec type si disponible.
- Adresse email si utile pour l'identification.
- Groupes ou statuts d'appartenance.
- Date de dernière modification si disponible.
Mode d'accès retenu :
- API HTTP Galette.
Points à préciser au démarrage :
- Endpoints exacts disponibles côté Galette.
- Mode d'authentification API.
- Mapping exact des champs téléphone.
- Mapping exact des groupes Galette vers les groupes applicatifs.
### 4.3 Contacts locaux
Les contacts locaux sont créés directement dans l'application. Ils ne sont jamais supprimés par une synchronisation distante.
Chaque contact doit conserver :
- Son origine : `local`, `galette`, `carddav`, puis identifiant de source.
- Son statut actif/inactif.
- Les numéros associés.
- Les groupes locaux auxquels il appartient.
- Ses informations de favori.
- Son indicateur de refus SMS, géré manuellement.
## 5. Règles de synchronisation
- Les sources sont cumulables.
- CardDAV/Nextcloud et Galette sont deux sources distinctes ; elles ne partagent ni connecteur, ni configuration, ni nommage fonctionnel.
- Un contact synchronisé est identifié par le couple clé de source (`carddav`, `galette`, etc.) + identifiant externe.
- Si un contact disparaît de la source distante, il est supprimé ou désactivé localement selon la politique choisie.
- Politique recommandée MVP : suppression logique (`deleted_at`) pour préserver l'historique SMS.
- Les groupes importés restent visibles dans l'interface.
- Les groupes importés ne sont pas supprimables manuellement, sauf suppression/désactivation de la source.
- Les groupes locaux sont créables, modifiables et supprimables depuis l'application.
- Si un contact disparaît d'un groupe distant, l'appartenance locale synchronisée est retirée.
- Les appartenances locales ne doivent pas être écrasées par les synchronisations distantes.
- Un bouton "Synchroniser" permet de lancer une synchronisation manuelle.
- Une tâche planifiée lance les synchronisations selon la fréquence configurée.
## 6. Envoi SMS
### 6.1 Formulaire
Le formulaire d'envoi doit permettre :
- La recherche de contacts.
- La recherche de groupes.
- L'ajout de plusieurs contacts et groupes.
- La déduplication automatique des numéros.
- La saisie du message.
- L'affichage du nombre de caractères.
- L'estimation du nombre de SMS selon encodage GSM-7/UCS-2.
- La prévisualisation du nombre de destinataires et numéros valides.
- La validation avant envoi.
- Le choix entre envoi immédiat et envoi programmé.
- Le respect d'une limite configurable de 100 SMS par envoi par défaut.
### 6.2 Gestion des numéros
- Normaliser les numéros au format E.164.
- Conserver le numéro original pour audit.
- Marquer les numéros invalides et les exclure de l'envoi.
- Gérer plusieurs numéros par contact.
- Envoyer à tous les numéros détectés comme téléphones portables.
- Détecter les téléphones portables avec une expression régulière configurable.
- Expression régulière par défaut à prévoir pour les numéros français de type `+33 6`, `+33 7`, `06`, `07`.
- Exclure les contacts marqués en refus SMS.
- Afficher un résumé des exclusions : numéros invalides, numéros non mobiles, doublons, contacts en refus SMS.
### 6.3 Envoi différé
L'utilisateur peut choisir une date et une heure d'envoi. L'envoi programmé reste modifiable ou annulable tant qu'il n'a pas été pris en charge par le job d'envoi.
Règles attendues :
- Stocker l'état `draft`, `scheduled`, `sending`, `sent`, `partial`, `failed`, `cancelled`.
- Refuser une programmation dans le passé.
- Déclencher les envois dus via scheduler Laravel lancé par cron.
- Résoudre les destinataires au moment de la validation de l'envoi, puis conserver cette liste pour audit.
- Option à confirmer en implémentation : revalider les refus SMS et numéros invalides juste avant l'envoi effectif.
### 6.4 Journalisation
Chaque envoi doit créer :
- Une campagne ou opération d'envoi.
- Le message envoyé.
- L'utilisateur émetteur.
- Les destinataires résolus.
- Le fournisseur utilisé.
- Les statuts par numéro : préparé, envoyé, erreur, livré si disponible.
- La réponse brute ou référence externe du fournisseur.
- Les dates de création, envoi et dernière mise à jour.
- La date de programmation si l'envoi est différé.
- Une date d'expiration d'historique calculée selon la configuration, par défaut à 3 ans.
## 7. Connecteurs SMS
### 7.1 Interface technique
Prévoir une interface applicative, par exemple :
```php
interface SmsProviderInterface
{
public function send(SmsMessage $message, array $recipients): SmsSendResult;
public function getName(): string;
public function supportsDeliveryStatus(): bool;
}
```
Le code métier dépend de cette interface et non d'OVHcloud directement.
### 7.2 Connecteur OVHcloud
Fonctions attendues :
- Configuration des identifiants API.
- Sélection du service SMS OVHcloud.
- Envoi vers un ou plusieurs numéros.
- Récupération de l'identifiant de message distant.
- Gestion des erreurs API.
- Journalisation technique des réponses.
Point d'attention : le SDK `ovh/ovh` existe, mais sa compatibilité PHP doit être validée avec la version cible du projet. Une implémentation via client HTTP standard peut être préférable si le SDK officiel n'est pas encore compatible avec la version PHP retenue.
## 8. Agenda et rappels
Fonction à prévoir mais hors MVP.
Cas d'usage :
- Synchroniser des événements depuis un agenda.
- Associer un événement à un groupe ou une liste de contacts.
- Déclencher un rappel X minutes/heures avant l'événement.
- Journaliser les rappels envoyés comme des SMS classiques.
Règles à clarifier :
- Source agenda : CalDAV, ICS, API spécifique.
- Responsabilité de la création des événements.
- Gestion des modifications et suppressions d'événements.
- Anti-doublon sur les rappels.
## 9. Modèle de données cible
Tables principales :
- `users`
- `sources`
- `source_sync_runs`
- `contacts`
- `contact_phone_numbers`
- `groups`
- `contact_group_memberships`
- `favorites`
- `sms_providers`
- `sms_campaigns`
- `sms_recipients`
- `sms_delivery_events`
- `app_settings`
- `message_templates` en V2
- `calendar_sources` en V2
- `calendar_events` en V2
- `scheduled_reminders` en V2
Champs clés recommandés :
- `contacts.source` : `local`, `carddav`, `galette` ou autre clé de source explicite.
- `contacts.external_id` nullable, rempli pour les contacts synchronisés.
- `contacts.deleted_at` pour suppression logique.
- `groups.source` : `local`, `carddav`, `galette` ou autre clé de source explicite.
- `groups.external_id` nullable, rempli pour les groupes synchronisés.
- `contact_group_memberships.source` : `local`, `carddav`, `galette` ou autre clé de source explicite.
- `contact_phone_numbers.normalized_number`.
- `contacts.sms_opt_out` pour le refus SMS manuel.
- `sms_campaigns.status`.
- `sms_campaigns.scheduled_at` nullable.
- `sms_campaigns.expires_at`.
- `sms_recipients.provider_message_id`.
## 10. Configuration applicative
La configuration doit pouvoir être ajustée depuis l'interface administrateur ou, pour les secrets, via variables d'environnement.
Paramètres attendus :
- Identifiants et URL CardDAV/Nextcloud.
- Identifiants et URL Galette, à activer seulement au dernier lot.
- Identifiants et paramètres du fournisseur SMS OVHcloud.
- Fournisseur SMS actif.
- Expression régulière de détection des téléphones portables.
- Pays par défaut pour la normalisation des numéros.
- Limite maximale de SMS par envoi, valeur par défaut : 100.
- Durée de conservation de l'historique, valeur par défaut : 3 ans.
- Fréquence de synchronisation des sources.
- Activation ou non des envois programmés.
Les secrets API ne doivent pas être stockés en clair dans une table applicative si l'environnement permet de les gérer par variables d'environnement ou coffre de secrets.
## 11. Choix techniques recommandés
### 11.1 Stack principale
- PHP 8.3 ou 8.4.
- Laravel 13 si PHP 8.3 est disponible.
- Laravel 12 possible si l'environnement cible impose PHP 8.2.
- PostgreSQL ou MariaDB pour la base principale.
- Eloquent ORM + migrations Laravel.
- Queue Laravel avec driver `database`.
- Scheduler Laravel lancé par cron système.
- Blade pour les vues.
- Livewire ou Alpine.js en complément léger pour les recherches dynamiques et formulaires interactifs.
- Tailwind CSS ou CSS applicatif structuré pour l'interface.
Pourquoi Laravel :
- Suffisant pour le périmètre et moins lourd à mettre en place.
- Bons composants intégrés pour console, jobs, planification, validation, sécurité, migrations et HTTP.
- Interface Blade/Livewire adaptée à une application métier sans SPA complète.
- Architecture propre pour des connecteurs `SourceConnectorInterface` et `SmsProviderInterface`.
- Déploiement simple en PHP classique.
- Driver de queue en base adapté au volume attendu, sans service de queue additionnel.
Alternative possible :
- PHP natif structuré avec Composer peut suffire pour un très petit périmètre, mais Laravel apporte un socle utile pour auth, jobs, migrations et scheduler.
### 11.2 Librairies pressenties
| Besoin | Librairie / composant |
| --- | --- |
| Framework | `laravel/framework` |
| ORM et migrations | Eloquent + migrations Laravel |
| Authentification | Laravel Breeze ou auth applicative Laravel |
| Jobs asynchrones | Laravel Queue driver `database` |
| Planification | Laravel Scheduler + cron |
| HTTP API | Client HTTP Laravel ou `guzzlehttp/guzzle` |
| CardDAV/vCard | `sabre/dav`, `sabre/vobject` |
| Normalisation téléphone | `giggsey/libphonenumber-for-php` |
| SMS OVHcloud | Adapter maison via HTTP, ou `ovh/ovh` si compatible PHP cible |
| Tests | PHPUnit ou Pest |
| Qualité | PHPStan ou Larastan, Laravel Pint |
| Front | Blade, Livewire ou Alpine.js, Tailwind CSS ou CSS maintenu localement |
## 12. Architecture applicative
### 12.1 Domaines
- `Contact`
- `Group`
- `Source`
- `Sync`
- `Sms`
- `Provider`
- `Audit`
- `Calendar` en V2
### 12.2 Interfaces métier
```php
interface SourceConnectorInterface
{
public function sync(SourceConfig $config): SyncResult;
}
```
```php
interface ContactResolverInterface
{
public function resolve(RecipientSelection $selection): ResolvedRecipients;
}
```
```php
interface PhoneNumberNormalizerInterface
{
public function normalize(string $rawNumber, string $defaultCountry): PhoneNumberResult;
}
```
## 13. Découpage en tâches
### Lot 0 - Cadrage
- Acter la séparation entre CardDAV/Nextcloud et Galette.
- Relever les inconnues Galette sans bloquer les premiers lots.
- Valider les paramètres d'accès CardDAV/Nextcloud.
- Valider le fournisseur OVHcloud, endpoints, credentials et quota.
- Définir les rôles utilisateurs.
- Définir la politique de suppression : logique ou physique.
- Valider la regex portable par défaut.
- Valider les paramètres de conservation et de limite d'envoi.
Livrable : document de décisions techniques.
### Lot 1 - Socle applicatif
- Initialiser le projet Laravel.
- Configurer base, migrations, environnement et sécurité.
- Créer l'authentification.
- Mettre en place layout, navigation et design system minimal.
- Ajouter CI locale : tests, lint, analyse statique.
Critère d'acceptation : un utilisateur connecté accède à l'interface vide.
### Lot 2 - Modèle contacts/groupes
- Créer entités et migrations.
- Gérer contacts locaux.
- Gérer groupes locaux.
- Gérer numéros multiples.
- Ajouter normalisation et validation téléphone.
- Ajouter flag manuel de refus SMS.
- Ajouter favoris.
Critère d'acceptation : création d'un contact local, ajout à un groupe, recherche dans l'interface.
### Lot 3 - Synchronisation CardDAV/Nextcloud
- Implémenter `CardDavSourceConnector`.
- Lire les vCards et extraire téléphones/catégories.
- Utiliser ETag/UID si disponible.
- Gérer disparition distante.
- Écrire les logs de synchronisation.
- Gérer création, mise à jour et disparition.
- Ajouter déclenchement manuel.
Critère d'acceptation : un carnet Nextcloud peut être synchronisé et ses contacts apparaissent dans l'interface.
### Lot 4 - Sélection destinataires
- Construire recherche contacts/groupes.
- Résoudre les groupes vers contacts.
- Dédupliquer les numéros.
- Appliquer la regex portable configurable.
- Exclure les contacts en refus SMS.
- Signaler invalides, non mobiles, doublons, refus SMS et contacts sans téléphone.
- Préparer un résumé avant envoi.
Critère d'acceptation : sélectionner plusieurs groupes et contacts produit une liste de numéros uniques et valides.
### Lot 5 - Envoi SMS
- Créer `SmsProviderInterface`.
- Implémenter connecteur OVHcloud.
- Créer campagnes et destinataires.
- Envoyer via job Laravel Queue.
- Gérer erreurs et retries.
- Afficher l'état d'envoi.
Critère d'acceptation : un SMS peut être envoyé via OVHcloud et apparaît dans l'historique.
### Lot 6 - Historique et audit
- Lister les campagnes.
- Filtrer par date, utilisateur, groupe, statut.
- Voir le détail par destinataire.
- Export CSV simple.
- Mettre en place l'expiration automatique à 3 ans, configurable.
Critère d'acceptation : un administrateur peut retrouver qui a envoyé quoi, quand et à quels numéros.
### Lot 7 - Envois programmés
- Ajouter date/heure d'envoi au formulaire.
- Créer un statut `scheduled`.
- Déclencher les campagnes dues via Scheduler Laravel.
- Permettre l'annulation avant prise en charge.
- Journaliser le passage de `scheduled` à `sending`.
Critère d'acceptation : un SMS peut être programmé, puis envoyé automatiquement à l'heure prévue.
### Lot 8 - Agenda et rappels V2
- Configurer une source agenda.
- Synchroniser événements.
- Programmer des rappels.
- Empêcher les doublons.
- Journaliser comme campagne SMS.
Critère d'acceptation : un événement peut générer un rappel SMS automatiquement.
### Lot 9 - Synchronisation Galette
- Revalider l'état de l'instance Galette après upgrade.
- Implémenter `GaletteSourceConnector`, séparé du connecteur CardDAV.
- Choisir le mécanisme d'accès Galette : API HTTP, export CSV authentifié ou autre mécanisme validé.
- Mapper adhérents, téléphones et groupes/statuts Galette.
- Gérer création, mise à jour et disparition.
- Ajouter déclenchement manuel et logs de synchronisation.
Critère d'acceptation : une synchronisation Galette met à jour le cache local sans écraser les données locales ni réutiliser le nommage CardDAV/Nextcloud.
## 14. Parcours interface
### Écran principal - Envoi
- Barre latérale : Envoi, Contacts, Groupes, Sources, Historique, Paramètres.
- Zone de sélection destinataires avec recherche rapide.
- Panneau contacts/groupes favoris.
- Zone message avec compteur.
- Résumé compact avant envoi : destinataires, numéros valides, invalides, non mobiles, refus SMS, nombre de SMS estimé.
- Choix envoi immédiat ou programmé.
- Bouton d'envoi avec confirmation.
### Écran contacts
- Liste filtrable.
- Badges d'origine.
- Téléphones associés.
- Groupes.
- Favori.
- Refus SMS.
- État synchronisé/local/supprimé.
### Écran sources
- Liste des sources.
- Dernière synchronisation.
- Statut.
- Bouton synchroniser.
- Détail des erreurs.
### Écran historique
- Liste des campagnes.
- Détail destinataires.
- Statuts d'envoi.
- Référence fournisseur.
- Export.
## 15. Sécurité et conformité
- Accès authentifié obligatoire.
- Protection CSRF sur formulaires.
- Journalisation des actions sensibles.
- Stockage chiffré ou protégé des secrets API.
- Masquage partiel des numéros dans certaines vues si nécessaire.
- Conservation de l'historique compatible avec les obligations internes.
- Expiration automatique de l'historique au bout de 3 ans par défaut, durée configurable.
- Prévoir une procédure de suppression/anonymisation sur demande.
- Sauvegarde régulière de la base.
## 16. Décisions actées
- CardDAV/Nextcloud et Galette sont deux systèmes d'import/synchronisation distincts.
- Galette est reporté au dernier lot, car l'instance cible n'est pas prête.
- Les SMS partent vers tous les numéros détectés comme portables.
- La détection portable est basée sur une regex configurable.
- Les contacts peuvent être marqués manuellement en refus SMS.
- Le volume maximal attendu par envoi est de 100 SMS.
- Il n'y a pas de validation à deux personnes avant envoi.
- L'interface affiche le nombre de SMS estimé, pas le coût.
- L'historique expire à 3 ans par défaut, avec durée configurable.
## 17. Références techniques consultées
- Laravel release notes : https://laravel.com/docs/releases
- SDK PHP OVHcloud : https://github.com/ovh/php-ovh
- SabreDAV : https://sabre.io/dav/
+236
View File
@@ -0,0 +1,236 @@
# RelaisSMS - Lot 0 - Decisions techniques
Date : 2026-06-11
## Objectif du lot
Ce document fixe les decisions de cadrage avant le demarrage du socle applicatif. Il complete le cahier des charges et sert de reference pour les lots suivants.
## Decisions actees
| Sujet | Decision | Impact |
| --- | --- | --- |
| Source CardDAV/Nextcloud | CardDAV/Nextcloud est le premier systeme d'import/synchronisation distant a implementer. | Le lot 3 porte sur `CardDavSourceConnector` et ne depend pas de Galette. |
| Source Galette | Galette est prepare comme source future, mais le connecteur n'est pas implemente tant que l'instance n'est pas mise a jour. | Galette est reporte au dernier lot. Le socle et CardDAV/Nextcloud avancent sans dependance Galette. |
| Nommage des sources | CardDAV/Nextcloud et Galette sont deux sources separees, meme si Galette expose ou exporte un format vCard. | Les connecteurs, configurations, cles de source et libelles UI restent distincts. Pas de regroupement sous un meme lot ou nommage. |
| Version Galette cible | Galette 1.2.1 cote projet. La documentation `latest` consultee affiche 1.2.2. | Les endpoints exacts seront revalides apres upgrade, avant implementation du dernier lot Galette. |
| Fournisseur SMS | OVHcloud SMS via API REST OVH Europe. | Le connecteur initial depend de l'API OVH, isole derriere `SmsProviderInterface`. |
| SDK OVH | Ne pas imposer le SDK au lot 0. Preferer un adaptateur HTTP Laravel au demarrage, sauf validation explicite du SDK avec la version PHP cible. | Moins de dependance externe et controle clair de la signature API. |
| Roles | Un seul role applicatif au MVP : administrateur complet. | Pas de RBAC fin au lot 1. Tous les utilisateurs authentifies ont les memes droits. |
| Suppression contacts synchronises | Suppression logique par `deleted_at`, pas suppression physique. | L'historique SMS reste auditable meme si un contact disparait d'une source. |
| Suppression contacts locaux | Jamais supprimes par synchronisation. | Un contact local reste sous controle applicatif. |
| Refus SMS | Flag manuel `sms_opt_out` au niveau contact. | Les contacts opt-out sont exclus au moment de la resolution des destinataires. |
| Limite d'envoi | 100 SMS par envoi par defaut, configurable. | Validation cote serveur obligatoire avant creation de campagne. |
| Conservation historique | 3 ans par defaut, configurable. | `sms_campaigns.expires_at` est calcule a la creation. |
| Programmation | Prevue dans le modele, activee par configuration. | Le lot 7 pourra utiliser le scheduler sans revoir le modele. |
## CardDAV/Nextcloud
### Etat retenu
CardDAV/Nextcloud est la source distante prioritaire pour l'import/synchronisation. Elle est traitee comme un systeme autonome : son connecteur lit des carnets CardDAV, des vCards, des UID et des ETags, sans reutiliser le contrat Galette.
### Contrat cible pour RelaisSMS
Le connecteur CardDAV devra fournir au cache local :
| Donnee RelaisSMS | Source CardDAV attendue |
| --- | --- |
| Identifiant distant | UID vCard, complete par l'URL ou l'adresse du carnet si necessaire. |
| Nom affiche | `FN` vCard. |
| Nom / prenom | Champs structures vCard si presents. |
| Telephones | Proprietes `TEL`, avec conservation du type si disponible. |
| Groupes | Categories vCard si presentes. |
| Synchronisation incrementale | ETag, `ctag` ou horodatage distant selon les capacites du serveur. |
Decision : nommer cette source `carddav` ou `carddav_nextcloud` cote configuration/UI, jamais `galette_vcard`.
## Galette
### Etat retenu
Galette est laisse de cote pour l'implementation immediate, conformement au cadrage projet : l'upgrade est planifie mais pas encore effectif. Le lot 0 documente donc le contrat attendu sans figer un endpoint qui pourrait changer au moment de l'upgrade. Le connecteur Galette sera le dernier lot.
### Documentation consultee
- Documentation Galette fournie : <https://apidoc.galette.eu/en/latest/functions_m.html>
- La page consultee est une documentation Doxygen de classes PHP, pas une specification REST/OpenAPI.
- Elements reperes utiles au cadrage :
- `Galette\Controllers\Crud\MembersController::list`
- `Galette\Controllers\Crud\MembersController::ajaxList`
- `Galette\Controllers\Crud\MembersController::show`
- `Galette\Controllers\Crud\MembersController::vcard`
- `Galette\Controllers\CsvController::membersExport`
### Contrat cible pour RelaisSMS
Le connecteur Galette devra fournir au cache local :
| Donnee RelaisSMS | Source Galette attendue |
| --- | --- |
| Identifiant distant | ID adherent Galette, par exemple `Numéro adhérent` dans l'export actuel. |
| Nom | Champ nom. |
| Prenom | Champ prenom. |
| Structure | Personne morale si presente. |
| Email | Courriel. |
| Telephones | Telephone et GSM, avec conservation du numero original. |
| Groupes/statuts | Statut adherent au minimum ; groupes Galette a confirmer apres upgrade. |
| Date de modification | Date de modification si exposee par API/export. |
### Endpoints a valider apres upgrade
Avant le lot 9, valider sur l'instance Galette 1.2.1 :
| Usage | Endpoint ou mecanisme attendu | Statut |
| --- | --- | --- |
| Liste complete des adherents | Endpoint HTTP ou export membres base sur `membersExport` | A confirmer |
| Detail adherent | Route detail membre ou endpoint dedie | A confirmer |
| Fiche/export vCard Galette | Route `vcard` si exploitable dans le connecteur Galette | Optionnel, sans lien avec CardDAV/Nextcloud |
| Groupes/statuts | Route groupes ou champs presents dans la liste/export | A confirmer |
| Authentification | Session, jeton, basic auth ou autre mecanisme Galette | A confirmer |
| Filtrage incremental | Date de modification, ETag ou pagination | A confirmer |
Decision : ne pas coupler RelaisSMS a une route Galette non confirmee. Le connecteur sera concu avec un adaptateur remplacable : API JSON si disponible, export CSV authentifie en solution de repli. Une eventuelle route vCard Galette reste un mecanisme interne au connecteur Galette, pas une source CardDAV.
## OVHcloud SMS
### Documentation consultee
- Page produit API SMS OVHcloud : <https://www.ovhcloud.com/fr/sms/api-sms/>
- Definition API OVH SMS Europe : <https://eu.api.ovh.com/1.0/sms.json>
### Endpoints retenus pour le MVP
Base API : `https://eu.api.ovh.com/1.0`
| Usage | Methode | Endpoint | Notes |
| --- | --- | --- | --- |
| Lister les services SMS | `GET` | `/sms` | Permet de selectionner `serviceName`. |
| Lire le compte SMS | `GET` | `/sms/{serviceName}` | Retourne notamment statut et credits restants. |
| Estimer un message | `POST` | `/sms/estimate` | Parametres : `message`, `noStopClause`, `senderType`. |
| Envoyer un job SMS | `POST` | `/sms/{serviceName}/jobs` | Parametres principaux : `message`, `receivers`, `sender`, `noStopClause`, `tag`. |
| Lire jobs en attente | `GET` | `/sms/{serviceName}/jobs` | Utile pour debug et suivi. |
| Lire un job | `GET` | `/sms/{serviceName}/jobs/{id}` | Suivi fin si necessaire. |
| Annuler un job | `DELETE` | `/sms/{serviceName}/jobs/{id}` | A utiliser seulement tant que l'envoi est stoppable. |
| Historique sortant | `GET` | `/sms/{serviceName}/outgoing` | Filtres disponibles : dates, destinataire, sender, tag, etc. |
| Detail sortant | `GET` | `/sms/{serviceName}/outgoing/{id}` | Permet de recuperer les informations par SMS envoye. |
| Expediteurs autorises | `GET` | `/sms/{serviceName}/senders` | Pour valider la configuration sender. |
Les endpoints batch existent aussi (`/sms/{serviceName}/batches`) et supportent les envois differes via `deferred`. Decision MVP : demarrer avec `/jobs` pour l'envoi immediat et gerer la programmation cote RelaisSMS via queue/scheduler. Les batches restent une option a reevaluer au lot 7 si l'on veut deleguer le differe a OVH.
### Credentials attendus
Configuration par variables d'environnement :
```dotenv
OVH_ENDPOINT=ovh-eu
OVH_APPLICATION_KEY=
OVH_APPLICATION_SECRET=
OVH_CONSUMER_KEY=
OVH_SMS_SERVICE_NAME=
OVH_SMS_SENDER=
OVH_SMS_NO_STOP_CLAUSE=true
```
Les secrets ne sont pas stockes en clair en base. L'interface pourra afficher l'etat de configuration sans exposer les valeurs.
### Quota et garde-fous
- Le compte OVH expose `creditsLeft` via `GET /sms/{serviceName}`.
- RelaisSMS applique sa propre limite de 100 SMS par campagne par defaut.
- Avant envoi, le connecteur doit refuser une campagne si :
- aucun `serviceName` n'est configure ;
- aucun expediteur valide n'est configure quand necessaire ;
- la liste finale depasse la limite applicative ;
- OVH retourne des destinataires invalides.
## Numeros de telephone
### Echantillon Galette
Fichier analyse : `doc/tmp/liste_adherents_filtree.csv`
- 179 lignes adherents.
- 86 numeros presents dans `Telephone` ou `GSM`.
- Exemples fictifs de formats : `06 11 11 11 11`, `0622222222`, `(+33) 6 33 33 33 33`, `+33 (0)6 44 44 44 44`, fixes francais, numeros internationaux.
### Regex portable par defaut
La regex par defaut detecte les mobiles francais bruts avant normalisation :
```regex
^\s*(?:(?:\(\s*\+33\s*\)|\+33|0033)\s*(?:\(0\)\s*)?|0)\s*[67](?:[\s.-]*\d{2}){4}\s*$
```
Elle accepte notamment :
- `06 11 11 11 11`
- `0622222222`
- `07 55 55 55 55`
- `(+33) 6 33 33 33 33`
- `+33 (0)6 44 44 44 44`
- `0033 6 11 11 11 11`
Elle exclut les fixes francais (`01`, `02`, `03`, `04`, `05`, `09`) et les numeros mobiles non francais. Le pays par defaut est `FR`.
Decision importante : cette regex sert a classifier les portables. La normalisation E.164 reste separee et devra etre faite avec `giggsey/libphonenumber-for-php` au lot 2.
## Parametres applicatifs par defaut
| Cle | Valeur par defaut | Source |
| --- | --- | --- |
| `default_country` | `FR` | Cadrage projet |
| `mobile_phone_regex` | Regex ci-dessus | Echantillon Galette |
| `max_sms_per_campaign` | `100` | Cahier des charges |
| `history_retention_years` | `3` | Cahier des charges |
| `scheduled_sends_enabled` | `true` | MVP |
| `active_sms_provider` | `ovh` | Fournisseur cible |
| `galette_sync_enabled` | `false` | Upgrade Galette non fait |
| `carddav_sync_enabled` | `false` | Active a partir du lot 3 |
## Impacts pour les lots suivants
### Lot 1
- Initialiser Laravel sans dependance Galette active.
- Authentification simple.
- Tous les utilisateurs authentifies sont administrateurs.
- Prevoir `app_settings` ou configuration equivalente pour les parametres non secrets.
### Lot 2
- Stocker `raw_number`, `normalized_number`, `is_valid`, `is_mobile`.
- Utiliser la regex configurable pour `is_mobile`.
- Ajouter `sms_opt_out` sur les contacts.
- Prevoir `deleted_at` sur contacts synchronises.
- Prevoir des cles de source distinctes pour `local`, `carddav` et `galette`.
### Lot 3
- Implementer `CardDavSourceConnector`.
- Synchroniser les vCards depuis Nextcloud/CardDAV avec UID et ETag si disponibles.
- Mapper les categories vCard en groupes importes CardDAV.
### Lot 5
- Implementer `OvhSmsProvider` via client HTTP signe.
- Journaliser reponse OVH brute et IDs retournes.
- Utiliser le champ `tag` OVH pour relier les SMS a une campagne RelaisSMS.
### Lot 9
- Revalider Galette 1.2.1 apres upgrade.
- Choisir entre API JSON, export CSV authentifie ou route Galette dediee.
- Mapper les statuts Galette en groupes applicatifs importes.
- Garder un nommage Galette explicite, separe de CardDAV/Nextcloud.
## Points ouverts
| Sujet | Question | A trancher avant |
| --- | --- | --- |
| Galette auth | Quel mecanisme d'authentification sera disponible apres upgrade ? | Lot 9 |
| Galette groupes | Les groupes sont-ils exposes directement ou seulement les statuts ? | Lot 9 |
| CardDAV categories | Les categories Nextcloud sont-elles renseignees et suffisantes comme groupes ? | Lot 3 |
| OVH sender | Expediteur alphanumerique valide ou numero reponse ? | Lot 5 |
| STOP OVH | `noStopClause=true` est-il conforme a l'usage AFPMA non publicitaire ? | Lot 5 |
| Numeros internationaux | Faut-il envoyer aux mobiles non francais presents dans les sources synchronisees ? | Lot 4 |
+736
View File
@@ -0,0 +1,736 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Maquette RelaisSMS</title>
<style>
:root {
--bg: #f4f7fb;
--panel: #ffffff;
--ink: #172033;
--muted: #687184;
--line: #dde4ef;
--soft: #eef3f8;
--brand: #156f73;
--brand-dark: #0d5558;
--accent: #f1b544;
--danger: #bf3d3d;
--ok: #2f8756;
--radius: 8px;
--shadow: 0 18px 40px rgba(31, 49, 78, 0.12);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--ink);
background: var(--bg);
overflow-x: hidden;
}
.app {
min-height: 100vh;
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
}
.sidebar {
background: #102a35;
color: #e9f4f5;
padding: 22px 18px;
display: flex;
flex-direction: column;
gap: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 760;
font-size: 18px;
}
.brand-mark {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--accent);
color: #102a35;
font-weight: 820;
}
.nav {
display: grid;
gap: 6px;
}
.nav button,
.icon-button,
.primary,
.secondary,
.chip button {
font: inherit;
}
.nav button {
width: 100%;
height: 42px;
border: 0;
border-radius: 8px;
background: transparent;
color: #c7dadd;
display: flex;
align-items: center;
gap: 12px;
padding: 0 12px;
text-align: left;
cursor: default;
}
.nav button.active {
background: #1b4655;
color: #ffffff;
}
.nav .ico {
width: 22px;
text-align: center;
color: var(--accent);
}
.sync-card {
margin-top: auto;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
padding: 14px;
background: rgba(255, 255, 255, 0.06);
}
.sync-card strong {
display: block;
font-size: 13px;
margin-bottom: 4px;
}
.sync-card span {
display: block;
color: #b8ccd0;
font-size: 12px;
line-height: 1.4;
}
.main {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.title h1 {
margin: 0;
font-size: 28px;
line-height: 1.15;
letter-spacing: 0;
}
.title p {
margin: 6px 0 0;
color: var(--muted);
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
}
.icon-button,
.primary,
.secondary {
border: 1px solid var(--line);
border-radius: 8px;
min-height: 40px;
background: var(--panel);
color: var(--ink);
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: default;
white-space: nowrap;
}
.icon-button {
width: 40px;
padding: 0;
}
.primary {
background: var(--brand);
color: #ffffff;
border-color: var(--brand);
font-weight: 720;
}
.secondary {
background: #ffffff;
font-weight: 650;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 18px;
align-items: start;
max-width: 980px;
}
.layout > *,
.panel,
.composer,
.search {
min-width: 0;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 18px 18px 12px;
border-bottom: 1px solid var(--line);
}
.panel-header h2 {
margin: 0;
font-size: 17px;
letter-spacing: 0;
}
.tabs {
display: flex;
padding: 4px;
background: var(--soft);
border-radius: 8px;
gap: 4px;
}
.tabs span {
padding: 7px 10px;
border-radius: 6px;
font-size: 13px;
color: var(--muted);
}
.tabs span.active {
background: #ffffff;
color: var(--ink);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.composer {
padding: 18px;
display: grid;
gap: 18px;
}
.field {
display: grid;
gap: 8px;
}
label {
color: #374053;
font-weight: 700;
font-size: 13px;
}
.search {
min-height: 48px;
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
background: #fbfcfe;
}
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
max-width: 100%;
min-height: 30px;
padding: 0 8px 0 10px;
border-radius: 999px;
background: #e7f3f0;
color: #164f4d;
font-size: 13px;
font-weight: 670;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip.group {
background: #fff1d2;
color: #6c4b05;
}
.chip button {
border: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.08);
color: inherit;
padding: 0;
}
.placeholder {
color: #8590a3;
font-size: 14px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.suggestions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.suggestion {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
min-width: 0;
background: #ffffff;
}
.suggestion strong {
display: block;
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.suggestion span {
display: block;
margin-top: 4px;
color: var(--muted);
font-size: 12px;
}
textarea {
width: 100%;
min-height: 178px;
resize: vertical;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
font: inherit;
line-height: 1.5;
color: var(--ink);
background: #fbfcfe;
}
.meta-row {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 13px;
flex-wrap: wrap;
}
.delivery-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.delivery-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
background: #fbfcfe;
min-width: 0;
}
.delivery-card.active {
border-color: var(--brand);
background: #eef8f6;
}
.delivery-card strong {
display: block;
font-size: 13px;
margin-bottom: 5px;
}
.delivery-card span {
display: block;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.compact-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 11px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fbfcfe;
}
.summary-pill {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 9px;
border-radius: 999px;
background: var(--soft);
color: #374053;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.summary-pill.ok {
background: #e7f3f0;
color: var(--ok);
}
.summary-pill.warn {
background: #fff1d2;
color: #76530b;
}
.send-row {
display: flex;
justify-content: space-between;
gap: 14px;
align-items: center;
padding-top: 2px;
flex-wrap: wrap;
}
.warning {
display: inline-flex;
align-items: center;
gap: 8px;
color: #77520a;
background: #fff5dc;
border: 1px solid #f3d38b;
border-radius: 8px;
padding: 9px 11px;
font-size: 13px;
}
.history {
margin-top: 18px;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 13px 16px;
border-bottom: 1px solid var(--line);
text-align: left;
font-size: 13px;
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 720;
background: #fbfcfe;
}
tr:last-child td {
border-bottom: 0;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: #e7f3f0;
color: var(--ok);
white-space: nowrap;
}
.badge.pending {
background: #fff1d2;
color: #76530b;
}
.badge.error {
background: #ffe8e8;
color: var(--danger);
}
@media (max-width: 980px) {
.app {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
.nav {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 680px) {
.main {
padding: 16px;
}
.topbar,
.panel-header,
.send-row {
align-items: stretch;
flex-direction: column;
}
.actions,
.primary,
.secondary {
width: 100%;
}
.actions {
display: grid;
grid-template-columns: 1fr;
}
.icon-button {
width: 100%;
}
.search {
display: grid;
grid-template-columns: 1fr;
}
.suggestions,
.delivery-options,
.nav {
grid-template-columns: 1fr;
}
table {
min-width: 620px;
}
.history {
overflow-x: auto;
}
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar" aria-label="Navigation principale">
<div class="brand">
<div class="brand-mark">R</div>
<div>RelaisSMS</div>
</div>
<nav class="nav">
<button class="active"><span class="ico">✉</span><span>Envoi</span></button>
<button><span class="ico">⌕</span><span>Contacts</span></button>
<button><span class="ico">▦</span><span>Groupes</span></button>
<button><span class="ico">↻</span><span>Sources</span></button>
<button><span class="ico">◷</span><span>Historique</span></button>
<button><span class="ico">⚙</span><span>Paramètres</span></button>
</nav>
<div class="sync-card">
<strong>Dernière synchronisation</strong>
<span>Galette il y a 18 min</span>
<span>CardDAV hier à 21:04</span>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="title">
<h1>Nouvel envoi SMS</h1>
<p>Connecté avec OVHcloud · 1 284 crédits disponibles</p>
</div>
<div class="actions">
<button class="icon-button" aria-label="Notifications">!</button>
<button class="secondary">↻ Synchroniser</button>
<button class="primary">+ Contact local</button>
</div>
</header>
<section class="layout">
<div>
<section class="panel">
<div class="panel-header">
<h2>Composer</h2>
<div class="tabs" aria-label="Mode d'envoi">
<span class="active">SMS</span>
<span>Modèle</span>
<span>Rappel</span>
</div>
</div>
<div class="composer">
<div class="field">
<label>Destinataires</label>
<div class="search">
<span class="chip group">Bénévoles soirée <button aria-label="Retirer">×</button></span>
<span class="chip">Claire Martin <button aria-label="Retirer">×</button></span>
<span class="chip group">Galette · CA <button aria-label="Retirer">×</button></span>
<span class="placeholder">Rechercher un contact ou un groupe...</span>
</div>
</div>
<div class="suggestions" aria-label="Favoris">
<div class="suggestion">
<strong>★ Encadrants</strong>
<span>18 contacts · local</span>
</div>
<div class="suggestion">
<strong>★ Adhérents actifs</strong>
<span>246 contacts · Galette</span>
</div>
<div class="suggestion">
<strong>★ Urgence bureau</strong>
<span>7 contacts · mixte</span>
</div>
</div>
<div class="field">
<label>Message</label>
<textarea>Bonjour, rappel pour la réunion AFPMA de ce soir à 19h30. Merci de confirmer votre présence si ce n'est pas déjà fait.</textarea>
<div class="meta-row">
<span>127 caractères · encodage GSM-7</span>
<span>1 SMS par destinataire</span>
</div>
</div>
<div class="field">
<label>Planification</label>
<div class="delivery-options">
<div class="delivery-card active">
<strong>Envoyer maintenant</strong>
<span>Départ immédiat après confirmation.</span>
</div>
<div class="delivery-card">
<strong>Programmer</strong>
<span>Demain à 09:00 · modifiable avant envoi.</span>
</div>
</div>
</div>
<div class="compact-summary" aria-label="Résumé avant envoi">
<span class="summary-pill ok">54 numéros valides</span>
<span class="summary-pill">1 SMS / destinataire</span>
<span class="summary-pill warn">3 sans portable</span>
<span class="summary-pill warn">2 refus SMS exclus</span>
<span class="summary-pill">Limite 100 SMS</span>
</div>
<div class="send-row">
<div class="warning">! 5 destinataires exclus avant envoi</div>
<button class="primary">Envoyer 54 SMS</button>
</div>
</div>
</section>
<section class="panel history">
<div class="panel-header">
<h2>Derniers envois</h2>
<button class="secondary">Voir tout</button>
</div>
<table>
<thead>
<tr>
<th>Date</th>
<th>Message</th>
<th>Destinataires</th>
<th>Statut</th>
</tr>
</thead>
<tbody>
<tr>
<td>Aujourd'hui 10:42</td>
<td>Rappel atelier</td>
<td>32</td>
<td><span class="badge">Envoyé</span></td>
</tr>
<tr>
<td>Hier 18:07</td>
<td>Changement de salle</td>
<td>19</td>
<td><span class="badge pending">Partiel</span></td>
</tr>
<tr>
<td>08/06 09:15</td>
<td>Convocation CA</td>
<td>11</td>
<td><span class="badge error">2 erreurs</span></td>
</tr>
</tbody>
</table>
</section>
</div>
</section>
</main>
</div>
</body>
</html>