Commit Graph

66 Commits

Author SHA1 Message Date
Serreau Jovann
95d33a9a6d feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails
Devis :
- Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval)
- Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public
- Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable
- Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf
- DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events
- DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV
- OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite
- OrderNumber::markAsUnused() ajoute

DocuSeal integration devis :
- DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true)
- WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison)
- DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel
- Page evenements admin /admin/devis/{id}/events avec badges et payload JSON

Signature client :
- DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel)
- Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif)
- Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr

Avis de paiement :
- Entity AdvertLine (pos, title, description, priceHt) liee a Advert
- Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt
- AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch
- AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer"
- OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder)
- Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber

Meilisearch :
- Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state)
- CRUD indexation sur chaque action (create, edit, send, cancel, create-advert)
- Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism
- Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync

Emails :
- MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier
- Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration
- TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev)

Commande NDD :
- app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr
- Cron quotidien 8h (docker + ansible)

Divers :
- Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers)
- VAULT_URL dev = https://kms.esy-web.dev (comme prod)
- app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis
- app.scss : styles drag & drop
- setasign/fpdi-fpdf installe pour fusion PDF
- 5 migrations Doctrine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:35 +02:00
Serreau Jovann
e03233d922 feat: relation revendeur sur Customer/Website + WebsiteConfiguration
Customer :
- Ajout revendeurCode (VARCHAR 10, nullable) : stocke le code du revendeur
  apporteur d'affaire (pas de FK, suppression revendeur sans impact)
- Select revendeur dans le formulaire de création client
- Champ revendeur dans la fiche client (info + section système)

Website :
- Ajout revendeurCode (VARCHAR 10, nullable) : même logique que Customer

WebsiteConfiguration (nouvelle entité) :
- website (ManyToOne CASCADE) : site parent
- type (VARCHAR 25) : clé de configuration
- value (TEXT) : valeur
- Contrainte unique (website_id, type)

Formulaire création client :
- Select "Revendeur (apporteur d'affaire)" avec liste des revendeurs actifs

Fiche client :
- Onglet Info : champ code revendeur éditable
- Section système : affiche le code revendeur

Migrations : ALTER TABLE customer/website ADD revendeur_code,
CREATE TABLE website_configuration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:39:26 +02:00
Serreau Jovann
c849a31ea1 feat: barre de recherche globale dans la navbar admin
Navbar admin :
- Barre de recherche persistante en haut de toutes les pages admin
- Recherche dans tous les index Meilisearch simultanément :
  clients (5), NDD (5), sites (5), contacts (5), revendeurs (3)
- Résultats en dropdown glassmorphism avec icône par type
- Clic sur un résultat → page + tab correspondant :
  Client → /admin/clients/{id}
  NDD → /admin/clients/{id}?tab=ndd
  Site → /admin/clients/{id}?tab=sites
  Contact → /admin/clients/{id}?tab=contacts
  Revendeur → /admin/revendeurs/{id}/edit

DashboardController::globalSearch :
- Route GET /admin/global-search?q=...
- Agrège les résultats de 5 index Meilisearch
- Retourne [{type, label, sub, url}]

app.js :
- Debounce 250ms, min 2 chars
- Badges type (Client, NDD, Site, Contact, Revendeur)
- Fermeture Escape / clic extérieur

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:29:36 +02:00
Serreau Jovann
f68712bd02 feat: pages services NDD/Esy-Web + index Meilisearch + sync + recherche
Pages services :
- /admin/services/ndd : liste tous les NDD avec client, registrar,
  Cloudflare, gestion, facturation, expiration + barre recherche
- /admin/services/esyweb : liste tous les sites avec client, UUID,
  type, statut + barre recherche
- Liens sidebar mis à jour (Esy-Web → esyweb, Nom de domaine → ndd)

MeilisearchService :
- Index customer_ndd : searchable fqdn/registrar/customerName/customerEmail,
  filterable customerId/isGestion/isBilling
- Index customer_website : searchable name/uuid/customerName/customerEmail,
  filterable customerId/type/state
- CRUD : indexDomain/removeDomain/searchDomains, indexWebsite/removeWebsite/searchWebsites
- Serializers avec infos client intégrées (customerName, customerEmail, customerId)

SyncController :
- Route POST /admin/sync/domains : sync tous les Domain dans Meilisearch
- Route POST /admin/sync/websites : sync tous les Website dans Meilisearch
- Compteurs totalDomains et totalWebsites dans index

Template admin/sync :
- Bloc "Noms de domaine" (slate) avec bouton sync
- Bloc "Sites Internet" (blue) avec bouton sync

Recherche (app.js) :
- renderHit adapté : affiche fqdn/name pour NDD/sites, customerName en sous-texte
- Lien vers la fiche client (customerId) pour les résultats NDD/Website
- setupSearch configuré pour search-ndd et search-websites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:26:17 +02:00
Serreau Jovann
9316743ac6 fix: onglet Securite - un seul bouton qui génère + envoie le lien au client
Remplace les 2 sections (changer mot de passe + générer temporaire) par
un seul bouton "Envoyer un lien de reinitialisation" qui :
1. Génère un mot de passe temporaire (bin2hex 16 chars)
2. Hash et stocke dans User (password + tempPassword)
3. Envoie l'email bienvenue avec le lien set_password au client

Plus de champ mot de passe à saisir manuellement — tout est automatique.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:19:20 +02:00
Serreau Jovann
42ab59ce07 feat: onglet Securite dans fiche client
Onglet Securite (tab=securite) :

Statut du compte :
- Email, statut mot de passe (Temporaire jaune / Defini vert)
- 2FA Email (Active/Desactive), 2FA Google (Active/Desactive)

Changer le mot de passe :
- Formulaire avec nouveau mot de passe (min 8 chars)
- Hash via UserPasswordHasherInterface, clearTempPassword

Generer mot de passe temporaire :
- Genere 16 chars aleatoires, hash + setTempPassword
- Affiche le mot de passe en flash (pour renvoi email bienvenue)
- Modal confirmation avant action

Desactiver 2FA :
- Desactive 2FA Email + Google Authenticator + supprime secret + backup codes
- Bouton rouge avec modal confirmation
- Section visible uniquement si au moins 1 methode 2FA active

handleSecurityForm() :
- Actions : reset_password, disable_2fa, generate_temp_password

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:13:37 +02:00
Serreau Jovann
45972058ef feat: onglet Sites Internet dans fiche client + compteur sites dans liste
Onglet Sites Internet (tab=sites) :
- Table : nom, UUID, type (Vitrine bleu / E-Commerce violet),
  statut (Cree / Installation / En ligne / Suspendu / Ferme), date
- Badges colorés par statut
- Message "Aucun site internet" si vide

buildCustomersInfo :
- sites compte maintenant les Website liés au customer (plus hardcodé à 0)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:10:42 +02:00
Serreau Jovann
310439cca2 fix: séparer Config DNS Esy-Mail et Config DNS Esy-Mailer
2 configurations DNS distinctes par domaine :
- Config Esy-Mail : MX, SPF, DKIM, DMARC pour la réception (Dovecot/Mailcow)
- Config Esy-Mailer : SPF, DKIM SES, MAIL FROM pour l'envoi (AWS SES)

Les 2 sont à false/KO par défaut — seront branchés sur les checks
DNS réels quand les services seront activés sur le domaine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:58:08 +02:00
Serreau Jovann
d65fc102af feat: sous-ligne services par domaine (EsyMail, EsyMailer, Config DNS)
Sous chaque NDD dans l'onglet Noms de domaine, une ligne affiche :
- Esy-Mail : check vert si au moins 1 DomainEmail lié + nombre de boîtes
- Esy-Mailer : check vert/rouge (placeholder, false pour le moment)
- Config DNS : OK (vert) si zone Cloudflare configurée, KO (rouge) sinon

