Aller au contenu

API Platform - Documentation Technique

🎯 Vue d'ensemble

API Platform est utilisé dans Teadle pour exposer automatiquement les APIs REST avec une configuration personnalisée via des Resources, Processors et Providers custom.

🏗️ Architecture API Platform

graph TB
    subgraph "Client"
        A[Frontend Vue.js]
        B[Mobile App]
        C[Third Party]
    end

    subgraph "API Platform"
        D[API Resources]
        E[Custom Processors]
        F[Custom Providers]
        G[Serialization]
        H[Validation]
    end

    subgraph "Application Layer"
        I[Commands]
        J[Handlers]
        K[Application Services]
    end

    subgraph "Domain Layer"
        L[Entities]
        M[Value Objects]
        N[Domain Services]
    end

    A --> D
    B --> D
    C --> D

    D --> E
    D --> F
    E --> I
    F --> J

    I --> L
    J --> M
    K --> N

    style D fill:#e3f2fd
    style E fill:#e8f5e8
    style F fill:#fff3e0

📁 Structure des Resources

Organisation des Resources

src/Infrastructure/Api/Resource/
├── Security/
│   ├── PasswordResetResource.php
│   ├── MagicLinkResource.php
│   ├── TrainerActivationResource.php
│   ├── AuthCodeResource.php
│   ├── OAuthResource.php
│   ├── RegisterResource.php
│   ├── Request/
│   │   ├── PasswordResetRequest.php
│   │   ├── PasswordResetPerformRequest.php
│   │   └── ...
│   └── Response/
│       ├── LoginResponse.php
│       └── ...
├── Trainer/
│   ├── TrainerResource.php
│   ├── TrainerPreferencesResource.php
│   └── ...
├── Organization/
│   ├── OrganizationResource.php
│   ├── OrganizationPreferencesResource.php
│   └── ...
├── Dashboard/
│   ├── TrainerIncomeResource.php
│   ├── TrainerEventsResource.php
│   └── ...
└── ...

🔧 Resources Custom

1. Security Resources

PasswordResetResource

#[ApiResource(
    operations: [
        new Post(
            processor: PasswordResetRequestProcessor::class,
            input: PasswordResetRequest::class,
            uriTemplate: '/security/password-reset/request'
        ),
        new Put(
            processor: PasswordResetPerformProcessor::class,
            input: PasswordResetPerformRequest::class,
            uriTemplate: '/security/password-reset/perform'
        ),
    ]
)]
class PasswordResetResource
{
    public function __construct()
    {
    }
}

Caractéristiques : - Pas d'entité : Resource pure pour orchestration - Processors custom : Logique métier déléguée - Input validation : Classes de validation dédiées

AuthCodeResource

#[ApiResource(
    operations: [
        new Post(
            processor: AuthCodeSendProcessor::class,
            input: AuthCodeSendRequest::class,
            uriTemplate: '/security/auth-code/send'
        ),
        new Post(
            processor: AuthCodeVerifyProcessor::class,
            input: AuthCodeVerifyRequest::class,
            uriTemplate: '/security/auth-code/verify'
        ),
    ]
)]
class AuthCodeResource
{
    // Configuration pour les codes d'authentification
}

MagicLinkResource (login passwordless)

  • POST /security/magic-link/request : MagicLinkRequestProcessorRequestMagicLinkCommand ; input { "email" } ; 204 sans corps. Purpose LOGIN ; token brut généré par MagicLinkTokenGenerator (64 hex), hashé SHA-256 avant persistance ; email via EmailService::sendMagicLinkEmail (template Brevo magic_link_login #27, URL MAGIC_LINK_LINK?magic_link_token=…). Rotation : MagicLinkRequestRepository::rotateToken (TTL 1 h).
  • PUT /security/magic-link/perform : MagicLinkPerformProcessorPerformMagicLinkCommand ; input { "token" } ; cookies JWT httpOnly. Consommation atomique via consumeValidToken(tokenHash, LOGIN) ; erreurs distinctes si token expiré / déjà utilisé (findOneByTokenHash).

TrainerActivationResource (FEAT-187)

#[ApiResource(
    operations: [
        new Post(
            processor: TrainerActivationRequestProcessor::class,
            input: TrainerActivationRequest::class,
            output: false,
            status: Response::HTTP_OK,
            uriTemplate: '/security/trainer/activate',
        ),
        new Post(
            processor: TrainerActivationVerifyProcessor::class,
            input: TrainerActivationVerifyRequest::class,
            output: OAuthLoginToken::class,
            uriTemplate: '/security/trainer/activate/verify',
        ),
    ]
)]
class TrainerActivationResource {}
  • POST /security/trainer/activate : RequestTrainerActivationCommand — crée ou reprend un formateur non vérifié (CreateNewTrainerCommand::processActivation, password NULL, CGV acceptées) ; envoie l’email d’activation (MagicLinkPurpose::TRAINER_ACTIVATION, TTL 15 min, template Brevo trainer_activation #28, lien TRAINER_ACTIVATION_LINK?token=…). Anti-énumération : 200 identique si email déjà vérifié ou compte non-formateur existant.
  • POST /security/trainer/activate/verify : PerformTrainerActivationCommand + CreateAccessTokenCommand ; input { "token", "password"? } (password optionnel, min 8 car.) ; active le compte (verifiedEmail, slug via TrainerSlugGenerator), mot de passe hashé si fourni, dispatch TrainerRegisteredEvent ; réponse 200 + cookies (CookieTokenResponseManager). Token invalide / expiré → 400.

2. User Management Resources

TrainerPreferencesResource

#[ApiResource(
    operations: [
        new Put(
            processor: TrainerPreferencesProcessor::class,
            input: TrainerPreferencesRequest::class,
            uriTemplate: '/trainer/updatePreferences'
        ),
    ]
)]
class TrainerPreferencesResource
{
    // Mise à jour des préférences formateur
}

OrganizationPreferencesResource

#[ApiResource(
    operations: [
        new Put(
            processor: OrganizationPreferencesProcessor::class,
            input: OrganizationPreferencesRequest::class,
            uriTemplate: '/organization/updatePreferences'
        ),
    ]
)]
class OrganizationPreferencesResource
{
    // Mise à jour des préférences organisation
}

3. Company & Facturation (Trainer)

Company (GET / PUT /trainer/company)

  • GET : retourne l’entreprise du formateur (Provider + TrainerCompanyResponse) : identité (dont nda — numéro de déclaration d’activité, 11 chiffres sans espaces) et infos bancaires (IBAN, BIC), emails / téléphone de contact.
  • PUT : met à jour via UpdateTrainerCompanyProcessor (identité + bancaire). Champ optionnel nda : 11 chiffres (espaces tolérés en entrée, normalisés sans espaces — validation TrainerCompanyUpdateRequest) ; null ou chaîne vide autorisés. Champ optionnel phone : validé et stocké en E.164 (PhoneNumberStringNormalizer).

Documents entreprise (/trainer/company/documents, FEAT-033)

  • GET /trainer/company/documents : TrainerCompanyDocumentProviderListTrainerCompanyDocumentsHandler (Application) → DTOs TrainerCompanyDocumentItemDto mappés en TrainerCompanyDocumentResponse (list).
  • POST /trainer/company/documents/upload : UploadTrainerCompanyDocumentProcessorUploadTrainerCompanyDocumentCommand (multipart document, PDF/PNG/JPEG, max 5 Mo). Exceptions upload / MIME / taille (TrainerDocumentUpload*) → 400 ; erreurs lecture fichier locale ou écriture stockage (TrainerDocumentStorageReadException, TrainerDocumentStorageWriteException) → 500.
  • PUT /trainer/company/documents/{uuid} : UpdateTrainerCompanyDocumentProcessor → validation UpdateTrainerCompanyDocumentRequest puis UpdateTrainerCompanyDocumentCommand (actuellement : displayName). TrainerDocumentInvalidUuidException400 ; TrainerDocumentNotFoundException404.
  • DELETE /trainer/company/documents/{uuid} : DeleteTrainerCompanyDocumentProcessorDeleteTrainerCompanyDocumentCommand. TrainerDocumentInvalidUuidException400 ; TrainerDocumentNotFoundException404.

Billing settings — FEAT-017, BUG-031, UX-039, FEAT-267

  • GET /trainer/billing/settings : paramètres de facturation (TrainerBillingSettingsResponse). Le Provider délègue à GetTrainerBillingSettingsHandler (Application) qui charge ou crée l’entité, calcule prévisualisations et variables de numérotation (InvoiceNumberGenerator), résout l’URL publique du logo via StorageUrlResolverInterface (logoUrl dans le read model) ; TrainerBillingSettingsResponseMapper ne fait que mapper TrainerBillingSettingsReadModelTrainerBillingSettingsResponse. Champs : tarifs par défaut (defaultPrice, defaultVatRate, defaultUnit, paymentTerm) ; numérotation facture (art. 289 CGI) : invoicePrefixTemplate, invoiceNextSequence, invoiceIsLocked, invoicePreview, invoiceAvailableVariables ; numérotation CRA : activityReportPrefixTemplate, activityReportNextSequence, activityReportPreview ; numérotation avoir (FEAT-267) : creditNotePrefixTemplate, creditNoteNextSequence, creditNoteIsLocked, creditNotePreview ; personnalisation documents : accentColor, fontSize (small | normal | large, enum BillingDocumentFontSize) ; mentions légales structurées : legalMentionsPenalties, legalMentionsConfidentialityEnabled, legalMentionsConfidentiality, legalMentionsCustomEnabled, legalMentionsCustom ; email factures : invoiceEmailBody ; coordonnées facturation optionnelles : billingName, billingEmail, billingPhone (validé et normalisé en E.164 via PhoneNumberStringNormalizer / libphonenumber, comme phone sur PUT entreprise) ; logo : logoUuid, logoUrl (URL publique résolue depuis le stockage S3).
  • PUT /trainer/billing/settings : le Processor appelle UpdateTrainerBillingSettingsCommand::process (tous les champs en paramètres) ; retourne un TrainerBillingSettingsReadModel via GetTrainerBillingSettingsHandler. Validation accentColor en #RRGGBB ; préfixes facture et avoir avec (annee) ; 422 si séquence facture ou avoir verrouillée et invoiceNextSequence / creditNoteNextSequence modifié, ou template invalide.
  • POST /trainer/billing/settings/logo : UploadTrainerBillingLogoCommand (upload S3 + read model) ; multipart/form-data (PHP ne remplit $_FILES qu’avec POST, pas PUT), clé logo (PNG, JPEG, GIF, WebP, max 2 Mo) ; chemin S3 {trainerUuid}/billing/logo/{uuid}. Fichier absent, MIME ou taille invalide : exceptions Domain TrainerBillingLogoMissingFileException, TrainerBillingLogoInvalidMimeTypeException, TrainerBillingLogoFileTooLargeException400 (BadRequestHttpException) dans UploadTrainerBillingLogoProcessor.
  • DELETE /trainer/billing/settings/logo : DeleteTrainerBillingLogoCommand (suppression fichier S3 + entité) ; réponse 204 No Content, corps vide.

