Commit Graph

192 Commits

Author SHA1 Message Date
Serreau Jovann
8b35e2b6d2 feat: comptabilite + prestataires + rapport financier + stats dynamiques
Comptabilite (Super Admin) :
- ComptabiliteController avec 7 exports CSV/JSON compatibles SAGE
  (journal ventes, grand livre, FEC, balance agee, reglements,
  commissions Stripe 1.5%+0.25E, couts services)
- Export PDF via ComptaPdf (FPDF) avec bloc legal pre-rempli,
  tableau pagine, champ signature DocuSeal
- Signature electronique DocuSeal + callback + envoi email signe
  avec template dedie (compta_export_signed.html.twig)
- Rapport financier public (RapportFinancierPdf) : recettes par
  service, depenses (Stripe, infra, prestataires), bilan excedent/deficit
- Codes comptables clients EC-XXXX (plus de 411xxx)

Prestataires (Super Admin) :
- Entite Prestataire (raisonSociale, siret, email, phone, adresse)
- Entite FacturePrestataire (numFacture, montantHt, montantTtc,
  year, month, isPaid, PDF via Vich)
- CRUD complet avec recherche SIRET via proxy API data.gouv.fr
- Commande cron app:reminder:factures-prestataire (5 du mois)
- Factures prestataires integrees dans export couts services
- Sidebar Super Admin : entree Prestataires + Comptabilite

Stats (/admin/stats) :
- Cout prestataire dynamique depuis FacturePrestataire
- Fusion Infra + Prestataire en "Cout de fonctionnement"
- Commission Stripe corrigee (1.5% + 0.25E par transaction)

Divers :
- DocuSealService::sendComptaForSignature() + getApi()
- Customer::generateCodeComptable() format EC-XXXX-XXXXX
- Protection double prefixe EC- a la creation client
- Bouton regenerer PDF cache quand advert state=accepted
- Modals sans script inline (data-modal-open/close dans app.js)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 23:39:31 +02:00
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
3870713412 feat: VaultService pour chiffrement Transit Hashicorp Vault
VaultService — chiffrement/déchiffrement via Vault Transit engine :

Gestion des clés :
- createKey(keyName, type) : crée une clé Transit (défaut aes256-gcm96)
- deleteKey(keyName) : marque deletable + supprime
- updateKey(keyName, config) : met à jour la config (rotation, export...)
- listKeys() : liste toutes les clés Transit
- keyExists(keyName) : vérifie l'existence d'une clé
- checkOrCreateKey(keyName) : crée la clé si elle n'existe pas

Chiffrement :
- encrypt(keyName, plaintext) : chiffre avec Transit, retourne vault:v1:...
  Auto-crée la clé si inexistante
- decrypt(keyName, ciphertext) : déchiffre le ciphertext Transit

Communication HTTP avec X-Vault-Token, gestion erreurs 4xx/5xx.

Configuration :
- .env : VAULT_URL, VAULT_TOKEN (vides par défaut)
- .env.local : VAULT_URL=http://vault:8200, VAULT_TOKEN=crm_siteconseil
- ansible/vault.yml : vault_url=https://kms.esy-web.dev pour la prod
- Transit engine activé sur le container Vault dev

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:45:27 +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
98db87eb05 feat: entité Website liée à Customer avec UUID, type et state machine
Entity Website :
- customer (ManyToOne, CASCADE) : client propriétaire
- name : nom du site
- uuid : UUID v4 auto-généré (unique, 36 chars)
- type : vitrine | ecommerce
- state : created → install_progress → open → suspended → closed
- createdAt / updatedAt (auto sur setState)
- isOpen() : vérifie si state === open

WebsiteTest (5 tests, 20 assertions) :
- testConstructor : valeurs par défaut, uuid 36 chars, type vitrine
- testConstructorEcommerce : type ecommerce
- testSetters : name, type, updatedAt
- testState : transitions created→install_progress→open→suspended→closed
- testUuidUnique : 2 sites ont des uuid différents

