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:MagicLinkRequestProcessor→RequestMagicLinkCommand; input{ "email" }; 204 sans corps. PurposeLOGIN; token brut généré parMagicLinkTokenGenerator(64 hex), hashé SHA-256 avant persistance ; email viaEmailService::sendMagicLinkEmail(template Brevomagic_link_login#27, URLMAGIC_LINK_LINK?magic_link_token=…). Rotation :MagicLinkRequestRepository::rotateToken(TTL 1 h).PUT /security/magic-link/perform:MagicLinkPerformProcessor→PerformMagicLinkCommand; input{ "token" }; cookies JWT httpOnly. Consommation atomique viaconsumeValidToken(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,passwordNULL, CGV acceptées) ; envoie l’email d’activation (MagicLinkPurpose::TRAINER_ACTIVATION, TTL 15 min, template Brevotrainer_activation#28, lienTRAINER_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"? }(passwordoptionnel, min 8 car.) ; active le compte (verifiedEmail,slugviaTrainerSlugGenerator), mot de passe hashé si fourni, dispatchTrainerRegisteredEvent; 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é (dontnda— 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 optionnelnda: 11 chiffres (espaces tolérés en entrée, normalisés sans espaces — validationTrainerCompanyUpdateRequest) ;nullou chaîne vide autorisés. 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, 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 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) :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, 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 et avoir avec(annee); 422 si séquence facture ou avoir verrouillée etinvoiceNextSequence/creditNoteNextSequencemodifié, 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 / avoir) : listes
invoiceTemplatesetactivityReportTemplates(chacun avecuuid,name,isDefault,settings), prévisualisations de prochain numéro (invoiceNumberPreview,activityReportNumberPreview,creditNoteNumberPreview— FEAT-267) 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 (TrainerInvoiceResponse) incluttitle,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,totalCentspositif,createdAt),hasDraftCreditNote(drapeau brouillon, FEAT-276), etc. (UX-034, UX-040, FEAT-273). - Résumé KPI (
summary) :pendingAmount,overdueAmount,paidThisMonthAmounten TTC net des avoirs finalisés (brouillons exclus). - Client affiché après fusion (DISC-029 / RM20) :
organizationName/organizationUuidsont 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 surGET /trainer/credit-notes.
Création d'un avoir depuis une facture (POST /trainer/invoices/{uuid}/credit-note) (FEAT-268)
- Corps :
CreateCreditNoteRequest—reasonobligatoire (motif, max 2000 car.). - Réponse :
CreditNoteResponse—uuid,publicNumber(publicIdtant quecodeest null),status(DRAFT),issueDate(Y-m-dounulltant que non finalisé, BUG-284), lignes pré-remplies (avoir total), totaux en centimes, snapshotlegalMentionsde la facture. Chaque ligne inclutproductUuid,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 :
UpdateCreditNoteRequest—reasonobligatoire (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/productpour un produit nouveau). - Réponse :
CreditNoteResponseà jour (updatedAtrenseigné). - 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 :
CreditNoteResponse—status: FINALIZED,codede 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 ;
CreditNoteFinalizedEventdispatché après commit.
Reste créditable (GET /trainer/invoices/{uuid}/creditable-balance) (FEAT-269)
- Réponse :
CreditableBalanceResponse—invoiceTotalCents,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 :
TrainerCreditNotesProvider→RetrieveTrainerCreditNotesHandler→CreditNoteToListDTOBuilder; persistance viaCreditNoteRepository::findAndCountFinalizedByTraineretsumFinalizedTotalCentsByTrainer(même query builder filtré pour liste et somme KPI). - Périmètre : avoirs
FINALIZEDdu 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— surCOALESCE(issueDate, createdAt)),search(numéro avoircode/publicId, motif, numéro facture créditée). - Tri :
COALESCE(issueDate, createdAt) DESC, puisid DESC. - Réponse :
TrainerCreditNotesResponse—summary(TrainerCreditNotesSummaryResponse:totalCreditedCentsseul, 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 racinetotal(absent du summary). Après fusion (RM20),organizationName/organizationUuidsuivent le client retenu — même DQL queGET /trainer/invoices. - Distinct de
GET /trainer/invoices/{uuid}/credit-notes(FEAT-269, par facture, brouillons inclus) et des lignes imbriquéescreditNotes[]dansGET /trainer/invoices(FEAT-273).
PDF d'un avoir (GET /trainer/credit-notes/{uuid}/pdf) (FEAT-277)
- Provider :
CreditNotePDFProvider→GenerateCreditNotePdfHandler. - 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 :
CreditNoteFinalizedEventSubscriberpersiste 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 :
PreviewCreditNoteRequest—lineItems[](même forme que l'aperçu facture :designation,quantity,unit,unitPriceen 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é :
codesi 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 :
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
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 viaconstants/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 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.
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 porteROLE_COMPLIANCEpour cet intervenant ; sinoncomplianceStatusabsent (null sérialisé). complianceStatus: enum anglaisStaffComplianceIntervenantStatus(compliant,expiring_soon,non_compliant,pending) — agrégé depuis les pièces partagées actives (FEAT-242) ;pendingsans 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 contactROLE_AGENDA(indépendant du gate conformité).presence: enumStaffIntervenantPresence(active,paused,departed) — dérivé du seulAccountStatusdu formateur (FEAT-231) ;DELETION_PENDINGest collapsé enpausedcôté serveur.departedDate: date de départ au formatd/m/Y, lue depuis le snapshotdeparted_atdu lien Organisation (null si actif).invoicesCount: int — nombre cumulé de factures envoyées (SENT+PAID, FEAT-252SENT_STATUSES) par l’intervenant aux organisations du staff (isolation O8, requête groupée anti-N+1) ; non gaté parcanViewCompliance(FEAT-259).firstname/lastname: pour un intervenant parti (presence: departed), lus depuis le snapshotdeparted_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 journalTrainerComplianceAccessLog(FEAT-242).- Pas de POST honorabilité/consult : la consultation des références est journalisée au GET documents lorsque
honorabilityest renseignée etactorLabelnon 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/otherLabeloptionnels. - Validation : exactement un mode parmi
documentUuid(C1),requestedType(C2 type connu) ouotherLabel(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 colonnes — Intervenant, 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 : StaffCreditNotesProvider → ListStaffCreditNotesHandler. 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 deInvoice::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}/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).
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) :truesi 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 (ResolveBatchOrganizationOnAcceptCommand→setAssignedOrganizationavant dispatch des events).createOrganization: true: conserver le comportement historique (création à l’acceptation viaCreateOrAssignOrganizationFromReservationBatchCommand).- Les deux champs sont mutuellement exclusifs ; si
needsOrganizationChoiceet 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 > 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). Champ mergeSuggestion ({ aUuid, aName, bUuid, bName } \| null) pour la bannière de doublon (DISC-029 / FEAT-339). |
GET |
/trainer/organization/{uuid}/events |
TrainerOrganizationEventsProvider → RetrieveTrainerOrganizationEventsHandler (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} |
TrainerOrganizationMergeCandidatesProvider → RetrieveOrganizationMergeCandidatesHandler : 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} |
TrainerOrganizationMergePreviewProvider → PreviewOrganizationMergeHandler : aperçu complet (survivor, absorbed, sirenComparison, skippedSteps, catalog). |
POST |
/trainer/organization/merge |
TrainerOrganizationMergeProcessor → MergeOrganizationsCommand : exécute la fusion (corps MergeOrganizationsRequest : survivor, absorbed, fieldChoices, contactsMode, selectedContacts, connectionChoice, bookingsChoice, spreadsheetChoice). Réponse { survivor, absorbed, keptWithin48h, mergedAt }. |
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 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?) ; UploadTrainerComplianceDocumentProcessor → UploadTrainerComplianceDocumentCommand ; 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) ; TrainerComplianceDocumentDetailProvider → GetTrainerComplianceDocumentDetailHandler ; 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-245 — TrainerHonorabilityProvider → TrainerHonorabilityResponse ; 404 si aucune référence enregistrée (JWT formateur, UserTypeSecurityTrait). |
PUT |
/trainer/honorability |
FEAT-245 — UpdateTrainerHonorabilityProcessor → UpsertTrainerHonorabilityReferenceCommand ; 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) : TrainerComplianceShareLinkProvider → ListTrainerComplianceShareLinksHandler ; réponse TrainerComplianceShareLinkResponse (list[] : uuid, token, documentTypeLabels[], createdAt, expiresAt, viewCount). |
POST |
/trainer/compliance/links |
FEAT-243 / FEAT-264 (Cas B) — création : CreateTrainerComplianceShareLinkProcessor → ManageTrainerComplianceShareLinkCommand ; 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 : RevokeTrainerComplianceShareLinkProcessor → ManageTrainerComplianceShareLinkCommand::revoke ; 204, idempotent si déjà révoqué ou introuvable. |
GET |
/trainer/compliance/export |
FEAT-247 — export intégral ZIP : TrainerComplianceExportProvider → ExportTrainerComplianceZipHandler ; 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) ; TrainerComplianceFicheProvider → GenerateTrainerFicheHandler → GenerateTrainerFichePdfCommand (Dompdf, gabarit pdf/trainer_fiche_intervenant.html.twig, gates RGPD identiques au CV PDF) ; 0 pièce → application/pdf (fiche-intervenant-teadle.pdf) ; ≥ 1 pièce → application/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:PublicComplianceLinkInfoProvider→PublicComplianceLinkInfoResponse(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 avecvalid: false,expired: true.GET /public/compliance-document/{token}:PublicComplianceDocumentDownloadProvider→DownloadPublicComplianceDocumentHandler— proxy téléchargement (lecture S3 viaFilesystemReaderInterface, aucune URL de stockage exposée) ; 1 pièce : binaire natif (Content-Type= MIME) ; ≥ 2 pièces :application/zip(documents-partages-teadle.zipviaComplianceZipBuilder) ;Content-Disposition: attachment,X-Robots-Tag: noindex, nofollow,Cache-Control: private, no-store; incrémenteviewCount/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 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.