Annexes facturation — documents (TrainerDocumentBillingAttachment, FEAT-033)

  • GET /trainer/billing/documents : TrainerDocumentBillingAttachmentProviderListTrainerBillingDocumentsHandler → DTOs TrainerBillingDocumentItemDtoTrainerDocumentBillingAttachmentCollectionResponse (items : métadonnées + champs hérités de Document, dont createdAt / updatedAt ISO 8601).
  • POST /trainer/billing/documents/upload : UploadTrainerDocumentBillingAttachmentProcessorUploadTrainerDocumentBillingAttachmentCommand ; multipart document (PDF, PNG, JPEG, max 5 Mo), optionnel documentType, includedByDefault, displayName ; chemin S3 {trainerUuid}/billing/documents/. Même schéma d’exceptions Domain / HTTP que les documents entreprise (upload + stockage).
  • PUT /trainer/billing/documents/{uuid} : UpdateTrainerDocumentBillingAttachmentProcessor → validation UpdateTrainerDocumentBillingAttachmentRequest puis UpdateTrainerDocumentBillingAttachmentCommand. TrainerDocumentInvalidUuidException / TrainerDocumentBillingAttachmentTypeInvalidException400 ; TrainerDocumentNotFoundException404.
  • DELETE /trainer/billing/documents/{uuid} : DeleteTrainerDocumentBillingAttachmentProcessorDeleteTrainerDocumentBillingAttachmentCommand ; suppression fichier + entité ; 204 ; uuid invalide 400, introuvable 404.

Modèles de facturation (BillingTemplate, FEAT-026/027)

  • Entity : Domain/Entity/Invoicing/BillingTemplate (uuid, trainer nullable pour les modèles système Teadle, name, type enum DocumentType = INVOICE | ACTIVITY_REPORT, isDefault, active (bool — modèles désactivés exclus du listing et du GET), isSystem, useBillingSettingsAppearance (bool, défaut true), settings JSON, logo_url URL publique du fichier optionnelle, timestamps). Fichiers toujours écrits sous {trainerUuid}/billing/templates/{templateUuid}/ ; l’URL résolue est persistée en base.
  • GET /trainer/billing/templates : BillingTemplateListProviderListBillingTemplatesHandlerBillingTemplateCollectionResponse (items ; uniquement modèles actifs côté trainer + modèles système ; chaque item inclut active, logoUrl résolu si défini).
  • GET /trainer/billing/template/{uuid} : BillingTemplateItemProvider ; récupère un modèle unique (trainer actif ou système). 404 si UUID connu mais modèle trainer désactivé. Inclut billingAppearanceDefaults (accentColor, fontSize, logoUrl depuis les paramètres facturation du formateur) pour l’éditeur sans appeler GET /trainer/billing/settings.
  • POST /trainer/billing/templates : CreateBillingTemplateProcessor (CreateBillingTemplateRequest) ; création modèle trainer, support isDefault + useBillingSettingsAppearance (défaut true).
  • PUT /trainer/billing/templates/{uuid} : UpdateBillingTemplateProcessor (UpdateBillingTemplateRequest) ; modification name / settings / useBillingSettingsAppearance et promotion défaut optionnelle.
  • POST /trainer/billing/templates/{uuid}/logo : UploadBillingTemplateLogoProcessorUploadBillingTemplateLogoCommand ; multipart logo (PNG, JPEG, GIF, WebP, max 2 Mo), même règles que /trainer/billing/settings/logo ; réponse BillingTemplateResponse (dont logoUrl).
  • DELETE /trainer/billing/templates/{uuid}/logo : DeleteBillingTemplateLogoProcessorDeleteBillingTemplateLogoCommand ; supprime le fichier + logo en base ; réponse BillingTemplateResponse (logoUrl null).
  • DELETE /trainer/billing/templates/{uuid} : DeleteBillingTemplateProcessor → désactivation logique (active = false), retrait du flag défaut si besoin, 204 ; interdit pour modèle système (422). Pas de suppression de ligne ni du fichier logo.
  • POST /trainer/billing/templates/{uuid}/duplicate : DuplicateBillingTemplateProcessor ; copie trainer ((copie), non défaut, non système).
  • POST /trainer/billing/templates/{uuid}/set-default : SetDefaultBillingTemplateProcessor ; reset + activation (1 défaut par type côté trainer) ; réponse BillingTemplateCollectionResponse (liste complète des modèles, même forme que GET /trainer/billing/templates) pour éviter un second appel.
  • POST /trainer/billing/templates/{uuid}/preview : PreviewBillingTemplateProcessor (validation + existence du modèle) → PreviewBillingTemplateHandler ; input PreviewBillingTemplateRequest : documentType obligatoire (INVOICE | ACTIVITY_REPORT), name, settings (validation unifiée avec la création : BillingTemplateRequestValidation + BillingTemplateSettingsValidator sur PreviewBillingTemplateRequest422 ; ex. style.accentColor, style.fontSize, style.logoUrl pour l’aperçu) ; optionnel : craPeriodInputSlot (bool) — si true en CRA (ACTIVITY_REPORT), l’aperçu inclut l’id #cra-period-input-slot sur la ligne « Période » (SPA CreateCRAView via Teleport ; l’éditeur de modèles ne l’envoie pas). Les données document sont construites côté serveur (BillingTemplatePreviewDocumentDataFactory, paramètres billing_template_preview.invoice / billing_template_preview.activity_report dans config/packages/billing_template_preview.yaml, BillingTemplatePreviewPartyDataProvider) ; le flag y ajoute _craPeriodInputSlot pour le compilateur. CRA : PreviewActivityReportBillingTemplateRenderCompiler + template Twig billing_template_preview.html.twig (récap sans accent, colonne Source forcée). Clés settings autorisées selon le type : facture (InvoiceBillingTemplateSettingKey) vs CRA (ActivityReportBillingTemplateSettingKey) — ex. CRA : activityReport.showModules / showSessions, signature.show / trainerLabel / clientLabel, table.columnLabels. Réponse PreviewBillingTemplateResponse (html, settings).

Config facturation (GET /trainer/invoices/config)

  • Réponse : RetrieveInvoiceConfigHandlerInvoiceConfigResponse avec deux objets distincts :
  • company (CompanyInfoDTO) : identité (nom, SIRET, adresse), bancaire (IBAN, BIC), et champs de présentation facture issus uniquement de TrainerBillingSettings : defaultLegalMentions (texte composé via BillingLegalMentionsComposer), emailTemplate (= invoiceEmailBody), logoUrl (URL S3 du logo billing).
  • billing (BillingConfigDTO) : defaultPrice, defaultVatRate, defaultUnit, paymentTerm.
  • Plus : organizations, products.
  • Bandeau document (facture / CRA / avoir) : listes invoiceTemplates et activityReportTemplates (chacun avec uuid, name, isDefault, settings), prévisualisations de prochain numéro (invoiceNumberPreview, activityReportNumberPreview, creditNoteNumberPreview — FEAT-267) et documentNumberingPreviewHint (rappel : numéro définitif à la finalisation côté facture ; côté CRA le code peut être attribué à la création selon la numérotation — le hint reste la source de vérité UX).

Aperçu HTML de facture (POST /trainer/invoices/preview)

