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 optionnelphone: validé et stocké en E.164 (PhoneNumberStringNormalizer).
Documents entreprise (/trainer/company/documents, FEAT-033)
- GET
/trainer/company/documents:TrainerCompanyDocumentProvider→ListTrainerCompanyDocumentsHandler(Application) → DTOsTrainerCompanyDocumentItemDtomappés enTrainerCompanyDocumentResponse(list). - POST
/trainer/company/documents/upload:UploadTrainerCompanyDocumentProcessor→UploadTrainerCompanyDocumentCommand(multipartdocument, 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→ validationUpdateTrainerCompanyDocumentRequestpuisUpdateTrainerCompanyDocumentCommand(actuellement :displayName).TrainerDocumentInvalidUuidException→ 400 ;TrainerDocumentNotFoundException→ 404. - DELETE
/trainer/company/documents/{uuid}:DeleteTrainerCompanyDocumentProcessor→DeleteTrainerCompanyDocumentCommand.TrainerDocumentInvalidUuidException→ 400 ;TrainerDocumentNotFoundException→ 404.
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 viaStorageUrlResolverInterface(logoUrldans le read model) ;TrainerBillingSettingsResponseMapperne fait que mapperTrainerBillingSettingsReadModel→TrainerBillingSettingsResponse. 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, enumBillingDocumentFontSize) ; 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 viaPhoneNumberStringNormalizer/ libphonenumber, commephonesur PUT entreprise) ; logo :logoUuid,logoUrl(URL publique résolue depuis le stockage S3). - PUT
/trainer/billing/settings: le Processor appelleUpdateTrainerBillingSettingsCommand::process(tous les champs en paramètres) ; retourne unTrainerBillingSettingsReadModelviaGetTrainerBillingSettingsHandler. ValidationaccentColoren#RRGGBB; préfixes facture avec(annee); 422 si séquence facture verrouillée etnextSequencemodifié, ou template invalide. - POST
/trainer/billing/settings/logo:UploadTrainerBillingLogoCommand(upload S3 + read model) ; multipart/form-data (PHP ne remplit$_FILESqu’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 DomainTrainerBillingLogoMissingFileException,TrainerBillingLogoInvalidMimeTypeException,TrainerBillingLogoFileTooLargeException→ 400 (BadRequestHttpException) dansUploadTrainerBillingLogoProcessor. - 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:TrainerDocumentBillingAttachmentProvider→ListTrainerBillingDocumentsHandler→ DTOsTrainerBillingDocumentItemDto→TrainerDocumentBillingAttachmentCollectionResponse(items: métadonnées + champs hérités deDocument, dontcreatedAt/updatedAtISO 8601). - POST
/trainer/billing/documents/upload:UploadTrainerDocumentBillingAttachmentProcessor→UploadTrainerDocumentBillingAttachmentCommand; multipartdocument(PDF, PNG, JPEG, max 5 Mo), optionneldocumentType,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→ validationUpdateTrainerDocumentBillingAttachmentRequestpuisUpdateTrainerDocumentBillingAttachmentCommand.TrainerDocumentInvalidUuidException/TrainerDocumentBillingAttachmentTypeInvalidException→ 400 ;TrainerDocumentNotFoundException→ 404. - DELETE
/trainer/billing/documents/{uuid}:DeleteTrainerDocumentBillingAttachmentProcessor→DeleteTrainerDocumentBillingAttachmentCommand; suppression fichier + entité ; 204 ; uuid invalide 400, introuvable 404.
Modèles de facturation (BillingTemplate, FEAT-026/027)
- Entity :
Domain/Entity/Invoicing/BillingTemplate(uuid,trainernullable pour les modèles système Teadle,name,typeenumDocumentType=INVOICE|ACTIVITY_REPORT,isDefault,active(bool — modèles désactivés exclus du listing et du GET),isSystem,useBillingSettingsAppearance(bool, défauttrue),settingsJSON,logo_urlURL 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:BillingTemplateListProvider→ListBillingTemplatesHandler→BillingTemplateCollectionResponse(items; uniquement modèles actifs côté trainer + modèles système ; chaque item inclutactive,logoUrlré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é. InclutbillingAppearanceDefaults(accentColor,fontSize,logoUrldepuis les paramètres facturation du formateur) pour l’éditeur sans appelerGET /trainer/billing/settings. - POST
/trainer/billing/templates:CreateBillingTemplateProcessor(CreateBillingTemplateRequest) ; création modèle trainer, supportisDefault+useBillingSettingsAppearance(défauttrue). - PUT
/trainer/billing/templates/{uuid}:UpdateBillingTemplateProcessor(UpdateBillingTemplateRequest) ; modificationname/settings/useBillingSettingsAppearanceet promotion défaut optionnelle. - POST
/trainer/billing/templates/{uuid}/logo:UploadBillingTemplateLogoProcessor→UploadBillingTemplateLogoCommand; multipartlogo(PNG, JPEG, GIF, WebP, max 2 Mo), même règles que/trainer/billing/settings/logo; réponseBillingTemplateResponse(dontlogoUrl). - DELETE
/trainer/billing/templates/{uuid}/logo:DeleteBillingTemplateLogoProcessor→DeleteBillingTemplateLogoCommand; supprime le fichier +logoen base ; réponseBillingTemplateResponse(logoUrlnull). - 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éponseBillingTemplateCollectionResponse(liste complète des modèles, même forme queGET /trainer/billing/templates) pour éviter un second appel. - POST
/trainer/billing/templates/{uuid}/preview:PreviewBillingTemplateProcessor(validation + existence du modèle) →PreviewBillingTemplateHandler; inputPreviewBillingTemplateRequest:documentTypeobligatoire (INVOICE|ACTIVITY_REPORT),name,settings(validation unifiée avec la création :BillingTemplateRequestValidation+BillingTemplateSettingsValidatorsurPreviewBillingTemplateRequest→ 422 ; ex.style.accentColor,style.fontSize,style.logoUrlpour l’aperçu) ; optionnel :craPeriodInputSlot(bool) — sitrueen CRA (ACTIVITY_REPORT), l’aperçu inclut l’id#cra-period-input-slotsur la ligne « Période » (SPACreateCRAViewviaTeleport; l’éditeur de modèles ne l’envoie pas). Les données document sont construites côté serveur (BillingTemplatePreviewDocumentDataFactory, paramètresbilling_template_preview.invoice/billing_template_preview.activity_reportdansconfig/packages/billing_template_preview.yaml,BillingTemplatePreviewPartyDataProvider) ; le flag y ajoute_craPeriodInputSlotpour le compilateur. CRA :PreviewActivityReportBillingTemplateRenderCompiler+ template Twigbilling_template_preview.html.twig(récap sans accent, colonne Source forcée). Cléssettingsautorisées selon le type : facture (InvoiceBillingTemplateSettingKey) vs CRA (ActivityReportBillingTemplateSettingKey) — ex. CRA :activityReport.showModules/showSessions,signature.show/trainerLabel/clientLabel,table.columnLabels. RéponsePreviewBillingTemplateResponse(html,settings).
Config facturation (GET /trainer/invoices/config)
- Réponse :
RetrieveInvoiceConfigHandler→InvoiceConfigResponseavec deux objets distincts : - company (
CompanyInfoDTO) : identité (nom, SIRET, adresse), bancaire (IBAN, BIC), et champs de présentation facture issus uniquement deTrainerBillingSettings:defaultLegalMentions(texte composé viaBillingLegalMentionsComposer),emailTemplate(=invoiceEmailBody),logoUrl(URL S3 du logo billing). - billing (
BillingConfigDTO) : defaultPrice, defaultVatRate, defaultUnit, paymentTerm. - Plus :
organizations,products. - Bandeau document (facture / CRA) : listes
invoiceTemplatesetactivityReportTemplates(chacun avecuuid,name,isDefault,settings), prévisualisations de prochain numéro (invoiceNumberPreview,activityReportNumberPreview) etdocumentNumberingPreviewHint(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 blocsTrainerBillingSettings(pénalités si texte non vide, confidentialité / texte perso si activés). - Output
PreviewInvoiceHtmlResponse:{ "html" }. - Processor :
PreviewInvoiceProcessor→PreviewInvoiceHtmlHandler. - 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,invoiceBillingLegalCustompour le bloc.bp-invoice-legal-stacksous le pied de modèle dansbilling_template_preview.html.twig) puis appellePreviewInvoiceBillingTemplateRenderCompiler. Ce compilateur bypasse les fixtures YAMLbilling_template_preview.invoicesi_invoiceSpaRealDataOnlyet 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 incluttitle(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 decode,createdAt,sent,organizationName,amount,status, etc. (UX-034, UX-040).
Détail facture (GET /trainer/invoices/{uuid})
- Réponse :
InvoiceDetailDTO→InvoiceDetailResponse:publicNumber,uuid, dates,paymentTerm(valeur enumPaymentTermcopiée depuis les paramètres entreprise à chaque enregistrement), titre, description, mentions légales, statut,paidAt,organization,lineItems(chaque ligne :discountPercent,totalHTaprè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 siforce: true(confirmation explicite). - Réponse :
uuidde la facture créée, et éventuellementhasExistingInvoices,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 delinkedInvoice(objet unique). Les factures en liste exposentlinkedActivityReportCode(code du CRA lié). - Détail CRA (draft) : La réponse de récupération/édition d’un CRA (
activityReport.lineItems) expose pour chaque ligneeventAlreadyInvoiced(booléen) etclass_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/preview—PreviewActivityReportRequest:organizationUuidest 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(incluantclass_name) ;billingTemplateUuid,documentData;codeoptionnel (sinon prochain numéro viaInvoiceNumberGenerator::previewActivityReportavec périodemonth/year, comme ailleurs). La page CreateCRAView n’appelle jamaisPOST /trainer/billing/templates/{uuid}/preview(seulement l’éditeur de modèles, etc.). Dès l’ouverture de l’écran, un premierPOST…/preview est possible sansorganizationUuidpour l’aperçu « brouillon sans client ».PreviewActivityReportHtmlHandleralimente 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 (_craEditorRealDataOnlycôtéBillingDocumentTemplatePdfDataFactory/PreviewActivityReportBillingTemplateRenderCompiler). Le HTML d’aperçu inclut des repèresdata-bp="…"(ex.cra-root,cra-issuer,cra-client,cra-period-slot,cra-line-items,cra-footer…) etdata-editablepour l’inline-edit. Le code du CRA en en-tête est un inputreadonly(cra-code-value/bp-cra-code-input). RéponsePreviewActivityReportHtmlResponse:{ "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}:UpdateActivityReportRequestaccepte en plus des lignes les champs optionnelsbillingTemplateUuidetdocumentData. - Envoi email CRA
POST /trainer/activity-reports/{uuid}/send(multipart,deserialize: false) : champsrecipient,cc,receiveCopy,subject,message, fichier optionnelattachment, etattachmentIds[](répétition de clés ou JSON dansattachmentIds) pour les annexes facturation (GET /trainer/billing/documents, typecra). Le PDF du CRA est toujours joint (plus de paramètreincludePDF). 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 typesinvoices_and_credits,invoicesoucredit_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 CreatedavecInviteContactToTeadleResponse(invited: true) - Flux : Processor →
InviteContactToTeadleCommand→EmailService::sendInviteContactToTeadleEmail()(template 19, paramsfirstname,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:uuidreste l’identifiant du TrainerOrganizationContact ; lorsqueisTeadleMemberest true,staffUserUuidexpose l’UUID User du staff (à utiliser commeparticipantUuidpourPOST /conversations).
Intervenants associés (GET /staff/associated-trainers)
- Chaque entrée
AssociatedTrainerResponseinclutuuid: 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}/reservationsPUT /staff/associated-trainers/planning/reservations- Le header
Idempotency-Keyest obligatoire. Sans ce header, le processor renvoie400 Bad Request. - Le backend exige le header
Idempotency-Keyet 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 (
wrapInTransactionvia 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.js — getConversation, 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 > dateEnd → 400.
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 : RequestFactory → SearchRequest → SearchCriteria → SearchConversationsHandler::ask → PaginatedResult ; 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 |
TrainerCalendarConnectorsConfigResource → TrainerCalendarConnectorsConfigProvider : 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 |
TrainerCalendarOAuthResource → TrainerCalendarOAuthAuthorizeProcessor : corps {"organizationUuid":"..."} ; réponse authorizationUrl. Stratégies dans Infrastructure/Calendar/Connector/<fournisseur>/ (registry à la racine Connector/). |
POST |
/trainer/calendar/oauth/{provider}/calendar/list |
TrainerCalendarOAuthResource → TrainerCalendarOAuthCalendarListProcessor : 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 |
TrainerCalendarOAuthResource → TrainerCalendarOAuthCallbackProcessor : 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} |
ProcessResource → ProcessProvider : 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 parseCalendar → TrainerCalendarConnectorParsedEvents ; flux ICS via IcsParserInterface → IcalVO, 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.