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
│   ├── 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
}

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é et infos bancaires (IBAN, BIC), emails / téléphone de contact.
  • PUT : met à jour via UpdateTrainerCompanyProcessor (identité + bancaire). 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

  • 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) : prefixTemplate, nextSequence, isLocked, preview, availableVariables ; numérotation CRA : activityReportPrefixTemplate, activityReportNextSequence, activityReportPreview ; 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 avec (annee) ; 422 si séquence facture verrouillée et nextSequence 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) : listes invoiceTemplates et activityReportTemplates (chacun avec uuid, name, isDefault, settings), prévisualisations de prochain numéro (invoiceNumberPreview, activityReportNumberPreview) 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 inclut title (titre de la facture, null si non renseigné), issueDate (date d’émission, null si brouillon), dueDate (date d’échéance, null si non renseignée), en plus de code, createdAt, sent, organizationName, amount, status, etc. (UX-034, UX-040).

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

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.

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).

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).
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 PersistTrainerCalendarConnectorEventsCommand, 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.

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.