Nouvel endpoint déclaré sur TrainerInvoiceResource : génère un rendu HTML de la facture sans persister de données.

  • Input PreviewInvoiceRequest : organizationUuid, billingTemplateUuid, lineItems[] (PreviewInvoiceLineItemRequest : designation, quantity, unit, unitPrice, discountPercent (0–100), vatRate, etc.), documentData, code, issueDate, dueDate, subjectTitle, subjectSubtitle, legalMentions (optionnel, texte facture : TVA 293 B, délai, etc.) — rendu en pied d’aperçu avec les blocs TrainerBillingSettings (pénalités si texte non vide, confidentialité / texte perso si activés).
  • Output PreviewInvoiceHtmlResponse : { "html" }.
  • Processor : PreviewInvoiceProcessorPreviewInvoiceHtmlHandler.
  • Handler : charge l'organisation, le template et les paramètres de facturation du formateur ; construit le payload via BillingDocumentTemplatePdfDataFactory::buildForInvoicePreview() (flags SPA + invoiceBodyLegalMentions, invoiceBillingLegalPenalties, invoiceBillingLegalConfidentiality, invoiceBillingLegalCustom pour le bloc .bp-invoice-legal-stack sous le pied de modèle dans billing_template_preview.html.twig) puis appelle PreviewInvoiceBillingTemplateRenderCompiler. Ce compilateur bypasse les fixtures YAML billing_template_preview.invoice si _invoiceSpaRealDataOnly et transmet les flags SPA au template Twig.
  • Slots SPA injectés dans le HTML rendu (cibles <Teleport> côté Vue) : #invoice-client-input-slot, #invoice-meta-issue-date-slot, #invoice-meta-due-date-slot, #invoice-custom-fields-slot, #invoice-subject-slot, #invoice-line-items-tbody-spa, #invoice-line-items-add-button-slot, #invoice-totals-slot, #invoice-payment-delay-slot (bloc Paiement / ligne Délai), #invoice-footer-custom-slot (pied « custom » uniquement).
  • Sans organizationUuid, l'aperçu s'affiche sans client (slot client vide).

Création de facture

  • Lors de la création d’une facture, les paramètres par défaut de l’entreprise (TVA, unité, délai de paiement) sont appliqués à la nouvelle facture.

Liste factures (GET /trainer/invoices)

  • Réponse : TrainerInvoicesDTO (liste paginée). Chaque élément (TrainerInvoiceResponse) inclut title, issueDate, dueDate, code, createdAt, sent, organizationName, amount (TTC en centimes, int — intangible vis-à-vis des avoirs), status, creditNotes[] (avoirs FINALIZED imbriqués : uuid, publicNumber, reason, totalCents positif, createdAt), hasDraftCreditNote (drapeau brouillon, FEAT-276), etc. (UX-034, UX-040, FEAT-273).
  • Résumé KPI (summary) : pendingAmount, overdueAmount, paidThisMonthAmount en TTC net des avoirs finalisés (brouillons exclus).
  • Client affiché après fusion (DISC-029 / RM20) : organizationName / organizationUuid sont ceux du rattachement retenu si l’organisation historique de la facture a été absorbée pour ce formateur (InvoiceRepository::resolveCurrentClientsForTrainer + TrainerOrganizationAssignment::resolveSurvivor(), y compris chaîne de tombstones). La facture n’est pas réécrite (RM35). Même résolution sur GET /trainer/credit-notes.

Création d'un avoir depuis une facture (POST /trainer/invoices/{uuid}/credit-note) (FEAT-268)

  • Corps : CreateCreditNoteRequestreason obligatoire (motif, max 2000 car.).
  • Réponse : CreditNoteResponseuuid, publicNumber (publicId tant que code est null), status (DRAFT), issueDate (Y-m-d ou null tant que non finalisé, BUG-284), lignes pré-remplies (avoir total), totaux en centimes, snapshot legalMentions de la facture. Chaque ligne inclut productUuid, title, quantity, unit, unitPrice, discountPercent, vatRate, totalHT, interventionDate, className.
  • 422 : facture brouillon, motif manquant, ou brouillon d'avoir déjà existant pour cette facture.
  • 403 / 404 : facture d'un autre formateur / introuvable.

Édition d'un brouillon d'avoir (PUT /trainer/credit-notes/{uuid}) (FEAT-271)

  • Corps : UpdateCreditNoteRequestreason obligatoire (max 2000 car.), lineItems[] (remplacement intégral, min 1 ligne). Chaque ligne : productUuid (obligatoire), quantity (> 0), unit, unitPrice (centimes), discountPercent, vatRate, interventionDate, className. Pas de champ désignation : le libellé vient du produit (POST /trainer/invoices/product pour un produit nouveau).
  • Réponse : CreditNoteResponse à jour (updatedAt renseigné).
  • 422 : avoir finalisé, motif vide, zéro ligne, produit inconnu, unité ou TVA invalide.
  • 403 / 404 : avoir d'un autre formateur / introuvable.

Finalisation d'un avoir (POST /trainer/credit-notes/{uuid}/finalize) (FEAT-269)

  • Corps : aucun (finalisation sans payload).
  • Réponse : CreditNoteResponsestatus: FINALIZED, code de la série AV- (FEAT-267).
  • 422 : avoir déjà finalisé, sans ligne, sans motif, montant nul ou négatif.
  • 409 : montant dépassant le reste créditable (message avec montants réels TTC).
  • 403 / 404 : avoir d'un autre formateur / introuvable.
  • Atomicité : verrou pessimiste sur la facture créditée pendant contrôle + écriture ; CreditNoteFinalizedEvent dispatché après commit.

Reste créditable (GET /trainer/invoices/{uuid}/creditable-balance) (FEAT-269)

  • Réponse : CreditableBalanceResponseinvoiceTotalCents, alreadyCreditedCents (avoirs FINALIZED uniquement), creditableBalanceCents (TTC, jamais négatif), fullyCredited.
  • 403 / 404 : facture d'un autre formateur / introuvable.

Liste des avoirs d'une facture (GET /trainer/invoices/{uuid}/credit-notes) (FEAT-269)

  • Réponse : CreditNoteResponse[] — brouillons inclus côté formateur.
  • 403 / 404 : facture d'un autre formateur / introuvable.

Recensement autonome des avoirs (GET /trainer/credit-notes) (FEAT-275)

  • Provider : TrainerCreditNotesProviderRetrieveTrainerCreditNotesHandlerCreditNoteToListDTOBuilder ; persistance via CreditNoteRepository::findAndCountFinalizedByTrainer et sumFinalizedTotalCentsByTrainer (même query builder filtré pour liste et somme KPI).
  • Périmètre : avoirs FINALIZED du formateur connecté uniquement (brouillons exclus).
  • Query : pagination page, limit (défaut 30 côté provider). Filtres (filters) : organizationUuids[], emission (current_month, last_7_days, last_month, current_quarter, current_year, last_year — sur COALESCE(issueDate, createdAt)), search (numéro avoir code / publicId, motif, numéro facture créditée).
  • Tri : COALESCE(issueDate, createdAt) DESC, puis id DESC.
  • Réponse : TrainerCreditNotesResponsesummary (TrainerCreditNotesSummaryResponse : totalCreditedCents seul, TTC positif, agrégé sur le périmètre filtré), data[] (TrainerCreditNoteResponse : uuid, publicNumber, code, issueDate, organizationName, organizationUuid, reason, totalCents, invoiceUuid, invoicePublicNumber), total, page, limit. Le nombre d'avoirs = champ racine total (absent du summary). Après fusion (RM20), organizationName / organizationUuid suivent le client retenu — même DQL que GET /trainer/invoices.
  • Distinct de GET /trainer/invoices/{uuid}/credit-notes (FEAT-269, par facture, brouillons inclus) et des lignes imbriquées creditNotes[] dans GET /trainer/invoices (FEAT-273).

PDF d'un avoir (GET /trainer/credit-notes/{uuid}/pdf) (FEAT-277)

  • Provider : CreditNotePDFProviderGenerateCreditNotePdfHandler.
  • Réponse : binaire application/pdf, Content-Disposition: attachment; filename="{publicNumber}.pdf".
  • Comportement : brouillon → génération Dompdf à la volée (sans écriture S3) ; finalisé → lecture S3 ({trainerUuid}/billing/credit-notes/{creditNoteUuid}), génération paresseuse si absent.
  • Finalisation : CreditNoteFinalizedEventSubscriber persiste le PDF sur S3 après commit (idempotent).
  • 403 / 404 : avoir d'un autre formateur / introuvable (exceptions HTTP héritées FEAT-269, sans mapping).

