Documentation Backend Teadle
📋 Table des matières
- Vue d'ensemble
- Architecture
- Technologies
- Structure du projet
- API Documentation
- Sécurité
- Base de données
- 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 dansemail_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) (ScanExpiringComplianceDocuments→SendComplianceDocumentExpiryAlertsHandler) ; notifications in-appDocumentExpiryNotificationaux 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 STIDocumentRequestNotificationprê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 viaMonthlyReportCalculator, envoi viasendMonthlyReportEmail(template Brevo #23). Protection anti-doublon parstateful($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 contenanttoday + 7 jours, fenêtre calculée en Europe/Paris). Paramètres Brevo viasendUpcomingWeekReportEmail(templaterecap_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
ScheduledTrainerCalendarRemoteResyncsur le transport async ;DispatchScheduledTrainerCalendarRemoteResyncCommandenchaîne les contrôles puisSyncProcessTrainerCalendarIcalCommand::processpour chaque configurateur ayant une URL iCal ou des jetons OAuth persistés (équivalent au POST/trainer/calendar/configurator/{uuid}/sync). Le traitement async passe parProcessTrainerCalendarConfiguratorSync:TrainerCalendarConfiguratorSynchronizerappelleparseCalendar(OAuth) ouIcsParserInterfacepuis boucle sur les stratégies (supportsIcal/supportsIcalProdId) etparseIcalVo, puisPersistTrainerCalendarConnectorEventsCommand(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 modeforce=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 (champssummary/description/locationselon 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 valeur0-0. - 📅 Agenda personnel distant (formateur) : sur
TrainerAvailabilityConfiguration— OAuth (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}.icsviaTrainerPersonalCalendarIcalFileStorageKeyResolver, 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,providerTypeoptionnel) etPOST /trainer/calendar/personal/ical/upload(multiparticsfile,providerTypeoptionnel), traités parCreateProcessTrainerPersonalCalendarIcalLinkCommand/CreateProcessTrainerPersonalCalendarIcalFileCommandpuis la même synchro asyncProcessTrainerPersonalCalendarSync→TrainerPersonalCalendarSynchronizer→PersistTrainerPersonalCalendarEventsCommand. Resync planifiée :TrainerAvailabilityConfigurationRepository::findAllWithPersonalCalendarRemoteSyncSource(). GET/trainer/availability/configurationexposepersonalCalendarRemoteSyncConfiguredetpersonalCalendarIcalUrl. DéconnexionDELETE …/personal/oauthefface OAuth et URL iCal. Le flux OAuth SPA réutiliseTrainerCalendarOAuthCallbackView+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/EmailServiceetEmailTemplateRegistrypour 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 (logoUrlfacturation, avatar, etc.) passent parFilesystemWriter::writePublic()(ACLpublic-readcô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-symfony5.x) : capture automatique des exceptions, erreurs Messenger, performance tracing (traces_sample_rate: 0.2). En prod, handlers Monolog en plus du fluxnested→php://stderr(Clever Cloud / New Relic) :LogToSentryIssueHandleretExceptionToSentryIssueHandler(levelerror). DSN viaSENTRY_DSNdans.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 viastart.shsiNEW_RELIC_LICENSE_KEYest défini. Région EU (collector.eu01.nr-data.net), frameworksymfony4.
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(helpersget/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-unitetbackend-tests-functional; rapport Allure parent Backend, sous-suites Unit / Functional.
🔌 API Documentation
Endpoints principaux
Authentification
POST /security/login- Connexion JWT. Corps JSON :email,password, optionnelrememberMe(bool, défautfalse, FEAT-183). SirememberMe: true, le cookieREFRESH_TOKENest persistant surREFRESH_TOKEN_TTL_REMEMBER_MEsecondes (défaut 30 j,backend/.env→ paramètreteadle.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 cookieACCESS_TOKENreste court (15 min). OAuth et magic link : cookie refresh session (pas de TTL).POST /token/refresh: cookie refresh réémis avecREFRESH_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 passePUT /security/password-reset/perform- Reset mot de passePOST /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 viaMagicLinkRequestRepository::rotateToken.PUT /security/magic-link/perform- Consommation Magic Link login : corps{ "token" }→ cookies httpOnly JWT + refresh (consumeValidTokenatomique). 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.passwordNULL), envoie un email d’activation (MagicLinkPurpose::TRAINER_ACTIVATION, TTL 15 min, template Brevotrainer_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 IP → 429 +Retry-After.POST /security/trainer/activate/verify- Vérification du lien d’activation : corps{ "token", "password"? }(passwordoptionnel, min 8 car.) → 200 + cookies JWT (ACCESS_TOKEN+ refresh session). Active le compte (verifiedEmail, générationslug), dispatchTrainerRegisteredEvent; mot de passe hashé seulement si fourni.POST /security/register- InscriptionPOST /security/auth-code/send- Envoi code d'authentificationPOST /security/auth-code/verify- Vérification codePOST /security/oauth/login- Connexion OAuth
Utilisateurs
GET /me- Informations utilisateur connecté. Pour un formateur : inclutonboardingProgress(état DISC-016 : étapes, compteurs,completedAt,checklistDismissedAt,sampleData:active,expiresAt,dismissedAt,firstRealReservationReceivedAt; fenêtre J+N depuisregistered_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 — calqueallowResumeDownload) ; 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) : champshowPublicAgenda(bool, null/absent = inchangé) ; persistance viaUpdateTrainerCommand(calqueallowResumeDownload).POST /trainer/deactivate— Désactivation réversible du compte formateur (DISC-026 / FEAT-226, R2) : sans corps → 200 + DTOAbstractBaseUser. PasseaccountStatus = deactivated, horodatedeactivatedAt. Formateur authentifié uniquement (requireTrainer()). Réactivation automatique au login (R3,ReactivateAccountCommanddansJWTAuthenticationSuccessListener). 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éutiliseReactivateAccountCommand(no-op si déjà actif oudeletion_pending). Exposé pour le bandeau UI (FEAT-227 frontend).GET /me(formateur) inclutaccountStatus(active|deactivated|deletion_pending|deleted, FEAT-224) etscheduledDeletionAt(ISO 8601, renseigné en grace period — Chemin A :UserToDtoMapper→ DTOUser\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 ;inactivityexclu — motif interne FEAT-260, validation viauserSelectableValues()) → 200 + DTO à jour (accountStatus = deletion_pending,scheduledDeletionAt = J+30). Motif persisté surtrainer.deletion_reason, recopié dansaccount_deletion_logà l'anonymisation. No-op si déjàdeletion_pendingoudeleted. Déclenche asyncGenerateAccountDataExport(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é pourAccountDeletionGraceBanner(FEAT-232). Annulation automatique au login durant la grace period (CancelAccountDeletionCommanddansJWTAuthenticationSuccessListener).- Effacement effectif J+30 (FEAT-229, RH11) : cron 02:00 Europe/Paris →
ProcessDueAccountDeletions(async) →AnonymizeTrainerCommandpour chaque formateurDELETION_PENDINGéchu. Anonymisation en place (ligneTrainerconservée, PII scrubée, email tombstonedeleted-{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éfautkernel.secret),ACCOUNT_DELETION_HMAC_KEY_VERSION(défautv1). - Moteur d'inactivité (FEAT-260, DISC-026 R16) : cron 03:00 Europe/Paris →
ProcessAccountInactivity(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 interneinactivity, jamais proposable à l'exit survey). Inactivité (RH3) = aucune connexion (lastActivityAt, repliregisteredAt), ni intervention active, ni facture émise sur la période. Idempotence : tableaccount_inactivity_notice; purge à la réactivation. Rattrapage legacy : un seul warning/jour (jalon le plus avancé non envoyé). Gabarits Brevoinactivity_warning_90…inactivity_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.yamlpar 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 Brevoaccount_data_export(#31) : variablesfirstname,lastname,downloadUrl,expiresAt; baseACCOUNT_DATA_EXPORT_LINK. Ré-émission :POST /trainer/data-export/resend(202). Relance J-3 : cron 09:00 →RelanceExpiringAccountDataExports(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 dansbackend/config/packages/teadle_onboarding_sample_data.yaml(paramètreteadle_onboarding_sample_data, lu viaParameterBagProviderInterface) ; GetTrainerSampleDataHandler renvoieTrainerSampleDataResultDto(VO Domain pour les demandes / interventions, DTO Application pour le bloc dashboard figé YAML) ;TrainerSampleDataResponseMapperproduitSampleDataResponsepour API Platform.- Prévu (sample organisations) : même principe que demandes / dashboard — ajouter côté backend une slice dédiée (organisations ou
assignmentsalignée sur le listing/trainer/organization/dashboardou équivalent consommé par OrganizationsView) lorsque le sample data est actif : données fictives dans le YAML + mapping dansTrainerSampleDataResultDto/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(snapshotonboardingProgress). 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 étapeOnboardingStepcomme complétée viaMarkTrainerOnboardingStepCompletedCommand;{step}invalide → 400. Réponse TrainerOnboardingProgressResponse. Idempotent (FEAT-088).PUT /trainer/updatePreferences- Mise à jour préférences formateurPUT /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 (labelrequis,organisme,date,urloptionnels). 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 restePOST /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— Remplacelanguages({ "languages": string[] }). 204.PUT /trainer/profile/about— Met à jourheadline(≤ 120 car.) etbio(≤ 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 (labelrequis,skillReferenceUuid?pour lien direct référentiel autocomplete). Résolution référentiel par UUID prioritaire, sinon label normalisé (R16). Création avecvalidated=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 + listesexperiences,educations,certifications,skills,languages. Chaque item exposeuuid(jamais d'identier) ; dates au format ISO 8601 (DateTimeInterface::ATOM).avatarUrlrésolu viaStorageUrlResolver. Lecture pure (TrainerCvProvider, formateur authentifié).POST /trainer/cv/analyze- Analyse PDF LinkedIn (multipartcv) →CvAnalyzerResponse. FEAT-191 : réservé aux formateurs authentifiés (requireTrainer()dansCvAnalyzerProcessor).POST /trainer/enrichment- Finalise l'enrichissement B3 (expériences + formations + compétences confirmées/manuelles). FEAT-192 : réutiliseAddTrainerCareerCommand+ persistanceTrainerSkill; 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 organisationPUT /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 itemverifiedExperience:{ matiere, org, sessions, heures }— agrégation des line items CRA SENT par (title × organization.name) ;[]si aucun CRA envoyé. Sansuuidsur les items (read-only affichage) ; dates au formatY-m-d(distinct du hub CV authentifié en ISO ATOM).avatarUrlrésolu viaStorageUrlResolver. Gate anti-énumération R21 : slug inconnu ouisProfilePublic=false→ 404 identique (Profil introuvable.). ExposeisSearchIndexable(défauttrue, migration Version20260612180000) ; émissionX-Robots-Tag= PUB-2.legal: { legalStatus, siret, nda } | null(null sansTrainerCompany). Impl. :PublicTrainerProfileMapper+RetrieveTrainerVerifiedExperienceHandler+ActivityReportRepository::findSentByTrainer(FEAT-207).allowResumeDownloadgate 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-LDProfilePage/Person. HeaderX-Robots-Tag: index, followsiisSearchIndexable=true, sinonnoindex, nofollow. Slug inconnu ou profil privé → 200 meta génériques Teadle (R21 SSR, indiscernables). Canonical /og:url={PUBLIC_PROFILE_URL}/{slug}.og:imageprofil 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; templatetemplates/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.pngviaTrainerOgImageObjectStorageService::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 DomainTrainerOgImageGeneratorInterface; impl.GdTrainerOgImageGenerator(Infrastructure/Image/). Policesassets/fonts/Inter-*.ttf(OFL). Dépendext-gd(FreeType pourimagettftext).
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.pngsur 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(transportasync) - 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.title ↔ TrainerSkill.label ; source = nom organisation du CRA :
- Message :
Domain/Messenger/SyncTrainerSkillValidations(transportasync) - Handler :
SyncTrainerSkillValidationsHandler→SyncTrainerSkillValidationsCommand - Dispatch direct via
MessageBusInterfacedansSendActivityReportCommand,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, ouallowResumeDownload=false→ 404 identique (Profil introuvable.). Réponseapplication/pdf+Content-Disposition: attachment; filename="cv-{slug}.pdf". Génération à la volée viaGenerateTrainerCvPdfCommand+CvPdfFormattingService+ templatetemplates/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 surTrainerResource; 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). PersistanceTrainerPublicContactRequest(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 slug → 429 +Retry-After. Événement domaineTrainerPublicContactRequestCreatedEvent→ 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 (templatetrainer_contact_notification). SubscriberTrainerPublicContactRequestCreatedEventSubscriber→ 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) +nextAvailabilitygrain mois (YYYY-MMounull). 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, oushowPublicAgenda=false→ 404 identique (Profil introuvable.). Fenêtre : 8 semaines par défaut depuis le lundi courant (tz formateur) ;dateStart/dateEndoptionnels (ISO 8601, les deux ou aucun) ; cap 26 semaines. RéutiliseTrainerFreeSlotsService::getFreeSlotsForTrainer(plan par défaut, sans share-link). Opération surTrainerResource; provider :PublicTrainerAvailabilityProvider; handler :RetrievePublicTrainerWeeklyAvailabilityHandler. Écriture du toggleshowPublicAgenda: 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). PosepublicProfileErasedAt=now+isProfilePublic=false(fermeture immédiate des surfaces publiques). Idempotent si déjà effacé. Log d'audit RGPD + dispatchErasePublicProfileData(transportasync) : purgeTrainerPublicContactRequest(deleteByTrainerId) + invalidation cache OG S3 (TrainerOgImageObjectStorageService::invalidate). 410 Gone surGET /public/trainer/{slug}siisPublicProfileErased()(testé avant le 404 privé — slug tombstoné conservé). Surfaces secondaires (SSR, CV PDF, dispo) → 404 viaisProfilePublic=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 asyncRecordPublicProfileView(trainerUuid)→ handler incrémenteTrainer.publicProfileViewCount+publicProfileLastViewedAt. Aucun dispatch sur 404/410. Exposé dansGET /me(publicProfileViewCount,publicProfileLastViewedAtISO). 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éfautEurope/Paris). La réponse inclutperiodMonth(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,previousYearCollectedCentsen 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é (facturespaidAt, centimes TTC nets des avoirs finalisés sur factures déjà encaissées). Période par défaut12_months(6_months,year; mois en cours inclus). Réponse{ period, data: [{ month, revenue }], pendingCollectionCents }—revenueen 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éfautyear(12 barres jan→déc ; modes glissants6_months/12_monthsfranchissent 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ètrescopesupporté (today,week, défautnull= comportement historique upcoming). Chaque item expose désormaisdurationHours. Retourne toujours 200 (plus de gating onboarding).GET /trainer/dashboard/recent-requests- Demandes récentes. Limite par défaut portée à 5 (surchargable vialimit) ; chaque item exposemodule,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ètreperiod:quarter(défaut),year,12_months. Liste des organisations = agenda (tarif renseigné) ; montant = encaisse (paidAt). Clé interne UUID (deux homonymes = deux lignes). Chaque item incluttrend,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=firstViewAtnull sur les lots NEW/PENDING non expirés), etunreadInbox{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).pendingcompte les demandes (groupesReservationBatchGroupavec statut agrégé « en attente », comme l’onglet Mes demandes), pas chaque lot isolément ;urgent/newUnseenrestent 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 incluentuuid,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,limitouitemsPerPage,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— PersistedismissedAtpour la notification identifiée paruuid(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 pourfirstViewAt: la liste ne marque plus le lot comme vu ; seulPATCH …/viewmet à jourfirstViewAt) ; chaque élément inclut notammentcreatedAt,firstViewAt,respondedAt,contactRole(rôle du membership organisation si présent). Chaque entrée dereservations[]exposeweekEvents: 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 typeDemande — {module}), triés par heure de début. Chaque entrée deweekEventsinclutisMainEvent(créneau correspondant à la lignereservations[]courante — « celui sur lequel on raisonne ») etisBatchEvent(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 NULLuniquement), puis renvoie 200 avec le même payload queGET /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 secondrefuseignore les nouveaux motif/message).slotsinchangé.-
accept— découpage sync/async :TrainerReservationBatchAcceptedEventSubscriberexé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 etnotifyStaff(NotifyStaffOfTrainerReservationResponseCommand: notification in-appNormalNotification+ email Brevo au staff demandeur viabatch.membership.user). Même commande branchée surTrainerReservationBatchRefusedEvent. Gabarits email :reservation_response_accepted(40),reservation_response_refused(41). Lots sansmembership(lien public invité) : pas de notification staff. L’import calendrier (création desInterventionEvent+ appels HTTP Onisep/Parcoursup), étape la plus lente, est déporté en async :createEventsdispatcheDomain/Messenger/Reservation/ImportEventsFromAcceptedBatch(uuid du lot, transportasync) ; le handlerImportEventsFromAcceptedBatchHandlerrecharge le lot et revérifie l’idempotence (statutACCEPTED, organisation assignée, ≥ 1 créneauACCEPTED) avant d’appelerImportEventFromReservationCommand. -
GET /trainer/organization/dashboard— Carte organisations : par assignation, métriques planifiées (totalHours,totalRevenue) et réalisées (totalRealizedHours,totalRealizedRevenue,realizedHoursPercent), pluscalendarColor,organizationInitials,lastImportSuccessful,isSyncPossible(aligné surCalendarConfigurator::canSyncFromRemote(): URL iCal, fichier.icsserveur ou jetons OAuth),providerType(id technique du connecteur FEAT-043 :google,edusign,hyperplanning,ical, etc. —nullsi pas de configurateur ; libellé FR dérivé côté front),countUnlinkedAssignments(nombre d’assignations iCal actives sans PMO / « à relier » ; remplace l’ancien booléenhasSynchonizedIcalError),lastImportErrorCode(enum string, présent seulement silastImportSuccessfulest false — libellés côté front), etstatus(enum métierok/conflict/error/nonecalculé côté backend :nonesi aucun configurateur,errorsi dernier import KO,conflictsi au moins une assignation active non reliée, sinonok). La règle est mutualisée dansDomain/Service/Dashboard/OrganizationStatusResolver. Compteur global inchangé :countSynchonizedIcalError.
Dashboard (autres)
GET /dashboard/trainer/income- Revenus formateurGET /dashboard/trainer/favorite-organizations- Organisations favoritesGET /dashboard/trainer/events- Événements formateurGET /dashboard/trainer/opportunity-proposals- Propositions d'opportunités
Calendrier / connecteurs (formateur, FEAT-043)
GET /trainer/calendar/configurator/{uuid}— réponse configurateur inclutproviderType(sélection hub),icalUrletdefaultPrice(tarif horaire par défaut du formateur :trainer_billing_settings.default_pricesi défini, sinon paramètre applicatiftrainer.default_hourly_price, défaut 35). Les imports iCal / réservation utilisent la même résolution viaTrainerDefaultHourlyPriceResolver.POST .../configurator/{organizationUuid}/ical/add— corps JSON :icalLink, optionnelproviderType; enregistre l’URL et le type sur le configurateur (jetons OAuth effacés si présents), renvoie un process, puis import asynchrone viaProcessTrainerCalendarConfiguratorSync(comme le callback OAuth).POST .../configurator/{organizationUuid}/ical/upload— multiparticsfile, optionnelproviderType; écrit le.icssur S3, met à jour le configurateur (ical_file, URL iCal effacée, jetons OAuth effacés), renvoie un process, puisProcessTrainerCalendarConfiguratorSync.POST /trainer/calendar/configurator/{uuid}/sync— relance d’import.POST /trainer/calendar/oauth/{provider}/authorize— API Platform (JWT formateur) : corps JSON{"organizationUuid":"<uuid>"}; réponse JSONauthorizationUrl. Fournisseurs :google,outlook,apple,edusign,hyperplanning,ypareo,ical(URL). Les stubs OAuth répondent 501 ;edusign/hyperplanning/ypareoexposent toutefois l’enrichissement iCal (parseurs PRODID via les stratégies connecteur (parseIcalVo/*IcalCalendarEventParser) sur le VOEvent, champsupportIcaldu hub inchangé côté API). Google / Outlook utilisentGOOGLE_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 ducode.POST /trainer/calendar/oauth/{provider}/calendar/list— API Platform (TrainerCalendarOAuthCalendarListProcessor) : JWT formateur ; corps{ "code": "…", "state": "…" }(ouerror). Échange le code contre des jetons sans les persister ; renvoie la liste des agendas (disabledsi 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 formateur —TrainerOAuthRemoteCalendarListConflictMarker, requêtes filtrées par formateur). Réponse JSONtokens+calendars.POST /trainer/calendar/oauth/{provider}/callback— API Platform (TrainerCalendarOAuthCallbackProcessor) : JWT formateur ; corps JSON{ "accessToken", "refreshToken?", "expiresAt?", "calendarId", "state" }ou{ "error" }. Vérifie le state signé, enregistre jetons +oauth_calendar_idsurcalendar_configurator, asyncProcessTrainerCalendarConfiguratorSync. Réponse 200ProcessResponse(comme iCal add / sync) :uuid,metadata(dontorganization_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 facturationGET /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 (inclutdefaultVatRate,defaultUnit,paymentTermappliqué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 viaInvoiceFinalizedEvent), 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 baseROLE_TRAINER: FormateurROLE_ORGANIZATION: OrganisationROLE_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_ACCOUNTest 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