buildDomainsInfo() :
- Compte les DomainEmail par domaine
- esyMail = emailCount > 0
- esyMailerConfig = zoneIdCloudflare != null (DNS géré)
- esyMailer = false (sera branché sur l'entité service)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:56:44 +02:00
Serreau Jovann
bd71f8fcc2 feat: gestion NDD avec auto-détection OVH/Cloudflare + service OvhService
OvhService (ovh/ovh SDK) :
- listDomains() : liste tous les NDD du compte OVH
- isDomainManaged(fqdn) : vérifie si un domaine est chez OVH
- getDomainInfo(fqdn) : infos domaine (nameServerType, offer, etc.)
- getDomainServiceInfo(fqdn) : expiration, création, status, contacts
- getZoneInfo(fqdn) : zone DNS (OVH ou externe, DNSSEC)

Ajout NDD (onglet Noms de domaine, fiche client) :
- Formulaire : nom de domaine + registrar (auto-détection ou manuel)
- autoDetectDomain() au submit :
  1. Check OVH : si trouvé → registrar=OVH, isGestion=true, isBilling=true,
     expiredAt depuis serviceInfos, check zone DNS OVH
  2. Check Cloudflare : si zone trouvée → zoneCloudflare=active,
     zoneIdCloudflare=zoneId. Si registrar=Cloudflare → gestion+billing actifs
  3. Si ni OVH ni CF : registrar manuel (Gandi/Autre), isGestion=false
- Suppression NDD avec data-confirm modal
- Colonne Actions ajoutée dans la table

Configuration :
- .env : OVH_KEY, OVH_SECRET, OVH_CUSTOMER
- .env.local : credentials OVH
- ansible/vault.yml : credentials OVH pour prod
- composer.json : ovh/ovh ^3.5

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:18:05 +02:00
Serreau Jovann
9fa0b1b629 fix: retirer bouton 'Lien activation' de la fiche client
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:08:21 +02:00
Serreau Jovann
5809c1a4df feat: route app_unsubscribe + fix envoi email bienvenue
UnsubscribeController :
- Route GET /unsubscribe/{email}/{token} (app_unsubscribe)
- Vérifie le token HMAC via UnsubscribeManager::isValidToken
- Si valide : désabonne l'email + page succès
- Si invalide : page erreur avec contact unsubscribe@siteconseil.fr

Templates :
- unsubscribe/success.html.twig : confirmation glassmorphism
- unsubscribe/invalid.html.twig : erreur glassmorphism

ClientsController :
- sendWelcomeEmail : suppression try/catch silencieux pour laisser
  remonter les erreurs (sinon mail jamais envoyé sans diagnostic)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:06:50 +02:00
Serreau Jovann
4a9952e226 feat: email bienvenue client + bouton renvoi sur fiche + lien activation
Email bienvenue (templates/emails/client_created.html.twig) :
- Envoyé automatiquement à la création du client
- Contenu : plateforme client.siteconseil.fr, identifiant email,
  bouton "Choisir mon mot de passe" avec lien app_set_password
- Compatible tous clients mail (table-based, CSS longhand)

ClientsController :
- sendWelcomeEmail() : génère le lien set_password et envoie via MailerService
- Appelé dans create() après ensureDefaultContact
- Route POST /{id}/resend-welcome : renvoie l'email si tempPassword disponible

Fiche client (show.html.twig, onglet Info) :
- Si tempPassword existe : bandeau indigo "Espace client non activé"
  + lien direct vers la page activation + bouton "Renvoyer email bienvenue"
- Si tempPassword null : bandeau vert "Espace client activé"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:53:33 +02:00
Serreau Jovann
619b068d9d feat: index Meilisearch customer_contact + sync contacts + onglet NDD
MeilisearchService :
- Nouvel index customer_contact (searchable: firstName, lastName, fullName,
  email, phone, role / filterable: customerId, isBillingEmail)
- indexContact(), removeContact(), searchContacts()
- serializeContact() avec tous les champs

SyncController :
- Route POST /admin/sync/contacts : sync tous les CustomerContact
  dans Meilisearch (setupIndexes + indexContact par contact)
- totalContacts ajouté dans index() via EntityManager

Template admin/sync/index.html.twig :
- Bloc "Contacts" violet avec compteur et bouton Synchroniser

Template admin/clients/show.html.twig :
- Nouvel onglet "Noms de domaine" : table des Domain liés au client
  (fqdn, registrar, Cloudflare, gestion, facturation, expiration)
- Expiration colorée : rouge si expiré, jaune si < 30j, gris sinon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:00:12 +02:00
Serreau Jovann
e7e3b2c6b1 feat: bouton 'Fiche' dans la table clients vers /admin/clients/{id}
Ajout bouton indigo 'Fiche' dans la colonne Actions, avant Suspendre/Supprimer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:16:57 +02:00
Serreau Jovann
d6061a07c9 feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
  Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=

Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
  entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
  adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour

Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner

Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
  affichent "Cette section sera disponible prochainement"

Customer entity :
- Ajout setUpdatedAt()

Template index :
- Nom du client cliquable (lien vers show)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:12:47 +02:00
Serreau Jovann
91b4100560 feat: ligne info services sous chaque client dans /admin/clients
Sous chaque ligne client, une ligne compacte affiche :
- Raison sociale, SIRET, type entreprise (si disponibles)
- Sites : nombre (placeholder, 0 pour l'instant)
- NDD : nombre de domaines liés au client
- Emails : nombre de DomainEmail liés aux domaines du client
- Sign : check vert/rouge (Esy-Signature activé)
- News : check vert/rouge (Esy-Mailer/Newsletter activé)
- Mail : check vert/rouge (au moins 1 email Esy-Mail)
- Statut paiement : OK (vert) ou IMPAYEE (rouge avec nombre)

ClientsController :
- index() reçoit EntityManagerInterface pour requêter Domain/DomainEmail
- buildCustomersInfo() : construit les compteurs par client
  (domains, emails, esyMail depuis DomainEmail count > 0)
- Les flags esySign/esyNewsletter/unpaid/sites seront branchés
  quand les entités correspondantes existeront

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:09:52 +02:00
Serreau Jovann
a047f61911 feat: suppression client avec état pending_delete + commande nettoyage nocturne
Customer entity :
- Ajout STATE_PENDING_DELETE = 'pending_delete'
- Ajout isPendingDelete() pour vérification rapide

ClientsController::delete :
- Route POST /admin/clients/{id}/delete
- Met le state à pending_delete (pas de suppression immédiate)
- Flash message expliquant la suppression automatique cette nuit
- Bloqué si déjà en pending_delete

Template admin/clients/index.html.twig :
- Badge "Suppression" rouge avec animation pulse pour pending_delete
- Bouton "Supprimer" avec data-confirm (modal native navigateur)
- Si pending_delete : boutons Suspendre/Activer masqués,
  texte "En attente" affiché

CleanPendingDeleteCommand (app:clean:pending-delete) :
- Cherche tous les clients state = pending_delete
- Pour chaque client :
  1. Supprime le customer Stripe (API delete)
  2. Supprime de Meilisearch (removeCustomer)
  3. Supprime le Customer + User en cascade (Doctrine)
- Log chaque suppression + compteur final
- A planifier en cron nocturne : 0 2 * * * (2h du matin)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:31:55 +02:00
Serreau Jovann
64dfcd5721 feat: stats enrichies - cout infra/prestataire, marge nette, statut rentabilité
StatsController :
- Séparation cout_infra et cout_prestataire par service
- cout_total = cout_infra + cout_prestataire (calculé, plus en dur)
- marge_nette = ca_ht - cout_total par service et global
- marge_nette globale = ca_ht - cout_total - commission_stripe
- resolveStatus() : Surplus (marge > 30% du CA), Rentable (marge >= 0),
  Negatif (marge < 0) — appliqué par service et global
- Tous les totaux calculés dynamiquement depuis les services

Template admin/stats/index.html.twig :
- KPI : 3 cartes cout (Infra, Prestataire, Total)
- KPI : Commission Stripe, Marge nette HT (vert/rouge), Badge statut
  (Surplus vert foncé, Rentable vert, Negatif rouge)
- Cartes services : colonnes Infra/Presta/Marge + badge statut
  au lieu de l'ancien cout/marge unique

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:28:39 +02:00
Serreau Jovann
ac9f7a0314 feat: sync clients vers Stripe dans /admin/sync
SyncController::syncStripeCustomers :
- Route POST /admin/sync/stripe/customers
- Pour chaque client en BDD : si stripeCustomerId existe → update,
  sinon → create dans Stripe
- Envoie : email, nom (raison sociale ou fullName), phone, metadata
  (crm_user_id, siret, code_comptable)
- Stocke le stripeCustomerId apr��s création
- Compteurs : créés, mis à jour, erreurs avec flash détaillé

Template admin/sync/index.html.twig :
- Bloc "Clients - Stripe" avec compteurs sync/non sync/total
- Bouton "Synchroniser Stripe" avec data-confirm

Index enrichi avec customersSynced / customersNotSynced

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:26:53 +02:00
Serreau Jovann
b498096af1 feat: coordonnées GPS auto (API IGN) + code comptable 411_ préfixé
Customer entity :
- Ajout geoLat et geoLong (DECIMAL 10,7 nullable)
- Migration : ALTER TABLE customer ADD geo_lat, geo_long

Géocodage automatique :
- API recherche entreprise : récupère siege.latitude/longitude directement
- Fallback API IGN (data.geopf.fr/geocodage/search) si coords absentes
  mais adresse remplie — appelé côté PHP dans geocodeIfNeeded()
- Champs hidden geoLat/geoLong dans le formulaire

Code comptable 411_ :
- Préfixe "411_" affiché en dur (glass-dark, non modifiable)
- L'utilisateur saisit uniquement la partie après (ex: 0001_DUPON)
- Si vide : génération automatique via generateUniqueCodeComptable()
- Concaténation '411_' + saisie dans le contrôleur

Tests mis à jour : testGeoCoordinates, HttpClientInterface ajouté dans
tous les appels create(), Customer 100% (48/48, 82/82)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:24:52 +02:00
Serreau Jovann
ec0c0366c4 feat: auto-détection type entreprise + RNA pour associations
Customer entity :
- Ajout champ rna (VARCHAR 20, nullable) pour identifiant RNA associations
- Migration : ALTER TABLE customer ADD rna

Recherche entreprise (entreprise-search.js) :
- resolveTypeCompany() : mapping nature_juridique vers type formulaire
  92xx/91xx/93xx → association, 10xx → auto-entrepreneur,
  54xx/55xx → sarl, 57xx → sas, 52xx → eurl, 65xx → sci
- Auto-remplissage typeCompany depuis nature_juridique
- Récupération RNA depuis complements.identifiant_association
- Badge "Association" affiché dans les résultats si nature_juridique 92xx
- RNA affiché dans les résultats (ex: RNA W502004724)

Template create.html.twig :
- Ajout champ "RNA (associations)" dans la section Entreprise

ClientsController :
- populateCustomerData : ajout setRna depuis le formulaire

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:04:43 +02:00
Serreau Jovann
db7f4eda7c feat: auto-remplissage RCS, APE, TVA depuis recherche entreprise
Customer entity :
- Ajout champ ape (VARCHAR 10, nullable) avec getter/setter
- Migration : ALTER TABLE customer ADD ape

Recherche entreprise (entreprise-search.js) :
- RCS construit depuis SIREN + ville du siège (ex: RCS Saint-Quentin 418664058)
- TVA intracommunautaire calculée depuis SIREN (clé modulo 97)
- Code APE/NAF récupéré depuis activite_principale de l'API
- APE affiché dans les résultats de recherche à côté du SIREN/SIRET
- Auto-remplissage des champs : raisonSociale, siret, rcs, numTva, ape,
  address, zipCode, city, firstName, lastName

Template create.html.twig :
- Ajout champ "Code APE / NAF" dans la section Entreprise

ClientsController :
- populateCustomerData : ajout setApe depuis le formulaire

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:59:24 +02:00
Serreau Jovann
a9a10e5584 feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
  (pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible

Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
  raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
  + firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape

Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
Serreau Jovann
a2a6e7f4af fix: code comptable client avec préfixe 411 (norme comptable)
- Customer::generateCodeComptable() : préfixe changé de 'EC' vers '411'
  (compte client 411 du plan comptable général)
- Format : 411_XXXX_XXXXX (ex: 411_0001_SITEC)
- Template : placeholder mis à jour
- Test : assertStringStartsWith('411_')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:20:51 +02:00
Serreau Jovann
8b6c10b842 feat: champ code comptable saisible à la création client
- Ajout champ codeComptable dans le formulaire de création client
  (section Entreprise, placeholder avec format EC_XXXX_XXXXX)
- Si rempli : utilise le code saisi, sinon génère automatiquement
  via CustomerRepository::generateUniqueCodeComptable()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:13:47 +02:00
Serreau Jovann
2fb90dfb0c Revert "feat: création boîte mail Esy-Mail lors de la création client"
This reverts commit 7a7796c090.
2026-04-03 20:12:39 +02:00
Serreau Jovann
7a7796c090 feat: création boîte mail Esy-Mail lors de la création client
EsyMailService :
- createMailbox(email, password, quotaMb) : INSERT dans la table mailbox
  de la base esymail avec hash bcrypt (BLF-CRYPT compatible Dovecot)
- mailboxExists(email) : vérifie si l'adresse existe déjà
- isAvailable() : vérifie si ESYMAIL_DATABASE_URL est configuré
- Connexion DBAL directe vers la base esymail (séparée de l'EntityManager)

ClientsController :
- Ajout paramètre EsyMailService dans create()
- Ajout méthode createMailboxIfRequested() : vérifie checkbox, valide
  email/password, vérifie existence, crée la boîte avec quota choisi
- Flash success/error selon le résultat

Template admin/clients/create.html.twig :
- Section "Messagerie Esy-Mail" avec checkbox toggle
- Champs : adresse email, mot de passe (min 8 chars), quota (1/2/5/10 Go)
- Masqué par défaut, affiché au clic sur la checkbox

Configuration :
- .env : ajout ESYMAIL_DATABASE_URL (vide par défaut)
- .env.local : connexion vers database:5432/esymail

Tests mis à jour avec EsyMailService stubé dans tous les appels create()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:50:50 +02:00
Serreau Jovann
d8113e9737 feat: ajout WebmailController avec page login Esy-Mail
Interface uniquement (pas de logique d'authentification) :
- WebmailController : route /webmail, render login.html.twig
- templates/webmail/login.html.twig : formulaire email + password,
  design glassmorphism avec header Esy-Mail (icône enveloppe SVG),
  labels accessibles (for/id), autocomplete email/current-password,
  flash messages support, footer SARL SITECONSEIL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:41:12 +02:00
Serreau Jovann
f0a5fdc849 refactor: suppression duplication templates PDF RGPD + test 100% DevisPdfController
Templates PDF :
- _base.html.twig : blocs verify_box et hmac_section avec contenu par défaut
  (QR code, verify_url, HMAC-SHA256) au lieu de blocs vides
- rgpd_access.html.twig : suppression blocs verify_box et hmac_section dupliqués
  (héritent du parent)
- rgpd_deletion.html.twig : idem
- rgpd_no_data.html.twig : idem

DevisPdfControllerTest (8 tests) :
- testDevisNotFound : devis null lance NotFoundHttpException
- testUnsignedPdfNotSet : unsignedPdf null lance NotFoundHttpException
- testFileNotExists : fichier absent lance NotFoundHttpException
- testUnsignedPdfSuccess : PDF unsigned retourné en BinaryFileResponse
- testSignedPdfSuccess : PDF signed retourné
- testAuditPdfSuccess : PDF audit retourné
- testAccessAsNonEmploye : accès sans ROLE_EMPLOYE (branche checkAccess)
- testDefaultTypeNull : type inconnu lance NotFoundHttpException

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:05:44 +02:00
Serreau Jovann
389b2c308c fix: corrections SonarQube - qualité code, accessibilité, complexité cognitive
Propriétés inutilisées supprimées :
- CheckDnsCommand : suppression de $urlGenerator (jamais lu, seulement injecté)
- PurgeEmailTrackingCommand : suppression de $repository (jamais lu, requêtes
  via $em->createQueryBuilder directement), suppression import EmailTrackingRepository

Corrections PHPStan / types :
- SyncController : suppression $wh['status'] ?? 'created' redondant, accès direct
  à $wh['status'] car le type retour inclut désormais status: string
- StripeWebhookService : PHPDoc createAllWebhooks corrigé de
  list<array{type, url, id}> vers list<array{type, url, id, status, secret?}>
  pour refléter les clés status et secret effectivement présentes
- DnsReportController : suppression ?? '' sur EXPECTED_MX[$domain] (clé toujours existante)
- CloudflareService : ajout @param array<string, mixed> $query sur request()
- CheckDnsCommand : suppression ?? '' sur EXPECTED_MX[$domain], ajout PHPDoc
  @param list<array<string, mixed>> $cfRecords sur checkMailcow

Méthode manquante :
- DnsCheckService : ajout getDkimTxtRecord() qui parcourt les TXT records
  et retourne le premier commençant par 'v=DKIM1', appelé dans checkDkim()

Code mort supprimé :
- MailcowService : suppression is_array($data) toujours vrai sur retour
  de $response->toArray(false), retour direct
- DnsInfraHelper : suppression getFirstTxtValueRaw() identique à getFirstTxtValue(),
  simplification de getActualDnsValue() qui n'appelle plus le fallback

Constantes pour littéraux dupliqués :
- DnsInfraHelper : ajout LABEL_AWS_SES, LABEL_MAILCOW, LABEL_MAILCOW_DNS,
  NOT_FOUND, NOT_CONFIGURED — remplace les chaînes 'AWS SES' (10×),
  'Non trouve' (4×), 'Non configure' (3×), 'Mailcow' et 'Mailcow DNS'
- Utilisation dans CheckDnsCommand (checkAwsSes, checkSesDomain, checkSesDkim,
  checkSesMailFrom, checkSesBounce, checkMailcow)

Réduction complexité cognitive checkAwsSes (61 → ~10 par méthode) :
- Extraction checkSesDomain() : vérifie isDomainVerified, ajoute check + erreur/succès
- Extraction checkSesDkim() : vérifie getDkimStatus (enabled+verified),
  parcourt les tokens DKIM CNAME avec enrichLastCheck
- Extraction checkSesMailFrom() : vérifie getMailFromStatus, MAIL FROM MX
  (checkMxExists + getMxValues), MAIL FROM TXT (checkTxtContains + getTxtSpfValue)
- Extraction checkSesBounce() : vérifie getNotificationStatus (forwarding ou bounce_topic)

Accessibilité WCAG AA :
- app.scss : contraste sidebar-nav-item augmenté de rgba(255,255,255,0.6)
  à rgba(255,255,255,0.75) pour ratio de contraste suffisant sur fond sombre
- tarification/index.html.twig : ajout for/id sur les 5 paires label/input
  (title-{id}, priceHt-{id}, monthPrice-{id}, period-{id}, description-{id})
- membres.html.twig : ajout for/id sur les 15 checkboxes de groupes
  (group-member, group-admin, group-esy-web, ..., group-esy-ndd),
  remplacement du label titre par <span> (n'est pas associé à un contrôle)
- 2fa_google.html.twig : ajout for="trusted-device" et id="trusted-device"
  sur le checkbox de confiance appareil
- tarif.html.twig : ajout <thead class="sr-only"> avec <th>Option</th><th>Tarif</th>
  sur la table options Esy-Mail (table sans en-têtes)

Ansible :
- vault.yml : ajout discord_webhook pour le déploiement prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:41:17 +02:00
Serreau Jovann
911a92ce88 refactor: sécurité Discord webhook, tests 100% coverage, factorisation templates PDF et DNS
Sécurité - Discord Webhook :
- Suppression de l'URL Discord webhook en dur dans CheckDnsCommand (ligne 34)
- Ajout de la variable d'environnement DISCORD_WEBHOOK dans .env (vide par défaut)
- Injection via #[Autowire(env: 'DISCORD_WEBHOOK')] dans le constructeur
- Vérification que le webhook est configuré avant envoi ('' !== $this->discordWebhook)
- Remplacement de l'URL en dur dans discord-notify.yml par ${{ secrets.DISCORD_WEBHOOK }}

Factorisation DNS (suppression duplication SonarQube) :
- Création de src/Service/DnsInfraHelper.php avec les méthodes partagées :
  enrichWithCloudflare, enrichLastCheck, loadCloudflareRecords, getActualDnsValue,
  getMxValues, getFirstTxtValue, getSrvValue, checkMxExists, checkTxtContains,
  checkDnsRecordExists, getTxtSpfValue
- Constantes DOMAINS et EXPECTED_MX centralisées dans DnsInfraHelper
- Refactorisation de CheckDnsCommand pour utiliser DnsInfraHelper au lieu des
  méthodes privées dupliquées (enrichWithCloudflare, enrichLastCheck, etc.)
- Refactorisation de DnsReportController pour utiliser DnsInfraHelper au lieu
  des méthodes privées dupliquées (enrichWithCloudflare, enrichLastCheck, etc.)

Factorisation templates PDF (suppression duplication lignes 6-22) :
- Création de templates/pdf/_base.html.twig comme layout commun avec :
  CSS partagé (banner, container, info-grid, verify-box, hmac, contact-box, data tables),
  blocs Twig configurables (title, font_size, extra_styles, content, verify_box,
  hmac_section, footer_contact, signature_box, footer_legal)
- Refactorisation de rgpd_access.html.twig : extends _base, accent #4338ca,
  bloc content avec sessions/events, styles session-meta et no-data
- Refactorisation de rgpd_deletion.html.twig : extends _base, accent #dc2626,
  font 11px, bloc content avec attestation-box et warning
- Refactorisation de rgpd_no_data.html.twig : extends _base, accent #fabf04/#111827,
  font 11px, bloc content avec attestation absence
- Refactorisation de admin/logs/pdf.html.twig : extends _base, accent #4338ca,
  bloc content avec tables utilisateur/requête et HMAC verification box,
  suppression du bloc signature, footer légal avec Siret/TVA

Tests - Couverture 100% (469 tests, 857 assertions, 0 failures) :

AnalyticsControllerTest (8 tests) :
- testTrackInvalidToken : token incorrect retourne 404
- testTrackEmptyPayload : payload sans clé 'd' retourne 400
- testTrackInvalidEncryptedData : données chiffrées invalides retourne 403
- testTrackNewVisitorCreation : création visiteur avec screen/language/UA, retourne uid+hash
- testTrackPageViewWithValidHash : page view avec uid/hash valides retourne 204
- testTrackSetUserWithValidHash : setUser avec uid/hash valides retourne 204
- testTrackWithInvalidHash : hash incorrect retourne 403
- testTrackWithMissingHash : hash absent retourne 403

AttestationControllerTest (8 tests) :
- testVerifyNotFound : référence inconnue retourne 200 (template not_found)
- testVerifyFound : attestation trouvée retourne 200 (template verify)
- testDownloadNotFound : référence inconnue lance NotFoundHttpException
- testDownloadNoPdf : attestation sans PDF lance NotFoundHttpException
- testDownloadWithPdf : attestation avec PDF signé retourne BinaryFileResponse 200
- testAuditNotFound : référence inconnue lance NotFoundHttpException
- testAuditNoCertificate : attestation sans certificat lance NotFoundHttpException
- testAuditWithCertificate : attestation avec certificat retourne BinaryFileResponse 200

CspReportControllerTest (13 tests) :
- testGetReturns204 : GET /my-csp-report retourne 204
- testReportEmptyPayload : payload vide retourne 400
- testReportInvalidJson : JSON invalide retourne 400
- testReportIgnoredExtension : chrome-extension ignoré, retourne 204
- testReportIgnoredMozExtension : moz-extension ignoré, retourne 204
- testReportIgnoredLocalhost : localhost ignoré, retourne 204
- testReportIgnoredLocalDomain : .local ignoré, retourne 204
- testReportIgnoredWasmEval : wasm-eval ignoré, retourne 204
- testReportIgnoredAboutBlank : about:blank ignoré, retourne 204
- testReportIgnoredNodeModulesInline : node_modules inline ignoré, retourne 204
- testReportRealViolationSendsEmail : violation réelle envoie email, retourne 204
- testReportRealViolationEmailFailure : échec email ne bloque pas, retourne 204
- testReportWithoutCspReportWrapper : payload sans wrapper csp-report fonctionne

EmailTrackingControllerTest (10 tests) :
- testTrackWithExistingTracking : tracking trouvé, markAsOpened appelé, état 'opened'
- testTrackWithNonExistingTracking : tracking absent, retourne image sans erreur
- testViewNotFound : messageId inconnu lance NotFoundHttpException
- testViewNoHtmlBody : tracking sans htmlBody lance NotFoundHttpException
- testViewWithHtmlBody : retourne HTML du tracking
- testViewWithAttachments : retourne HTML avec section pièces jointes
- testAttachmentNotFoundEmail : email inconnu lance NotFoundHttpException
- testAttachmentIndexNotFound : index absent lance NotFoundHttpException
- testAttachmentFileNotExists : fichier supprimé lance NotFoundHttpException
- testAttachmentSuccess : téléchargement pièce jointe retourne BinaryFileResponse

StatsControllerTest (4 tests) :
- testIndexCurrentPeriod : période 'current', dates du mois en cours
- testIndexCustomPeriod : période 'custom' avec from/to explicites
- testIndexMonthsPeriod : période '3', dateFrom = -3 mois
- testIndexDefaultPeriod : pas de paramètre, défaut 'current'

StatusControllerTest (20 tests) :
- testIndexEmpty : catégories vides retourne 200
- testIndexWithServices : catégorie avec service, appel getHistoryForDays/getDailyStatus
- testManage : page gestion retourne 200
- testCategoryCreateEmptyName : nom vide redirige avec flash error
- testCategoryCreateSuccess : création catégorie avec position redirige avec flash success
- testCategoryDelete : suppression catégorie redirige avec flash success
- testServiceCreateEmptyName : nom vide redirige avec flash error
- testServiceCreateCategoryNotFound : catégorie inexistante redirige avec flash error
- testServiceCreateSuccess : création service avec URL redirige avec flash success
- testServiceCreateWithExternalType : création service externe avec type http_check
- testServiceDelete : suppression service redirige avec flash success
- testUpdateValidStatus : statut 'down' avec message, setStatus appelé
- testUpdateInvalidStatus : statut invalide redirige avec flash error
- testUpdateStatusWithEmptyMessage : statut 'up' sans message (null passé)
- testMessageCreateEmptyFields : champs vides redirige avec flash error
- testMessageCreateServiceNotFound : service inexistant redirige avec flash error
- testMessageCreateSuccessNoUser : message créé sans utilisateur connecté
- testMessageCreateSuccessWithUser : message créé avec User injecté via tokenStorage
- testMessageResolve : message résolu, isActive=false, resolvedAt non null
- testApiDaily : retourne JsonResponse avec données getDailyStatus

SyncControllerTest (14 tests) :
- testIndexWithMixedPrices : prix avec/sans stripeId, compteurs stripeSynced/stripeNotSynced
- testSyncCustomersSuccess : indexation 1 client dans Meilisearch
- testSyncCustomersError : exception findAll, flash error
- testSyncRevendeursSuccess : indexation 1 revendeur dans Meilisearch
- testSyncRevendeursError : exception findAll, flash error
- testSyncPricesSuccess : indexation 1 tarif dans Meilisearch
- testSyncPricesError : exception findAll, flash error
- testSyncStripeWebhooksEmptyUrl : WEBHOOK_BASE_URL vide, flash error
- testSyncStripeWebhooksCreatedNew : webhook créé + webhook existant, persist nouveau secret
- testSyncStripeWebhooksUpdateExisting : mise à jour secret existant + erreurs Stripe
- testSyncStripePricesNoErrors : sync sans erreurs, flash success
- testSyncStripePricesWithErrors : sync avec erreurs, flash success + flash errors
- testSyncAllSuccess : sync all avec données, flash success
- testSyncAllError : exception setupIndexes, flash error

ServiceMessageTest (3 tests) :
- testConstructorDefaults : valeurs par défaut (info, active, null author/resolvedAt)
- testConstructorWithSeverityAndAuthor : severity custom + User author
- testResolve : isActive=false, resolvedAt DateTimeImmutable, fluent return

StripeWebhookSecretTest (4 tests) :
- testConstructorDefaults : type/secret, endpointId null, createdAt DateTimeImmutable
- testConstructorWithEndpointId : constructeur avec 3 arguments
- testSetSecret : modification du secret
- testSetEndpointId : set/unset endpointId (nullable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 00:42:07 +02:00
Serreau Jovann
33bd89e617 feat: page de verification en ligne des logs + QR code dans le PDF
src/Controller/LogVerifyController.php (nouveau):
- Route GET /admin/logs/verif/{id}/{hmac} accessible publiquement
- Le hmac dans l'URL est les 16 premiers caracteres du HMAC complet
  (suffisant pour identifier le log sans exposer la signature entiere)
- Verifie que le log existe et que le hmac partiel correspond
- Affiche la page de verification avec statut integrite

src/Controller/Admin/LogsController.php - pdf():
- Generation du QR code via Endroid\QrCode pointant vers l'URL
  de verification /admin/logs/verif/{id}/{hmac16}
- QR code encode en base64 et passe au template PDF

templates/admin/logs/verify.html.twig (nouveau):
- Page glassmorphism style attestation:
  - Log introuvable: bandeau rouge avec croix
  - Integrite verifiee: bandeau vert avec checkmark et message
    "La signature HMAC-SHA256 a ete verifiee avec succes"
  - Integrite compromise: bandeau rouge avec message d'alerte
- Tableau des details: ID, date, utilisateur, methode (badge colore),
  action, URL, route, IP
- Signature HMAC-SHA256 affichee en bas

templates/admin/logs/pdf.html.twig:
- Ajout du bloc verification avec QR code (72x72px) et lien URL
  identique au style des attestations RGPD (verify-box avec
  bordure indigo, QR a gauche, texte a droite)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:21:46 +02:00
Serreau Jovann
b2c6f0194d feat: suppression individuelle de logs + trace obligatoire des suppressions
src/Controller/Admin/LogsController.php:
- purge(): compte les logs avant suppression, supprime tous les logs,
  puis cree un nouveau log via logDirect() avec le message
  "Suppression de tous les logs (X entrees supprimees)" pour garder
  une trace de la purge meme apres suppression
- delete(): nouvelle route POST /admin/logs/{id}/delete, supprime un
  log individuel puis cree un log de trace avec le message
  "Suppression du log #X (action du date)" pour conserver l'historique

src/Service/AppLoggerService.php:
- logDirect(): nouvelle methode qui cree un AppLog avec une action
  personnalisee sans passer par le dictionnaire ROUTE_LABELS
  (utilisee pour les traces de suppression)

templates/admin/logs/index.html.twig:
- Bouton supprimer (croix rouge) ajoute a cote du bouton PDF
  sur chaque ligne du tableau, avec confirmation data-confirm

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:18:11 +02:00
Serreau Jovann
e093dae3ef feat: bouton purge des logs pour ROLE_ROOT
src/Controller/Admin/LogsController.php:
- Nouvelle route POST /admin/logs/purge: supprime tous les AppLog
  via requete DQL DELETE, accessible uniquement ROLE_ROOT
  (le controller entier est deja protege par ROLE_ROOT)

templates/admin/logs/index.html.twig:
- Bouton "Supprimer tous les logs" en haut a droite, rouge,
  visible uniquement pour ROLE_ROOT
- Confirmation data-confirm avant suppression

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:15:54 +02:00
Serreau Jovann
d3e76f00de fix: corriger HMAC des logs + PDF style attestation + pagination glassmorphism + purge logs
src/Entity/AppLog.php:
- createdAt initialise avec date('Y-m-d H:i:s') au lieu de
  new DateTimeImmutable() pour tronquer les microsecondes
  (PostgreSQL arrondit les microsecondes differemment de PHP,
  ce qui causait des HMAC invalides a la relecture)
- generateHmac(): format Y-m-d\TH:i:s sans microsecondes

templates/admin/logs/pdf.html.twig (reecrit):
- Meme style que les attestations RGPD (templates/pdf/rgpd_*.html.twig):
  banniere gold avec logo, doc-type badge indigo, titre italic uppercase,
  info-grid avec cellules bordure indigo, tableaux data avec header dark,
  bloc HMAC avec encadre vert/rouge, footer SARL SITECONSEIL
- Logo passe au template via base64

src/Controller/Admin/LogsController.php:
- pdf(): injection de kernel.project_dir, chargement du logo en base64
  et passage au template

src/Command/PurgeEmailTrackingCommand.php:
- Ajout de la purge des AppLog de plus de 90 jours (meme seuil
  que EmailTracking), affiche le nombre de logs supprimes

templates/components/pagination/glass.html.twig (nouveau):
- Template de pagination KnpPaginator style glassmorphism:
  boutons glass avec hover, page active en glass-gold,
  fleches precedent/suivant

config/packages/knp_paginator.yaml (nouveau):
- Configuration KnpPaginator pour utiliser le template glass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:15:00 +02:00
Serreau Jovann
9c1ea29505 feat: systeme de logs d'activite admin avec HMAC + export PDF
src/Entity/AppLog.php (nouveau):
- id, user (ManyToOne nullable, SET NULL on delete), method (GET/POST/etc),
  url (500 chars), route (nom de la route Symfony), action (description
  lisible de l'action), ip (nullable), hmac (SHA-256), createdAt
- Index sur created_at pour les requetes paginées
- HMAC genere dans le constructeur avec payload:
  method|url|route|action|ip|userId|createdAt (microsecondes)
- verifyHmac(): verification timing-safe avec hash_equals
- Aucun setter sur les champs (immutable apres creation)

src/Repository/AppLogRepository.php (nouveau):
- createPaginatedQueryBuilder(): ORDER BY createdAt DESC avec jointure user

src/Service/AppLoggerService.php (nouveau):
- Dictionnaire ROUTE_LABELS: 30+ routes admin avec descriptions
  lisibles (ex: app_admin_clients_create → "Creation d'un client")
- log(): cree un AppLog avec l'action lisible, persiste et flush
- verifyLog(): verifie le HMAC d'un log
- Si la route n'est pas dans le dictionnaire, utilise "Acces a {route}"
- Ajoute "(soumission)" pour les POST

src/EventListener/AdminLogListener.php (nouveau):
- Ecoute KernelEvents::CONTROLLER avec priorite -10
- Intercepte toutes les requetes dont la route commence par app_admin_
- Ignore les requetes AJAX de recherche (evite le spam)
- Recupere l'utilisateur connecte via TokenStorage
- Appelle AppLoggerService::log() dans un try/catch
  (ne bloque jamais la requete si le logging echoue)

src/Controller/Admin/LogsController.php (nouveau):
- Route /admin/logs, ROLE_ROOT
- index(): pagination KnpPaginator (20 par page), verifie le HMAC
  de chaque log affiche
- pdf(): genere un PDF Dompdf avec toutes les infos du log
  + verification HMAC (CONFORME vert / ALTEREES rouge)

templates/admin/logs/index.html.twig (nouveau):
- Tableau glassmorphism: date, utilisateur, methode (badge colore),
  action, URL (tronquee), IP, colonne HMAC (rond vert/rouge),
  bouton PDF par ligne
- Pagination KnpPaginator en bas

templates/admin/logs/pdf.html.twig (nouveau):
- PDF A4 avec tableau d'informations du log
- Bloc HMAC avec fond vert "INTEGRITE VERIFIEE" ou rouge
  "INTEGRITE COMPROMISE" + signature HMAC complete
- Footer avec mention SARL SITECONSEIL

templates/admin/_layout.html.twig:
- Ajout lien "Logs" dans la sidebar Super Admin avec icone document

migrations/Version20260402211054.php:
- Table app_log avec FK user_id, index sur created_at

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:11:34 +02:00
Serreau Jovann
ae560b1957 feat: affichage dynamique des compteurs sync/non sync sur la page admin/sync
src/Controller/Admin/SyncController.php - index():
- Calcul du nombre de tarifs synchronises avec Stripe (stripeId non vide)
  et non synchronises, passes au template
- Chargement des StripeWebhookSecret depuis la BDD pour afficher
  le nombre de webhooks configures

templates/admin/sync/index.html.twig:
- Bloc Tarifs Stripe: affiche "X sync" (vert) + "Y non sync" (rouge si > 0)
  + "/ Z total" (gris) au lieu du texte statique
- Bloc Webhooks Stripe: affiche "X/4 configure(s)" en vert si 4/4,
  orange si partiel, rouge si 0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 23:02:57 +02:00
Serreau Jovann
bec008bdc1 refactor: stocker les secrets webhook Stripe en BDD au lieu de .env.local
src/Entity/StripeWebhookSecret.php (nouveau):
- Constantes TYPE_MAIN_LIGHT, TYPE_MAIN_INSTANT, TYPE_CONNECT_LIGHT,
  TYPE_CONNECT_INSTANT pour les 4 types de webhook
- type: string(30) unique, identifie le webhook (main_light, etc.)
- secret: string(255), le signing secret retourne par Stripe (whsec_xxx)
- endpointId: string nullable, l'ID de l'endpoint Stripe (we_xxx)
- createdAt: DateTimeImmutable

src/Repository/StripeWebhookSecretRepository.php (nouveau):
- findByType(): trouve un secret par type
- getSecret(): retourne directement la valeur du secret ou null

src/Controller/WebhookStripeController.php (reecrit):
- Les 4 routes lisent le secret depuis la BDD via
  StripeWebhookSecretRepository::getSecret() au lieu de variables d'env
- Retourne HTTP 503 si le secret n'est pas encore configure
- Plus besoin des variables STRIPE_WH_*_SECRET dans .env

src/Controller/Admin/SyncController.php:
- syncStripeWebhooks(): sauvegarde les secrets en BDD
  (cree ou met a jour StripeWebhookSecret par type)
- Suppression de saveSecretsToEnvLocal() (plus de modification .env.local)
- URL de base lue depuis WEBHOOK_BASE_URL (env)

.env:
- Suppression des 4 variables STRIPE_WH_*_SECRET (stockees en BDD)
- Ajout WEBHOOK_BASE_URL (vide par defaut)

docker/ngrok/sync.sh:
- Ecrit aussi WEBHOOK_BASE_URL en plus de OUTSIDE_URL

ansible/env.local.j2:
- WEBHOOK_BASE_URL=https://stripe.siteconseil.fr pour la prod

migrations/Version20260402205935.php:
- Table stripe_webhook_secret avec type unique, secret, endpoint_id

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:59:51 +02:00
Serreau Jovann
0ab2c8d0aa feat: creation automatique des webhooks Stripe + controllers de reception
src/Service/StripeWebhookService.php (nouveau):
- createAllWebhooks(baseUrl): cree 4 webhook endpoints dans Stripe:
  - /webhooks/stripe/main/light: customer.created/updated/deleted,
    product.created/updated, price.created/updated, invoice.created/
    finalized/payment_succeeded/payment_failed, subscription.created/
    updated/deleted
  - /webhooks/stripe/main/instant: checkout.session.completed/expired,
    payment_intent.succeeded/payment_failed, charge.succeeded/failed/
    refunded/dispute.created, invoice.paid/payment_failed,
    customer.subscription.trial_will_end/deleted
  - /webhooks/stripe/connect/light: account.updated/application.
    authorized/deauthorized, transfer.created/updated, payout.created/
    paid/failed
  - /webhooks/stripe/connect/instant: payment_intent.succeeded/
    payment_failed, charge.succeeded/failed/refunded,
    checkout.session.completed
- Verification si le webhook existe deja par URL avant creation
  (pas de doublon)
- listWebhooks(): liste tous les webhooks du compte
- deleteWebhook(): supprime un webhook par ID

src/Controller/WebhookStripeController.php (nouveau):
- 4 routes POST sans authentification (firewall webhooks):
  /webhooks/stripe/main/light, /webhooks/stripe/main/instant,
  /webhooks/stripe/connect/light, /webhooks/stripe/connect/instant
- Verification de signature Stripe via Stripe\Webhook::constructEvent()
- Log de chaque evenement avec channel, type et event_id
- TODO pour dispatcher vers les handlers specifiques

src/Controller/Admin/SyncController.php:
- Nouvelle route POST /admin/sync/stripe/webhooks: prend une URL
  de base en parametre, appelle createAllWebhooks(), affiche les
  resultats (cree/existe deja) en flash messages

templates/admin/sync/index.html.twig:
- Nouveau bloc "Webhooks Stripe" (bleu) avec champ URL de base
  (pre-rempli avec l'URL courante), bouton "Creer les webhooks",
  et liste des 4 endpoints avec leurs evenements

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:53:29 +02:00
Serreau Jovann
62718b5942 feat: sync automatique Stripe pour les tarifs + boutons sync admin
src/Service/StripePriceService.php (nouveau):
- Utilise Stripe SDK v20 (StripeClient) avec STRIPE_SK
- syncPrice(): pour chaque PriceAutomatic, cree ou retrouve le produit
  Stripe via metadata price_auto_type, puis cree le Stripe Price
  (unique et/ou recurrent selon monthPrice)
- ensureProduct(): cherche un produit existant par metadata, le cree
  sinon, met a jour nom/description si modifies
- createStripePrice(): cree un prix Stripe en centimes, avec
  tax_behavior=exclusive, recurring si monthPrice > 0 avec
  interval=month (ou year si period >= 12)
- updateStripePriceIfNeeded(): si le montant a change, archive l'ancien
  prix Stripe et en cree un nouveau (Stripe ne permet pas de modifier
  le montant d'un prix existant)
- syncAll(): synchronise tous les tarifs, retourne synced + errors

src/Service/TarificationService.php:
- Injection optionnelle de StripePriceService
- ensureDefaultPrices(): apres creation des tarifs, sync automatique
  avec Stripe (cree produits + prix) en plus de Meilisearch

src/Controller/Admin/TarificationController.php:
- edit(): apres mise a jour d'un tarif, sync automatique avec Stripe
  (cree/archive/recree les prix si montant change) et Meilisearch
- Flash d'erreur si Stripe echoue, les modifs locales sont sauvegardees

src/Controller/Admin/SyncController.php:
- Nouvelle route POST /admin/sync/stripe/prices: synchronise tous
  les tarifs avec Stripe via StripePriceService::syncAll()

templates/admin/sync/index.html.twig:
- Section "Stripe" avec bouton "Synchroniser Stripe" (violet)
  pour les tarifs, avec confirmation avant execution
- Section Meilisearch tarifs renommee "Tarifs - Meilisearch"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:50:27 +02:00
Serreau Jovann
d2bf0279bd fix: cacher les champs Stripe Price ID de la page tarification
templates/admin/tarification/index.html.twig:
- Champs Stripe Price ID (unique) et Stripe Price ID (abonnement)
  remplaces par des input hidden pour conserver les valeurs existantes
  sans les afficher dans le formulaire

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:47:18 +02:00
Serreau Jovann
78c79a911c fix: mettre a jour le texte de synchronisation complete
templates/admin/sync/index.html.twig:
- Description changee de "Reindexe tous les clients et revendeurs"
  vers "Reindexe tous les clients, revendeurs et tarifs dans Meilisearch"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:46:06 +02:00
Serreau Jovann
49d4cb702d feat: index Meilisearch price_auto + bouton sync tarifs + statuts Stripe
src/Service/MeilisearchService.php:
- indexPrice(): indexe un PriceAutomatic dans l'index price_auto
- removePrice(): supprime un tarif de l'index
- searchPrices(): recherche dans les tarifs
- setupIndexes(): ajout de l'index price_auto avec searchableAttributes
  (type, title, description) et filterableAttributes (type, period)
- serializePrice(): serialise id, type, title, description, priceHt,
  monthPrice, period, stripeId, stripeAbonnementId

src/Service/TarificationService.php:
- Injection optionnelle de MeilisearchService dans le constructeur
- ensureDefaultPrices(): apres flush des nouveaux tarifs, les indexe
  automatiquement dans Meilisearch

src/Controller/Admin/SyncController.php:
- Injection de PriceAutomaticRepository dans index() pour compter les tarifs
- Nouvelle route POST /admin/sync/prices: synchronise tous les tarifs
  dans l'index price_auto de Meilisearch
- syncAll(): inclut maintenant les tarifs dans la synchronisation complete

templates/admin/sync/index.html.twig:
- Nouveau bloc "Tarifs" avec index price_auto, compteur en base,
  bouton "Synchroniser" vert

templates/admin/tarification/index.html.twig:
- Header de chaque tarif: badges de statut Stripe
  - "Stripe OK" (vert) si stripeId renseigne, "Non sync" (rouge) sinon
  - "Abo OK" (vert) si stripeAbonnementId renseigne, "Abo non sync"
    (rouge) sinon (affiche uniquement si monthPrice != 0.00)
- Champ Stripe Price ID abonnement masque si monthPrice == 0.00
  (pas d'abonnement pour les paiements uniques)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:45:52 +02:00
Serreau Jovann
32aa5b0d78 feat: page admin tarification + TarificationService + champs PriceAutomatic
src/Entity/PriceAutomatic.php:
- type: ajout contrainte unique pour eviter les doublons
- monthPrice: decimal(10,2) default 0.00, prix mensuel recurrent
- period: smallint default 1, duree de la periode en mois
  (1=mensuel, 3=trimestriel, 12=annuel)
- stripeId: string nullable, ID du Stripe Price pour le paiement unique
- stripeAbonnementId: string nullable, ID du Stripe Price pour l'abonnement

src/Service/TarificationService.php (nouveau):
- Constante DEFAULT_PRICES avec 16 tarifs par defaut:
  esyweb_business (500€ + 100€/mois), esyweb_premium (3200€ + 100€/mois),
  ecommerce_business (999€ + 150€/mois), ecommerce_premium (5110€ + 150€/mois),
  esymail (50€ + 30€/mois), esymailer (50€ + 30€/mois),
  esydefender_pro (50€ + 60€/mois periode 3), esymeet (50€ + 30€/mois),
  esytchat (50€ + 15€/mois), esycreator (500€ + 100€/mois periode 3),
  ndd_depot (20€), ndd_renouvellement (20€/an), ndd_gestion (30€/an),
  ndd_reactivation (50€), formation_pack10h (500€), formation_heure (70€)
- ensureDefaultPrices(): verifie les tarifs existants, cree ceux manquants
- getAll(), getByType(), getDefaultTypes()

src/Controller/Admin/TarificationController.php (nouveau):
- Route /admin/tarification, ROLE_ROOT
- index(): appelle ensureDefaultPrices() pour creer les tarifs manquants
  automatiquement a chaque visite, affiche tous les tarifs editables
- edit(): met a jour titre, description, prixHt, monthPrice, period,
  stripeId, stripeAbonnementId via formulaire POST

templates/admin/tarification/index.html.twig (nouveau):
- Liste de tous les tarifs sous forme de cards glassmorphism
- Header dark avec titre, type (badge) et prix
- Formulaire d'edition inline: titre, prix unique, prix mensuel,
  periode (select 1/2/3/6/12 mois), Stripe Price ID unique,
  Stripe Price ID abonnement, description (textarea)
- Bouton enregistrer par tarif

templates/admin/_layout.html.twig:
- Ajout lien "Tarification" dans la sidebar Super Admin avec icone dollar

migrations/Version20260402204223.php:
- Ajout colonnes month_price, period, stripe_id, stripe_abonnement_id
  sur price_automatic + index unique sur type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:42:43 +02:00
Serreau Jovann
cacd3ac66c feat: page admin de gestion de la numerotation des commandes
src/Controller/Admin/OrderNumberController.php (nouveau):
- Route /admin/numerotation, accessible ROLE_ROOT uniquement
- index(): affiche le prochain numero via OrderNumberService::preview()
  et les 20 derniers numeros generes (ORDER BY id DESC)
- update(): modifie le prochain numero en creant une entree placeholder
  avec le numero precedent (N-1) marque comme utilise, pour que le
  prochain generate() retourne le numero souhaite
  - Validation du format MM/YYYY-XXXXX via regex
  - Verification que le numero n'existe pas deja
  - Verification que le numero est au minimum 00001

templates/admin/order_number/index.html.twig (nouveau):
- Section "Prochain numero" : affiche le prochain numero en gros (gold)
  avec formulaire pour le modifier (input avec pattern regex,
  placeholder MM/YYYY-XXXXX, explication de l'utilite)
- Section "Derniers numeros generes" : tableau avec numero (font-mono),
  date de creation, statut (Utilise vert / Reserve gris)
- Design glassmorphism (glass, input-glass, btn-gold, glass-dark header)

templates/admin/_layout.html.twig:
- Ajout du lien "Numerotation" dans la sidebar Super Admin avec
  icone hash (#), route app_admin_order_number, style active-danger

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 22:23:07 +02:00
Serreau Jovann
8b7591a6de feat: page web de rapport DNS detaille + simplification du mail
src/Controller/DnsReportController.php (nouveau):
- Route /email/configuration/{token} accessible via le lien dans le mail
- Utilise le messageId de l'EmailTracking comme token d'acces
  (seuls les destinataires du mail ont le lien)
- Execute tous les checks en temps reel: DnsCheckService (SPF, DMARC, MX,
  Bounce via dig @1.1.1.1), AwsSesService (domaine, 3 DKIM CNAME,
  MAIL FROM MX/TXT, bounce notif), CloudflareService (zone, records),
  MailcowService (domaine, DKIM, MX, autodiscover, autoconfig, SRV, MTA-STS)
- Enrichit chaque check avec la colonne Cloudflare
- Passe les resultats au template Twig pour affichage complet

templates/dns_report/index.html.twig (nouveau):
- Page glassmorphism avec header glass
- Resume en haut: 3 cards (verifications OK, erreurs, avertissements)
  avec bordures laterales colorees vert/rouge/jaune
- Tableau par domaine avec 6 colonnes: Source (badge colore par type:
  orange AWS, violet Mailcow, bleu Cloudflare, gris DNS), Verification,
  Attendu, Dig (actuel), Cloudflare, Statut (rond colore)
- Section erreurs detaillees avec liste
- Section avertissements avec liste
- Footer "Esy-Infra - Service de monitoring d'infra"

templates/emails/dns_report.html.twig (simplifie):
- Mail ne contient plus les details: seulement un tableau avec
  chaque domaine et son statut (OK vert / WARN jaune / KO rouge)
- Bouton "Voir le rapport complet" avec lien vers la page web
  (VML fallback pour Outlook)
- Le lien utilise le placeholder __DNS_REPORT_URL__ remplace par
  le MailerService avec le messageId du mail

src/Service/MailerService.php:
- Ajout du remplacement de __DNS_REPORT_URL__ par l'URL absolue
  /email/configuration/{messageId} dans sendEmail(), au meme
  endroit que __VIEW_URL__

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:57:28 +02:00
Serreau Jovann
c666e0db65 feat: enrichir le rapport DNS avec colonnes attendu/dig/cloudflare + envoi a monitor@siteconseil.fr
src/Service/DnsCheckService.php:
- Methode check() enrichie avec 4 nouveaux champs: expected (valeur attendue),
  dig (valeur actuelle trouvee par dig), cloudflare (valeur dans la zone CF),
  cf_status (statut de la colonne CF: ok/error/vide)
- checkSpf(): expected = "include:X dans le SPF", dig = contenu SPF complet
- checkDmarc(): expected = "p=reject ou p=quarantine", dig = contenu DMARC
- checkDkim(): expected = "FQDN CNAME/TXT", dig = cible CNAME ou debut TXT
- checkMx(): expected = MX attendu, dig = liste des MX trouves avec priorite
- checkBounce(): expected = "feedback-smtp.*.amazonses.com", dig = valeur trouvee

src/Command/CheckDnsCommand.php:
- Nouveau champ MONITOR_EMAIL = 'monitor@siteconseil.fr' pour l'envoi du rapport
- loadCloudflareRecords(): charge les records CF une seule fois par domaine
  au debut de l'execution, retourne un array indexe par domaine
- enrichWithCloudflare(): apres chaque check DNS, parcourt les records CF
  pour trouver l'enregistrement correspondant et remplir les colonnes
  cloudflare et cf_status dans chaque check
- checkAwsSes(): utilise DnsCheckService::check() avec expected/dig
  (ex: expected="Success", dig="Absent" pour la verification domaine)
- checkMailcow(): utilise DnsCheckService::check() avec expected/dig
  (ex: expected="Cle Mailcow: abc...", dig="Cle DNS: xyz..." pour DKIM)
- sendReport(): envoie a MONITOR_EMAIL au lieu de l'admin email

templates/emails/dns_report.html.twig:
- Tableau par domaine avec 6 colonnes: Type, Check, Attendu, Dig (actuel),
  Cloudflare, Statut (OK/erreur/warning)
- Colonne Dig coloree en vert/rouge/jaune selon le statut du check
- Colonne Cloudflare coloree selon cf_status
- Colonnes avec word-break: break-all pour les longues valeurs DNS
- Bandeau resume en haut avec compteurs succes/erreurs/warnings
  avec bordures laterales colorees
- Pied de mail: "Rapport par Esy-Infra - Service de monitoring d'infra"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:35:46 +02:00
Serreau Jovann
fea7dbfb61 feat: refactoring complet de la verification DNS avec services separes
Architecture:
- Les domaines (siteconseil.fr, esy-web.dev) sont definis en constante
  dans la commande uniquement, pas dans les services
- 3 services independants reutilisables:

src/Service/DnsCheckService.php (nouveau):
- Methodes publiques checkSpf(), checkDmarc(), checkDkim(), checkMx(),
  checkBounce() qui prennent le domaine en parametre
- Verification SPF: presence des includes amazonses.com et mail.esy-web.dev
- Verification DMARC: politique, presence de rua
- Verification DKIM: test de 10 selecteurs en CNAME et TXT
- Verification MX: le MX attendu est passe en parametre par la commande
- Verification Bounce: MX/CNAME/TXT sur bounce.*

src/Service/AwsSesService.php (nouveau):
- Authentification AWS Signature V4 via HTTP direct (pas de SDK)
- isDomainVerified(): verification du statut du domaine dans SES
- getDkimStatus(): statut DKIM (enabled, verified, tokens)
- getNotificationStatus(): bounce_topic, complaint_topic, forwarding
- listVerifiedIdentities(): liste des domaines verifies
- isAvailable(): test de connectivite API

src/Service/CloudflareService.php (nouveau):
- Authentification Bearer token via HTTP direct (pas de SDK)
- getZoneId(): recupere le zone ID dynamiquement par nom de domaine
  (plus besoin de CLOUDFLARE_ZONE_ID en dur)
- getDnsRecords(): tous les enregistrements d'une zone
- getDnsRecordsByType(): filtrage par type (TXT, MX, CNAME...)
- getZone(): informations d'une zone
- isAvailable(): verification du token API

src/Command/CheckDnsCommand.php (reecrit):
- Utilise les 3 services pour orchestrer les verifications
- Affichage console colore avec icones OK/ERREUR/ATTENTION
- Envoie un rapport email via le template Twig dns_report.html.twig

templates/emails/dns_report.html.twig (nouveau):
- Template email compatible tous clients (table-based, CSS inline,
  margin/padding longhand, mso-line-height-rule, pas de rgba/border-radius)
- Bandeau colore vert/jaune/rouge selon le statut global
- Section succes avec checkmarks verts dans un tableau alterne
- Section erreurs en rouge avec croix dans un tableau fond #fef2f2
- Section avertissements en jaune avec triangles fond #fffbeb
- Detail par domaine avec tableau type/verification/statut
- Utilise le template email/base.html.twig (header gold, footer dark)

Variables d'environnement ajoutees:
- .env: AWS_PK, AWS_SECRET, AWS_REGION (eu-west-3), CLOUDFLARE_KEY (vides)
- .env.local: valeurs reelles des cles AWS et Cloudflare
- ansible/vault.yml: aws_pk, aws_secret, cloudflare_key
- ansible/env.local.j2: AWS_PK, AWS_SECRET, AWS_REGION, CLOUDFLARE_KEY
  avec references au vault
- CLOUDFLARE_ZONE_ID supprime (recupere dynamiquement via l'API)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:28:24 +02:00
Serreau Jovann
2119d4be88 fix: dupliquer les styles du body sur la table wrapper dans le base email
Gmail, ProtonMail, SFR, Yahoo, Outlook.com, Orange et d'autres clients
email suppriment ou remplacent la balise <body> par un <div>, ce qui
fait perdre tous les styles inline definis sur le body.

templates/email/base.html.twig:
- Table wrapper principale: ajout de margin-top/right/bottom/left: 0,
  padding-top/right/bottom/left: 0, font-family: Arial, color: #111827,
  -webkit-text-size-adjust: 100%, -ms-text-size-adjust: 100%
  en plus du background-color: #eeeef3 deja present
- Les styles restent aussi sur le body pour les clients qui le supportent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 21:13:57 +02:00