Aperçu HTML d'un avoir en cours de saisie (POST /trainer/invoices/{uuid}/credit-notes/preview) (FEAT-277)

  • Corps : PreviewCreditNoteRequestlineItems[] (même forme que l'aperçu facture : designation, quantity, unit, unitPrice en euros flottants, discountPercent, vatRate, etc.), code?, issueDate?, documentData?, legalMentions? (snapshot D3).
  • Réponse : PreviewCreditNoteHtmlResponse{ "html" } (mode NON-SPA, sans slots Teleport).
  • Rendu : titre « Avoir », mention « Avoir concernant la facture {numéro} du {date} », montants affichés en négatif (données positives R-AV1), pas d'échéance ni bloc Paiement ni pénalités L441-10.
  • Numéro affiché : code si fourni, sinon aperçu du prochain AV- (InvoiceNumberGenerator::previewCreditNote).
  • 400 : corps invalide ; 403 / 404 : facture créditée d'un autre formateur / introuvable.

Détail facture (GET /trainer/invoices/{uuid})

  • Réponse : InvoiceDetailDTOInvoiceDetailResponse : publicNumber, uuid, dates, paymentTerm (valeur enum PaymentTerm copiée depuis les paramètres entreprise à chaque enregistrement), titre, description, mentions légales, statut, paidAt, organization, lineItems (chaque ligne : discountPercent, totalHT après remise, etc.), et pour réhydrater l’éditeur : billingTemplateUuid, documentData (JSON contrôlé côté serveur : champs libres, texte de pied de page personnalisé, etc.).

Génération d’une facture depuis un CRA (POST /trainer/invoices/generate/activity-report/{uuid}) (BUG-034)

  • Corps (optionnel) : { "force": true }. Si le CRA a déjà des factures finalisées, la génération est refusée sauf si force: true (confirmation explicite).
  • Réponse : uuid de la facture créée, et éventuellement hasExistingInvoices, existingInvoiceCodes (pour affichage d’un warning côté frontend).
  • Relation CRA–Facture : ManyToOne (un CRA peut générer plusieurs factures, ex. correction). Les listes CRA exposent désormais linkedInvoices (tableau) au lieu de linkedInvoice (objet unique). Les factures en liste exposent linkedActivityReportCode (code du CRA lié).
  • Détail CRA (draft) : La réponse de récupération/édition d’un CRA (activityReport.lineItems) expose pour chaque ligne eventAlreadyInvoiced (booléen) et class_name (champ libre classe/groupe), afin d’afficher un avertissement côté frontend avant génération de facture si des interventions sont déjà facturées.
  • Aperçu HTML édition CRA : POST /trainer/activity-reports/previewPreviewActivityReportRequest : organizationUuid est optionnel (requis seulement pour l’en-tête client alimenté par l’organisation : sinon aperçu sans client : client vide, marqueur _craPreviewWithoutOrganization + bandeau d’info dans le Twig) ; champs requis ailleurs : month, year, lineItems (incluant class_name) ; billingTemplateUuid, documentData ; code optionnel (sinon prochain numéro via InvoiceNumberGenerator::previewActivityReport avec période month/year, comme ailleurs). La page CreateCRAView n’appelle jamais POST /trainer/billing/templates/{uuid}/preview (seulement l’éditeur de modèles, etc.). Dès l’ouverture de l’écran, un premier POST …/preview est possible sans organizationUuid pour l’aperçu « brouillon sans client ». PreviewActivityReportHtmlHandler alimente le moteur Twig sans fusion des fixtures YAML d’billing_template_preview.activity_report : le document est construit à partir des données formateur + client (org) ou client vide + saisie (_craEditorRealDataOnly côté BillingDocumentTemplatePdfDataFactory / PreviewActivityReportBillingTemplateRenderCompiler). Le HTML d’aperçu inclut des repères data-bp="…" (ex. cra-root, cra-issuer, cra-client, cra-period-slot, cra-line-items, cra-footer…) et data-editable pour l’inline-edit. Le code du CRA en en-tête est un input readonly (cra-code-value / bp-cra-code-input). Réponse PreviewActivityReportHtmlResponse : { "html" }.
  • Métadonnées CRA en tête de réponse (GenerateActivityReportResponse, ex. GET /trainer/activity-reports/{uuid}) : status (DRAFT | SENT), billingTemplateUuid, documentData (même principe que facture).
  • PUT /trainer/activity-reports/{uuid} : UpdateActivityReportRequest accepte en plus des lignes les champs optionnels billingTemplateUuid et documentData.
  • Envoi email CRA POST /trainer/activity-reports/{uuid}/send (multipart, deserialize: false) : champs recipient, cc, receiveCopy, subject, message, fichier optionnel attachment, et attachmentIds[] (répétition de clés ou JSON dans attachmentIds) pour les annexes facturation (GET /trainer/billing/documents, type cra). Le PDF du CRA est toujours joint (plus de paramètre includePDF). Ordre des pièces jointes : PDF CRA → annexes configurées → upload manuel. Annexe introuvable ou mauvais type → 400.
  • Envoi email facture POST /trainer/invoices/{uuid}/send : même principe ; PDF facture toujours joint ; attachmentIds[] pour types invoices_and_credits, invoices ou credit_notes.

⚙️ Custom Processors

1. Security Processors

PasswordResetRequestProcessor

class PasswordResetRequestProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly RequestPasswordResetCommand $requestPasswordResetCommand,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        // 1. Validation des données d'entrée
        $this->validator->validate($data);

        // 2. Appel de la commande métier
        $this->requestPasswordResetCommand->process($data->email);

        // 3. Retour null (pas de réponse)
        return null;
    }
}

Flux de traitement : 1. Validation : Vérification des contraintes 2. Orchestration : Appel de la commande métier 3. Réponse : Retour null (pas de données)

PasswordResetPerformProcessor

class PasswordResetPerformProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly PerformPasswordResetCommand $performPasswordResetCommand,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        $this->validator->validate($data);

        $this->performPasswordResetCommand->process(
            $data->token,
            $data->password
        );

        return null;
    }
}

2. Trainer organization contacts

Catalogue des rôles (GET /trainer/organization/contact/roles) — FEAT-248

  • Auth : formateur (requireTrainer()).
  • Réponse : ContactRolesResponse{ roles: ["ROLE_BILLING", "ROLE_CALENDAR", "ROLE_AGENDA", "ROLE_COMPLIANCE"] } (valeurs enum uniquement ; libellés FR côté front via constants/contactRoles.js).
  • SSOT métier : dérivé de TrainerOrganizationContactRoleType ; assignables en multi-rôle sur un contact.

InviteContactToTeadleProcessor

Envoi d’un email d’invitation simple (Brevo, template 19) à un contact non relié à un staff Teadle. Pas de token ni suivi de statut.

  • Endpoint : POST /trainer/organization/{uuid}/contact/{uuid_contact}/invite-teadle
  • Réponse : 201 Created avec InviteContactToTeadleResponse (invited: true)
  • Flux : Processor → InviteContactToTeadleCommandEmailService::sendInviteContactToTeadleEmail() (template 19, params firstname, lastname ; dispatch async via MessageBus en interne)
  • Erreurs : 404 (org/contact non trouvé), 409 (contact déjà membre Teadle), 403 (contact n’appartient pas à l’organisation du formateur)

Liste groupée des contacts (GET /trainer/organization/contact/list)

  • Chaque contact est sérialisé en TrainerOrganizationContactResponse : uuid reste l’identifiant du TrainerOrganizationContact ; lorsque isTeadleMember est true, staffUserUuid expose l’UUID User du staff (à utiliser comme participantUuid pour POST /conversations).

Intervenants associés (GET /staff/associated-trainers)

  • Chaque entrée AssociatedTrainerResponse inclut uuid : UUID User de l’intervenant (formateur), pour la messagerie et les écrans qui référencent l’utilisateur plutôt que l’email.

Hub conformité intervenants (GET /staff/associated-trainers/compliance) — FEAT-249

  • Auth : staff (requireStaff()).
  • Réponse : tableau de StaffComplianceIntervenantResponse — tous les intervenants associés, annotés conformité + planning.
  • Gate : canViewCompliance = true si au moins un contact staff porte ROLE_COMPLIANCE pour cet intervenant ; sinon complianceStatus absent (null sérialisé).
  • complianceStatus : enum anglais StaffComplianceIntervenantStatus (compliant, expiring_soon, non_compliant, pending) — agrégé depuis les pièces partagées actives (FEAT-242) ; pending sans partage actif ou sans pièce.
  • honorabilityVerifiableUntil : Y-m-d (FEAT-245), gaté COMPLIANCE ; null si références non renseignées.
  • planningShareUuid / planningShareExpired : dérivés du contact ROLE_AGENDA (indépendant du gate conformité).
  • presence : enum StaffIntervenantPresence (active, paused, departed) — dérivé du seul AccountStatus du formateur (FEAT-231) ; DELETION_PENDING est collapsé en paused côté serveur.
  • departedDate : date de départ au format d/m/Y, lue depuis le snapshot departed_at du lien Organisation (null si actif).
  • invoicesCount : int — nombre cumulé de factures envoyées (SENT + PAID, FEAT-252 SENT_STATUSES) par l’intervenant aux organisations du staff (isolation O8, requête groupée anti-N+1) ; non gaté par canViewCompliance (FEAT-259).
  • firstname / lastname : pour un intervenant parti (presence: departed), lus depuis le snapshot departed_trainer_* du lien (R23) — pas depuis le profil Trainer scrubé.

Drawer dossier conformité intervenant (FEAT-250)

Gate partagé StaffComplianceAccessResolver : ROLE_COMPLIANCE + partage actif (FEAT-242) + propriété intervenant → sinon 404 neutre (anti-IDOR).

Méthode URI Description
GET /staff/associated-trainers/{trainerUuid}/compliance/documents Liste pièces + catalogue types + honorabilité (4 refs FEAT-245) + nda (FEAT-263 — NDA formateur, nature « numéro »). Query optionnelle actorLabel (max 255) — journal C5 VIEW honorabilité si refs présentes.
GET /staff/associated-trainers/{trainerUuid}/compliance/documents/{documentUuid}/content Binaire intact (mode=view\|download, query actorLabel).
GET /staff/associated-trainers/{trainerUuid}/compliance/export ZIP dossier (_archivees/ pour expirées). Query actorLabel.
  • actorLabel : fourni par le frontend (Prénom NOM (Organisation)) pour le journal TrainerComplianceAccessLog (FEAT-242).
  • Pas de POST honorabilité/consult : la consultation des références est journalisée au GET documents lorsque honorability est renseignée et actorLabel non vide.
  • Fichiers servis sans filigrane (documents officiels intacts).

Demandes de pièces (FEAT-251)

Méthode URI Description
POST /staff/associated-trainers/{trainerUuid}/compliance/requests C1 documentUuid (renouvellement) ou C2 requestedType / otherLabel + message optionnel → DocumentRequestNotification (204). Gate identique FEAT-250.
GET /staff/compliance/document-catalog Catalogue types par catégorie (labels inclus) pour le mini-drawer « Demander une pièce ».
  • Payload notification : orgName, requestedById (staff, retour C4 FEAT-241), message / otherLabel optionnels.
  • Validation : exactement un mode parmi documentUuid (C1), requestedType (C2 type connu) ou otherLabel (C2 « Autre ») — combinaisons → 400 (ComplianceRequestAmbiguousException) ; uuid/type invalides → 400 (ComplianceRequestInvalidException) ; pièce absente → 404.
  • DocumentRequestNotificationRepositoryInterface::save() (additif FEAT-241).

Consultation factures organisation (/staff/invoices) — FEAT-252, FEAT-278