Dépendance : symfony/uid ajouté pour Uuid::v4()
Migration : CREATE TABLE website avec FK customer, uuid unique

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:06:41 +02:00
Serreau Jovann
7648946c2b feat: vérification DNS Esy-Mail et Esy-Mailer en temps réel par domaine
EsyMailService - 2 nouvelles méthodes de vérification DNS :

checkDnsEsyMail(domain) — config réception (Dovecot) :
- MX → doit pointer vers ESYMAIL_HOSTNAME (mail.esy-web.dev)
- SPF → doit contenir le hostname mail ou include:_spf
- DKIM → sélecteur dkim._domainkey.domain (TXT ou CNAME)
- DMARC → _dmarc.domain doit contenir v=DMARC1
- Retourne ok=true si les 4 checks passent

checkDnsEsyMailer(domain) — config envoi (AWS SES) :
- SES domaine vérifié (isDomainVerified = Success)
- SES DKIM activé et vérifié (getDkimStatus enabled+verified)
- SPF → doit contenir include:amazonses.com
- MAIL FROM → configuré et vérifié (getMailFromStatus = Success)
- Retourne ok=true si les 4 checks passent

Intégration :
- Les checks DNS sont exécutés seulement sur l'onglet NDD (pas sur les
  autres onglets pour éviter les appels réseau inutiles)
- Les résultats alimentent configDnsEsyMail et configDnsEsyMailer
  dans la sous-ligne de chaque domaine (OK vert / KO rouge)

Configuration :
- .env : ESYMAIL_HOSTNAME (vide par défaut)
- .env.local : ESYMAIL_HOSTNAME=mail.esy-web.dev
- ansible/vault.yml : esymail_hostname pour la prod

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:03:02 +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
5578d53cbd fix: récupérer expiration NDD via RDAP quand Cloudflare ou registrar externe
DnsCheckService :
- Ajout getExpirationDate(domain) : requête RDAP pour récupérer
  la date d'expiration d'un domaine (events[].eventAction=expiration)

autoDetectDomain :
- Fallback RDAP en fin de détection : si expiredAt est encore null
  après check OVH/Cloudflare, interroge RDAP pour l'expiration
- Fonctionne pour tous les TLD (.fr, .com, .dev, etc.)

