Aller au contenu

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’index uniq_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 à un staff) peuvent coexister avec email NULL (comportement PostgreSQL sur les contraintes UNIQUE avec NULL).

Tables document, annexes facturation et documents entreprise

  • Namespace PHP : App\Domain\Entity\DocumentDocument (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_at en datetimetz_immutableupdated_at rafraîchi via #[ORM\PreUpdate]) ; TrainerDocumentBillingAttachment ajoute trainer_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 sur invoicing_invoice.
  • Version20260326120000 (bloc fusionné) : création en une fois de document (avec created_at et updated_at), trainer_document_billing_attachment (discriminant trainer_billing_attachment) et trainer_document_company (documents entreprise, discriminant trainer_company) ; FK *.iddocument.id, trainer_idtrainer.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_id NULL) 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 (INVOICE ou ACTIVITY_REPORT), is_default, active (bool, défaut truefalse = désactivation logique, plus listé ni accessible en GET), use_billing_settings_appearance (bool, défaut true), settings (JSON), logo_url, created_at, updated_at.
  • Contrainte : index unique partiel uniq_billing_template_default_per_type sur (trainer_id, type) avec clause WHERE is_default = true pour garantir un seul modèle par défaut par type côté trainer.
  • Version20260326120000 (suite) : table billing_template sans colonne is_system (système = trainer_id NULL), FK trainer_id en ON DELETE CASCADE, seed des 2 modèles système avec settings JSON minimal (accent Teadle, typo, header.showLogo). Voir aussi .doc/backend/BILLING_TEMPLATE_SYSTEM_DEFAULTS.md.
  • Fixtures Alice : fixtures/billing_template.yaml recrée ces 2 lignes après purge (hautelook:alice:doctrine:load), avec les mêmes UUID et le même JSON settings que la migration.
  • Version20260330113000 : ajout de use_billing_settings_appearance (BOOLEAN NOT NULL DEFAULT TRUE) pour piloter l’héritage visuel (logo, couleur, taille typo) depuis trainer_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 : enum OrganizationColor) : seule source de vérité pour la couleur agenda / avatar (couple formateur–organisation). Attribuée à la création de l’assignation (TrainerOrganizationAssignmentRepository::assign via CalendarColorPicker::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 depuis calendar_configurator + backfill), Version20260401160000 (suppression de la colonne color sur calendar_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é par NotifyOrganizationsOfTrainerDepartureHandler avant le scrub du profil Trainer ; relu par ListStaffComplianceIntervenantsHandler pour 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 lorsque is_last_import_successful est false. Alimenté par RecordTrainerCalendarConfiguratorSyncFailureCommand (échec fetch URL/fichier ICS dans TrainerCalendarConfiguratorSynchronizer, ou erreur pendant PersistTrainerCalendarConnectorEventsCommand), effacé au succès. Le processus async expose aussi sync_error_code dans les métadonnées du Process. Migration Version20260401170000.
  • DISC-019 (FEAT-107) :
  • current_sync_process_id (FK nullable vers process.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éfaut 0) : 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 le Process est 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). Valeur ical_file : import par fichier ; la clé objet S3 du .ics est {trainerUuid}/ics/{calendarConfiguratorUuid}.ics (voir TrainerCalendarIcalFileStorageKeyResolverInterface), sans colonne de chemin. Lors d’un import dont la stratégie connecteur est ical_file, PersistTrainerCalendarConnectorEventsCommand force provider_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é dans CalendarConfiguratorResponse ; accepté en option sur ajout lien / upload iCal. Migrations Version20260401180000 ; suppression de ical_file_storage_path : Version20260414130000.

  • oauth_calendar_id (VARCHAR(512), nullable) : identifiant d’agenda côté fournisseur OAuth (Google calendarList id, Outlook calendars id). Renseigné au POST …/oauth/{provider}/callback après choix utilisateur. Migration Version20260420120000.

Table reservation_batch_message (FEAT-051)

  • Messages du thread formateur ↔ école pour un lot donné (reservation_batch_idreservation_batch, sender_iduser).
  • Colonnes : uuid (unique), content (TEXT), created_at (TIMESTAMPTZ immutable). Index idx_reservation_batch_message_batch sur reservation_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-Key et 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 étend AbstractServiceRepository (ORM) ; listes paginées exposées en PaginatedResult via les handlers SearchConversationsHandler / SearchConversationMessagesHandler (même principe que recherche organisations + TrainerOrganizationSearchProvider).
  • Namespace PHP : App\Domain\Entity\MessagingConversation, ConversationParticipant, Message ; enum ConversationType (direct, batch_thread, system).
  • conversation : uuid, type, subject, reservation_batch_id (nullable, FK vers reservation_batch pour futurs fils batch), last_message_at, last_message_preview, created_at, updated_at ; index idx_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) ; index idx_participant_unread (user_id, last_read_at).
  • message : uuid, conversation_id, sender_iduser, content (TEXT), created_at, updated_at ; index idx_message_conversation_date (conversation_id, created_at).
  • Migration Version20260410120000.