Méthode URI Description
GET /staff/invoices Liste paginée + summary KPI + facette intervenants. Filtres : statuses[] (PAID\|PENDING\|OVERDUE effectifs), trainerUuids[], emissionFrom / emissionTo, includeInactive (défaut true), q. Tri : intervenant\|numero\|periode\|montant\|statut. Gate : membership organisation (pas rôle COMPLIANCE). Chaque facture inclut creditNotes[] (avoirs FINALIZED : uuid, publicNumber, reason, issueDate, totalCents).
GET /staff/invoices/{uuid}/pdf PDF stocké S3 (Content-Disposition attachment). 404 si hors périmètre org ou statut non visible (DRAFT/FINALIZED exclus).
GET /staff/invoices/export?format=csv\|pdf Export du périmètre filtré. CSV : BOM UTF-8, séparateur ;, 9 colonnesIntervenant, Type (Facture | Avoir), Numero, Emise le, Periode (facture = Y-m ; avoir = motif), Montant HT, TVA, Montant TTC, Statut (avoir : vide). Les lignes avoir suivent leur facture ; montants avoir en négatif. PDF (format=pdf) : ZIP (application/zip) des PDF factures et avoirs du périmètre (max 200 documents). Filtre export filters[type]=creditNotes : périmètre avoirs seuls (CSV / ZIP AV uniquement).
GET /staff/credit-notes Recensement autonome des avoirs FINALIZED reçus par les organisations du staff (FEAT-278). Provider : StaffCreditNotesProviderListStaffCreditNotesHandler. Pagination page, limit (défaut 30). Filtres : trainerUuids[], emissionFrom / emissionTo, includeInactive (défaut true), q (numéro avoir, motif, numéro facture créditée, nom intervenant). Tri : COALESCE(issueDate, createdAt) DESC, id DESC. Réponse StaffCreditNotesResponse : summary (count, totalCreditedCents), data[] (StaffCreditNoteResponse + facture créditée imbriquée), total, page, limit.
GET /staff/credit-notes/{uuid}/pdf PDF avoir (StaffCreditNotePDFProvider → S3 / génération paresseuse). 404 si brouillon, hors périmètre org ou intervenant archivé.
  • Statuts UI (StaffInvoiceUiStatus) : PAID, PENDING (SENT non échue), OVERDUE (SENT échue) — dérivés de Invoice::getEffectiveStatus().
  • KPI nets (FEAT-278) : agrégats sur le périmètre filtré (hors filtre statut pour les cartes ; filtre statut pour filtered*) — montants TTC nets des avoirs finalisés (totalAmountCents, paidAmountCents, pendingAmountCents, overdueAmountCents, filteredTotalCents). Champs dédiés avoirs : creditedTotalCents, creditNotesCount, filteredCreditedCents, filteredCreditNotesCount.
  • grandTotal = nombre total de factures envoyées sans filtre (discriminateur états vides).
  • Contrat front : ?trainer=<uuid> pré-filtre l’intervenant (depuis drawer conformité FEAT-250).

Réservations planning public/staff — idempotence (BUG-041)

  • Endpoints concernés :
  • PUT /public/planning/{uuid}/reservations
  • PUT /staff/associated-trainers/planning/reservations
  • Le header Idempotency-Key est obligatoire. Sans ce header, le processor renvoie 400 Bad Request.
  • Le backend exige le header Idempotency-Key et considère la clé comme identifiant unique d’une tentative de soumission.
  • Si la clé existe déjà, la commande retourne le batch déjà créé (pas de recréation).
  • Les commandes de création encapsulent désormais persist + dispatch event dans une transaction Doctrine (wrapInTransaction via repository) pour éviter un commit partiel si un subscriber échoue.

3. User Management Processors

TrainerPreferencesProcessor

class TrainerPreferencesProcessor implements ProcessorInterface
{
    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly UpdateTrainerPreferencesCommand $updatePreferencesCommand,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        $this->validator->validate($data);

        $this->updatePreferencesCommand->process(
            acceptCgv: $data->acceptCgv,
            acceptOptIn: $data->acceptOptIn
        );

        return null;
    }
}

🔍 Custom Providers

1. Data Providers

TrainerIncomeProvider

class TrainerIncomeProvider implements ProviderInterface
{
    public function __construct(
        private readonly RetrieveIncomeByTrainerHandler $incomeHandler
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        // Récupération de l'utilisateur connecté
        $user = $this->getUserFromContext($context);

        if (!$user instanceof Trainer) {
            throw new AccessDeniedException('Access denied');
        }

        // Appel du handler métier
        return $this->incomeHandler->handle($user);
    }
}

TrainerEventsProvider

class TrainerEventsProvider implements ProviderInterface
{
    public function __construct(
        private readonly RetrieveEventsByTrainerHandler $eventsHandler
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $user = $this->getUserFromContext($context);

        if (!$user instanceof Trainer) {
            throw new AccessDeniedException('Access denied');
        }

        return $this->eventsHandler->handle($user);
    }
}

2. Collection Providers

TrainerOpportunityProposalsProvider

class TrainerOpportunityProposalsProvider implements CollectionProviderInterface
{
    public function __construct(
        private readonly RetrieveOpportunityProposalByTrainerHandler $proposalsHandler
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable
    {
        $user = $this->getUserFromContext($context);

        if (!$user instanceof Trainer) {
            throw new AccessDeniedException('Access denied');
        }

        return $this->proposalsHandler->handle($user);
    }
}

📝 Request/Response Classes

1. Request Classes

PasswordResetRequest

class PasswordResetRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Email]
        public readonly string $email
    ) {}
}

PasswordResetPerformRequest

class PasswordResetPerformRequest
{
    public function __construct(
        #[Assert\NotBlank]
        public readonly string $token,

        #[Assert\NotBlank]
        #[Assert\Length(min: 8)]
        public readonly string $password
    ) {}
}

TrainerPreferencesRequest

class TrainerPreferencesRequest
{
    public function __construct(
        #[Assert\NotNull]
        public readonly bool $acceptCgv,

        #[Assert\NotNull]
        public readonly bool $acceptOptIn
    ) {}
}

2. Response Classes

LoginResponse

class LoginResponse
{
    public function __construct(
        public readonly string $token,
        public readonly string $refreshToken,
        public readonly User $user
    ) {}
}

InviteContactToTeadleResponse

Réponse minimale pour POST /trainer/organization/{uuid}/contact/{uuid_contact}/invite-teadle (invitation email Teadle, template Brevo 19).

class InviteContactToTeadleResponse
{
    public function __construct(
        public bool $invited = true,
    ) {}
}

🔒 Sécurité et Authentification

1. JWT Authentication

Configuration

# config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'
    token_ttl: 3600 # 1 heure

Utilisation dans les Processors

class SecureProcessor implements ProcessorInterface
{
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
    {
        // Récupération de l'utilisateur connecté
        $user = $this->getUserFromContext($context);

        if (!$user) {
            throw new AccessDeniedException('User not authenticated');
        }

        // Logique métier sécurisée
        return $this->businessLogic->process($user, $data);
    }
}

2. Access Control

Configuration des routes

