Base de données - Documentation Technique
🎯 Vue d'ensemble
La base de données de Teadle utilise PostgreSQL avec Doctrine ORM pour le mapping objet-relationnel. L'architecture suit les principes DDD avec des entités métier bien définies.
Table trainer_organization_contact
- Les emails sont persistés en minuscules (normalisation dans l’entité
TrainerOrganizationContact). - Unicité : couple
(trainer_organization_assignment_id, email)via l’indexuniq_toc_assignment_email(un même email ne peut pas apparaître deux fois pour la même affectation formateur–organisation). Les lignes sans email (ex. contact rattaché uniquement à unstaff) peuvent coexister avecemailNULL (comportement PostgreSQL sur les contraintes UNIQUE avec NULL).
Tables document, annexes facturation et documents entreprise
- Namespace PHP :
App\Domain\Entity\Document—Document(abstraite),TrainerDocumentBillingAttachment,TrainerDocumentCompany. - Héritage Doctrine JOINED : classe abstraite
Document(champs communs : uuid, chemin de stockage, nom, libellé affiché, MIME, taille en octets,created_at/updated_atendatetimetz_immutable—updated_atrafraîchi via#[ORM\PreUpdate]) ;TrainerDocumentBillingAttachmentajoutetrainer_id,attachment_type(TrainerDocumentBillingAttachmentType: factures/avoirs, CRA, etc.),included_by_default. Stockage S3 sous{uuid}/billing/documents/. Les PDF de facture figés (hors brouillon) sont stockés séparément sous{trainerUuid}/billing/invoices/{invoiceUuid}(StoragePathType::TRAINER_INVOICE_PDF), sans colonne surinvoicing_invoice. - Version20260326120000 (bloc fusionné) : création en une fois de
document(aveccreated_atetupdated_at),trainer_document_billing_attachment(discriminanttrainer_billing_attachment) ettrainer_document_company(documents entreprise, discriminanttrainer_company) ; FK*.id→document.id,trainer_id→trainer.id.
Table billing_template
-
FK
trainer_id:ON DELETE CASCADE— suppression du compte formateur = suppression de ses modèles ; les modèles système (trainer_idNULL) ne sont pas liés à un trainer et restent en base. -
Namespace PHP :
App\Domain\Entity\Invoicing\BillingTemplate. - Champs :
uuid(unique),trainer_id(nullable,NULL= modèle système Teadle),name,type(INVOICEouACTIVITY_REPORT),is_default,active(bool, défauttrue—false= désactivation logique, plus listé ni accessible en GET),use_billing_settings_appearance(bool, défauttrue),settings(JSON),logo_url,created_at,updated_at. - Contrainte : index unique partiel
uniq_billing_template_default_per_typesur(trainer_id, type)avec clauseWHERE is_default = truepour garantir un seul modèle par défaut par type côté trainer. - Version20260326120000 (suite) : table
billing_templatesans colonneis_system(système =trainer_idNULL), FKtrainer_idenON DELETE CASCADE, seed des 2 modèles système avecsettingsJSON minimal (accent Teadle, typo,header.showLogo). Voir aussi.doc/backend/BILLING_TEMPLATE_SYSTEM_DEFAULTS.md. - Fixtures Alice :
fixtures/billing_template.yamlrecrée ces 2 lignes après purge (hautelook:alice:doctrine:load), avec les mêmes UUID et le même JSONsettingsque la migration. - Version20260330113000 : ajout de
use_billing_settings_appearance(BOOLEAN NOT NULL DEFAULT TRUE) pour piloter l’héritage visuel (logo, couleur, taille typo) depuistrainer_billing_settings. - Version20260401120000 : colonne
active(BOOLEAN NOT NULL DEFAULT TRUE) —DELETE /trainer/billing/templates/{uuid}désactive le modèle (plus de suppression physique ni suppression du fichier logo).
Table trainer_organization_assignment
color(VARCHAR(7), Doctrine : enumOrganizationColor) : seule source de vérité pour la couleur agenda / avatar (couple formateur–organisation). Attribuée à la création de l’assignation (TrainerOrganizationAssignmentRepository::assignviaCalendarColorPicker::pickFirstAvailableOrDefault). Si besoin sur d’anciennes données sans couleur à la première synchro iCal :CalendarColorAssigner::assignColorIfMissing. Les couleurs déjà prises :TrainerOrganizationAssignmentRepository::findUsedOrganizationColorHexValuesByTrainer. Migrations : Version20260401140000 (copie initiale depuiscalendar_configurator+ backfill), Version20260401160000 (suppression de la colonnecolorsurcalendar_configurator).- DISC-026 / FEAT-231 (R23) — snapshot d’identité au départ du formateur (effacement J+30) :
departed_trainer_firstname,departed_trainer_lastname(VARCHAR 255, nullable)departed_at(TIMESTAMPTZ immutable, nullable) — figé parNotifyOrganizationsOfTrainerDepartureHandleravant le scrub du profilTrainer; relu parListStaffComplianceIntervenantsHandlerpour la liste « Anciens intervenants ». Migration Version20260724120000.
Table calendar_configurator
last_import_error_code(VARCHAR(64), nullable, PHP :CalendarSyncErrorCode— ex.UNKNOWN,CONNECTION_FAILED) : code de la dernière erreur de synchro iCal lorsqueis_last_import_successfulest false. Alimenté parRecordTrainerCalendarConfiguratorSyncFailureCommand(échec fetch URL/fichier ICS dansTrainerCalendarConfiguratorSynchronizer, ou erreur pendantPersistTrainerCalendarConnectorEventsCommand), effacé au succès. Le processus async expose aussisync_error_codedans les métadonnées duProcess. Migration Version20260401170000.- DISC-019 (FEAT-107) :
current_sync_process_id(FK nullable versprocess.id,ON DELETE SET NULL) : pointeur vers la sync active, utilisé pour reconstruire le polling frontend après F5 / nouvel onglet.consecutive_failures(INT, défaut0) : compteur d'échecs consécutifs, incrémenté à l'échec et remis à zéro au succès.-
last_sync_end_date(TIMESTAMPTZ, nullable) : snapshot terminal conservé côté configurateur, même si leProcessest purgé plus tard. -
provider_type(VARCHAR(50), nullable, FEAT-043) : choix utilisateur dans le hub connecteurs (google,outlook,apple,edusign,hyperplanning,ypareo,ical,ical_file). Distinct de la détection de format côté parseur iCal (PRODID). Valeurical_file: import par fichier ; la clé objet S3 du.icsest{trainerUuid}/ics/{calendarConfiguratorUuid}.ics(voirTrainerCalendarIcalFileStorageKeyResolverInterface), sans colonne de chemin. Lors d’un import dont la stratégie connecteur estical_file,PersistTrainerCalendarConnectorEventsCommandforceprovider_typeàical_file(même si le hub avait transmis un autre libellé), pour que la resynchro planifiée lise bien le fichier sur S3. Sérialisé dansCalendarConfiguratorResponse; accepté en option sur ajout lien / upload iCal. Migrations Version20260401180000 ; suppression deical_file_storage_path: Version20260414130000. -
oauth_calendar_id(VARCHAR(512), nullable) : identifiant d’agenda côté fournisseur OAuth (GooglecalendarListid, Outlookcalendarsid). Renseigné auPOST …/oauth/{provider}/callbackaprès choix utilisateur. Migration Version20260420120000.
Table reservation_batch_message (FEAT-051)
- Messages du thread formateur ↔ école pour un lot donné (
reservation_batch_id→reservation_batch,sender_id→user). - Colonnes :
uuid(unique),content(TEXT),created_at(TIMESTAMPTZ immutable). Indexidx_reservation_batch_message_batchsurreservation_batch_id. - Migration Version20260404203000.
Table reservation_batch — idempotence réservation (BUG-041)
- Colonnes ajoutées :
idempotency_key(VARCHAR(255), nullable, unique)- Rôle : garantir l’idempotence des soumissions de réservation (public/staff) lors des retries réseau et doubles soumissions.
- Stratégie : la clé est portée en header HTTP
Idempotency-Keyet persistée sur le premier lot créé ; toute requête rejouée avec la même clé réutilise le lot existant. - Migration : Version20260416110000.
Messagerie directe / inbox (FEAT-065)
ConversationRepositoryétendAbstractServiceRepository(ORM) ; listes paginées exposées enPaginatedResultvia les handlersSearchConversationsHandler/SearchConversationMessagesHandler(même principe que recherche organisations +TrainerOrganizationSearchProvider).- Namespace PHP :
App\Domain\Entity\Messaging—Conversation,ConversationParticipant,Message; enumConversationType(direct,batch_thread,system). conversation:uuid,type,subject,reservation_batch_id(nullable, FK versreservation_batchpour futurs fils batch),last_message_at,last_message_preview,created_at,updated_at; indexidx_conversation_last_message,idx_conversation_type_batch(type,reservation_batch_id).conversation_participant:conversation_id,user_id,last_read_at,is_archived,joined_at; unique(conversation_id, user_id); indexidx_participant_unread(user_id,last_read_at).message:uuid,conversation_id,sender_id→user,content(TEXT),created_at,updated_at; indexidx_message_conversation_date(conversation_id,created_at).- Migration Version20260410120000.
Notifications in-app — dismiss, sous-types association / calendar (FEAT-066)
- Table
notification: colonneuuid(UUID, unique, non null après backfill) pour les appels API (ex. dismiss) ; colonnedismissed_at(TIMESTAMPTZ nullable) pour bannières / alertes dismissables. - Héritage JOINED (discriminant
typesurnotification) : nouvelles tablesassociation_notification(organization_id→organization,related_user_id→usernullable) etcalendar_notification(reservation_id→reservationnullable). - Migration Version20260410200000.
- Migration Version20260411120000 :
notification.uuid+ index unique.
Notifications Coffre-Fort — expiration et demandes de pièces (FEAT-236 / DISC-027)
- Héritage JOINED : deux sous-types supplémentaires dans le discriminant
notification.type: document_expiry→ tabledocument_expiry_notification:compliance_document_id(FK →trainer_compliance_document, NOT NULL),threshold(VARCHAR 20, enumDocumentExpiryThreshold:thirty_days_before,seven_days_before,on_expiry). Contrainte unique(compliance_document_id, threshold)— dédup par pièce et seuil.document_request→ tabledocument_request_notification:compliance_document_id(FK nullable,ON DELETE SET NULL),requested_type(VARCHAR 50 nullable, enumTrainerComplianceDocumentType). Socle STI pour les demandes organisation (dispatch V2-4) ; l'organisation demandeuse est portée dans lepayload(orgId,orgName).- Migration Version20260715120000 (DISC-027 squash : documents, notifications, partage, honorabilité).
Statut de lot FULFILLED (FEAT-051)
- Valeur VARCHAR supplémentaire dans
reservation_batch.status(enum PHPBatchStatus) : auto-fermeture des autres lots du même groupe lorsqu’un formateur accepte (first-claim). Ne pas confondre avecCANCELLED(annulation par l’école). Aucune migration de type enum SQL : la colonne existante accepte la nouvelle valeur.
🗄️ Schéma de base de données
Table magic_link_request — entité unifiée Magic Link (FEAT-187)
- Namespace PHP :
App\Domain\Entity\MagicLinkRequest; enumMagicLinkPurpose:login|trainer_activation. - Colonnes :
user_id(FK →user,ON DELETE CASCADE),purpose(VARCHAR 32),token_hash(VARCHAR 64, UNIQUE — hash SHA-256 du token brut ; le token brut n'existe que dans l'URL email),expires_at(TIMESTAMPTZ immutable),used(bool). - Index :
idx_magic_link_expires(expires_at),idx_magic_link_user_purpose(user_id,purpose). - Repository
MagicLinkRequestRepositoryInterface: rotateToken(user, purpose, tokenHash, expiresAt): invalide les tokens actifs (used = true) du couple(user, purpose), puis crée le nouveau (transaction).consumeValidToken(tokenHash, purpose): UPDATE atomiqueused = truesi non expiré et non consommé ; retourne l'entité ounull.findOneByTokenHash(tokenHash): lookup pour messages d'erreur login (expiré / déjà utilisé).- TTL applicatif : LOGIN 1 h (
RequestMagicLinkCommand) ; TRAINER_ACTIVATION 15 min (RequestTrainerActivationCommand). - Migration Version20260612180000 (DISC-025 squash, ex-INS-3) : ajout
purpose(défaut'login', invalidation de tous les tokens existants), renommagetoken→token_hash(VARCHAR 64), index(user_id, purpose);user.passwordnullable (comptes passwordless).
Diagramme ER complet
erDiagram
%% Tables principales
"user" {
int id PK
string firstname
string lastname
string email UK
string password "nullable"
array roles
string oauthProvider
string oauthProviderId UK
boolean acceptCgv
boolean acceptOptIn
boolean verifiedEmail
boolean finishedOnBoarding
string type
datetime createdAt
datetime updatedAt
}
"trainer" {
int id PK
string SIRET
string cv
string cv_analysis
json preferences
}
"organization" {
int id PK
string name
string organizationType
string service
phone_number phoneNumber
json preferences
}
%% Tables d'événements
"event" {
int id PK
int trainer_id FK
datetime date_start
datetime date_end
string type
string name
text description
}
"intervention_event" {
int id PK
int organization_id FK
string name
float amount
boolean is_approve_by_organization
datetime date_start
datetime date_end
}
"personal_event" {
int id PK
string name
text description
}
%% Tables d'opportunités
"opportunity" {
int id PK
int organization_id FK
string name
text description
datetime date_start
datetime date_end
string status
float budget
string location
}
"opportunity_proposal" {
int id PK
int opportunity_id FK
int trainer_id FK
int candidacy_id FK
string status
datetime createdAt
float proposedAmount
}
"candidacy" {
int id PK
int trainer_id FK
datetime date
string status
text motivation
float proposedAmount
}
%% Tables de facturation
"trainer_company" {
int id PK
string companyName
string siret
string address
string vatNumber
string iban
string bic
string defaultVatRate
string defaultUnit
string paymentTerm
text legalMentionVatExempt
text legalMentionPayment
}
"invoice" {
int id PK
int trainer_id FK
int organization_id FK
int intervention_event_id FK
string amount
string status
datetime issueDate
datetime dueDate
string invoiceNumber
}
%% Tables de profil
"trainer_experience" {
int id PK
int trainer_id FK
string title
string company
text description
datetime startDate
datetime endDate
boolean current
}
"trainer_education" {
int id PK
int trainer_id FK
string degree
string institution
string field
datetime graduationDate
string description
}
%% Tables de sécurité
"auth_code" {
int id PK
int user_id FK
string code
datetime expiresAt
boolean used
string type
}
"password_reset_request" {
int id PK
int user_id FK
string token
datetime expiresAt
boolean used
}
"magic_link_request" {
int id PK
int user_id FK
string purpose
string token_hash UK
datetime expiresAt
boolean used
}
"refresh_tokens" {
int id PK
string refresh_token UK
string username
datetime valid
}
"task" {
int id PK
int trainer_id FK
string title
text description
string status
datetime dueDate
int priority
}
%% Relations
"user" ||--|| "trainer" : "inherits"
"user" ||--|| "organization" : "inherits"
"trainer" ||--o| "trainer_company" : "has"
"trainer" ||--o{ "event" : "has"
"trainer" ||--o{ "candidacy" : "has"
"trainer" ||--o{ "invoice" : "has"
"trainer" ||--o{ "trainer_experience" : "has"
"trainer" ||--o{ "trainer_education" : "has"
"trainer" ||--o{ "task" : "has"
"trainer" ||--o{ "opportunity_proposal" : "proposes"
"organization" ||--o{ "intervention_event" : "has"
"organization" ||--o{ "opportunity" : "has"
"organization" ||--o{ "invoice" : "has"
"opportunity" ||--o{ "opportunity_proposal" : "has"
"candidacy" ||--o{ "opportunity_proposal" : "linked_to"
"intervention_event" ||--o{ "invoice" : "generates"
"user" ||--o{ "auth_code" : "has"
"user" ||--o{ "password_reset_request" : "has"
"user" ||--o{ "magic_link_request" : "has"
🏗️ Entités détaillées
1. User (Entité abstraite)
#[ORM\Entity]
#[ORM\Table(name: '`user`')]
#[ORM\UniqueConstraint(name: 'UNIQ_IDENTIFIER_EMAIL', fields: ['email'])]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string')]
#[ORM\DiscriminatorMap([
'trainer' => Trainer::class,
'organization' => Organization::class
])]
abstract class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $firstname = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $lastname = null;
#[ORM\Column(length: 180)]
private ?string $email = null;
#[ORM\Column]
private array $roles = [];
#[ORM\Column(nullable: true)]
private ?string $password = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $oauthProvider = null;
#[ORM\Column(length: 255, nullable: true, unique: true)]
private ?string $oauthProviderId = null;
#[ORM\Column(type: 'boolean', nullable: true)]
private bool $acceptCgv = false;
#[ORM\Column(type: 'boolean', nullable: true)]
private bool $acceptOptIn = false;
#[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])]
private bool $verifiedEmail = false;
#[ORM\Column(type: 'boolean', nullable: false, options: ['default' => false])]
private bool $finishedOnBoarding = false;
}
Caractéristiques :
- Héritage : Table-per-class avec discriminator
- Authentification : Implémente UserInterface ; password nullable (FEAT-187) pour les formateurs créés via activation Magic Link (CreateNewTrainerCommand::processActivation) — mot de passe optionnel à la vérification (PerformTrainerActivationCommand).
- OAuth : Support multi-providers
- Onboarding : Suivi du processus d'inscription
- registered_at (TIMESTAMP, NOT NULL) : date d’inscription ; utilisée notamment pour calculer la fin de fenêtre sample data (registered_at + teadle.sample_data_duration_days, défaut 14). Migration Version20260429120000 : ajout de la colonne avec back-fill NOW() pour les comptes existants.
- last_activity_at (TIMESTAMP, nullable) : dernière activité authentifiée ; remplie au plus une fois par jour par UserActivitySubscriber (FEAT-197, rétention J30). NULL = jamais revenu après inscription. Migration Version20260612180000 (DISC-025 squash).
2. Trainer (Hérite de User)
#[ORM\Entity]
class Trainer extends User
{
public const TYPE = 'trainer';
#[ORM\Column(length: 255, nullable: true)]
private ?string $SIRET = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $cv = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $cvAnalysis = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $preferences = null;
/**
* @var Collection<int, Candidacy>
*/
#[ORM\OneToMany(mappedBy: 'trainer', targetEntity: Candidacy::class)]
private Collection $candidacies;
/**
* @var Collection<int, Event>
*/
#[ORM\OneToMany(mappedBy: 'trainer', targetEntity: Event::class)]
private Collection $events;
/**
* @var Collection<int, Invoice>
*/
#[ORM\OneToMany(mappedBy: 'trainer', targetEntity: Invoice::class)]
private Collection $invoices;
}
Caractéristiques :
- SIRET : Numéro d'identification professionnel
- CV : Stockage du CV et analyse
- Préférences : Configuration JSON
- Relations : Candidatures, événements, factures
- Onboarding (DISC-016 / FEAT-086, FEAT-087) : onboarding_completed_steps (JSON, existant) ; Version20260429120000 — checklist_dismissed_at, onboarding_completed_at (5/5 étapes), sample_data_dismissed_at ; Version20260430120000 — first_real_reservation_received_at (1ère vraie ReservationBatch, désactive le sample data). L’état agrégé est exposé sur GET /me (formateur) dans onboardingProgress (voir GetTrainerOnboardingProgressHandler).
- Profil public (DISC-025 / FEAT-184, INS-1) : specialite, headline, bio, languages (JSON, liste de chaînes), slug (VARCHAR 255, UNIQUE, nullable — URL publique {base}/profile/{slug}), is_profile_public (bool, défaut false), is_search_indexable (bool, défaut true, FEAT-206 PUB-1a — indexation moteurs, consommé par PUB-2), show_public_agenda (bool, défaut false, FEAT-214 PUB-11 — opt-in publication agenda public free/busy grain semaine ; écriture FEAT-215 CV-7 via PATCH /trainer), is_open_to_requests (bool, défaut false, opt-in « recevoir des demandes », distinct de la publication du profil). Génération du slug : App\Domain\Service\Trainer\TrainerSlugGenerator (base prenom-nom via TextSlugifierInterface, collisions -2, -3, … ; impl. infra SymfonyTextSlugifier). Lookup : TrainerRepositoryInterface::findBySlug(). Migration : Version20260612180000 (DISC-025 squash complet). Exposition API publique : GET /public/trainer/{slug} (FEAT-206) ; agenda free/busy : GET /public/trainer/{slug}/availability (FEAT-214). SSR crawlers : GET /seo/trainer/{slug} (FEAT-208).
- Brouillon enrichissement B3 (DISC-025 / FEAT-193, INS-9) : enrichment_draft (JSON, nullable) — snapshot partiel { experiences, education, skills[{ label, status, skillReferenceUuid? }], reviewSource? } pour « Sauvegarder et continuer plus tard » ; non exposé dans /me. API : GET|PUT|DELETE /trainer/enrichment/draft. Migration Version20260612180000 (DISC-025 squash).
- Compétences (DISC-025 / FEAT-185, INS-2a/2b) : skill_reference (référentiel global : uuid public, label, normalized_label UNIQUE, category, source ESCO|ROME|CUSTOM, esco_uri, rome_code) + trainer_skill (FK trainer_id, FK nullable skill_reference_id, label dénormalisé, validated def false, position top 5, source tooltip = origine validation ex. org CRA, uuid public). validated/source recalculés async (SyncTrainerSkillValidations) par match normalisé titre ligne CRA SENT ↔ label compétence. Collection Trainer::trainerSkills (cascade persist/remove, orphanRemoval). Repo : SkillReferenceRepositoryInterface (findByUuid, findByNormalizedLabel, findByCategory, search). Seed V1 curé (17 compétences) + extension pg_trgm : migration Version20260612180000 (DISC-025 squash complet — profil public, contact, agenda, RGPD, analytics inclus). Autocomplete : GET /public/skill/search?q= (FEAT-186, réponse { uuid, label, category }).
- Demande de contact GUEST (DISC-025 / SEC-004, PUB-10a) : table trainer_public_contact_request — FK trainer_id, champs demandeur (requester_name, requester_email, organisation, message), status (pending_verification | verified), dedupe_hash (index), verification_token (UNIQUE, 64 hex), verification_expires_at, created_at, verified_at. Entité TrainerPublicContactRequest ; repo TrainerPublicContactRequestRepositoryInterface. Migration Version20260612180000 (DISC-025 squash complet). API : POST /public/trainer/{slug}/contact (202 générique R21). Événement TrainerPublicContactRequestCreatedEvent (PUB-10b : email vérif + notif formateur). Distinct de trainer_organization_contact (contacts staff/org).
- Cycle de vie du compte formateur (DISC-026 / FEAT-224) : sur trainer — account_status (App\Domain\Enum\AccountStatus : active | deactivated | deletion_pending | deleted, défaut active), deactivated_at, deletion_requested_at, scheduled_deletion_at, deletion_reason (App\Domain\Enum\AccountDeletionReason, nullable — exit survey étape 2) ; index idx_trainer_account_status. Helpers entité : isActive(), isDeactivated(), isPendingDeletion(), isDeleted(). Transitions : FEAT-226 — POST /trainer/deactivate ; réactivation auto login + FEAT-227 — POST /trainer/reactivate manuel ; FEAT-229 — POST /trainer/request-deletion (grace 30 j, corps optionnel { reason }), POST /trainer/cancel-deletion, effacement cron 02:00 via AnonymizeTrainerCommand ; GET /me expose accountStatus et scheduledDeletionAt (DTO User\Response\Trainer, Chemin A / FEAT-232). Migrations Version20260624120000 (squash DISC-026, incl. motif exit survey).
- Registre audit effacement compte (DISC-026 / FEAT-229) : table account_deletion_log — trainer_id, email_hmac (VARCHAR 64, empreinte HMAC-SHA256 de l'email normalisé), key_version (VARCHAR 16), erased_at, reason (AccountDeletionReason, nullable — copié depuis trainer.deletion_reason à l'anonymisation ; inclut inactivity pour suppressions auto FEAT-260). Index idx_account_deletion_log_email_hmac (email_hmac, key_version), idx_account_deletion_log_trainer_id. Entité AccountDeletionLog ; repo AccountDeletionLogRepositoryInterface. Écriture dans AnonymizeTrainerCommand avant scrub PII. Vérification : AccountDeletionVerifier + commande app:account-deletion:verify. Clés : env ACCOUNT_DELETION_HMAC_KEY / ACCOUNT_DELETION_HMAC_KEY_VERSION. Migration Version20260624120000 (squash DISC-026, incl. motif exit survey).
- Trace séquence inactivité (DISC-026 / FEAT-260) : table account_inactivity_notice — trainer_id, threshold (AccountInactivityThreshold), sent_at. Contrainte unique (trainer_id, threshold) ; index idx_inactivity_notice_trainer. Idempotence du scan quotidien ; purge deleteByTrainerId à la réactivation. Migration Version20260730140000.
- Export données compte (DISC-026 / FEAT-230) : table account_data_export — FK trainer_id, token (UNIQUE, 64 hex), s3_key, created_at, expires_at, last_sent_at, relance_sent_at. Entité AccountDataExport ; repo AccountDataExportRepositoryInterface. ZIP S3 {trainerUuid}/exports/{token}.zip. Migration Version20260624120000 (squash DISC-026).
3. TrainerCompany (Entreprise du formateur)
Entité liée au formateur (OneToOne) : identité (nom, SIRET, adresse, statut juridique, NDA, capital, email / téléphone de contact), infos bancaires (IBAN, BIC). La présentation facture (mentions, corps d’e-mail, logo) est sur TrainerBillingSettings.
nda(VARCHAR(11), nullable) — numéro de déclaration d’activité (NDA, récépissé DREETS) : 11 chiffres stockés sans espaces (TrainerCompany::setNda()strippe les espaces). Source de vérité pour la pièceactivity_declaration(nature « numéro », FEAT-263) — jamais persistée comme fichier danstrainer_compliance_document.- Relation : Un formateur a au plus une entreprise.
3bis. TrainerBillingSettings (Paramètres de facturation) — FEAT-017, BUG-031
Entité OneToOne avec Trainer : paramètres par défaut pour la facturation (tarif, TVA, unité, délai de paiement), numérotation des factures (art. 289 CGI), personnalisation des documents et coordonnées / contenus propres à la facturation.
- Champs tarif :
defaultPrice,defaultVatRate,defaultUnit,paymentTerm. - Numérotation facture :
invoicePrefixTemplate(template avec variables(annee),(mois),(jour)),invoiceNextSequence,invoiceLastResolvedPrefix,invoiceSequenceLocked,invoiceLockedYear. Génération du code facture viaInvoiceNumberGenerator(Domain/Service/Invoicing) ; verrouillage de la séquence après première finalisation par année fiscale ; déverrouillage au changement d’année. - Numérotation CRA (UX-039, même moteur que factures) :
activityReportPrefixTemplate(défautCRA-(annee)(mois)-),activityReportNextSequence,activityReportLastResolvedPrefix; génération viaInvoiceNumberGenerator::previewActivityReport/ consommation de séquence ; pas de verrouillage CRA comme pour les factures. - Numérotation avoir (FEAT-267, calque facture) :
creditNotePrefixTemplate(défautAV-(annee)(mois)-),creditNoteNextSequence,creditNoteLastResolvedPrefix,creditNoteSequenceLocked,creditNoteLockedYear. Génération viaInvoiceNumberGenerator::generateCreditNote/previewCreditNote/validateCreditNoteTemplate; garde(annee)et verrou d’exercice identiques à la facture ; série autonome (distincte des factures). Aucun consommateur métier en V0 (entitéCreditNote: FEAT-A2). - Branding :
accent_color,font_size(chaîne stockée : valeurs enumBillingDocumentFontSize:small,normal,large),logo(UUID fichier sur S3, dossier{trainerUuid}/billing/logo/). - Mentions légales (structurées) :
legal_mentions_penalties,legal_mentions_confidentiality_enabled,legal_mentions_confidentiality,legal_mentions_custom_enabled,legal_mentions_custom; assemblage texte pour PDF / config viaBillingLegalMentionsComposer. - Email / coordonnées :
invoice_email_body,billing_email,billing_phone(optionnels). - Migrations : … ; Version20260802200000 (FEAT-267/268) : numérotation avoir sur
trainer_billing_settings+ tablesinvoicing_credit_note/invoicing_credit_note_line_item.
4. Organization (Hérite de User)
#[ORM\Entity]
class Organization extends User
{
public const TYPE = 'organization';
#[ORM\Column(length: 255, nullable: true)]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $organizationType = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $service = null;
#[ORM\Column(type: 'phone_number', nullable: true)]
private ?PhoneNumber $phoneNumber = null;
#[ORM\Column(type: 'json', nullable: true)]
private ?array $preferences = null;
/**
* @var Collection<int, Opportunity>
*/
#[ORM\OneToMany(mappedBy: 'organization', targetEntity: Opportunity::class)]
private Collection $opportunities;
/**
* @var Collection<int, InterventionEvent>
*/
#[ORM\OneToMany(mappedBy: 'organization', targetEntity: InterventionEvent::class)]
private Collection $interventionEvents;
}
Caractéristiques : - Informations : Nom, type, service - Téléphone : Value Object PhoneNumber - Préférences : Configuration JSON - Relations : Opportunités, interventions
5. Event (Événements)
#[ORM\Entity]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string')]
#[ORM\DiscriminatorMap([
'intervention' => InterventionEvent::class,
'personal' => PersonalEvent::class
])]
abstract class Event
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'events')]
#[ORM\JoinColumn(nullable: false)]
private ?Trainer $trainer = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $dateStart = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $dateEnd = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
}
Caractéristiques : - Héritage : Différents types d'événements - Planning : Dates de début et fin - Trainer : Propriétaire de l'événement
6. Opportunity (Opportunités)
#[ORM\Entity]
class Opportunity
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'opportunities')]
#[ORM\JoinColumn(nullable: false)]
private ?Organization $organization = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column(type: 'text', nullable: true)]
private ?string $description = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $dateStart = null;
#[ORM\Column(type: 'datetime_immutable')]
private ?DateTimeImmutable $dateEnd = null;
#[ORM\Column(length: 50)]
private ?string $status = null;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2, nullable: true)]
private ?string $budget = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $location = null;
/**
* @var Collection<int, OpportunityProposal>
*/
#[ORM\OneToMany(mappedBy: 'opportunity', targetEntity: OpportunityProposal::class)]
private Collection $proposals;
}
Caractéristiques : - Organisation : Créateur de l'opportunité - Planning : Dates et localisation - Budget : Montant disponible - Statut : État de l'opportunité
🔧 Value Objects
1. PhoneNumber
#[ORM\Embeddable]
class PhoneNumber
{
#[ORM\Column(length: 20)]
private string $value;
public function __construct(string $value)
{
$this->validate($value);
$this->value = $value;
}
private function validate(string $value): void
{
// Validation avec libphonenumber-for-php
$phoneUtil = PhoneNumberUtil::getInstance();
$phoneNumber = $phoneUtil->parse($value, 'FR');
if (!$phoneUtil->isValidNumber($phoneNumber)) {
throw new InvalidPhoneNumberException('Invalid phone number');
}
}
public function getValue(): string
{
return $this->value;
}
public function __toString(): string
{
return $this->value;
}
}
Caractéristiques : - Validation : Utilise libphonenumber-for-php - Immutabilité : Pas de modification après création - Embeddable : Intégré dans d'autres entités
📊 Index et contraintes
1. Index de performance
Index critiques (PERF-011) sur les colonnes fréquemment utilisées dans WHERE, JOIN et ORDER BY :
-- ReservationBatch : trainer_id, status, email, response_deadline
CREATE INDEX idx_reservation_batch_trainer ON reservation_batch (trainer_id);
CREATE INDEX idx_reservation_batch_status ON reservation_batch (status);
CREATE INDEX idx_reservation_batch_email ON reservation_batch (email);
CREATE INDEX idx_reservation_batch_deadline ON reservation_batch (response_deadline);
-- Event : trainer_id, plage de dates (planning)
CREATE INDEX idx_event_trainer ON event (trainer_id);
CREATE INDEX idx_event_dates ON event (date_start, date_end);
-- TrainerOrganizationAssignment : organization_id seul (trainer_id couvert par UniqueConstraint)
CREATE INDEX idx_toa_organization ON trainer_organization_assignment (organization_id);
Autres index :
-- Index sur les emails (unique)
CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email);
-- Index sur les providers OAuth
CREATE INDEX IDX_USER_OAUTH_PROVIDER ON "user" (oauthProvider, oauthProviderId);
-- Index sur les opportunités par organisation
CREATE INDEX IDX_OPPORTUNITY_ORG ON opportunity (organization_id, status);
-- Index sur les candidatures par formateur
CREATE INDEX IDX_CANDIDACY_TRAINER ON candidacy (trainer_id, status);
Index PERF-011 (Version20260316205032) – entités critiques :
| Table | Index | Colonnes | Usage |
|---|---|---|---|
reservation_batch |
idx_reservation_batch_trainer |
trainer_id | findByTrainer |
reservation_batch |
idx_reservation_batch_status |
status | findByTrainerNewAndPending |
reservation_batch |
idx_reservation_batch_email |
findByEmail | |
reservation_batch |
idx_reservation_batch_deadline |
response_deadline | requêtes sur échéances |
event |
idx_event_trainer |
trainer_id | planning par formateur |
event |
idx_event_dates |
date_start, date_end | plage de dates |
trainer_organization_assignment |
idx_toa_organization |
organization_id | lookups par organisation |
2. Contraintes de données
-- Contrainte sur les rôles
ALTER TABLE "user" ADD CONSTRAINT check_roles CHECK (roles IS NOT NULL AND array_length(roles, 1) > 0);
-- Contrainte sur les dates d'événements
ALTER TABLE event ADD CONSTRAINT check_event_dates CHECK (date_start < date_end);
-- Contrainte sur les montants
ALTER TABLE opportunity ADD CONSTRAINT check_budget CHECK (budget IS NULL OR budget > 0);
-- Contrainte sur les statuts
ALTER TABLE opportunity ADD CONSTRAINT check_status CHECK (status IN ('draft', 'published', 'closed', 'cancelled'));
🔄 Migrations
1. Structure des migrations
// Exemple : Version20250808213946.php
final class Version20250808213946 extends AbstractMigration
{
public function up(Schema $schema): void
{
// Création de la table user
$this->addSql(<<<'SQL'
CREATE TABLE "user" (
id SERIAL NOT NULL,
firstname VARCHAR(255) DEFAULT NULL,
lastname VARCHAR(255) DEFAULT NULL,
email VARCHAR(180) NOT NULL,
roles JSON NOT NULL,
password VARCHAR(255) NOT NULL,
oauthProvider VARCHAR(255) DEFAULT NULL,
oauthProviderId VARCHAR(255) DEFAULT NULL,
acceptCgv BOOLEAN DEFAULT false,
acceptOptIn BOOLEAN DEFAULT false,
verifiedEmail BOOLEAN DEFAULT false NOT NULL,
finishedOnBoarding BOOLEAN DEFAULT false NOT NULL,
type VARCHAR(255) NOT NULL,
PRIMARY KEY(id)
)
SQL);
// Contraintes d'unicité
$this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL ON "user" (email)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_USER_OAUTH_PROVIDER_ID ON "user" (oauthProviderId)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE "user"');
}
}
2. Commandes de migration
# Générer une migration (obligatoire après changement d'entité — ne pas écrire le SQL à la main)
php bin/console doctrine:migrations:diff
# Exécuter les migrations
php bin/console doctrine:migrations:migrate
# Annuler la dernière migration
php bin/console doctrine:migrations:migrate prev
CI (doctrine:schema:validate) : après migrate, le schéma PostgreSQL doit être identique au mapping Doctrine. Échec typique si la migration utilise des noms d'index/FK « lisibles » (UNIQ_MAGIC_LINK_TOKEN) au lieu des noms générés par Doctrine (UNIQ_94AE28765F37A13B). Workflow recommandé :
- Modifier l'entité dans
src/Domain/Entity/ php bin/console doctrine:migrations:diff(revoir le diff généré)php bin/console doctrine:migrations:migratepuisdoctrine:schema:validate- En cas de doute :
doctrine:schema:update --dump-sqldoit être vide après migrate
3. Migrations récentes (exemples)
- Version20260624120000 (DISC-026 squash — FEAT-224 + FEAT-225 + FEAT-229 + FEAT-230) : cycle de vie compte formateur sur
trainer—account_status(NOT NULL, défautactive),deactivated_at,deletion_requested_at,scheduled_deletion_at; indexidx_trainer_account_status.invoicing_invoice.trainer_idnullable + FKFK_B7805BCEFB08EDF6ON DELETE SET NULL(conservation factures L123-22 à la suppression formateur ; snapshot émetteur = FEAT-004). Tablesaccount_deletion_log(registre audit HMAC email) etaccount_data_export(lien export DSAR + comptable tokenisé, 30j ré-émissible). - Version20260612180000 (DISC-025 squash — remplace Version20260612072107 … Version20260612170000) :
- INS-1 : sur
trainer—specialite,headline,bio,languages(JSON),slug(UNIQUE),is_profile_public,is_open_to_requests; indexUNIQ_C5150820989D9B62. - INS-2a/2b : tables
skill_reference(avecuuidUNIQUE + seed 17 compétences) ettrainer_skill(avecuuidUNIQUE) ; extensionpg_trgm. - INS-3 :
magic_link_request.purpose,token→token_hash, index(user_id, purpose);user.passwordDROP NOT NULL. - INS-9 :
trainer.enrichment_draft(JSON). - INS-12 :
user.last_activity_at. - CV-1 : table
trainer_certification(avecuuidUNIQUE). - CV-2 : colonne
uuidUNIQUE surtrainer_experience,trainer_education(backfillgen_random_uuid()).
Table trainer_education
school_name,degree:VARCHAR(512)(migration Version20260730120000) — élargi depuis 255 pour titres RNCP longs et import CV ; validation APIAssert\Length(max: 512)sur create/update.- Version20260430120000 (FEAT-087) :
trainer.first_real_reservation_received_at(TIMESTAMP NULL) — date de la 1ère vraie reservation batch pendant la fenêtre sample data. - Version20260430153000 (DISC-019 / FEAT-107) :
process.created_at+process.ended_at; surcalendar_configuratorajout decurrent_sync_process_id(FK),consecutive_failures,last_sync_end_date. - Version20260429120000 (FEAT-086) :
user.registered_at(NOT NULL, back-fillNOW()) ; surtrainer:checklist_dismissed_at,onboarding_completed_at,sample_data_dismissed_at; back-fillonboarding_completed_atquandjsonb_array_length(onboarding_completed_steps::jsonb) = 5. - Version20260404190000 (FEAT-050) :
reservation_batch.staff_last_view_at(TIMESTAMP TZ NULL) — dernière consultation de la réponse par un utilisateur staff ; sert au flagisNewResponsecôté API école. - Version20260316205032 (PERF-011) : index de performance sur entités critiques.
reservation_batch: trainer_id, status, email, response_deadline.event: trainer_id, date_start/date_end.trainer_organization_assignment: organization_id. - Version20260219120000 (FEAT-003) : ajout de
intervention_date(DATE NULL) surinvoicing_invoice_line_item. Date de l'intervention dupliquée depuis l'événement pour traçabilité de la facture (non modifiable par API). - Version20260303100000 (FEAT-010) : ajout de
public_id(VARCHAR 10, UNIQUE, NOT NULL) surinvoicing_invoice. ID technique court de 10 caractères alphanumériques (alphabet sans ambiguïté), généré à la création, distinct du numéro de facture légal (code). Permet de référencer les factures même en brouillon. - Version20260305140000 (BUG-031) : numérotation facture art. 289 CGI. Sur
trainer_billing_settings:invoice_prefix_template,invoice_next_sequence,invoice_last_resolved_prefix,invoice_sequence_locked,invoice_locked_year. Migration des compteurs depuistrainer.last_invoice_number. - Version20260306120000 (UX-039) : numérotation CRA sur
trainer_billing_settings(même moteur que factures) :activity_report_prefix_template,activity_report_next_sequence,activity_report_last_resolved_prefix,activity_report_sequence_locked,activity_report_locked_year. Migration des séquences depuistrainer.last_activity_report_number. - Version20260802200000 (FEAT-267/268) : numérotation avoir sur
trainer_billing_settings+ tablesinvoicing_credit_note/invoicing_credit_note_line_item(brouillon sanscode,public_iddès la création, index partiel un brouillon par facture). - Version20260811100000 (BUG-284) :
issue_date(DATE nullable, calqueinvoicing_invoice.issue_date) surinvoicing_credit_note— date d'émission du document, figée à la finalisation ; sans backfill (repli consommateurs :getIssueDate() ?? getCreatedAt()). - Version20260306150000 (Brevo #20) : ajout de
send_trainer_summary_organization_info_date(TIMESTAMP NULL) surcalendar_configurator. Sert de flag d'idempotence pour l'email de synthèse organisation envoyé au formateur après validation de ses tarifs. Si non-null, l'email n'est pas renvoyé pour ce couple trainer/organisation. - Version20260303140000 (BUG-034) : relation CRA↔Facture en ManyToOne. Suppression des contraintes UNIQUE sur
invoicing_invoice(activity_report_id),invoicing_activity_report_line_item(intervention_event_id)etinvoicing_invoice_line_item(intervention_event_id); ajout d’index simples pour les jointures. Un CRA peut avoir plusieurs factures (ex. correction) ; un même événement peut apparaître dans plusieurs CRA et plusieurs factures. - Version20260219140000 (FACT-07) : sur
trainer_company, ajout dedefault_vat_rate,default_unit,payment_term,legal_mention_vat_exempt,legal_mention_payment(paramètres de facturation par défaut et mentions légales). - Version20260326120000 inclut aussi la suppression sur
trainer_companydedefault_legal_mentions,email_template,logo_url(déjà portés partrainer_billing_settings).
# Voir le statut des migrations
php bin/console doctrine:migrations:status
# Valider le schéma
php bin/console doctrine:schema:validate
🎯 Optimisations
1. Requêtes optimisées
// Repository avec requêtes optimisées
class TrainerRepository extends ServiceEntityRepository
{
public function findWithEvents(int $trainerId): ?Trainer
{
return $this->createQueryBuilder('t')
->leftJoin('t.events', 'e')
->addSelect('e')
->where('t.id = :trainerId')
->setParameter('trainerId', $trainerId)
->getQuery()
->getOneOrNullResult();
}
public function findWithIncomeData(int $trainerId, DateTimeImmutable $startDate, DateTimeImmutable $endDate): array
{
return $this->createQueryBuilder('t')
->leftJoin('t.invoices', 'i')
->leftJoin('i.interventionEvent', 'ie')
->addSelect('i', 'ie')
->where('t.id = :trainerId')
->andWhere('i.issueDate BETWEEN :startDate AND :endDate')
->setParameter('trainerId', $trainerId)
->setParameter('startDate', $startDate)
->setParameter('endDate', $endDate)
->getQuery()
->getResult();
}
}
2. Cache Doctrine
# config/packages/doctrine.yaml
doctrine:
orm:
metadata_cache_driver:
type: pool
pool: doctrine.system_cache_pool
query_cache_driver:
type: pool
pool: doctrine.system_cache_pool
result_cache_driver:
type: pool
pool: doctrine.result_cache_pool
🔒 Sécurité des données
1. Chiffrement des données sensibles
// Exemple : Chiffrement du CV
class Trainer extends User
{
#[ORM\Column(type: 'encrypted_text', nullable: true)]
private ?string $cv = null;
#[ORM\Column(type: 'encrypted_json', nullable: true)]
private ?array $cvAnalysis = null;
}
2. Audit des modifications
// Trait pour l'audit
trait AuditableTrait
{
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
#[ORM\PrePersist]
public function setCreatedAt(): void
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
#[ORM\PreUpdate]
public function setUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}
📈 Monitoring et maintenance
1. Requêtes de monitoring
-- Taille des tables
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
-- Index non utilisés
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY schemaname, tablename;
-- Requêtes lentes
SELECT
query,
calls,
total_time,
mean_time,
rows
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;
2. Maintenance automatique
-- Nettoyage des tokens expirés
DELETE FROM password_reset_request
WHERE expires_at < NOW();
DELETE FROM auth_code
WHERE expires_at < NOW();
-- Archivage des événements anciens
INSERT INTO event_archive
SELECT * FROM event
WHERE date_end < NOW() - INTERVAL '2 years';
DELETE FROM event
WHERE date_end < NOW() - INTERVAL '2 years';
Cette architecture de base de données garantit la performance, la sécurité et la maintenabilité du système Teadle.