Aller au contenu

Documentation Backend Teadle

📋 Table des matières

  1. Vue d'ensemble
  2. Architecture
  3. Technologies
  4. Structure du projet
  5. API Documentation
  6. Sécurité
  7. Base de données
  8. Déploiement

🎯 Vue d'ensemble

Teadle est une plateforme de mise en relation entre formateurs et organisations. Le backend est développé en Symfony 7.3 avec une architecture Domain-Driven Design (DDD) et utilise API Platform pour exposer les APIs REST.

Fonctionnalités principales

  • 🔐 Authentification : JWT, OAuth (LinkedIn, Google, Microsoft), codes d'authentification
  • 👥 Gestion des utilisateurs : Formateurs et Organisations
  • 📋 Onboarding : Processus d'inscription et de configuration
  • 💼 Opportunités : Gestion des missions et candidatures
  • 📅 Événements : Planning et interventions
  • 🔗 Partage de planning : Liens publics avec description facultative (255 car., création et édition)
  • 💰 Facturation : Gestion des factures
  • 📧 Notifications : Emails via Brevo, centralisés par EmailService (méthodes métier par type d'email ; templates Brevo mappés dans email_templates.yaml). Inclut notamment l'email de synthèse organisation envoyé au formateur après validation de ses tarifs (sendTrainerSummaryOrganizationInfoEmail, template #20). Coffre-Fort (FEAT-236) : alertes d'expiration de pièces de conformité — cron quotidien 08:00 (Europe/Paris) (ScanExpiringComplianceDocumentsSendComplianceDocumentExpiryAlertsHandler) ; notifications in-app DocumentExpiryNotification aux seuils J-30 / J-7 / J+0 (DocumentExpiryThreshold) avec dédup pièce+seuil ; emails Brevo J-30 (compliance_document_expiry_j30 / #32) et J+0 (compliance_document_expiry_j0 / #33) seulement ; libellés FR des types de pièces dans le handler (documentTypeLabel() privée). Socle STI DocumentRequestNotification prêt pour les demandes organisation (V2-4).
  • 📊 Bilan mensuel automatique : Email envoyé le 1er du mois à 08h via Symfony Scheduler aux formateurs ayant opté pour l'optin MONTHLY_SUMMARY. Calcul via MonthlyReportCalculator, envoi via sendMonthlyReportEmail (template Brevo #23). Protection anti-doublon par stateful($cache) + processOnlyLastMissedRun(true).
  • 📅 Récap semaine à venir : Email chaque vendredi 17:00 (Europe/Paris) via Symfony Scheduler (SendUpcomingWeekReports → transport async). Cible : formateurs onboarding terminé avec au moins une intervention (affectation active) dont le début tombe dans la semaine calendaire suivante (lundi–dimanche contenant today + 7 jours, fenêtre calculée en Europe/Paris). Paramètres Brevo via sendUpcomingWeekReportEmail (template recap_week_upcoming / #24). Commande de debug : app:debug:send-upcoming-week-reports.
  • 🔄 Resync calendrier (lien iCal ou OAuth) : Chaque nuit à 01:00 (Europe/Paris), le scheduler envoie ScheduledTrainerCalendarRemoteResync sur le transport async ; DispatchScheduledTrainerCalendarRemoteResyncCommand enchaîne les contrôles puis SyncProcessTrainerCalendarIcalCommand::process pour chaque configurateur ayant une URL iCal ou des jetons OAuth persistés (équivalent au POST /trainer/calendar/configurator/{uuid}/sync). Le traitement async passe par ProcessTrainerCalendarConfiguratorSync : TrainerCalendarConfiguratorSynchronizer appelle parseCalendar (OAuth) ou IcsParserInterface puis boucle sur les stratégies (supportsIcal / supportsIcalProdId) et parseIcalVo, puis PersistTrainerCalendarConnectorEventsCommand (Application/Command/Trainer/Calendar/). Purge incrémentale (CleanLegacyEventsService + IcalSyncPurgePolicy) : lors d'une synchro, seuls les événements futurs absents du flux et situés dans la fenêtre temporelle couverte par le flux reçu sont supprimés ; l'historique passé n'est jamais archivé ni purgé (flux vide → aucune purge). Le mode force=true (désassignation orga) conserve l'ancien comportement archive/remove. Pour la déduction d’année scolaire côté parseurs iCal, la date de l’événement (DTSTART) est désormais prioritaire ; l’analyse textuelle reste un fallback (champs summary / description / location selon le parseur), avec normalisation des tirets Unicode avant lecture des plages d’années. Côté OAuth Outlook (OutlookOAuthCalendarEventsParser), l’année scolaire est maintenant toujours renseignée dans cet ordre : date de l’événement, fallback texte (subject / bodyPreview), puis année courante ; garde-fou explicite pour éviter toute valeur 0-0.
  • 📅 Agenda personnel distant (formateur) : sur TrainerAvailabilityConfigurationOAuth (jetons + oauth_calendar_id + personal_calendar_provider_type), URL iCal (personal_calendar_ical_url, migration Version20260420140000), ou fichier .ics (personal_calendar_provider_type = ical_file, clé S3 …/personal-availability-{id}.ics via TrainerPersonalCalendarIcalFileStorageKeyResolver, aligné sur le configurateur orga). API agenda personnel distant : TrainerPersonalCalendarOAuthResource — OAuth (/trainer/calendar/personal/oauth/...) et import iCal sans compte : POST /trainer/calendar/personal/ical/add (corps JSON identique au configurateur orga : icalLink, providerType optionnel) et POST /trainer/calendar/personal/ical/upload (multipart icsfile, providerType optionnel), traités par CreateProcessTrainerPersonalCalendarIcalLinkCommand / CreateProcessTrainerPersonalCalendarIcalFileCommand puis la même synchro async ProcessTrainerPersonalCalendarSyncTrainerPersonalCalendarSynchronizerPersistTrainerPersonalCalendarEventsCommand. Resync planifiée : TrainerAvailabilityConfigurationRepository::findAllWithPersonalCalendarRemoteSyncSource(). GET /trainer/availability/configuration expose personalCalendarRemoteSyncConfigured et personalCalendarIcalUrl. Déconnexion DELETE …/personal/oauth efface OAuth et URL iCal. Le flux OAuth SPA réutilise TrainerCalendarOAuthCallbackView + sessionStorage.teadle_calendar_oauth_personal.

🏗️ Architecture

Architecture Hexagonale (DDD)

Le projet suit une architecture hexagonale avec séparation claire des couches :

┌─────────────────────────────────────────────────────────────┐
│                    Infrastructure Layer                     │
├─────────────────────────────────────────────────────────────┤
│                    Application Layer                        │
├─────────────────────────────────────────────────────────────┤
│                      Domain Layer                           │
└─────────────────────────────────────────────────────────────┘

Couches de l'architecture

1. Domain Layer (src/Domain/)

  • Entities : Modèles métier (User, Trainer, Organization, etc.)
  • Value Objects : Objets de valeur (PhoneNumber, etc.)
  • Repositories : Interfaces des repositories
  • Services : Services métier
  • Events : Événements du domaine
  • Exceptions : Exceptions métier

2. Application Layer (src/Application/)

  • Commands : Commandes CQRS
  • Handlers : Gestionnaires de commandes
  • Services : Services d'application (dont Service/Email/EmailService et EmailTemplateRegistry pour l'envoi d'emails centralisé)

3. Infrastructure Layer (src/Infrastructure/)

  • API : Resources et Processors API Platform
  • ORM : Implémentations Doctrine
  • Security : Authentification et autorisation
  • Notification : Services d'envoi d'emails
  • Symfony : Intégrations Symfony (dont EventSubscribers écoutant les Domain Events pour déclencher des effets de bord — emails, notifications)

🛠️ Technologies

Framework et composants principaux

  • Symfony 7.3 : Framework PHP (inclut Symfony Scheduler pour les tâches planifiées)
  • API Platform 3.2 : API REST automatique
  • Doctrine ORM 3.3 : Mapping objet-relationnel
  • Lexik JWT Bundle : Authentification JWT
  • Gesdinet JWT Refresh Token : Refresh tokens
  • OAuth2 Client Bundle : Authentification OAuth

Services externes

  • Brevo : Service d'envoi d'emails
  • Stockage fichiers (S3 / Clever Cloud Cellar) : bucket formateurs via Flysystem (config/packages/flysystem.yaml). Les ressources exposées par URL directe (logoUrl facturation, avatar, etc.) passent par FilesystemWriter::writePublic() (ACL public-read côté S3) ; sans cela, l’URL renvoie 403 Access Denied. Les fichiers déjà uploadés en privé : ré-uploader pour corriger.
  • LinkedIn OAuth : Authentification LinkedIn
  • Google OAuth : Authentification Google
  • Microsoft OAuth : Authentification Microsoft

Monitoring & Observabilité

  • Sentry (sentry/sentry-symfony 5.x) : capture automatique des exceptions, erreurs Messenger, performance tracing (traces_sample_rate: 0.2). En prod, handlers Monolog en plus du flux nestedphp://stderr (Clever Cloud / New Relic) : LogToSentryIssueHandler et ExceptionToSentryIssueHandler (level error). DSN via SENTRY_DSN dans .env.prod.
  • New Relic (agent PHP natif, extension newrelic.so) : APM, slow queries Doctrine, distributed tracing. Installé dans le Dockerfile prod, configuré dynamiquement au démarrage via start.sh si NEW_RELIC_LICENSE_KEY est défini. Région EU (collector.eu01.nr-data.net), framework symfony4.

Outils de développement

  • PHPStan : Analyse statique
  • PHP CS Fixer : Standards de code
  • Alice Bundle : Fixtures de données
  • Doctrine Migrations : Gestion des migrations

📁 Structure du projet

backend/
├── src/
│   ├── Domain/                    # Couche domaine
│   │   ├── Entity/               # Entités métier
│   │   ├── Repository/           # Interfaces repositories
│   │   ├── Service/              # Services métier
│   │   ├── ValueObject/          # Objets de valeur
│   │   ├── Event/                # Événements
│   │   ├── Exception/            # Exceptions métier
│   │   ├── Enum/                 # Énumérations (ex. Onboarding/OnboardingStep ; Optin/OptinType)
│   │   ├── Security/             # Sécurité domaine
│   │   ├── Notification/         # Notifications
│   │   └── Messenger/            # Messages (ex. SendMonthlyReports)
│   ├── Application/              # Couche application
│   │   ├── Command/              # Commandes CQRS
│   │   ├── Handler/              # Gestionnaires
│   │   └── Service/              # Services application
│   └── Infrastructure/           # Couche infrastructure
│       ├── Api/                  # API Platform
│       │   ├── Resource/         # Resources API
│       │   └── State/            # Processors API
│       ├── Orm/                  # Doctrine ORM
│       ├── Security/             # Sécurité infrastructure
│       ├── Notification/         # Services notification
│       ├── Symfony/              # Intégrations Symfony
│       │   └── EventSubscriber/ # Subscribers de Domain Events (emails, side effects)
│       ├── AuthCode/             # Codes d'authentification
│       ├── PdfParser/            # Parsing PDF
│       ├── IcsParser/            # Parsing ICS
│       └── PhoneNumber/          # Gestion numéros
├── config/                       # Configuration
├── migrations/                   # Migrations Doctrine
├── fixtures/                     # Fixtures de données
└── tests/
    ├── Unit/                     # PHPUnit — Domain + Application (mocks)
    └── Functional/               # WebTestCase — appels HTTP API uniquement

Tests

Suite Commande Contenu
Unit make test-backend-unit tests/Unit/ — handlers, domaine (sans kernel)
Functional make test-backend-functional tests/Functional/ — API JSON via WebTestCase + BrowserKit
Tout make test-backend Les deux suites (APP_ENV=test, Postgres requis pour Functional)
  • Classe de base TF : AbstractApiTestCase (helpers get/post, loginAsTrainer, assertions JSON).
  • BDD test : suffixe Doctrine _test ; schéma + fixtures Alice au premier lancement de la suite Functional.
  • JWT test : config/jwt/test-*.pem (versionnés) + variables dans .env.test.
  • CI : jobs séparés backend-tests-unit et backend-tests-functional ; rapport Allure parent Backend, sous-suites Unit / Functional.

🔌 API Documentation

Endpoints principaux

Authentification

  • POST /security/login - Connexion JWT. Corps JSON : email, password, optionnel rememberMe (bool, défaut false, FEAT-183). Si rememberMe: true, le cookie REFRESH_TOKEN est persistant sur REFRESH_TOKEN_TTL_REMEMBER_ME secondes (défaut 30 j, backend/.env → paramètre teadle.security.refresh_token_ttl_remember_me, aligné TTL entité refresh en base) ; sinon cookie session (expire=0). REFRESH_TOKEN_TTL_COOKIE_DEFAULT (défaut 7 j) : référence legacy, évolutions possibles (ex. refresh JWT). Le cookie ACCESS_TOKEN reste court (15 min). OAuth et magic link : cookie refresh session (pas de TTL). POST /token/refresh : cookie refresh réémis avec REFRESH_TOKEN_TTL_COOKIE_DEFAULT (défaut 7 j) — le TTL rememberMe 30 j du login initial n’est pas conservé après refresh.
  • POST /security/password-reset/request - Demande reset mot de passe
  • PUT /security/password-reset/perform - Reset mot de passe
  • POST /security/magic-link/request - Demande Magic Link login (MagicLinkPurpose::LOGIN, TTL 1 h) : corps { "email" } ; rate-limit IP/email, anti-énumération → 204 sans corps. Token brut (64 hex) envoyé par email ; seul le hash SHA-256 est persisté (magic_link_request.token_hash). Rotation via MagicLinkRequestRepository::rotateToken.
  • PUT /security/magic-link/perform - Consommation Magic Link login : corps { "token" } → cookies httpOnly JWT + refresh (consumeValidToken atomique). Erreurs métier distinctes (invalide / expiré / déjà utilisé).
  • POST /security/trainer/activate - Activation formateur passwordless (FEAT-187, DISC-025 INS-3) : corps { "email", "firstName", "lastName", "acceptOptIn" }200 sans corps. Crée ou reprend un formateur non vérifié (user.password NULL), envoie un email d’activation (MagicLinkPurpose::TRAINER_ACTIVATION, TTL 15 min, template Brevo trainer_activation #28). Anti-énumération : réponse identique si email déjà vérifié ou compte non-formateur. Rate-limit SEC-003 : 3/h email + 10/h IP429 + Retry-After.
  • POST /security/trainer/activate/verify - Vérification du lien d’activation : corps { "token", "password"? } (password optionnel, min 8 car.) → 200 + cookies JWT (ACCESS_TOKEN + refresh session). Active le compte (verifiedEmail, génération slug), dispatch TrainerRegisteredEvent ; mot de passe hashé seulement si fourni.
  • POST /security/register - Inscription
  • POST /security/auth-code/send - Envoi code d'authentification
  • POST /security/auth-code/verify - Vérification code
  • POST /security/oauth/login - Connexion OAuth

Utilisateurs

  • GET /me - Informations utilisateur connecté. Pour un formateur : inclut onboardingProgress (état DISC-016 : étapes, compteurs, completedAt, checklistDismissedAt, sampleData : active, expiresAt, dismissedAt, firstRealReservationReceivedAt ; fenêtre J+N depuis registered_at + teadle.sample_data_duration_days) ; FEAT-190 : slug, specialite, isProfilePublic (profil public opt-in, false par défaut) ; FEAT-215 (CV-7) : showPublicAgenda (opt-in agenda public free/busy, false par défaut — calque allowResumeDownload) ; FEAT-220 (PUB-6) : publicProfileViewCount, publicProfileLastViewedAt (consultation agrégée anonyme du profil public, R23 — jamais sur le DTO public).
  • PATCH /trainer — Mise à jour merge-patch du formateur connecté. FEAT-215 (CV-7) : champ showPublicAgenda (bool, null/absent = inchangé) ; persistance via UpdateTrainerCommand (calque allowResumeDownload).
  • POST /trainer/deactivate — Désactivation réversible du compte formateur (DISC-026 / FEAT-226, R2) : sans corps → 200 + DTO AbstractBaseUser. Passe accountStatus = deactivated, horodate deactivatedAt. Formateur authentifié uniquement (requireTrainer()). Réactivation automatique au login (R3, ReactivateAccountCommand dans JWTAuthenticationSuccessListener). Masquage profil public / demandes / partages = issues récepteur (FEAT-228+).
  • POST /trainer/reactivate — Réactivation manuelle du compte désactivé (DISC-026 / FEAT-227, R3) : sans corps → 200 + DTO à jour (accountStatus = active). Réutilise ReactivateAccountCommand (no-op si déjà actif ou deletion_pending). Exposé pour le bandeau UI (FEAT-227 frontend).
  • GET /me (formateur) inclut accountStatus (active | deactivated | deletion_pending | deleted, FEAT-224) et scheduledDeletionAt (ISO 8601, renseigné en grace period — Chemin A : UserToDtoMapper → DTO User\Response\Trainer, consommé par le bandeau FEAT-232).
  • POST /trainer/request-deletion — Demande de suppression avec grace period 30 jours (DISC-026 / FEAT-229, R4) : corps optionnel { "reason": "<AccountDeletionReason>" } (changing_tool | stopped_training | not_enough_demand | price_issue | other, exit survey FEAT-232 ; inactivity exclu — motif interne FEAT-260, validation via userSelectableValues()) → 200 + DTO à jour (accountStatus = deletion_pending, scheduledDeletionAt = J+30). Motif persisté sur trainer.deletion_reason, recopié dans account_deletion_log à l'anonymisation. No-op si déjà deletion_pending ou deleted. Déclenche async GenerateAccountDataExport (FEAT-230). Formateur authentifié uniquement.
  • POST /trainer/cancel-deletion — Annulation de la suppression planifiée (FEAT-229, R5) : sans corps → 200 + DTO (accountStatus = active, scheduledDeletionAt = null). No-op si pas en grace period. Exposé pour AccountDeletionGraceBanner (FEAT-232). Annulation automatique au login durant la grace period (CancelAccountDeletionCommand dans JWTAuthenticationSuccessListener).
  • Effacement effectif J+30 (FEAT-229, RH11) : cron 02:00 Europe/ParisProcessDueAccountDeletions (async) → AnonymizeTrainerCommand pour chaque formateur DELETION_PENDING échu. Anonymisation en place (ligne Trainer conservée, PII scrubée, email tombstone deleted-{id}@deleted.teadle.invalid). Registre audit séparé account_deletion_log (HMAC email, sans PII en clair). Vérification a posteriori : app:account-deletion:verify <email>. Variables d'environnement : ACCOUNT_DELETION_HMAC_KEY (défaut kernel.secret), ACCOUNT_DELETION_HMAC_KEY_VERSION (défaut v1).
  • Moteur d'inactivité (FEAT-260, DISC-026 R16) : cron 03:00 Europe/ParisProcessAccountInactivity (async) → ProcessAccountInactivityCommand. Séquence 5 emails (J+275 / J+335 / J+358 / J+365 / J-7 avant effacement), désactivation auto à 12 mois, demande de suppression auto à J+60 après pause (motif interne inactivity, jamais proposable à l'exit survey). Inactivité (RH3) = aucune connexion (lastActivityAt, repli registeredAt), ni intervention active, ni facture émise sur la période. Idempotence : table account_inactivity_notice ; purge à la réactivation. Rattrapage legacy : un seul warning/jour (jalon le plus avancé non envoyé). Gabarits Brevo inactivity_warning_90inactivity_deletion_warning (#35–39) — cf. section déploiement ci-dessous.
  • Gabarits Brevo inactivité (prérequis MEP FEAT-260) — créer dans Brevo avant activation du cron :
  • #35 inactivity_warning_90 — params : firstname, deadlineDate, actionUrl
  • #36 inactivity_warning_30 — idem
  • #37 inactivity_warning_7 — idem
  • #38 inactivity_deactivated — idem
  • #39 inactivity_deletion_warning — idem + dataExportUrl (lien export DSAR actif)
  • Remplacer les IDs placeholder dans config/packages/email_templates.yaml par les IDs Brevo réels.
  • Export DSAR + comptable (FEAT-230, R7/R8/R9 ; avoirs FEAT-279) : à la demande de suppression, génération async d'un ZIP (données perso JSON/CSV + parcours CSV + factures émises PDF + récap CSV + avoirs finalisés PDF + récap CSV avec renvoi facture_creditee) stocké S3 (StoragePathType::TRAINER_DATA_EXPORT{trainerUuid}/exports/{token}.zip). PDF avoirs : CreditNotePdfObjectStorageService (StoragePathType::TRAINER_CREDIT_NOTE_PDF). Lien tokenisé 30 jours : GET /public/account-export/{token} (public, application/zip). Email Brevo account_data_export (#31) : variables firstname, lastname, downloadUrl, expiresAt ; base ACCOUNT_DATA_EXPORT_LINK. Ré-émission : POST /trainer/data-export/resend (202). Relance J-3 : cron 09:00RelanceExpiringAccountDataExports (async).
  • GET /trainer/onboarding/sample-data — Si le sample data est actif : payload illustratif (mêmes DTOs que demandes + dashboard : requests, interventions, dashboard, expiresAt) ; sinon { "active": false } (200). Formateur authentifié uniquement. Le contenu fictif est défini dans backend/config/packages/teadle_onboarding_sample_data.yaml (paramètre teadle_onboarding_sample_data, lu via ParameterBagProviderInterface) ; GetTrainerSampleDataHandler renvoie TrainerSampleDataResultDto (VO Domain pour les demandes / interventions, DTO Application pour le bloc dashboard figé YAML) ; TrainerSampleDataResponseMapper produit SampleDataResponse pour API Platform.
  • Prévu (sample organisations) : même principe que demandes / dashboard — ajouter côté backend une slice dédiée (organisations ou assignments alignée sur le listing /trainer/organization/dashboard ou équivalent consommé par OrganizationsView) lorsque le sample data est actif : données fictives dans le YAML + mapping dans TrainerSampleDataResultDto / SampleDataResponse (et pas de logique « sample » dans le listing frontend, comme pour les autres blocs). À traiter dans une issue API dédiée quand le périmètre métier (nombre de lignes, statuts, UUID fictifs) sera figé.
  • POST /trainer/onboarding/sample-data/dismiss — Masque les données d’exemple (sample_data_dismissed_at), réponse = TrainerOnboardingProgressResponse (snapshot onboardingProgress). Sans corps. Formateur authentifié uniquement.
  • POST /trainer/onboarding/dismiss — Masque la checklist « Démarrer » (checklist_dismissed_at), même réponse TrainerOnboardingProgressResponse. Idempotent. Formateur authentifié uniquement (FEAT-088).
  • POST /trainer/onboarding/restore — Réaffiche la checklist (checklist_dismissed_at = null), même réponse. Idempotent (FEAT-088).
  • POST /trainer/onboarding/step/{step}/complete — Marque une étape OnboardingStep comme complétée via MarkTrainerOnboardingStepCompletedCommand ; {step} invalide → 400. Réponse TrainerOnboardingProgressResponse. Idempotent (FEAT-088).
  • PUT /trainer/updatePreferences - Mise à jour préférences formateur
  • PUT /trainer/profile/visibility - Publication opt-in du profil formateur ({ "isProfilePublic": bool }, FEAT-190 / DISC-025)
  • PUT /trainer/profile/finalize - Finalise le profil post-activation ({ "specialite"?: string, "isOpenToRequests": bool }, FEAT-195 / DISC-025). Spécialité vide n'écrase pas une valeur existante.
  • PUT /trainer/profile/slug - Met à jour le slug public vanity ({ "slug": string }, FEAT-196 / DISC-025 R40). 409 si déjà pris ; 422 format invalide.
  • GET /trainer/profile/slug/check?slug= - Vérifie la disponibilité ({ "available": bool }, libre ou slug courant du formateur).
  • FEAT-198 (CV-1 — persistance enrichissement + meter) :
  • POST /trainer/certification — Crée une certification (label requis, organisme, date, url optionnels). 201 { "uuid": string }.
  • PUT /trainer/certification/{uuid} — Met à jour (404 si uuid étranger). 204.
  • DELETE /trainer/certification/{uuid} — Supprime (404 si uuid étranger). 204.
  • PUT /trainer/experience/{uuid} — Édite une expérience (404 anti-énumération). 204. L'ajout reste POST /trainer/career.
  • DELETE /trainer/experience/{uuid} — Supprime une expérience. 204.
  • PUT /trainer/education/{uuid} — Édite une formation. 204.
  • DELETE /trainer/education/{uuid} — Supprime une formation. 204.
  • PUT /trainer/profile/languages — Remplace languages ({ "languages": string[] }). 204.
  • PUT /trainer/profile/about — Met à jour headline (≤ 120 car.) et bio (≤ 5000 car.) ({ "headline"?: string|null, "bio"?: string|null }). 204. FEAT-201 (CV-4).
  • FEAT-202 (CV-5a — CRUD compétences individuelles) :
    • POST /trainer/skill — Ajoute une compétence (label requis, skillReferenceUuid? pour lien direct référentiel autocomplete). Résolution référentiel par UUID prioritaire, sinon label normalisé (R16). Création avec validated=false ; recalcul async via CRA SENT (cf. ci-dessous). 201 { uuid, label, position, validated, skillReferenceUuid? }.
    • PUT /trainer/skill/positions — Réordonne ({ "uuids": string[] }, position = index, ignore silencieusement les UUID étrangers). 204 (R19 top 5).
    • DELETE /trainer/skill/{uuid} — Supprime si appartient au formateur (404 anti-énumération). 204.
  • GET /trainer/profile/completion — Meter complétude profil CV (3 niveaux Q11, reachedCount, topActionLabel/Section).
  • FEAT-199 (CV-2 — lecture agrégée profil CV) :
  • GET /trainer/profile/cv — Profil formateur + listes experiences, educations, certifications, skills, languages. Chaque item expose uuid (jamais d'id entier) ; dates au format ISO 8601 (DateTimeInterface::ATOM). avatarUrl résolu via StorageUrlResolver. Lecture pure (TrainerCvProvider, formateur authentifié).
  • POST /trainer/cv/analyze - Analyse PDF LinkedIn (multipart cv) → CvAnalyzerResponse. FEAT-191 : réservé aux formateurs authentifiés (requireTrainer() dans CvAnalyzerProcessor).
  • POST /trainer/enrichment - Finalise l'enrichissement B3 (expériences + formations + compétences confirmées/manuelles). FEAT-192 : réutilise AddTrainerCareerCommand + persistance TrainerSkill ; sans effet onboarding (≠ POST /trainer/career). Corps : { experiences[], education[], skills[{ label, position }] } → 200.
  • GET /trainer/enrichment/draft - Brouillon enrichissement B3 (expériences/formations/compétences partiels). FEAT-193 : toujours 200 (tableaux vides si absent).
  • PUT /trainer/enrichment/draft - Upsert brouillon ({ experiences, education, skills[{ label, status, skillReferenceUuid? }], reviewSource? }, permissif).
  • DELETE /trainer/enrichment/draft - Efface le brouillon (enrichment_draft = null, 204 idempotent).
  • PUT /organization/updatePreferences - Mise à jour préférences organisation
  • PUT /organization/updateInfo - Mise à jour infos organisation

Endpoints publics (sans authentification)

  • FEAT-206 (PUB-1a — profil formateur public) :
  • GET /public/trainer/{slug} — Profil formateur public (hero, bio, compétences, expériences, formations, certifications, langues, bloc légal, verifiedExperience, allowResumeDownload). Chaque item verifiedExperience : { matiere, org, sessions, heures } — agrégation des line items CRA SENT par (title × organization.name) ; [] si aucun CRA envoyé. Sans uuid sur les items (read-only affichage) ; dates au format Y-m-d (distinct du hub CV authentifié en ISO ATOM). avatarUrl résolu via StorageUrlResolver. Gate anti-énumération R21 : slug inconnu ou isProfilePublic=false404 identique (Profil introuvable.). Expose isSearchIndexable (défaut true, migration Version20260612180000) ; émission X-Robots-Tag = PUB-2. legal: { legalStatus, siret, nda } | null (null sans TrainerCompany). Impl. : PublicTrainerProfileMapper + RetrieveTrainerVerifiedExperienceHandler + ActivityReportRepository::findSentByTrainer (FEAT-207). allowResumeDownload gate bouton CV PDF (FEAT-211 PUB-4).
  • FEAT-208 (PUB-2a — SSR meta OG/SEO pour crawlers) :
  • GET /seo/trainer/{slug} — HTML (pas JSON) : <title>, meta description, OpenGraph, Twitter Card, <link rel="canonical">, JSON-LD ProfilePage/Person. Header X-Robots-Tag: index, follow si isSearchIndexable=true, sinon noindex, nofollow. Slug inconnu ou profil privé → 200 meta génériques Teadle (R21 SSR, indiscernables). Canonical / og:url = {PUBLIC_PROFILE_URL}/{slug}. og:image profil public = {PUBLIC_PROFILE_URL}/{slug}/og.png (facade nginx, cache S3 — FEAT-209 PUB-2b) ; fallback générique = {APP_BASE_URL}/og-default.png. Route PUBLIC_ACCESS (^/seo/). Contrôleur : SeoTrainerProfileController ; template templates/seo/meta.html.twig. Dynamic rendering nginx : crawlers sur /profile/{slug} → rewrite interne /api/seo/trainer/{slug} ; humains → SPA (PUB-4).
  • FEAT-209 (PUB-2b — image OG dynamique 1200×630) :
  • GET /seo/trainer/{slug}/og-image.png — PNG généré server-side (GD) : avatar circulaire ou initiales + nom + headline + wordmark Teadle, fond DS blue-800 + accent corail. Cache S3 {trainerUuid}/public/og.png via TrainerOgImageObjectStorageService::readOrGenerateAndPersist (pattern factures). URL publique facade : {PUBLIC_PROFILE_URL}/{slug}/og.png (nginx rewrite → endpoint interne). Invalidation S3 sur update profil / avatar / visibilité. Content-Type: image/png, Cache-Control: public, max-age=86400, ETag. R21 : slug inexistant ou profil privé → carte générique 200 (sans donnée perso). Port Domain TrainerOgImageGeneratorInterface ; impl. GdTrainerOgImageGenerator (Infrastructure/Image/). Polices assets/fonts/Inter-*.ttf (OFL). Dépend ext-gd (FreeType pour imagettftext).
Workflow OG / SEO profil public (PUB-2a + PUB-2b)

Objectif : les humains voient le SPA Vue ; les crawlers sociaux / moteurs reçoivent du HTML SSR + une image OG personnalisée pour le rich unfurl (LinkedIn, WhatsApp, Slack…).

Variables d’environnement

Variable Exemple Rôle
APP_BASE_URL https://app.teadle.com Racine du site (og-default.png statique)
PUBLIC_PROFILE_URL https://app.teadle.com/profile Préfixe des profils publics (canonical, facade OG)

Partage social (crawler) — 2 requêtes HTTP

sequenceDiagram
    participant Bot as Crawler social
    participant Nginx
    participant SSR as SeoTrainerProfileController
    participant OG as SeoTrainerOgImageController
    participant S3 as S3 Cellar

    Bot->>Nginx: GET /profile/jean-martin (UA bot)
    Nginx->>SSR: rewrite /api/seo/trainer/jean-martin
    SSR-->>Bot: HTML + og:image = …/profile/jean-martin/og.png

    Bot->>Nginx: GET /profile/jean-martin/og.png
    Nginx->>OG: rewrite /api/seo/trainer/jean-martin/og-image.png
    alt Cache S3 hit
        OG->>S3: read {uuid}/public/og.png
        S3-->>OG: PNG
    else Cache S3 miss
        OG->>OG: GdTrainerOgImageGenerator (GD)
        Note over OG: fetch avatar HTTP si présent
        OG->>S3: writePublic {uuid}/public/og.png
    end
    OG-->>Bot: PNG 1200×630 + Cache-Control + ETag

Visite humaine

flowchart LR
    H[Humain] --> N[Nginx]
    N -->|UA non-bot| SPA[SPA Vue /profile/slug]
    N -->|GET …/og.png direct| OG[Endpoint OG PNG]

Contenu des meta SSR (profil public)

Meta Valeur
og:url / canonical {PUBLIC_PROFILE_URL}/{slug}
og:image / twitter:image {PUBLIC_PROFILE_URL}/{slug}/og.png
og:title {Prénom Nom} — {headline\|specialite} · Teadle

Profil privé ou slug inconnu (R21 SSR)

  • HTML : meta génériques Teadle, sans canonical / JSON-LD.
  • og:image = {APP_BASE_URL}/og-default.png (asset statique frontend).
  • GET …/og.png sur slug privé/inconnu → carte GD générique (indiscernable), sans écriture S3.

Image OG vs avatar profil

Avatar (avatarUrl) Image OG (og:image)
Nature Photo uploadée brute Carte composée 1200×630 (avatar + nom + headline + branding)
Stockage S3 {uuid}/avatar/{avatarUuid} S3 {uuid}/public/og.png (cache)
URL publique Cellar direct (API publique) Facade {PUBLIC_PROFILE_URL}/{slug}/og.png
Mise à jour Upload avatar Invalidation cache OG → regénération lazy

Invalidation cache OG S3 (async via Messenger) — les commandes profil appellent TrainerOgImageCacheInvalidationDispatcher ; le worker exécute TrainerOgImageObjectStorageService::invalidate :

  • Message : Domain/Messenger/InvalidateTrainerOgImageCache (transport async)
  • Handler : InvalidateTrainerOgImageCacheHandler
  • Déclenchée dans :

  • UploadTrainerAvatarCommand

  • UpdateTrainerAboutCommand (headline)
  • UpdateTrainerCommand (prénom / nom)
  • UpdateTrainerVisibilityCommand (dépublier → purge)
  • FinalizeTrainerProfileCommand (spécialité renseignée, si pas de headline)

Validation compétences via CRA SENT (async via Messenger) — match normalisé ActivityReportLineItem.titleTrainerSkill.label ; source = nom organisation du CRA :

  • Message : Domain/Messenger/SyncTrainerSkillValidations (transport async)
  • Handler : SyncTrainerSkillValidationsHandlerSyncTrainerSkillValidationsCommand
  • Dispatch direct via MessageBusInterface dans SendActivityReportCommand, AddTrainerSkillCommand, AddTrainerSkillsCommand. Création factorisée : CreateTrainerSkillService (createForHub / createForEnrichment). Pas de backfill rétroactif sur les formateurs existants.

Fichiers clés

Couche Fichier
SSR meta Infrastructure/Http/Seo/SeoTrainerProfileController.php, templates/seo/meta.html.twig
Endpoint PNG Infrastructure/Http/Seo/SeoTrainerOgImageController.php
Génération GD Infrastructure/Image/GdTrainerOgImageGenerator.php
Cache S3 Application/Service/Seo/TrainerOgImageObjectStorageService.php, TrainerOgImageCacheInvalidationDispatcher.php
nginx .infra/dev/nginx/default.conf, .infra/prod/nginx/default.conf (map $is_seo_bot, rewrite /profile/{slug} et /profile/{slug}/og.png)
  • GET /public/skill/search?q= — Autocomplete compétences référentiel ({ uuid, label, category }[], FEAT-185).
  • FEAT-210 (PUB-3 — export CV PDF profil public) :
  • GET /public/trainer/{slug}/cv/pdf — Téléchargement CV PDF (Dompdf). Gate triple R21 + toggle : slug inexistant, isProfilePublic=false, ou allowResumeDownload=false404 identique (Profil introuvable.). Réponse application/pdf + Content-Disposition: attachment; filename="cv-{slug}.pdf". Génération à la volée via GenerateTrainerCvPdfCommand + CvPdfFormattingService + template templates/pdf/trainer_cv.html.twig (cartes alignées maquette espace-public : hero initiales, bio, compétences clés top 5, verifiedExperience, expériences déclarées, formations/certifications, légal, langues). Opération sur TrainerResource ; provider : PublicTrainerCvPdfProvider. Distinct du hub JSON authentifié GET /trainer/profile/cv.
  • SEC-004 (PUB-10a — demande de contact GUEST + anti-abus) :
  • POST /public/trainer/{slug}/contact — Body { name, email, organisation?, message }202 { message } (réponse générique anti-énumération R21 : identique que le slug existe/soit public ou non). Validation Symfony (422 si invalide). Persistance TrainerPublicContactRequest (status=pending_verification, token vérif 256 bits, expiry 48 h) si profil public ; anti-doublon 1 h (même trainer+email+message). Rate-limit : 10/h par IP (clé hashée SHA-256) + 30/h par slug429 + Retry-After. Événement domaine TrainerPublicContactRequestCreatedEvent → email vérif demandeur (FEAT-213). Processor : PublicTrainerContactProcessor ; command : RequestTrainerPublicContactCommand.
  • FEAT-213 (PUB-10b — double opt-in + notif formateur) :
  • POST /public/trainer/contact/verify — Body { token: string(64) }200 { message } (réponse générique anti-énumération : identique token valide/expiré/inconnu). Token valide + pending + non expiré → status=verified + notif formateur async (template trainer_contact_notification). Subscriber TrainerPublicContactRequestCreatedEventSubscriber → email vérif (trainer_contact_verification, lien {TRAINER_CONTACT_VERIFICATION_LINK}?token=). Command : VerifyTrainerPublicContactCommand. Pas de rate-limit verify (token 256 bits).
  • FEAT-214 (PUB-11 — agenda public free/busy grain semaine) :
  • GET /public/trainer/{slug}/availability?dateStart=&dateEnd= — Free/busy grain semaine (lundi→dimanche) + nextAvailability grain mois (YYYY-MM ou null). Réponse { weeks: [{ weekStart, weekEnd, status: 'free'|'busy' }], nextAvailability } — dates ISO uniquement (formatage FR = PUB-12). Gate triple R21 + opt-in : slug inexistant, isProfilePublic=false, ou showPublicAgenda=false404 identique (Profil introuvable.). Fenêtre : 8 semaines par défaut depuis le lundi courant (tz formateur) ; dateStart/dateEnd optionnels (ISO 8601, les deux ou aucun) ; cap 26 semaines. Réutilise TrainerFreeSlotsService::getFreeSlotsForTrainer (plan par défaut, sans share-link). Opération sur TrainerResource ; provider : PublicTrainerAvailabilityProvider ; handler : RetrievePublicTrainerWeeklyAvailabilityHandler. Écriture du toggle showPublicAgenda : FEAT-215 (CV-7) PATCH /trainer + hub CV ; page settings-sharing complète = PUB-8.
  • FEAT-218 (PUB-7 — droit à l'effacement profil public, RGPD RH11) :
  • DELETE /trainer/profile/public — Formateur authentifié (requireTrainer) → 202 (cascade async). Pose publicProfileErasedAt=now + isProfilePublic=false (fermeture immédiate des surfaces publiques). Idempotent si déjà effacé. Log d'audit RGPD + dispatch ErasePublicProfileData (transport async) : purge TrainerPublicContactRequest (deleteByTrainerId) + invalidation cache OG S3 (TrainerOgImageObjectStorageService::invalidate). 410 Gone sur GET /public/trainer/{slug} si isPublicProfileErased() (testé avant le 404 privé — slug tombstoné conservé). Surfaces secondaires (SSR, CV PDF, dispo) → 404 via isProfilePublic=false. Re-publication hors V1. Resource : TrainerProfileResource ; command : ErasePublicProfileCommand ; handler : ErasePublicProfileDataHandler.
  • FEAT-220 (PUB-6 — consultation agrégée anonyme, R23) :
  • À chaque 200 sur GET /public/trainer/{slug} (profil réellement servi), dispatch async RecordPublicProfileView(trainerUuid) → handler incrémente Trainer.publicProfileViewCount + publicProfileLastViewedAt. Aucun dispatch sur 404/410. Exposé dans GET /me (publicProfileViewCount, publicProfileLastViewedAt ISO). Affichage read-only frontend : SharingSettingsView (FEAT-216). Pas d'identité visiteur, pas de notification par vue, compteur absent du DTO public.

Dashboard formateur (/trainer/dashboard/*)

  • GET /trainer/dashboard/summary - Résumé (revenu mensuel, heures, taux d’utilisation, clients actifs, taux de réponse). Les agrégations « mois courant » et le trimestre clients actifs utilisent le fuseau du formateur (user.timezone, défaut Europe/Paris). La réponse inclut periodMonth (1–12), periodYear, timezone (IANA), plus les enrichissements FEAT-074 : weeklyLoad (interventionCount, totalHours, realizedHours, dailyBreakdown), monthlySessions (total, realized, previousMonthTotal, evolution), schoolBreakdowns (top 3 écoles), monthlyRevenue.sessionsToInvoice (sessions terminées non facturées — compteur, pas un montant) / realizedHours, monthlyRevenue.amount = prévisionnel du mois (agenda non facturé, euros), monthlyHours.previousMonthHours/evolution, et FEAT-076 : concentrationCA (encaissé trimestre, percentage, topClient, level), effectiveHourlyRate (current, previousMonth, evolution — agenda), annualEvolution (FEAT-283 : currentYearCollectedCents, previousYearCollectedCents en centimes TTC, evolutionPercent, comparisonStartMonth, comparisonEndMonth, comparisonYear). Les mois de comparaison sont renvoyés en numérique (1..12) pour traduction côté frontend.
  • GET /trainer/dashboard/revenue-evolution - FEAT-280 : évolution du CA encaissé (factures paidAt, centimes TTC nets des avoirs finalisés sur factures déjà encaissées). Période par défaut 12_months (6_months, year ; mois en cours inclus). Réponse { period, data: [{ month, revenue }], pendingCollectionCents }revenue en centimes ; pendingCollectionCents = facturé non encaissé (chiffre transitoire, pas une série).
  • GET /trainer/dashboard/revenue-forecast - FEAT-280 : prévisionnel agenda × tarif (événements non facturés, euros). Période par défaut year (12 barres jan→déc ; modes glissants 6_months/12_months franchissent le 31/12). Réponse { period, data: [{ key, month, year, amount, status }], totalAmount, upcomingAmount, hasElapsedMonths, untariffedCount }status : ecoule | courant | avenir.
  • GET /trainer/dashboard/upcoming-interventions - Prochaines interventions. Paramètre scope supporté (today, week, défaut null = comportement historique upcoming). Chaque item expose désormais durationHours. Retourne toujours 200 (plus de gating onboarding).
  • GET /trainer/dashboard/recent-requests - Demandes récentes. Limite par défaut portée à 5 (surchargable via limit) ; chaque item expose module, slotsCount, conflictsCount (initialisé à 0 dans FEAT-074). Retourne toujours 200 (plus de gating onboarding).
  • GET /trainer/dashboard/revenue-by-client - FEAT-283 : CA encaissé par organisation (collectedCents, centimes TTC). Paramètre period : quarter (défaut), year, 12_months. Liste des organisations = agenda (tarif renseigné) ; montant = encaisse (paidAt). Clé interne UUID (deux homonymes = deux lignes). Chaque item inclut trend, nextSession, healthStatus, organizationStatus, organizationId.
  • GET /trainer/dashboard/badges — Compteurs pour le polling (sidebar / barre d’état) : notifications (notifications in-app non lues, non expirées — même filtre que /notification/list), requests { pending, urgent, newUnseen } (newUnseen = firstViewAt null sur les lots NEW/PENDING non expirés), et unreadInbox { total, conversations, notifications, critical } : agrégat inbox unifiée (total = conversations + notifications ; critical = notifications non lues de niveau URGENT). Le front limite déjà la fréquence des appels (ex. 2 min). Pas de 409 onboarding.
  • GET /staff/dashboard/badges — Même principe que le formateur (notifications, requests, unreadInbox). pending compte les demandes (groupes ReservationBatchGroup avec statut agrégé « en attente », comme l’onglet Mes demandes), pas chaque lot isolément ; urgent / newUnseen restent par lot NEW/PENDING dans ces demandes.

Notifications in-app (NotificationResource)

  • GET /notification/list — Liste des notifications actives non lues pour l’utilisateur connecté (sous-types discriminés : reservation, invoice, normal, association, calendar, sync_failure, document_expiry, document_request ; champs communs incluent uuid, dismissedAt, title, body). 401 si non authentifié (plus de réponse vide anonyme).
  • GET /notifications/search — Recherche paginée (mêmes sous-types). Paramètres query (comme la liste conversations) : page, limit ou itemsPerPage, sort[…], filters[…] — ex. filters[uuid] pour cibler une notification par UUID. 401 si non authentifié.
  • POST /notifications/read-all — Marque toutes les notifications actives encore non lues comme lues ; réponse JSON { "updated": <nombre> }.
  • POST /notifications/{uuid}/read — Marque une notification comme lue (readAt) ; idempotent si déjà lue ; réponse = ressource notification (même forme que les autres endpoints) ; 403 / 404 comme ci-dessous.
  • POST /notifications/{uuid}/dismiss — Persiste dismissedAt pour la notification identifiée par uuid (jamais par id entier) ; 403 si la notification appartient à un autre utilisateur ; 404 si absente ou UUID invalide.

Demandes de réservation (formateur)

  • GET /trainer/reservation/requests — Liste des lots (lecture seule pour firstViewAt : la liste ne marque plus le lot comme vu ; seul PATCH …/view met à jour firstViewAt) ; chaque élément inclut notamment createdAt, firstViewAt, respondedAt, contactRole (rôle du membership organisation si présent). Chaque entrée de reservations[] expose weekEvents : pour la semaine lun–ven du créneau, fusion des événements du planning du formateur et des créneaux demandés du lot en cours (titres du type Demande — {module}), triés par heure de début. Chaque entrée de weekEvents inclut isMainEvent (créneau correspondant à la ligne reservations[] courante — « celui sur lequel on raisonne ») et isBatchEvent (autre créneau du même lot) ; les événements planning ont les deux à false. Vide sans contexte formateur.
  • PATCH /trainer/reservation/batch/{uuid}/view — Marque le lot comme vu (firstViewAt), idempotent ; 204 sans corps ; 404 si inconnu ; 401 si le lot n’appartient pas au formateur connecté.
  • POST /trainer/reservation/batches/mark-all-as-viewed — Marque en masse tous les lots du formateur comme vus (firstViewAt) avec une mise à jour idempotente (firstViewAt IS NULL uniquement), puis renvoie 200 avec le même payload que GET /trainer/reservation/requests (liste + compteurs).
  • accept / refuse : idempotents si le lot est déjà ACCEPTED ou REFUSED (réponse DTO sans ré-exécuter les effets de bord — pas de double création d’événements agenda ; un second refuse ignore les nouveaux motif/message). slots inchangé.
  • accept — découpage sync/async : TrainerReservationBatchAcceptedEventSubscriber exécute en synchrone la création/association d’organisation, la création du contact, l’annulation des autres lots du groupe (CancelOtherBatchesInGroupCommand, garde la règle « premier formateur qui accepte gagne »), la lecture de la notification formateur et notifyStaff (NotifyStaffOfTrainerReservationResponseCommand : notification in-app NormalNotification + email Brevo au staff demandeur via batch.membership.user). Même commande branchée sur TrainerReservationBatchRefusedEvent. Gabarits email : reservation_response_accepted (40), reservation_response_refused (41). Lots sans membership (lien public invité) : pas de notification staff. L’import calendrier (création des InterventionEvent + appels HTTP Onisep/Parcoursup), étape la plus lente, est déporté en async : createEvents dispatche Domain/Messenger/Reservation/ImportEventsFromAcceptedBatch (uuid du lot, transport async) ; le handler ImportEventsFromAcceptedBatchHandler recharge le lot et revérifie l’idempotence (statut ACCEPTED, organisation assignée, ≥ 1 créneau ACCEPTED) avant d’appeler ImportEventFromReservationCommand.

  • GET /trainer/organization/dashboard — Carte organisations : par assignation, métriques planifiées (totalHours, totalRevenue) et réalisées (totalRealizedHours, totalRealizedRevenue, realizedHoursPercent), plus calendarColor, organizationInitials, lastImportSuccessful, isSyncPossible (aligné sur CalendarConfigurator::canSyncFromRemote() : URL iCal, fichier .ics serveur ou jetons OAuth), providerType (id technique du connecteur FEAT-043 : google, edusign, hyperplanning, ical, etc. — null si pas de configurateur ; libellé FR dérivé côté front), countUnlinkedAssignments (nombre d’assignations iCal actives sans PMO / « à relier » ; remplace l’ancien booléen hasSynchonizedIcalError), lastImportErrorCode (enum string, présent seulement si lastImportSuccessful est false — libellés côté front), et status (enum métier ok/conflict/error/none calculé côté backend : none si aucun configurateur, error si dernier import KO, conflict si au moins une assignation active non reliée, sinon ok). La règle est mutualisée dans Domain/Service/Dashboard/OrganizationStatusResolver. Compteur global inchangé : countSynchonizedIcalError.

Dashboard (autres)

  • GET /dashboard/trainer/income - Revenus formateur
  • GET /dashboard/trainer/favorite-organizations - Organisations favorites
  • GET /dashboard/trainer/events - Événements formateur
  • GET /dashboard/trainer/opportunity-proposals - Propositions d'opportunités

Calendrier / connecteurs (formateur, FEAT-043)

  • GET /trainer/calendar/configurator/{uuid} — réponse configurateur inclut providerType (sélection hub), icalUrl et defaultPrice (tarif horaire par défaut du formateur : trainer_billing_settings.default_price si défini, sinon paramètre applicatif trainer.default_hourly_price, défaut 35). Les imports iCal / réservation utilisent la même résolution via TrainerDefaultHourlyPriceResolver.
  • POST .../configurator/{organizationUuid}/ical/add — corps JSON : icalLink, optionnel providerType ; enregistre l’URL et le type sur le configurateur (jetons OAuth effacés si présents), renvoie un process, puis import asynchrone via ProcessTrainerCalendarConfiguratorSync (comme le callback OAuth).
  • POST .../configurator/{organizationUuid}/ical/upload — multipart icsfile, optionnel providerType ; écrit le .ics sur S3, met à jour le configurateur (ical_file, URL iCal effacée, jetons OAuth effacés), renvoie un process, puis ProcessTrainerCalendarConfiguratorSync.
  • POST /trainer/calendar/configurator/{uuid}/sync — relance d’import.
  • POST /trainer/calendar/oauth/{provider}/authorizeAPI Platform (JWT formateur) : corps JSON {"organizationUuid":"<uuid>"} ; réponse JSON authorizationUrl. Fournisseurs : google, outlook, apple, edusign, hyperplanning, ypareo, ical (URL). Les stubs OAuth répondent 501 ; edusign / hyperplanning / ypareo exposent toutefois l’enrichissement iCal (parseurs PRODID via les stratégies connecteur (parseIcalVo / *IcalCalendarEventParser) sur le VO Event, champ supportIcal du hub inchangé côté API). Google / Outlook utilisent GOOGLE_CLIENT_* / MICROSOFT_CLIENT_* + TRAINER_CALENDAR_*_REDIRECT_URI : URL du SPA (ex. https://app…/trainer/calendar/oauth/google/callback) enregistrée chez le fournisseur ; identique à l’URL utilisée pour l’échange du code.
  • POST /trainer/calendar/oauth/{provider}/calendar/listAPI Platform (TrainerCalendarOAuthCalendarListProcessor) : JWT formateur ; corps { "code": "…", "state": "…" } (ou error). Échange le code contre des jetons sans les persister ; renvoie la liste des agendas (disabled si pour ce formateur l’identifiant d’agenda distant est déjà lié à une autre organisation ou déjà utilisé comme agenda personnel OAuth de ce formateurTrainerOAuthRemoteCalendarListConflictMarker, requêtes filtrées par formateur). Réponse JSON tokens + calendars.
  • POST /trainer/calendar/oauth/{provider}/callbackAPI Platform (TrainerCalendarOAuthCallbackProcessor) : JWT formateur ; corps JSON { "accessToken", "refreshToken?", "expiresAt?", "calendarId", "state" } ou { "error" }. Vérifie le state signé, enregistre jetons + oauth_calendar_id sur calendar_configurator, async ProcessTrainerCalendarConfiguratorSync. Réponse 200 ProcessResponse (comme iCal add / sync) : uuid, metadata (dont organization_uuid, calendar_oauth_provider). Erreur ou paramètres invalides : 400.

Company & Facturation (formateur)

  • GET /trainer/company - Entreprise du formateur (identité + infos bancaires uniquement)
  • PUT /trainer/company - Mise à jour entreprise (identité + bancaire)
  • GET /trainer/billing/settings - Paramètres de facturation (TVA, unité, délai, tarif par défaut, mentions, template email, logo)
  • PUT /trainer/billing/settings - Mise à jour des paramètres de facturation
  • GET /trainer/invoices/config - Config facturation (company + billing séparés, organizations, products ; utilisé pour préremplir la création de facture)
  • GET /trainer/invoices/{uuid} - Détail facture (inclut defaultVatRate, defaultUnit, paymentTerm appliqués, en lecture seule)
  • GET /trainer/invoices/{uuid}/pdf - PDF facture : en brouillon (DRAFT), généré à la volée sans stockage S3 ; dès que la facture n’est plus brouillon (FINALIZED, SENT, PAID, etc.), le backend sert d’abord l’objet S3 {trainerUuid}/billing/invoices/{invoiceUuid} (même clé que celle écrite à la finalisation via InvoiceFinalizedEvent), et ne régénère + réécrit que si l’objet est absent.
  • GET /trainer/activity-reports/{uuid}/pdf - PDF CRA : toujours généré à la volée à chaque requête, sans stockage S3.

Validation factures : l'entité Invoice impose dueDate >= issueDate lorsque les deux dates sont renseignées (Assert Symfony sur le Domain). La commande UpdateInvoiceCommand valide avant persistance ; les Processors Create/Update renvoient 400 en cas de violation.

Format des réponses

Toutes les réponses sont au format JSON avec la structure suivante :

{
  "data": {
    // Données de la réponse
  },
  "meta": {
    "timestamp": "2025-01-13T10:00:00Z",
    "version": "1.0.0"
  }
}

🔒 Sécurité {#securite}

Authentification

JWT (JSON Web Tokens)

  • Algorithme : RS256
  • Durée de vie : 1 heure
  • Refresh token : 30 jours
  • Header : Authorization: Bearer <token>

OAuth 2.0

  • Providers supportés : LinkedIn, Google, Microsoft
  • Flow : Authorization Code Flow
  • Scopes : email, profile

Codes d'authentification

  • Format : 6 chiffres
  • Durée de vie : 10 minutes
  • Envoi : Email via Brevo

Autorisation

Rôles utilisateurs

  • ROLE_USER : Utilisateur de base
  • ROLE_TRAINER : Formateur
  • ROLE_ORGANIZATION : Organisation
  • ROLE_ADMIN : Administrateur

Contrôle d'accès

  • Routes publiques : /security/*, /onboarding/*, /health/*
  • Routes protégées : Toutes les autres routes
  • Validation : Via annotations Symfony Security

🗄️ Base de données {#base-de-donnees}

Schéma principal

erDiagram
    User {
        int id PK
        string firstname
        string lastname
        string email UK
        string password
        array roles
        string oauthProvider
        string oauthProviderId UK
        boolean acceptCgv
        boolean acceptOptIn
        boolean verifiedEmail
        boolean finishedOnBoarding
        string type
    }

    Trainer {
        string SIRET
        json onboardingCompletedSteps
    }

    Organization {
        string name
        string organizationType
        string service
        phone_number phoneNumber
    }

    Event {
        int id PK
        int trainer_id FK
        datetime date_start
        datetime date_end
        string type
    }

    InterventionEvent {
        int organization_id FK
        string name
        float amount
        boolean is_approve_by_organization
    }

    Opportunity {
        int id PK
        int organization_id FK
        string name
        datetime date_start
        datetime date_end
    }

    OpportunityProposal {
        int id PK
        int opportunity_id FK
        int trainer_id FK
        int candidacy_id FK
    }

    Candidacy {
        int id PK
        datetime date
    }

    Invoice {
        int id PK
        int trainer_id FK
        int organization_id FK
        int intervention_event_id FK
        string amount
    }

    User ||--|| Trainer : "inherits"
    User ||--|| Organization : "inherits"
    Trainer ||--o{ Event : "has"
    Trainer ||--o{ Candidacy : "has"
    Trainer ||--o{ Invoice : "has"
    Organization ||--o{ InterventionEvent : "has"
    Organization ||--o{ Opportunity : "has"
    Organization ||--o{ Invoice : "has"
    Opportunity ||--o{ OpportunityProposal : "has"
    Trainer ||--o{ OpportunityProposal : "proposes"
    Candidacy ||--o{ OpportunityProposal : "linked_to"

Onboarding formateur

Parcours unique DISC-016 :

  • Étapes backend : CREATE_ACCOUNT, VERIFY_AVAILABILITY, SHARE_LINK, IMPORT_CALENDAR, DEFINE_PRICING.
  • L’avancement est stocké sur le Trainer dans onboardingCompletedSteps (JSON : liste de valeurs d’enum des étapes validées). À l’inscription (CreateNewTrainerCommand, formulaire ou OAuth), CREATE_ACCOUNT est enregistrée dès la création du formateur.
  • L’exposition API canonique passe par onboardingProgress (GetTrainerOnboardingProgressHandler + TrainerOnboardingProgressResponse).

Migrations

Le projet utilise Doctrine Migrations pour la gestion du schéma :

# Créer une nouvelle migration
php bin/console doctrine:migrations:diff

# Exécuter les migrations
php bin/console doctrine:migrations:migrate

# Annuler la dernière migration
php bin/console doctrine:migrations:migrate prev

🚀 Déploiement {#deploiement}

Prérequis

  • PHP : 8.2+
  • Composer : 2.0+
  • PostgreSQL : 13+
  • Redis : 6.0+ (optionnel, pour le cache)

Variables d'environnement

# Base de données
DATABASE_URL="postgresql://user:password@localhost:5432/teadle"

# Sécurité
APP_SECRET="your-secret-key"
JWT_SECRET_KEY="path/to/private.key"
JWT_PUBLIC_KEY="path/to/public.key"

# OAuth
LINKEDIN_CLIENT_ID="your-linkedin-client-id"
LINKEDIN_CLIENT_SECRET="your-linkedin-client-secret"
LINKEDIN_REDIRECT_URI="https://your-domain.com/oauth/linkedin/callback"

GOOGLE_CLIENT_ID="your-google-client-id"
GOOGLE_CLIENT_SECRET="your-google-client-secret"
GOOGLE_REDIRECT_URI="https://your-domain.com/oauth/google/callback"

MICROSOFT_CLIENT_ID="your-microsoft-client-id"
MICROSOFT_CLIENT_SECRET="your-microsoft-client-secret"
MICROSOFT_REDIRECT_URI="https://your-domain.com/oauth/microsoft/callback"

# OAuth calendrier formateur (callbacks dédiés ; identifiants = GOOGLE_CLIENT_* / MICROSOFT_CLIENT_* ci-dessus)
TRAINER_CALENDAR_GOOGLE_REDIRECT_URI="https://app.example.com/trainer/calendar/oauth/google/callback"
TRAINER_CALENDAR_MICROSOFT_REDIRECT_URI="https://app.example.com/trainer/calendar/oauth/outlook/callback"
TRAINER_CALENDAR_OAUTH_SUCCESS_REDIRECT_URL="https://app.example.com"

# Email (Brevo)
BREVO_SECRET="your-brevo-api-key"

# URLs
PASSWORD_RESET_LINK="https://your-domain.com/password-reset"
MAGIC_LINK_LINK="https://app.teadle.com/signin"
TRAINER_ACTIVATION_LINK="https://app.teadle.com/activation"

# Profil public / SSR OG (PUB-2a + PUB-2b)
APP_BASE_URL="https://app.teadle.com"
PUBLIC_PROFILE_URL="https://app.teadle.com/profile"

Installation

# Installer les dépendances
composer install --no-dev --optimize-autoloader

# Configurer la base de données
php bin/console doctrine:database:create
php bin/console doctrine:migrations:migrate

# Charger les fixtures (développement)
php bin/console hautelook:alice:doctrine:load

# Vider le cache
php bin/console cache:clear --env=prod

# Configurer les permissions
chmod -R 755 var/
chmod -R 755 public/

Commandes utiles

# Analyser le code (niveau 6, extensions Symfony/Doctrine/PHPUnit)
./vendor/bin/phpstan analyse --configuration=phpstan.dist.neon
# ou : make backend-phpstan (si défini dans le Makefile)

# Corriger le style de code
php bin/console php-cs-fixer:fix

# Générer les clés JWT
php bin/console lexik:jwt:generate-keypair

# Vider le cache
php bin/console cache:clear

# Vérifier la santé de l'application
curl http://localhost:8000/health

# Rattrapage manuel DISC-025 (formateurs pré-MEP : slugs manquants, sync compétences CRA)
php bin/console app:trainer:backfill-disc025 --dry-run
php bin/console app:trainer:backfill-disc025
php bin/console app:trainer:backfill-disc025 --email=trainer+1@teadle.com --slugs-only

# Le bilan mensuel est déclenché automatiquement par le Symfony Scheduler
# (via le worker supervisor symfony-scheduler consommant scheduler_default)
# Pas de commande console à lancer manuellement

📚 Ressources supplémentaires