# config/packages/security.yaml
security:
    access_control:
        - { path: ^/security/*, roles: PUBLIC_ACCESS }
        - { path: ^/onboarding/*, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

UserTypeSecurityTrait (QUAL-001)

Les endpoints Trainer (/trainer/*) et Staff (/staff/*) doivent vérifier le type d'utilisateur connecté. Le trait App\Infrastructure\Api\Traits\UserTypeSecurityTrait centralise ce guard.

Emplacement : backend/src/Infrastructure/Api/Traits/UserTypeSecurityTrait.php

Utilisation : la classe doit injecter Security $security au constructeur et utiliser le trait.

use App\Infrastructure\Api\Traits\UserTypeSecurityTrait;
use App\Infrastructure\Symfony\Security\Security;

class TrainerCompanyProvider implements ProviderInterface
{
    use UserTypeSecurityTrait;

    public function __construct(
        private readonly Security $security,
        // ...
    ) {}

    public function provide(...): object|array|null
    {
        $user = $this->requireTrainer();  // ou requireStaff() pour les endpoints Staff
        // ...
    }
}

Méthodes disponibles : requireTrainer(): Trainer et requireStaff(): Staff.

Staff — liste des demandes (GET /staff/requests/groups, FEAT-050)

Ressource StaffReservationResource : la réponse groupée (groups[]) agrège les lots par demande multi-formateurs ; chaque lot expose notamment firstViewAt (vue formateur), trainerPhone (E.164), refusalReason / refusalMessage, lastRelaunchAt, secondsBeforeExpiration, responseLevel, isNewResponse (réponse formateur plus récente que la dernière consultation staff, basé sur staffLastViewAt en base), threadMessagesCount, threadConversationUuid (UUID de la Conversation messagerie batch_thread — la conversation est créée à la persistance du lot via EnsureBatchThreadConversationCommand (méthode ensureBatchThreadConversation de TrainerReservationBatchCreatedEventSubscriber sur ReservationBatchCreatedEvent) ; les builders ne font que lire l’entité), unreadMessagesCount. Chaque entrée de reservations[] inclut le status du créneau (ACCEPTED / REFUSED / PENDING / CANCELLED). Le status du lot côté API inclut notamment FULFILLED (distinct de CANCELLED : annulation école).

Effet de bord : pour les lots déjà répondus (respondedAt non null), un GET sur /staff/requests/groups met à jour staffLastViewAt après construction de la réponse (pour que isNewResponse redevienne faux au chargement suivant). Le listing staff ne doit pas appeler setFirstView sur le lot (champ réservé au parcours formateur).

Fils de demande — messagerie unifiée (Conversation + message, type batch_thread)

Les échanges d'un lot ne passent plus par des routes dédiées « batch message ». La source unique est la messagerie : table message et Conversation de type batch_thread liée au ReservationBatch. L'UUID de conversation est fourni sur les DTOs de lot (threadConversationUuid via findOneByReservationBatch). La commande EnsureBatchThreadConversationCommand est déclenchée à la création du lot (même transaction que la persistance du batch) ; elle reste le point unique pour (re)peupler les participants quand c’est pertinent côté métier.

Méthode Route Rôle
GET /conversations/{uuid} Détail : ConversationDetailProvider / ConversationResponse — le uuid est celui reçu dans threadConversationUuid sur le lot.
GET /conversations/{uuid}/messages Messages paginés : MessageListResponse / MessageResponse
POST /conversations/{uuid}/messages SendMessageRequest : { "content": "..." } — ressource ConversationResource

Frontend : frontend/src/api/conversation.jsgetConversation, getMessages, sendMessage, markAsRead (le uuid de conversation vient des réponses liste demandes, pas d'un endpoint spécifique lot).

Application : SendMessageCommand pour l'envoi ; compteurs : countUnreadThreadMessagesForBatch, countMessagesInBatchThread sur ConversationRepository.

Données historiques : migration Version20260424120000 (recopie reservation_batch_message → messages unifiés puis drop table).

Formateur — réponse aux demandes (TrainerReservationBatchResource)

Ressource TrainerReservationBatchResource — liste et actions sur les lots de réservation côté formateur.

Méthode Route Input Effet
GET /trainer/reservation/requests Liste des lots (TrainerReservationBatchesCollectionResponse)
POST /trainer/reservation/batch/{uuid}/accept AcceptBatchRequest Acceptation globale du lot
POST /trainer/reservation/batch/{uuid}/refuse RefuseReasonRequest Refus global
PUT /trainer/reservation/batch/{uuid}/slots UpdateSlotsStatusRequest Réponses par créneau (acceptation partielle possible)
PATCH /trainer/reservation/batch/{uuid}/view Marquer comme vu
POST /trainer/reservation/batches/mark-all-as-viewed Tout marquer comme vu

Champs enrichis sur chaque lot (liste et détail) :

  • needsOrganizationChoice (bool) : true si l’école saisie n’est pas encore rattachée au référentiel (assignedOrganization === null) — le formateur doit choisir avant acceptation.
  • organizationMatchSuggestion ({ uuid, name } | null) : meilleure correspondance dans l’annuaire du formateur (OrganizationDuplicateMatcher::findBestMatchForName), pré-sélectionnée côté UI.

Acceptation avec école hors référentiel — body optionnel sur POST …/accept et sur PUT …/slots lorsque le lot passera à ACCEPTED :

{
  "message": "Merci, je confirme.",
  "organizationUuid": "550e8400-e29b-41d4-a716-446655440000",
  "createOrganization": false
}
  • organizationUuid : lier le lot à une organisation déjà présente dans l’annuaire du formateur (ResolveBatchOrganizationOnAcceptCommandsetAssignedOrganization avant dispatch des events).
  • createOrganization: true : conserver le comportement historique (création à l’acceptation via CreateOrAssignOrganizationFromReservationBatchCommand).
  • Les deux champs sont mutuellement exclusifs ; si needsOrganizationChoice et aucun choix → 400 « Un choix d’organisation est requis… ».

Commandes : AcceptTrainerReservationBatchCommand, UpdateTrainerReservationBatchSlotsCommand (résolution org uniquement si le batch deviendra ACCEPTED).

Validation dans les Providers

class SecureProvider implements ProviderInterface
{
    use UserTypeSecurityTrait;

    public function __construct(private readonly Security $security, ...) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
    {
        $user = $this->requireTrainer();

        return $this->dataHandler->handle($user);
    }
}

🔄 Flux de traitement

1. Requête entrante

sequenceDiagram
    participant Client
    participant API Resource
    participant Processor
    participant Validator
    participant Command
    participant Domain

    Client->>API Resource: POST /security/password-reset/request
    API Resource->>Processor: process(data)
    Processor->>Validator: validate(data)
    Validator-->>Processor: validation result
    Processor->>Command: process(email)
    Command->>Domain: business logic
    Domain-->>Command: result
    Command-->>Processor: success
    Processor-->>API Resource: null
    API Resource-->>Client: 201 Created

2. Requête de lecture

sequenceDiagram
    participant Client
    participant API Resource
    participant Provider
    participant Handler
    participant Repository
    participant Database

    Client->>API Resource: GET /dashboard/trainer/income
    API Resource->>Provider: provide()
    Provider->>Handler: handle(user)
    Handler->>Repository: findByTrainer(user)
    Repository->>Database: SELECT
    Database-->>Repository: data
    Repository-->>Handler: income data
    Handler-->>Provider: formatted data
    Provider-->>API Resource: response
    API Resource-->>Client: 200 OK + JSON

🎨 Avantages de cette approche

1. Séparation des responsabilités

  • Resources : Configuration API
  • Processors : Logique de traitement
  • Providers : Récupération de données
  • Commands : Orchestration métier

2. Testabilité

  • Tests unitaires : Chaque composant isolé
  • Tests d'intégration : Flux complets
  • Mocks : Simulation des dépendances

3. Maintenabilité

  • Code modulaire : Composants réutilisables
  • Validation centralisée : Contraintes dans les Request classes
  • Gestion d'erreurs : Exceptions métier

4. Sécurité

  • Authentification : JWT intégré
  • Autorisation : Vérifications dans les Providers
  • Validation : Multi-niveaux de validation

🚀 Bonnes pratiques

1. Nommage

// ✅ Bon
class TrainerPreferencesProcessor
class PasswordResetRequest
class UserIncomeProvider

// ❌ Éviter
class Processor
class Request
class Provider

2. Validation

// ✅ Validation dans les Request classes
class UserRequest
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Email]
        public readonly string $email
    ) {}
}

// ✅ Validation croisée via Assert\Callback (ex. TrainerAvailabilitySlotRequest)
class TrainerAvailabilitySlotRequest
{
    #[Assert\NotBlank]
    #[Assert\Regex(pattern: '/^\d{2}:\d{2}$/')]
    public string $startTime;

    #[Assert\NotBlank]
    #[Assert\Regex(pattern: '/^\d{2}:\d{2}$/')]
    public string $endTime;

    #[Assert\Callback]
    public function validateTimeRange(ExecutionContextInterface $context): void
    {
        // Vérifie que endTime > startTime, sinon violation sur le path 'endTime'
    }
}

// ✅ Validation dans les Processors
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
    $this->validator->validate($data);
    // Logique métier
}

3. Gestion d'erreurs

// ✅ Exceptions métier
if (!$user instanceof Trainer) {
    throw new AccessDeniedException('Only trainers can access this resource');
}

// ✅ Messages d'erreur clairs
throw new InvalidArgumentException('Invalid email format');

4. Documentation

/**
 * Processor pour la demande de reset de mot de passe
 * 
 * @param PasswordResetRequest $data Données de la requête
 * @param Operation $operation Opération API Platform
 * @return null Pas de réponse
 * 
 * @throws ValidationException Si les données sont invalides
 * @throws UserNotFoundException Si l'utilisateur n'existe pas
 */
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
    // Implémentation
}

Planning — chargement des événements par plage de dates

Les réponses incluent eventRangeStart et eventRangeEnd (ISO 8601) : bornes effectives utilisées pour générer les occurrences affichées.

Endpoint Paramètres Comportement
GET /trainer/planning dateStart, dateEnd (query, optionnels, tous les deux ou aucun) Sans paramètres : année scolaire courante (1er août → 31 juillet). Avec les deux : occurrences dans cette fenêtre. En plus des événements persistés : créneaux synthétiques type: intervention, status: pending pour les réservations PENDING des lots NEW/PENDING non expirés (même fenêtre), avec data.requestId = UUID du lot ; dédupliqués si un InterventionEvent couvre déjà le créneau.
GET /public/planning/{uuid} idem idem
POST /staff/associated-trainers/calendars uuids + dateStart / dateEnd optionnels (même règle) Même logique pour chaque calendrier retourné.

Validation : si un seul des deux est fourni → 400. Si dateStart > dateEnd400.

Le frontend charge une fenêtre glissante de 5 mois (mois central ±2) et refetch lors d’un changement de période visible dans FullCalendar (visible-range-change).

Messagerie (FEAT-065)

Resource ConversationResource : préfixe effectif /api côté client (axios baseURL + chemins ci‑dessous).

Méthode Chemin Rôle
GET /conversations Liste paginée — même flux que TrainerOrganizationSearchProvider : RequestFactorySearchRequestSearchCriteriaSearchConversationsHandler::askPaginatedResult ; ConversationRepository (étend AbstractServiceRepository) exécute la requête paginée. Défaut / plafond page size : 20 / 100.
POST /conversations Création conversation directe (participantUuid, subject) — 409 si déjà existante, 403 si pas d’adhésion commune active ou deux formateurs.
GET /conversations/{uuid} Détail + 20 derniers messages + participants dénormalisés.
GET /conversations/{uuid}/messages Messages paginés (ASC) — SearchConversationMessagesHandler::ask + PaginatedResult.
POST /conversations/{uuid}/messages Envoi message (max 2000 car.) — rate limit 20/min par utilisateur (429).
POST /conversations/{uuid}/read Marquer lu (lastReadAt du participant courant).
POST /conversations/{uuid}/unread Marquer non lu (lastReadAt remis à null pour le participant courant).