Notifications in-app — dismiss, sous-types association / calendar (FEAT-066)

  • Table notification : colonne uuid (UUID, unique, non null après backfill) pour les appels API (ex. dismiss) ; colonne dismissed_at (TIMESTAMPTZ nullable) pour bannières / alertes dismissables.
  • Héritage JOINED (discriminant type sur notification) : nouvelles tables association_notification (organization_idorganization, related_user_iduser nullable) et calendar_notification (reservation_idreservation nullable).
  • 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 → table document_expiry_notification : compliance_document_id (FK → trainer_compliance_document, NOT NULL), threshold (VARCHAR 20, enum DocumentExpiryThreshold : thirty_days_before, seven_days_before, on_expiry). Contrainte unique (compliance_document_id, threshold) — dédup par pièce et seuil.
  • document_request → table document_request_notification : compliance_document_id (FK nullable, ON DELETE SET NULL), requested_type (VARCHAR 50 nullable, enum TrainerComplianceDocumentType). Socle STI pour les demandes organisation (dispatch V2-4) ; l'organisation demandeuse est portée dans le payload (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 PHP BatchStatus) : auto-fermeture des autres lots du même groupe lorsqu’un formateur accepte (first-claim). Ne pas confondre avec CANCELLED (annulation par l’école). Aucune migration de type enum SQL : la colonne existante accepte la nouvelle valeur.

🗄️ Schéma de base de données

  • Namespace PHP : App\Domain\Entity\MagicLinkRequest ; enum MagicLinkPurpose : 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 atomique used = true si non expiré et non consommé ; retourne l'entité ou null.
  • 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), renommage tokentoken_hash (VARCHAR 64), index (user_id, purpose) ; user.password nullable (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) ; Version20260429120000checklist_dismissed_at, onboarding_completed_at (5/5 étapes), sample_data_dismissed_at ; Version20260430120000first_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 traineraccount_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-226POST /trainer/deactivate ; réactivation auto login + FEAT-227POST /trainer/reactivate manuel ; FEAT-229POST /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_logtrainer_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_noticetrainer_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èce activity_declaration (nature « numéro », FEAT-263) — jamais persistée comme fichier dans trainer_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 via InvoiceNumberGenerator (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éfaut CRA-(annee)(mois)-), activityReportNextSequence, activityReportLastResolvedPrefix ; génération via InvoiceNumberGenerator::previewActivityReport / consommation de séquence ; pas de verrouillage CRA comme pour les factures.
  • Numérotation avoir (FEAT-267, calque facture) : creditNotePrefixTemplate (défaut AV-(annee)(mois)-), creditNoteNextSequence, creditNoteLastResolvedPrefix, creditNoteSequenceLocked, creditNoteLockedYear. Génération via InvoiceNumberGenerator::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 enum BillingDocumentFontSize : 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 via BillingLegalMentionsComposer.
  • Email / coordonnées : invoice_email_body, billing_email, billing_phone (optionnels).
  • Migrations : … ; Version20260802200000 (FEAT-267/268) : numérotation avoir sur trainer_billing_settings + tables invoicing_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 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é :

  1. Modifier l'entité dans src/Domain/Entity/
  2. php bin/console doctrine:migrations:diff (revoir le diff généré)
  3. php bin/console doctrine:migrations:migrate puis doctrine:schema:validate
  4. En cas de doute : doctrine:schema:update --dump-sql doit ê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 traineraccount_status (NOT NULL, défaut active), deactivated_at, deletion_requested_at, scheduled_deletion_at ; index idx_trainer_account_status. invoicing_invoice.trainer_id nullable + FK FK_B7805BCEFB08EDF6 ON DELETE SET NULL (conservation factures L123-22 à la suppression formateur ; snapshot émetteur = FEAT-004). Tables account_deletion_log (registre audit HMAC email) et account_data_export (lien export DSAR + comptable tokenisé, 30j ré-émissible).
  • Version20260612180000 (DISC-025 squash — remplace Version20260612072107 … Version20260612170000) :
  • INS-1 : sur trainerspecialite, headline, bio, languages (JSON), slug (UNIQUE), is_profile_public, is_open_to_requests ; index UNIQ_C5150820989D9B62.
  • INS-2a/2b : tables skill_reference (avec uuid UNIQUE + seed 17 compétences) et trainer_skill (avec uuid UNIQUE) ; extension pg_trgm.
  • INS-3 : magic_link_request.purpose, tokentoken_hash, index (user_id, purpose) ; user.password DROP NOT NULL.
  • INS-9 : trainer.enrichment_draft (JSON).
  • INS-12 : user.last_activity_at.
  • CV-1 : table trainer_certification (avec uuid UNIQUE).
  • CV-2 : colonne uuid UNIQUE sur trainer_experience, trainer_education (backfill gen_random_uuid()).

Table trainer_education

  • school_name, degree : VARCHAR(512) (migration Version20260730120000) — élargi depuis 255 pour titres RNCP longs et import CV ; validation API Assert\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 ; sur calendar_configurator ajout de current_sync_process_id (FK), consecutive_failures, last_sync_end_date.
  • Version20260429120000 (FEAT-086) : user.registered_at (NOT NULL, back-fill NOW()) ; sur trainer : checklist_dismissed_at, onboarding_completed_at, sample_data_dismissed_at ; back-fill onboarding_completed_at quand jsonb_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 flag isNewResponse cô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) sur invoicing_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) sur invoicing_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 depuis trainer.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 depuis trainer.last_activity_report_number.
  • Version20260802200000 (FEAT-267/268) : numérotation avoir sur trainer_billing_settings + tables invoicing_credit_note / invoicing_credit_note_line_item (brouillon sans code, public_id dès la création, index partiel un brouillon par facture).
  • Version20260811100000 (BUG-284) : issue_date (DATE nullable, calque invoicing_invoice.issue_date) sur invoicing_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) sur calendar_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) et invoicing_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 de default_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_company de default_legal_mentions, email_template, logo_url (déjà portés par trainer_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.