Flux : OVH serviceInfos → Cloudflare (pas d'expiration) → RDAP fallback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:50:02 +02:00
Serreau Jovann
aa8df3687c fix: supprimer DomainSyncListener - pas de création auto domaine Dovecot
L'ajout d'un NDD ne doit pas créer automatiquement le domaine dans
la base esymail/Dovecot. La création de domaine mail sera manuelle
quand on active le service Esy-Mail sur un domaine.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:20:14 +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
bf4a0fcb38 feat: auto-création contact Directeur à la création client et à l'ouverture fiche
ensureDefaultContact() :
- Vérifie si le client a au moins 1 contact dans CustomerContact
- Si aucun contact : crée automatiquement un contact avec
  firstName/lastName du client, email, phone, role='Directeur',
  isBillingEmail=true
- Appelé à la création du client (après flush)
- Appelé à l'ouverture de la fiche client (show) pour les clients
  existants qui n'ont pas encore de contact

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 17:55:13 +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
c2c05505c8 feat: modal confirmation glassmorphism + crontabs nettoyage
Modal confirmation custom (assets/app.js) :
- Remplace le confirm() natif du navigateur par une modal glassmorphism
- Header glass-dark avec icône warning rouge + "Confirmation"
- Message dynamique depuis data-confirm du formulaire
- Boutons Annuler (glass) et Confirmer (rouge)
- Fermeture via overlay, bouton Annuler ou Escape
- Au clic Confirmer : supprime data-confirm et submit le formulaire

Crontab Docker (docker/cron/crontab) :
- 0 2 * * * app:clean:pending-delete (nettoyage clients pending_delete)
- 0 5 * * * app:email-tracking:purge (purge tracking > 90j)
- 0 6 * * * app:dns:check (vérification DNS)
- 0 4 * * 0 app:meilisearch:setup (reindex complet dimanche 4h)
- 0 7 * * * app:cloudflare:clean (nettoyage _acme-challenge)

Ansible deploy.yml.disabled :
- Ajout cron clean pending delete (daily 2h)
- Ajout cron meilisearch full reindex (weekly dimanche 4h)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:05:25 +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
a58c9873ab feat: enrichir la création client Stripe avec toutes les infos entreprise
initStripeCustomer amélioré :
- name : raison sociale (priorité) ou nom complet du contact
- phone : téléphone du client
- address : adresse complète (line1, line2, postal_code, city, country FR)
- metadata : crm_user_id, siret, code_comptable
- tax_id_data : numéro TVA intracommunautaire (eu_vat) si disponible

finalizeStripeCustomer amélioré :
- metadata enrichi avec code_comptable en plus du crm_user_id

Le stripeCustomerId est stocké dans Customer::stripeCustomerId après
la création et persiste en BDD via le flush.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:17:28 +02:00
Serreau Jovann
7ae63dd996 feat: entité CustomerContact pour contacts additionnels d'un client
Entity CustomerContact :
- customer (ManyToOne, CASCADE) : client parent
- firstName, lastName : nom/prénom du contact
- email : adresse email (nullable)
- phone : téléphone (nullable)
- role : fonction dans l'entreprise (Gérant, Comptable, etc.)
- isBillingEmail : si true, reçoit les factures par email
- createdAt / updatedAt : timestamps
- getFullName() : prénom + nom

CustomerContactTest (2 tests, 19 assertions) :
- testConstructor : valeurs par défaut
- testSetters : tous les setters/getters

Migration : CREATE TABLE customer_contact avec FK customer ON DELETE CASCADE

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:13:33 +02:00
Serreau Jovann
5369682f35 test: couverture 100% ClientsController et Customer après ajout APE/RNA
CustomerTest :
- testLegal : ajout assertions setApe/getApe ('62.01Z') et setRna/getRna ('W502004724')

ClientsControllerTest (3 tests ajoutés) :
- testEntrepriseSearchTooShort : query < 2 chars retourne results vide
- testEntrepriseSearchSuccess : proxy API retourne résultats avec SIREN
- testEntrepriseSearchApiError : API down retourne 502 avec message erreur

Résultat : ClientsController 100% (7/7, 68/68), Customer 100% (44/44, 76/76)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:08:41 +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
53364b0068 feat: sync temps réel Domain/DomainEmail vers Dovecot via listeners
DomainSyncListener (postPersist, postRemove sur Domain) :
- postPersist : crée le domaine dans esymail si inexistant
- postRemove : supprime le domaine dans esymail (cascade mailboxes)

DomainEmailSyncListener (postPersist, postUpdate, postRemove sur DomainEmail) :
- postPersist : crée le domaine si besoin + crée la boîte mail dans
  esymail avec mot de passe temporaire (bcrypt BLF-CRYPT)
- postUpdate : met à jour displayName, quota, isActive dans esymail
- postRemove : supprime la boîte dans esymail

Flux : Doctrine flush → Listener → EsyMailService → base esymail → Dovecot
Les listeners vérifient isAvailable() avant chaque opération (no-op si
ESYMAIL_DATABASE_URL non configuré)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:06:12 +02:00
Serreau Jovann
51585e33f8 feat: entité DomainEmail liée à Domain avec migration
Entity DomainEmail :
- domain (ManyToOne, CASCADE) : domaine parent
- name : partie locale de l'email (lowercase auto, ex: contact)
- state : active/suspended/disabled (défaut active)
- quotaMb : quota en Mo (défaut 5120 = 5 Go)
- createdAt / updatedAt : timestamps (updatedAt auto sur setState)
- getFullEmail() : retourne name@domain.fqdn
- isActive() : vérifie si state === active

DomainEmailTest (3 tests, 19 assertions) :
- testConstructor : valeurs par défaut, name lowercase/trim, fullEmail
- testSetters : name, quotaMb, updatedAt
- testState : active→suspended→disabled, updatedAt mis à jour

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:05:00 +02:00
Serreau Jovann
817fad4150 feat: entité Domain liée à Customer avec migration
Entity Domain :
- customer (ManyToOne, CASCADE) : client propriétaire du domaine
- fqdn (unique) : nom de domaine complet, lowercase auto
- registrar : bureau d'enregistrement (OVH, Gandi, etc.)
- zoneCloudflare : statut zone Cloudflare (active, pending, etc.)
- zoneIdCloudflare : identifiant zone Cloudflare
- expiredAt : date d'expiration du domaine
- isGestion : domaine géré par SITECONSEIL
- isBilling : domaine facturé par SITECONSEIL
- createdAt / updatedAt : timestamps
- isExpired() : vérifie si le domaine est expiré
- isExpiringSoon(days) : vérifie si expiration dans les N jours

DomainTest (4 tests, 25 assertions) :
- testConstructor : valeurs par défaut, fqdn lowercase/trim
- testSetters : tous les setters/getters
- testIsExpired : null/passé/futur
- testIsExpiringSoon : null/15j (true pour 30j)/60j (false pour 30j)

Migration : CREATE TABLE domain avec FK customer ON DELETE CASCADE

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:02:30 +02:00
Serreau Jovann
fe42f221a6 feat: service EsyMailService complet pour gestion messagerie
EsyMailService - connexion DBAL directe vers base esymail :

Gestion domaines :
- listDomains() : liste avec count mailboxes par domaine
- getDomain(name) : détails d'un domaine
- createDomain(name, maxMailboxes, defaultQuotaMb) : création
- updateDomain(name, maxMailboxes, defaultQuotaMb, isActive) : mise à jour
- deleteDomain(name) : suppression cascade (mailboxes + alias)
- domainExists(name) : vérification existence

Gestion boîtes mail :
- listMailboxes(?domain) : liste toutes ou par domaine
- getMailbox(email) : détails d'une boîte
- createMailbox(email, password, ?displayName, quotaMb) : création avec
  hash bcrypt BLF-CRYPT, vérification domaine existant
- updateMailbox(email, displayName, quotaMb, isActive) : mise à jour
- changePassword(email, newPassword) : changement mot de passe
- deleteMailbox(email) : suppression
- mailboxExists(email) : vérification existence
- countMailboxes(domain) : nombre de boîtes par domaine

Gestion alias :
- listAliases(?domain) : liste tous ou par domaine
- createAlias(source, destination, domain) : création redirection
- deleteAlias(id) : suppression

Stats :
- getStats() : compteurs domains, mailboxes, aliases, active_mailboxes

Base de données esymail :
- Table domain : name unique, max_mailboxes, default_quota_mb, is_active
- Table mailbox : email unique, password bcrypt, domain FK, display_name,
  quota_mb, is_active, timestamps
- Table alias : source/destination unique, domain FK, is_active
- Domaines dev : siteconseil.fr, esy-web.dev
- Compte test : test@siteconseil.fr / test1234

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:17:01 +02:00
Serreau Jovann
3061600fba fix: Dovecot auth via TCP au lieu de socket Unix (inter-containers)
Le socket Unix /var/spool/postfix/private/auth ne fonctionne pas entre
containers Docker. Remplacé par un inet_listener TCP sur port 12345
pour permettre l'auth SASL depuis le container Postfix.

Suppression de la référence à l'user postfix (inexistant dans le
container Dovecot Alpine).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 23:14:11 +02:00
Serreau Jovann
fded9c2528 fix: Postfix dev relay vers Mailpit pour capturer tous les emails
- RELAYHOST changé de "" vers "[mailpit]:1025" : tous les emails
  sortants passent par Mailpit (visible sur http://localhost:8025)
- smtp_tls_security_level=none : pas de TLS vers Mailpit en dev
- depends_on mailpit ajouté pour garantir le démarrage

En prod : RELAYHOST sera vide (envoi direct) ou vers AWS SES.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 22:08:24 +02:00
Serreau Jovann
77f0f0eed7 fix: Dovecot Dockerfile basé sur alpine:3.20 au lieu de dovecot/dovecot
L'image dovecot/dovecot:latest est minimaliste sans shell ni gestionnaire
de paquets. Remplacement par alpine:3.20 avec dovecot, dovecot-pop3d
et dovecot-pgsql installés via apk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 21:58:35 +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
5c4576ca27 feat: ajout Dovecot avec authentification PostgreSQL (base esymail)
Architecture :
- Base de données esymail sur PostgreSQL existant, table mailbox
  (email, password BLF-CRYPT, domain, quota_mb, is_active, timestamps)
- Dovecot auth via dovecot-sql.conf : passdb + userdb en SQL
- Stockage mails en Maildir /var/mail/vhosts/%d/%n
- UID/GID 1000 (vmail) pour les fichiers mail
- Socket auth Postfix pour SASL (/var/spool/postfix/private/auth)

Fichiers :
- docker/dovecot/Dockerfile : dovecot/dovecot + dovecot-pgsql, user vmail
- docker/dovecot/dovecot.conf : protocols imap/pop3, auth SQL, logging
- docker/dovecot/dovecot-sql.conf : connexion PostgreSQL, queries
  password_query/user_query/iterate_query sur table mailbox
- docker/dovecot/init-esymail.sql : CREATE DATABASE esymail, CREATE TABLE
  mailbox avec index, compte test test@siteconseil.fr/test1234

Docker :
- Service dovecot sans port exposé (interne uniquement)
- Volumes dovecot-mail (Maildir) et dovecot-logs (partagé avec fail2ban)
- Dépend de database (healthcheck)
- init-esymail.sql monté dans /docker-entrypoint-initdb.d/ de PostgreSQL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 16:44:45 +02:00
Serreau Jovann
1449981995 feat: listener redirection par sous-domaine (webmail/status)
SubdomainRedirectListener (priority 256, KernelEvents::REQUEST) :
- webmail.siteconseil.fr → /webmail (app_webmail_login)
- status.siteconseil.fr → /status (app_status)
- Ignore les sub-requests, ne redirige pas si déjà sur la bonne route
- Mapping dans constante SUBDOMAIN_ROUTES (extensible)

Tests (6 tests) :
- testWebmailRedirect : host webmail → redirect /webmail
- testStatusRedirect : host status → redirect /status
- testNoRedirectOnCrmHost : host crm → pas de redirect
- testNoRedirectWhenAlreadyOnTarget : déjà sur /webmail → pas de redirect
- testNoRedirectOnSubPath : /status/api → pas de redirect
- testSubRequestIgnored : sub-request ignorée

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:54:43 +02:00
Serreau Jovann
8c6b485acd feat: ajout Fail2ban pour protection Dovecot IMAPS/POP3S (993/995)
Configuration :
- docker/fail2ban/jail.local : jail dovecot uniquement sur ports 993,995,
  bantime 1h, findtime 10min, maxretry 5 tentatives
- docker/fail2ban/filter.d/dovecot.conf : regex pour auth failed,
  disconnected, aborted login (IMAP + POP3)

Docker :
- Image crazymax/fail2ban, network_mode host (accès iptables),
  cap_add NET_ADMIN + NET_RAW pour manipuler les règles firewall
- Volume dovecot-logs partagé en lecture seule pour lire les logs Dovecot
- Volume fail2ban-data pour persister la DB des bans

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 14:42:11 +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