Réponse liste / détail (ConversationResponse) : pour type === batch_thread, reservationBatchStatus expose la valeur BatchStatus du lot (ex. NEW, PENDING, ACCEPTED) — le client formateur n’affiche Accepter / Refuser que si le statut est NEW ou PENDING.

Voters : ConversationVoter (VIEW, PARTICIPATE), MessageVoter (MESSAGE pour la création de conversation). Handlers : SearchConversationsHandler, SearchConversationMessagesHandler. Processors : CreateConversationProcessor, SendMessageProcessor, MarkConversationReadProcessor ; providers : ConversationListProvider, ConversationDetailProvider, ConversationMessagesListProvider.

OAuth connecteur calendrier formateur (FEAT-043)

Méthode Chemin Rôle
GET /trainer/calendar/connectors/config TrainerCalendarConnectorsConfigResourceTrainerCalendarConnectorsConfigProvider : une entrée par stratégie enregistrée (providerType, supportOauth, supportIcal — issus des TrainerCalendarConnectorStrategyInterface) ; libellés et ordre du hub : front uniquement.
POST /trainer/calendar/oauth/{provider}/authorize TrainerCalendarOAuthResourceTrainerCalendarOAuthAuthorizeProcessor : corps {"organizationUuid":"..."} ; réponse authorizationUrl. Stratégies dans Infrastructure/Calendar/Connector/<fournisseur>/ (registry à la racine Connector/).
POST /trainer/calendar/oauth/{provider}/calendar/list TrainerCalendarOAuthResourceTrainerCalendarOAuthCalendarListProcessor : corps TrainerCalendarOAuthCalendarListRequest (code, state, error optionnels) ; échange le code sans persister les jetons ; réponse TrainerCalendarOAuthCalendarListResponse (tokens : TrainerCalendarOAuthCalendarListTokensResponse, calendars : liste TrainerCalendarOAuthCalendarListRowResponse). Google / Outlook.
POST /trainer/calendar/oauth/{provider}/callback TrainerCalendarOAuthResourceTrainerCalendarOAuthCallbackProcessor : corps JSON TrainerCalendarOAuthCallbackRequest (accessToken, refreshToken, expiresAt, calendarId, state, error optionnels selon le cas) ; persistance jetons + oauth_calendar_id, async synchro ; réponse ProcessResponse.
POST /trainer/calendar/configurator/{uuid_cc}/sync Remise en file d’attente resynchro (OAuth, URL iCal ou fichier S3 selon le configurateur ; validation CalendarSyncEnqueueMode::Any) — SyncTrainerCalendarConfiguratorIcalProcessor.
GET /trainer/organization/dashboard TrainerOrganizationDashboardProvider : chaque assignment inclut la télémétrie sync cross-page lastSyncEndDate, consecutiveFailures, currentSyncProcessUuid, currentSyncStartedAt (issue DISC-019 / FEAT-107). Champ mergeSuggestion ({ aUuid, aName, bUuid, bName } \| null) pour la bannière de doublon (DISC-029 / FEAT-339).
GET /trainer/organization/{uuid}/events TrainerOrganizationEventsProviderRetrieveTrainerOrganizationEventsHandler (BC Calendar). Liste paginée des InterventionEvent de l’org (org courante ou mergedFromOrganization). Query SearchRequest : page, limit (défaut 30), sort[dateStart] (ASC | DESC, défaut DESC), filters[label] (recherche intitulé), filters[origin] (connector | request), filters[period] (+ filters[periodStart] / filters[periodEnd] si custom). Réponse TrainerOrganizationEventsResponse : items[] (uuid, label, dateStart, dateEnd, durationHours, classLocation, origin connector | request, requestId), currentPage, itemsPerPage, totalItems, totalPages. 404 si org absente ou non rattachée. Distinct de GET /trainer/activity-reports/events (contrat facturation).
GET /trainer/organization/merge/candidates?from={uuid} TrainerOrganizationMergeCandidatesProviderRetrieveOrganizationMergeCandidatesHandler : liste les autres rattachements actifs du formateur (hors from, hors absorbés). Réponse { candidates: [{ uuid, name, campus, isSuggested, origin, totalHours, moduleCount, hasRemoteConnector, providerType }] }.
GET /trainer/organization/merge/preview?survivor={uuid}&absorbed={uuid} TrainerOrganizationMergePreviewProviderPreviewOrganizationMergeHandler : aperçu complet (survivor, absorbed, sirenComparison, skippedSteps, catalog).
POST /trainer/organization/merge TrainerOrganizationMergeProcessorMergeOrganizationsCommand : exécute la fusion (corps MergeOrganizationsRequest : survivor, absorbed, fieldChoices, contactsMode, selectedContacts, connectionChoice, bookingsChoice, spreadsheetChoice). Réponse { survivor, absorbed, keptWithin48h, mergedAt }.
GET /process/{uuid} ProcessResourceProcessProvider : suivi asynchrone d'un process de synchronisation calendrier (state, allStates, metadata). Consommé en polling par le frontend (FEAT-108 / DISC-019). PERF-116 : cacheHeaders: ['etag' => true, 'max_age' => 0, 'shared_max_age' => 0] — Symfony calcule un ETag MD5 du contenu sérialisé ; le client envoie If-None-Match au tick suivant et reçoit 304 Not Modified si le contenu est identique (phases inactives : pending, attentes inter-transitions). Gain bande passante ~50-70% sur les ticks à contenu stable ; CPU serveur inchangé (Symfony HttpCache inactif — provider + AutoMapper s'exécutent toujours).

FEAT-117 (notification persistante sync en échec) : quand consecutiveFailures franchit strictement 1 -> 2 dans RecordTrainerCalendarConfiguratorSyncFailureCommand, le backend crée une SyncFailureNotification (sous-classe dédiée de Notification, type sync_failure) avec notificationType = SYSTEM, level = WARNING, expiresAt = +7 jours. Payload : category=calendar_sync_failure, organizationUuid, organizationName, calendarConfiguratorUuid, errorCode, consecutiveFailuresCount, lastImportEndDate. Anti-spam : aucun nouvel item pour 3+ tant qu’il n’y a pas de reset du compteur après succès.

Messages Messenger calendrier : ProcessTrainerCalendarConfiguratorSync, ProcessTrainerCalendarSpreadsheetImport et ProcessTrainerPersonalCalendarSync transportent uniquement des UUID (trainerUuid, organizationUuid, processUuid, etc.) — pas d’entités Doctrine sérialisées. Les handlers rechargent les entités depuis les repositories avant toute mutation.

FEAT-236 (notifications Coffre-Fort — expiration de pièces) : chaque jour à 08:00 (Europe/Paris), le scheduler envoie ScanExpiringComplianceDocuments sur le transport async ; ScanExpiringComplianceDocumentsHandler appelle SendComplianceDocumentExpiryAlertsHandler. Pour chaque valeur de DocumentExpiryThreshold (thirty_days_before → J-30, seven_days_before → J-7, on_expiry → J+0), le handler sélectionne les TrainerComplianceDocument courantes (archived_at IS NULL, FEAT-240) dont expiresAt égale today + N jours (findByExpiryDate) et crée une DocumentExpiryNotification (type document_expiry, notificationType = SYSTEM) si aucune alerte n'existe déjà pour le couple (pièce, seuil) — contrainte unique uniq_doc_expiry_document_threshold. Niveaux : J-30 INFO, J-7 WARNING, J+0 URGENT. expiresAt notification = date d'expiration de la pièce + 90 jours. Payload : documentUuid, documentType, expiresAt, threshold, daysBefore. Emails Brevo via EmailService::sendComplianceDocumentExpiryJ30Email / sendComplianceDocumentExpiryJ0Email uniquement à J-30 (template compliance_document_expiry_j30 / #32) et J+0 (compliance_document_expiry_j0 / #33) ; J-7 = in-app seulement. Le handler prépare les paramètres (libellés FR via documentTypeLabel() privée), documentsUrl = {TRAINER_COMPLIANCE_DOCUMENTS_LINK}?renew={documentUuid}. Socle STI DocumentRequestNotification (document_request) : entité + table prêtes pour les demandes organisation (dispatch V2-4) ; compliance_document_id nullable, requested_type optionnel.

Coffre-Fort conformité formateur (DISC-027)

Méthode Chemin Rôle
GET /trainer/compliance/documents Hub Coffre-Fort (FEAT-241) : list (pièces + statut RAG compliant / expiring_soon / non_compliant) et requests[] (demandes org DocumentRequestNotification : state, kind, typeValue, orgName). GetTrainerComplianceHubHandler via TrainerComplianceDocumentProvider.
GET /trainer/compliance/catalog Catalogue catégories + types attendus (validityMonths, permanent, seuils).
POST /trainer/compliance/documents/upload FEAT-238 — dépôt multipart (document, type, issuedAt?, expiresAt?) ; UploadTrainerComplianceDocumentProcessorUploadTrainerComplianceDocumentCommand ; stockage StoragePathType::TRAINER_COMPLIANCE_DOCUMENT ; expiresAt forcée à null si type permanent ; réponse TrainerComplianceDocumentItemResponse (201). Garde-fous : ≤ 5 Mo, MIME pdf/png/jpeg, type ∈ enum ; dates : issuedAt obligatoire ; si type non permanent, expiresAt obligatoire, issuedAt ≤ expiresAt ≤ issuedAt + validityMonths (durée enum) ; calque upload company document. FEAT-263 : types TrainerComplianceDocumentType::isNumberNature() (ex. activity_declaration) → 400 (jamais déposables). FEAT-241 : clôture automatique des demandes org ouvertes pour le type (RespondPendingTrainerComplianceRequestsCommand).
POST /trainer/compliance/documents/{uuid}/renew FEAT-240 — renouvellement versionné … FEAT-241 : clôture auto des demandes ouvertes pour le type (RespondPendingTrainerComplianceRequestsSubscriber sur TrainerComplianceDocumentProvidedEvent). FEAT-263 : renouvellement refusé (400) si le type courant est de nature « numéro ».
GET /trainer/compliance/documents/{uuid} FEAT-258 — détail pièce courante + historique versions (item + versions[], chaîne previousVersion, courante en tête) ; TrainerComplianceDocumentDetailProviderGetTrainerComplianceDocumentDetailHandler ; pièce archivée / autre formateur → 404.
GET /trainer/compliance/documents/{uuid}/download FEAT-258 — binaire (Content-Type = MIME, Content-Disposition: attachment avec repli ASCII) ; versions archivées téléchargeables ; fichier absent → 404.
DELETE /trainer/compliance/documents/{uuid} FEAT-258 — suppression manuelle (204) : pièce courante + chaîne versions ; purge DocumentExpiryNotification (FK RESTRICT) dans la même transaction ; S3 best-effort après flush ; pièce archivée / autre formateur → 404.
GET /trainer/honorability FEAT-245TrainerHonorabilityProviderTrainerHonorabilityResponse ; 404 si aucune référence enregistrée (JWT formateur, UserTypeSecurityTrait).
PUT /trainer/honorability FEAT-245UpdateTrainerHonorabilityProcessorUpsertTrainerHonorabilityReferenceCommand ; input TrainerHonorabilityUpdateRequest (4 champs non pénaux, validation Symfony) ; réponse 200 + TrainerHonorabilityResponse ; formats invalides → 422.
GET /trainer/compliance/shares FEAT-242 — organisations connectées + statut partage Cas A + journal d'accès (lecture seule).
POST /trainer/compliance/shares FEAT-242 — opt-in partage (assignmentId) ; émet TrainerComplianceShareGrantedEvent → clôture demandes org correspondantes.
DELETE /trainer/compliance/shares/{assignmentId} FEAT-242 — révocation (204, idempotent).
GET /trainer/compliance/links FEAT-243 / FEAT-264 (Cas B) — liens externes actifs (ni expirés ni révoqués) : TrainerComplianceShareLinkProviderListTrainerComplianceShareLinksHandler ; réponse TrainerComplianceShareLinkResponse (list[] : uuid, token, documentTypeLabels[], createdAt, expiresAt, viewCount).
POST /trainer/compliance/links FEAT-243 / FEAT-264 (Cas B) — création : CreateTrainerComplianceShareLinkProcessorManageTrainerComplianceShareLinkCommand ; input CreateComplianceShareLinkRequest (documentUuids[] — 1..N pièces, min 1 → 422 si vide ; durationDays défaut 7, borné 1–90) ; réponse TrainerComplianceShareLinkItemResponse (201, inclut le token opaque 64 hex + documentTypeLabels[]). Pièce introuvable → 404 (TrainerComplianceDocumentNotFoundException).
DELETE /trainer/compliance/links/{uuid} FEAT-243 (Cas B) — révocation : RevokeTrainerComplianceShareLinkProcessorManageTrainerComplianceShareLinkCommand::revoke ; 204, idempotent si déjà révoqué ou introuvable.
GET /trainer/compliance/export FEAT-247 — export intégral ZIP : TrainerComplianceExportProviderExportTrainerComplianceZipHandler ; application/zip, Content-Disposition: attachment (mes-documents-teadle.zip) ; entrées <Catégorie>/<Fichier> dédupliquées ; coffre sans pièce téléchargeable → 404 (TrainerComplianceExportEmptyException).
GET /trainer/compliance/fiche FEAT-246 (R16) — fiche intervenant Qualiopi : query sections (identity,experiences,educations,skills) + pieces (UUIDs) ; TrainerComplianceFicheProviderGenerateTrainerFicheHandlerGenerateTrainerFichePdfCommand (Dompdf, gabarit pdf/trainer_fiche_intervenant.html.twig, gates RGPD identiques au CV PDF) ; 0 pièceapplication/pdf (fiche-intervenant-teadle.pdf) ; ≥ 1 pièceapplication/zip (fiche-intervenant-teadle.zip = fiche-intervenant.pdf + pieces/<originaux> via ComplianceZipBuilder::$extraEntries) ; aucune section ni pièce → 400 ; UUIDs hors périmètre formateur ou sans storagePath ignorés.

FEAT-243 / FEAT-264 — accès public Cas B (ComplianceSharedDocumentResource, PUBLIC_ACCESS via ^/public/*) :

  • GET /public/compliance-document/{token}/info : PublicComplianceLinkInfoProviderPublicComplianceLinkInfoResponse (valid, expired, documentTypeLabel, fileName, expiresAt, documents[] : { typeLabel, fileName } pour chaque pièce du lien — champs singuliers = 1ʳᵉ pièce, rétrocompat). Token inconnu → 404 ; expiré ou révoqué → 200 avec valid: false, expired: true.
  • GET /public/compliance-document/{token} : PublicComplianceDocumentDownloadProviderDownloadPublicComplianceDocumentHandler — proxy téléchargement (lecture S3 via FilesystemReaderInterface, aucune URL de stockage exposée) ; 1 pièce : binaire natif (Content-Type = MIME) ; ≥ 2 pièces : application/zip (documents-partages-teadle.zip via ComplianceZipBuilder) ; Content-Disposition: attachment, X-Robots-Tag: noindex, nofollow, Cache-Control: private, no-store ; incrémente viewCount / lastViewedAt. Token inconnu → 404 ; expiré ou révoqué → 410 Gone.

Entité : TrainerComplianceShareLink (table trainer_compliance_share_link) — relation ManyToMany vers TrainerComplianceDocument (table de jointure trainer_compliance_share_link_document, migration Version20260730130000, FEAT-264).

FEAT-263 (NDA — pièce de nature « numéro », CR 2026-07-20) : TrainerComplianceDocumentType::ACTIVITY_DECLARATION (activity_declaration) — isNumberNature() retourne true ; suivie dans le catalogue / anneau de complétion mais jamais stockée comme TrainerComplianceDocument. Valeur sur TrainerCompany::$nda (11 chiffres, PUT /trainer/company). Côté staff, StaffComplianceDocumentsResponse expose le champ racine nda (lecture seule, issu de l’entreprise du formateur).

FEAT-245 (références honorabilité — DISC-027 / R-HONOR-FINAL) : entité TrainerHonorabilityReference (table trainer_honorability_reference, relation 1:1 Trainer, onDelete: CASCADE). Stocke uniquement les 4 champs non pénaux du formulaire officiel casier-judiciaire.justice.gouv.fr/verif : deliveredAt (date_immutable, Y-m-d), deliveredTime (HH:MM), documentIdentifier (13 car. [A-Z0-9]), controlKey (8 car. [A-Z0-9]). Aucun fichier (pas d’upload bulletin n°3), aucun statut (« néant » / « vierge » = donnée art. 10, jamais persistée). verifiableUntil = deliveredAt + 6 mois (TrainerHonorabilityReference::getVerifiableUntil, constante VALIDITY_MONTHS) ; exposé en lecture avec expired (bool, comparé à now). Réponse TrainerHonorabilityResponse : les 4 champs + verifiableUntil, expired, updatedAt (ISO 8601). Upsert via UpsertTrainerHonorabilityReferenceCommand (création ou updateReferences).

FEAT-239 (cloche unifiée — conformité) : NotificationMapper mappe document_expiry + document_request vers ComplianceNotificationResponse + ComplianceNotificationData (kind, bucket, titleCode, bodyCode, documentType, expiresAt, requestedType, orgName, threshold, requestVariant). Pas de libellés FR en PHP — codes i18n consommés par le front (utils/notificationDisplay.js). Bucket : todo = demande non résolue ou expiry on_expiry ; recent = J-30/J-7 ou demande résolue (dismissedAt, FEAT-241). Le front BellDropdown : 2 onglets « À faire / Récents », badge = actionableUnreadCount, sticky J+0, CTA → trainer-documents?renew=.

Flux SPA (Google / Outlook) : la route front …/trainer/calendar/oauth/{provider}/callback lit code / state / error dans la query, appelle d’abord POST …/calendar/list (liste des agendas, jetons renvoyés au client sans être enregistrés côté serveur), affiche le choix d’agenda, puis POST …/callback avec les jetons + calendarId + state (re-vérification du state signé). Réponse finale : ProcessResponse (même contrat que l’ajout iCal / resync). Les deux POST sont JWT formateur. Le redirect_uri OAuth (TRAINER_CALENDAR_*_REDIRECT_URI) pointe vers l’URL front de cette page.

L’exécution async commune : OAuth via parseCalendarTrainerCalendarConnectorParsedEvents ; flux ICS via IcsParserInterfaceIcalVO, puis sélection du connecteur (supportsIcal + supportsIcalProdId / parseur ICS) et parseIcalVo. TrainerCalendarConfiguratorSynchronizer enchaîne puis appelle PersistTrainerCalendarConnectorEventsCommand, utilisé par ProcessTrainerCalendarConfiguratorSyncHandler et réutilisable hors Messenger. Si le fetch ICS distant échoue (URL 404, fichier S3 absent, etc.), TrainerCalendarIcalRemoteContentParser lève TrainerCalendarIcalRemoteFetchException : le synchroniseur enregistre l’échec via RecordTrainerCalendarConfiguratorSyncFailureCommand (last_import_error_code, process en error) sans relancer l’exception vers Messenger (pas de retries / failed queue pour ce cas métier).

Cette architecture API Platform custom permet une exposition RESTful propre et sécurisée des fonctionnalités métier de Teadle.