Compare commits

..

10 Commits

Author SHA1 Message Date
1db8a7a0b2 fix: Corriger les tests E2E après l'introduction du cache-aside paginé
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
Le commit 23dd717 a introduit un cache Redis (paginated_queries.cache)
pour les requêtes paginées. Les tests E2E qui modifient les données via
SQL direct (beforeAll, cleanup) contournent la couche applicative et ne
déclenchent pas l'invalidation du cache, provoquant des données obsolètes.

De plus, plusieurs problèmes d'isolation entre tests ont été découverts :
- Les tests classes.spec.ts supprimaient les données d'autres specs via
  DELETE FROM school_classes sans nettoyer les FK dépendantes
- Les tests user-blocking utilisaient des emails partagés entre les
  projets Playwright (chromium/firefox/webkit) exécutés en parallèle,
  causant des race conditions sur l'état du compte utilisateur
- Le handler NotifyTeachersPedagogicalDayHandler s'exécutait de manière
  synchrone, bloquant la réponse HTTP pendant l'envoi des emails
- La sélection d'un enseignant remplaçant effaçait l'autre dropdown car
  {#if} supprimait l'option sélectionnée du DOM

Corrections appliquées :
- Ajout de cache:pool:clear après chaque modification SQL directe
- Nettoyage des FK dépendantes avant les DELETE (classes, subjects)
- Emails uniques par projet navigateur pour éviter les race conditions
- Routage de JourneePedagogiqueAjoutee vers le transport async
- Remplacement de {#if} par disabled sur les selects de remplacement
- Recherche par nom sur la page classes pour gérer la pagination
- Patterns toPass() pour la fiabilité Firefox sur les color pickers
2026-03-01 23:33:42 +01:00
23dd7177f2 feat: Optimiser la pagination avec cache-aside et ports de lecture dédiés
Les listes paginées (utilisateurs, classes, matières, affectations,
invitations parents, droits à l'image) effectuaient des requêtes SQL
complètes à chaque chargement de page, sans aucun cache. Sur les
établissements avec plusieurs centaines d'enregistrements, cela causait
des temps de réponse perceptibles et une charge inutile sur PostgreSQL.

Cette refactorisation introduit un cache tag-aware (Redis en prod,
filesystem en dev) avec invalidation événementielle, et extrait les
requêtes de lecture dans des ports Application / implémentations DBAL
conformes à l'architecture hexagonale. Un middleware Messenger garantit
l'invalidation synchrone du cache même pour les événements routés en
asynchrone (envoi d'emails), évitant ainsi toute donnée périmée côté UI.
2026-03-01 14:33:56 +01:00
ce05207c64 feat: Réorganiser la navigation admin en catégories pour améliorer l'UX mobile-first
Le menu d'administration contenait 13 liens à plat dans le header, ce qui
débordait sur desktop et rendait le drawer mobile trop long à scanner.

Les liens sont maintenant regroupés en 4 catégories (Personnes, Organisation,
Année scolaire, Paramètres) avec des dropdowns au survol sur desktop et des
accordéons repliables dans le drawer mobile. Le nombre d'éléments visibles
passe de 13 à 5 (1 lien direct + 4 catégories), la catégorie active
s'auto-déplie dans le menu mobile.
2026-02-28 16:37:10 +01:00
be1b0b60a6 feat: Permettre la génération et l'envoi de codes d'invitation aux parents
Les administrateurs ont besoin d'un moyen simple pour inviter les parents
à rejoindre la plateforme. Cette fonctionnalité permet de générer des codes
d'invitation uniques (8 caractères alphanumériques) avec une validité de
48h, de les envoyer par email, et de les activer via une page publique
dédiée qui crée automatiquement le compte parent.

L'interface d'administration offre l'envoi unitaire et en masse, le renvoi,
le filtrage par statut, ainsi que la visualisation de l'état de chaque
invitation (en attente, activée, expirée).
2026-02-28 16:37:10 +01:00
de5880e25e feat: Permettre l'import d'enseignants via fichier CSV ou XLSX
L'établissement a besoin d'importer en masse ses enseignants depuis les
exports des logiciels de vie scolaire (Pronote, EDT, etc.), comme c'est
déjà possible pour les élèves. Le wizard en 4 étapes (upload → mapping
→ aperçu → import) réutilise l'architecture de l'import élèves tout en
ajoutant la gestion des matières et des classes enseignées.

Corrections de la review #2 intégrées :
- La commande ImportTeachersCommand est routée en async via Messenger
  pour ne pas bloquer la requête HTTP sur les gros fichiers.
- Le handler est protégé par un try/catch Throwable pour marquer le
  batch en échec si une erreur inattendue survient, évitant qu'il
  reste bloqué en statut "processing".
- Les domain events (UtilisateurInvite) sont dispatchés sur l'event
  bus après chaque création d'utilisateur, déclenchant l'envoi des
  emails d'invitation.
- L'option "mettre à jour les enseignants existants" (AC5) permet de
  choisir entre ignorer ou mettre à jour nom/prénom et ajouter les
  affectations manquantes pour les doublons détectés par email.
2026-02-27 16:39:47 +01:00
f2f57bb999 fix: Corriger les tests E2E de blocage utilisateur qui échouent de manière intermittente
Sur Firefox, l'hydration Svelte peut prendre plus de temps que sur
Chromium. L'assertion tabs.nth(1).toHaveAttribute manquait de timeout
dans periods.spec.ts, et le formulaire de login n'était pas
complètement hydraté avant interaction dans admin-responsive-nav.spec.ts.
2026-02-25 20:42:52 +01:00
2420e35492 feat: Permettre l'import d'élèves via fichier CSV ou XLSX
L'import manuel élève par élève est fastidieux pour les établissements
qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes
(upload → mapping → preview → confirmation) permet de traiter un
fichier complet en une seule opération, avec détection automatique
du format (Pronote, École Directe) et validation avant import.

L'import est traité de manière asynchrone via Messenger pour ne pas
bloquer l'interface, avec suivi de progression en temps réel et
réutilisation des mappings entre imports successifs.
2026-02-25 16:51:13 +01:00
560b941821 feat: Permettre la création manuelle d'élèves et leur affectation aux classes
Les administrateurs et secrétaires avaient besoin de pouvoir inscrire un
élève en cours d'année sans passer par un import CSV. Cette fonctionnalité
pose aussi les fondations du modèle élève↔classe (ClassAssignment) qui
sera réutilisé par l'import CSV en masse (Story 3.1).

L'email est désormais optionnel pour les élèves : si fourni, une invitation
est envoyée (User::inviter) ; sinon l'élève est créé avec le statut
INSCRIT sans accès compte (User::inscrire). La création de l'utilisateur
et l'affectation à la classe sont atomiques (transaction DBAL).

Côté frontend, la page /admin/students offre liste paginée, recherche,
filtrage par classe, création via modale (avec détection de doublons
côté serveur), et changement de classe avec optimistic update.
2026-02-24 11:53:02 +01:00
e5203097ef fix: Corriger les tests E2E de blocage utilisateur qui échouent de manière intermittente
Le test user-blocking-session échouait sur Firefox car Playwright
détruit le navigateur au timeout, puis le bloc finally tentait de
fermer des contextes déjà détruits. Les appels browserContext.close()
sont désormais protégés par .catch().

Le test user-blocking ne réinitialisait pas l'état du compte cible
entre les exécutions, ce qui faisait échouer la recherche du bouton
"Bloquer" si l'utilisateur était resté suspendu d'une exécution
précédente.
2026-02-21 00:05:25 +01:00
6fd084063f feat: Permettre la personnalisation du logo et de la couleur principale de l'établissement
Les administrateurs peuvent désormais configurer l'identité visuelle
de leur établissement : upload d'un logo (PNG/JPG, redimensionné
automatiquement via Imagick) et choix d'une couleur principale
appliquée aux boutons et à la navigation.

La couleur est validée côté client et serveur pour garantir la
conformité WCAG AA (contraste ≥ 4.5:1 sur fond blanc). Les
personnalisations sont injectées dynamiquement via CSS variables
et visibles immédiatement après sauvegarde.
2026-02-20 19:35:43 +01:00
325 changed files with 36853 additions and 1659 deletions

View File

@@ -13,7 +13,11 @@ RUN apk add --no-cache \
file \ file \
gettext \ gettext \
git \ git \
freetype-dev \
icu-dev \ icu-dev \
imagemagick-dev \
libjpeg-turbo-dev \
libpng-dev \
libzip-dev \ libzip-dev \
postgresql-dev \ postgresql-dev \
rabbitmq-c-dev \ rabbitmq-c-dev \
@@ -21,7 +25,11 @@ RUN apk add --no-cache \
$PHPIZE_DEPS $PHPIZE_DEPS
# Install PHP extensions (opcache is pre-installed in FrankenPHP) # Install PHP extensions (opcache is pre-installed in FrankenPHP)
RUN docker-php-ext-install intl pcntl pdo_pgsql zip sockets RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install gd intl pcntl pdo_pgsql zip sockets
# Install Imagick extension for image processing (logo resize, etc.)
RUN pecl install imagick && docker-php-ext-enable imagick
# Install AMQP extension for RabbitMQ # Install AMQP extension for RabbitMQ
RUN pecl install amqp && docker-php-ext-enable amqp RUN pecl install amqp && docker-php-ext-enable amqp

View File

@@ -17,6 +17,7 @@
"doctrine/orm": "^3.3", "doctrine/orm": "^3.3",
"lexik/jwt-authentication-bundle": "^3.2", "lexik/jwt-authentication-bundle": "^3.2",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"phpoffice/phpspreadsheet": "^5.4",
"promphp/prometheus_client_php": "^2.14", "promphp/prometheus_client_php": "^2.14",
"ramsey/uuid": "^4.7", "ramsey/uuid": "^4.7",
"sentry/sentry-symfony": "^5.8", "sentry/sentry-symfony": "^5.8",

505
backend/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "fb9fd4887621a91ef8635fd6092e53b2", "content-hash": "8b72e362a7720afa0811f80f9ef6e8d5",
"packages": [ "packages": [
{ {
"name": "api-platform/core", "name": "api-platform/core",
@@ -284,6 +284,85 @@
], ],
"time": "2025-11-24T14:40:29+00:00" "time": "2025-11-24T14:40:29+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{ {
"name": "doctrine/collections", "name": "doctrine/collections",
"version": "2.6.0", "version": "2.6.0",
@@ -1822,6 +1901,191 @@
], ],
"time": "2025-12-20T17:47:00+00:00" "time": "2025-12-20T17:47:00+00:00"
}, },
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -1990,6 +2254,115 @@
}, },
"time": "2026-01-12T15:59:08+00:00" "time": "2026-01-12T15:59:08+00:00"
}, },
{
"name": "phpoffice/phpspreadsheet",
"version": "5.4.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/48f2fe37d64c2dece0ef71fb2ac55497566782af",
"reference": "48f2fe37d64c2dece0ef71fb2ac55497566782af",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-filter": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": "^8.1",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-intl": "*",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.5",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1 || ^2.0",
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": "^10.5",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
},
{
"name": "Owen Leibman"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.4.0"
},
"time": "2026-01-11T04:52:00+00:00"
},
{ {
"name": "promphp/prometheus_client_php", "name": "promphp/prometheus_client_php",
"version": "v2.14.1", "version": "v2.14.1",
@@ -2472,6 +2845,57 @@
}, },
"time": "2024-09-11T13:17:53+00:00" "time": "2024-09-11T13:17:53+00:00"
}, },
{
"name": "psr/simple-cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/simple-cache.git",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
"reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\SimpleCache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interfaces for simple caching",
"keywords": [
"cache",
"caching",
"psr",
"psr-16",
"simple-cache"
],
"support": {
"source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
},
"time": "2021-10-29T13:26:27+00:00"
},
{ {
"name": "ralouphie/getallheaders", "name": "ralouphie/getallheaders",
"version": "3.0.3", "version": "3.0.3",
@@ -8139,85 +8563,6 @@
], ],
"time": "2022-12-23T10:58:28+00:00" "time": "2022-12-23T10:58:28+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{ {
"name": "composer/semver", "name": "composer/semver",
"version": "3.4.4", "version": "3.4.4",

View File

@@ -39,6 +39,12 @@ framework:
adapter: cache.adapter.filesystem adapter: cache.adapter.filesystem
default_lifetime: 604800 # 7 jours default_lifetime: 604800 # 7 jours
# Pool dédié au cache des requêtes paginées (1h TTL, tag-aware)
paginated_queries.cache:
adapter: cache.adapter.filesystem
default_lifetime: 3600 # 1 heure
tags: true
# Test environment uses Redis to avoid filesystem cache timing issues in E2E tests # Test environment uses Redis to avoid filesystem cache timing issues in E2E tests
# (CLI creates tokens, FrankenPHP must see them immediately) # (CLI creates tokens, FrankenPHP must see them immediately)
when@test: when@test:
@@ -73,6 +79,10 @@ when@test:
adapter: cache.adapter.redis adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%' provider: '%env(REDIS_URL)%'
default_lifetime: 604800 default_lifetime: 604800
paginated_queries.cache:
adapter: cache.adapter.redis_tag_aware
provider: '%env(REDIS_URL)%'
default_lifetime: 3600
when@prod: when@prod:
framework: framework:
@@ -110,3 +120,7 @@ when@prod:
adapter: cache.adapter.redis adapter: cache.adapter.redis
provider: '%env(REDIS_URL)%' provider: '%env(REDIS_URL)%'
default_lifetime: 604800 # 7 jours default_lifetime: 604800 # 7 jours
paginated_queries.cache:
adapter: cache.adapter.redis_tag_aware
provider: '%env(REDIS_URL)%'
default_lifetime: 3600 # 1 heure

View File

@@ -25,6 +25,7 @@ framework:
middleware: middleware:
- App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware - App\Shared\Infrastructure\Messenger\AddCorrelationIdStampMiddleware
- App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware - App\Shared\Infrastructure\Messenger\CorrelationIdMiddleware
- App\Administration\Infrastructure\Middleware\PaginatedCacheInvalidationMiddleware
- App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware - App\Shared\Infrastructure\Messenger\MessengerMetricsMiddleware
transports: transports:
@@ -52,3 +53,11 @@ framework:
App\Administration\Domain\Event\MotDePasseChange: async App\Administration\Domain\Event\MotDePasseChange: async
# CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert) # CompteBloqueTemporairement: sync (SendLockoutAlertHandler = immediate security alert)
# ConnexionReussie, ConnexionEchouee: sync (audit-only, no email) # ConnexionReussie, ConnexionEchouee: sync (audit-only, no email)
# Parent invitation events → async (email sending)
App\Administration\Domain\Event\InvitationParentEnvoyee: async
App\Administration\Domain\Event\InvitationParentActivee: async
# Notification enseignants journée pédagogique → async (envoi d'emails)
App\Administration\Domain\Event\JourneePedagogiqueAjoutee: async
# Import élèves/enseignants → async (batch processing, peut être long)
App\Administration\Application\Command\ImportStudents\ImportStudentsCommand: async
App\Administration\Application\Command\ImportTeachers\ImportTeachersCommand: async

View File

@@ -31,3 +31,10 @@ framework:
limit: 10 limit: 10
interval: '1 hour' interval: '1 hour'
cache_pool: cache.rate_limiter cache_pool: cache.rate_limiter
# Limite les tentatives d'activation par IP (protection contre DoS via bcrypt)
parent_activation_by_ip:
policy: sliding_window
limit: 10
interval: '15 minutes'
cache_pool: cache.rate_limiter

View File

@@ -54,7 +54,7 @@ security:
jwt: ~ jwt: ~
provider: super_admin_provider provider: super_admin_provider
api_public: api_public:
pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|docs)(/|$) pattern: ^/api/(activation-tokens|activate|token/(refresh|logout)|password/(forgot|reset)|parent-invitations/activate|docs)(/|$)
stateless: true stateless: true
security: false security: false
api: api:
@@ -78,6 +78,8 @@ security:
- { path: ^/api/token/logout, roles: PUBLIC_ACCESS } - { path: ^/api/token/logout, roles: PUBLIC_ACCESS }
- { path: ^/api/password/forgot, roles: PUBLIC_ACCESS } - { path: ^/api/password/forgot, roles: PUBLIC_ACCESS }
- { path: ^/api/password/reset, roles: PUBLIC_ACCESS } - { path: ^/api/password/reset, roles: PUBLIC_ACCESS }
- { path: ^/api/parent-invitations/activate, roles: PUBLIC_ACCESS }
- { path: ^/api/import, roles: ROLE_ADMIN }
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY }
when@test: when@test:

View File

@@ -25,6 +25,8 @@ services:
Psr\Cache\CacheItemPoolInterface $sessionsCache: '@sessions.cache' Psr\Cache\CacheItemPoolInterface $sessionsCache: '@sessions.cache'
# Bind student guardians cache pool (no TTL - persistent data) # Bind student guardians cache pool (no TTL - persistent data)
Psr\Cache\CacheItemPoolInterface $studentGuardiansCache: '@student_guardians.cache' Psr\Cache\CacheItemPoolInterface $studentGuardiansCache: '@student_guardians.cache'
# Bind paginated queries cache pool (1h TTL, tag-aware)
Symfony\Contracts\Cache\TagAwareCacheInterface $paginatedQueriesCache: '@paginated_queries.cache'
# Bind named message buses # Bind named message buses
Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus' Symfony\Component\Messenger\MessageBusInterface $eventBus: '@event.bus'
Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus' Symfony\Component\Messenger\MessageBusInterface $commandBus: '@command.bus'
@@ -158,6 +160,10 @@ services:
App\Administration\Domain\Repository\GradingConfigurationRepository: App\Administration\Domain\Repository\GradingConfigurationRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineGradingConfigurationRepository
# Class Assignment (Story 3.0 - Affectation élèves aux classes)
App\Administration\Domain\Repository\ClassAssignmentRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineClassAssignmentRepository
# Teacher Assignment (Story 2.8 - Affectation enseignants) # Teacher Assignment (Story 2.8 - Affectation enseignants)
App\Administration\Domain\Repository\TeacherAssignmentRepository: App\Administration\Domain\Repository\TeacherAssignmentRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherAssignmentRepository
@@ -187,6 +193,44 @@ services:
arguments: arguments:
$dataDirectory: '%kernel.project_dir%/var/data/calendar' $dataDirectory: '%kernel.project_dir%/var/data/calendar'
# School Branding (Story 2.13 - Personnalisation visuelle)
App\Administration\Domain\Model\SchoolBranding\ContrastValidator:
autowire: true
App\Administration\Domain\Repository\SchoolBrandingRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSchoolBrandingRepository
App\Administration\Application\Port\LogoStorage:
alias: App\Administration\Infrastructure\Storage\LocalLogoStorage
App\Administration\Infrastructure\Storage\LocalLogoStorage:
arguments:
$uploadDir: '%kernel.project_dir%/public/uploads'
$publicPath: '/uploads'
App\Administration\Application\Port\ImageProcessor:
alias: App\Administration\Infrastructure\Storage\ImagickImageProcessor
# Import Batch Repository (Story 3.1 - Import élèves via CSV)
App\Administration\Domain\Repository\ImportBatchRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineImportBatchRepository
# Saved Column Mapping Repository (Story 3.1 - T3.3 Réutilisation des mappings)
App\Administration\Domain\Repository\SavedColumnMappingRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedColumnMappingRepository
# Teacher Import Batch Repository (Story 3.2 - Import enseignants via CSV)
App\Administration\Domain\Repository\TeacherImportBatchRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineTeacherImportBatchRepository
# Saved Teacher Column Mapping Repository (Story 3.2 - Réutilisation des mappings enseignants)
App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineSavedTeacherColumnMappingRepository
# Parent Invitation Repository (Story 3.3 - Invitation parents)
App\Administration\Domain\Repository\ParentInvitationRepository:
alias: App\Administration\Infrastructure\Persistence\Doctrine\DoctrineParentInvitationRepository
# Student Guardian Repository (Story 2.7 - Liaison parents-enfants) # Student Guardian Repository (Story 2.7 - Liaison parents-enfants)
App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository:
arguments: arguments:
@@ -195,6 +239,25 @@ services:
App\Administration\Domain\Repository\StudentGuardianRepository: App\Administration\Domain\Repository\StudentGuardianRepository:
alias: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository alias: App\Administration\Infrastructure\Persistence\Cache\CacheStudentGuardianRepository
# Paginated Read Model Ports
App\Administration\Application\Port\PaginatedUsersReader:
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedUsersReader
App\Administration\Application\Port\PaginatedClassesReader:
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedClassesReader
App\Administration\Application\Port\PaginatedSubjectsReader:
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedSubjectsReader
App\Administration\Application\Port\PaginatedAssignmentsReader:
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedAssignmentsReader
App\Administration\Application\Port\PaginatedParentInvitationsReader:
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedParentInvitationsReader
App\Administration\Application\Port\PaginatedStudentImageRightsReader:
alias: App\Administration\Infrastructure\ReadModel\DbalPaginatedStudentImageRightsReader
# GradeExistenceChecker (stub until Notes module exists) # GradeExistenceChecker (stub until Notes module exists)
App\Administration\Application\Port\GradeExistenceChecker: App\Administration\Application\Port\GradeExistenceChecker:
alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker alias: App\Administration\Infrastructure\Service\NoOpGradeExistenceChecker
@@ -213,6 +276,11 @@ services:
$passwordResetByEmailLimiter: '@limiter.password_reset_by_email' $passwordResetByEmailLimiter: '@limiter.password_reset_by_email'
$passwordResetByIpLimiter: '@limiter.password_reset_by_ip' $passwordResetByIpLimiter: '@limiter.password_reset_by_ip'
# Parent Activation Processor with rate limiter
App\Administration\Infrastructure\Api\Processor\ActivateParentInvitationProcessor:
arguments:
$parentActivationByIpLimiter: '@limiter.parent_activation_by_ip'
# Login handlers # Login handlers
App\Administration\Infrastructure\Security\LoginSuccessHandler: App\Administration\Infrastructure\Security\LoginSuccessHandler:
tags: tags:

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260220071333 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create school_branding table for visual identity customization';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE school_branding (
school_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
logo_url TEXT,
logo_updated_at TIMESTAMPTZ,
primary_color VARCHAR(7),
secondary_color VARCHAR(7),
accent_color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
SQL);
$this->addSql(<<<'SQL'
CREATE INDEX idx_branding_tenant ON school_branding(tenant_id)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS school_branding');
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260221093719 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create class_assignments table and extend users for student enrollment';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE class_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
school_class_id UUID NOT NULL,
academic_year_id UUID NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, academic_year_id),
CONSTRAINT fk_class_assignments_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_class_assignments_class FOREIGN KEY (school_class_id) REFERENCES school_classes(id)
)
SQL);
$this->addSql('CREATE INDEX idx_class_assignments_class ON class_assignments(school_class_id)');
$this->addSql('CREATE INDEX idx_class_assignments_tenant ON class_assignments(tenant_id)');
$this->addSql('ALTER TABLE users ALTER COLUMN email DROP NOT NULL');
$this->addSql('ALTER TABLE users ADD COLUMN student_number VARCHAR(11)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE class_assignments');
$this->addSql('ALTER TABLE users DROP COLUMN student_number');
$this->addSql("UPDATE users SET email = 'removed-' || id || '@placeholder.local' WHERE email IS NULL");
$this->addSql('ALTER TABLE users ALTER COLUMN email SET NOT NULL');
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260224143000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create student_import_batches table for CSV/XLSX student import wizard';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE student_import_batches (
id UUID NOT NULL,
tenant_id UUID NOT NULL,
original_filename VARCHAR(255) NOT NULL,
total_rows INT NOT NULL DEFAULT 0,
detected_columns JSONB NOT NULL DEFAULT \'[]\',
detected_format VARCHAR(50) DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT \'pending\',
mapping_data JSONB DEFAULT NULL,
imported_count INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
rows_data JSONB NOT NULL DEFAULT \'[]\',
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ DEFAULT NULL,
PRIMARY KEY (id)
)');
$this->addSql('CREATE INDEX idx_student_import_batches_tenant ON student_import_batches (tenant_id)');
$this->addSql('CREATE INDEX idx_student_import_batches_status ON student_import_batches (status)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE student_import_batches');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260224214219 extends AbstractMigration
{
public function getDescription(): string
{
return 'Ajouter la table saved_column_mappings pour réutiliser les mappings d\'import';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE saved_column_mappings (
tenant_id UUID NOT NULL,
format VARCHAR(50) NOT NULL,
mapping_data JSONB NOT NULL,
saved_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant_id, format)
)
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE saved_column_mappings');
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260225211435 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create teacher_import_batches and saved_teacher_column_mappings tables for CSV/XLSX teacher import wizard';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE teacher_import_batches (
id UUID NOT NULL,
tenant_id UUID NOT NULL,
original_filename VARCHAR(255) NOT NULL,
total_rows INT NOT NULL DEFAULT 0,
detected_columns JSONB NOT NULL DEFAULT \'[]\',
detected_format VARCHAR(50) DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT \'pending\',
mapping_data JSONB DEFAULT NULL,
imported_count INT NOT NULL DEFAULT 0,
error_count INT NOT NULL DEFAULT 0,
rows_data JSONB NOT NULL DEFAULT \'[]\',
created_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ DEFAULT NULL,
PRIMARY KEY (id)
)');
$this->addSql('CREATE INDEX idx_teacher_import_batches_tenant ON teacher_import_batches (tenant_id)');
$this->addSql('CREATE INDEX idx_teacher_import_batches_status ON teacher_import_batches (status)');
$this->addSql('CREATE TABLE saved_teacher_column_mappings (
tenant_id UUID NOT NULL,
format VARCHAR(50) NOT NULL,
mapping_data JSONB NOT NULL,
saved_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (tenant_id, format)
)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE saved_teacher_column_mappings');
$this->addSql('DROP TABLE teacher_import_batches');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226141803 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add composite index on teacher_import_batches (tenant_id, created_at DESC)';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE INDEX idx_teacher_import_batches_tenant_created ON teacher_import_batches (tenant_id, created_at DESC)');
$this->addSql('DROP INDEX IF EXISTS idx_teacher_import_batches_tenant');
}
public function down(Schema $schema): void
{
$this->addSql('CREATE INDEX idx_teacher_import_batches_tenant ON teacher_import_batches (tenant_id)');
$this->addSql('DROP INDEX IF EXISTS idx_teacher_import_batches_tenant_created');
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260227162304 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create parent_invitations table';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE parent_invitations (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
student_id UUID NOT NULL,
parent_email VARCHAR(255) NOT NULL,
code VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
created_by UUID NOT NULL,
sent_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
activated_user_id UUID
)
SQL);
$this->addSql('CREATE INDEX idx_parent_invitations_tenant ON parent_invitations (tenant_id)');
$this->addSql('CREATE INDEX idx_parent_invitations_code ON parent_invitations (code)');
$this->addSql('CREATE INDEX idx_parent_invitations_status ON parent_invitations (status)');
$this->addSql('CREATE INDEX idx_parent_invitations_student ON parent_invitations (student_id)');
$this->addSql('CREATE INDEX idx_parent_invitations_expires ON parent_invitations (status, expires_at)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE IF EXISTS parent_invitations');
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateParentInvitation;
final readonly class ActivateParentInvitationCommand
{
public function __construct(
public string $code,
public string $firstName,
public string $lastName,
public string $password,
) {
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateParentInvitation;
use App\Administration\Application\Port\PasswordHasher;
use App\Administration\Domain\Exception\ParentInvitationNotFoundException;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Clock;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ActivateParentInvitationHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private PasswordHasher $passwordHasher,
private Clock $clock,
) {
}
/**
* Validates the invitation code and prepares activation data.
* Actual user creation, activation, and linking is done in the Processor.
*
* @throws ParentInvitationNotFoundException if code is invalid
*/
public function __invoke(ActivateParentInvitationCommand $command): ActivateParentInvitationResult
{
$code = new InvitationCode($command->code);
$invitation = $this->invitationRepository->findByCode($code);
if ($invitation === null) {
throw ParentInvitationNotFoundException::withCode($code);
}
$now = $this->clock->now();
// Validate only - does not change state
$invitation->validerPourActivation($now);
$hashedPassword = $this->passwordHasher->hash($command->password);
return new ActivateParentInvitationResult(
invitationId: (string) $invitation->id,
studentId: (string) $invitation->studentId,
parentEmail: (string) $invitation->parentEmail,
tenantId: $invitation->tenantId,
hashedPassword: $hashedPassword,
firstName: $command->firstName,
lastName: $command->lastName,
);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ActivateParentInvitation;
use App\Shared\Domain\Tenant\TenantId;
final readonly class ActivateParentInvitationResult
{
public function __construct(
public string $invitationId,
public string $studentId,
public string $parentEmail,
public TenantId $tenantId,
public string $hashedPassword,
public string $firstName,
public string $lastName,
) {
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignStudentToClass;
final readonly class AssignStudentToClassCommand
{
public function __construct(
public string $tenantId,
public string $studentId,
public string $classId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\AssignStudentToClass;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EleveDejaAffecteException;
use App\Administration\Domain\Exception\UserNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class AssignStudentToClassHandler
{
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private Clock $clock,
) {
}
public function __invoke(AssignStudentToClassCommand $command): ClassAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$studentId = UserId::fromString($command->studentId);
$classId = ClassId::fromString($command->classId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence des entités référencées et leur tenant
$student = $this->userRepository->get($studentId);
if (!$student->tenantId->equals($tenantId)) {
throw UserNotFoundException::withId($studentId);
}
$class = $this->classRepository->get($classId);
if (!$class->tenantId->equals($tenantId)) {
throw ClasseNotFoundException::withId($classId);
}
if (!$class->status->peutRecevoirEleves()) {
throw ClasseNotFoundException::withId($classId);
}
// Vérifier qu'il n'y a pas déjà une affectation pour cette année scolaire
$existing = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
if ($existing !== null) {
throw EleveDejaAffecteException::pourAnneeScolaire($studentId);
}
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $studentId,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $this->clock->now(),
);
$this->classAssignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ChangeStudentClass;
final readonly class ChangeStudentClassCommand
{
public function __construct(
public string $tenantId,
public string $studentId,
public string $newClassId,
public string $academicYearId,
) {
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ChangeStudentClass;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ChangeStudentClassHandler
{
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
private ClassRepository $classRepository,
private Clock $clock,
) {
}
public function __invoke(ChangeStudentClassCommand $command): ClassAssignment
{
$tenantId = TenantId::fromString($command->tenantId);
$studentId = UserId::fromString($command->studentId);
$newClassId = ClassId::fromString($command->newClassId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence de la nouvelle classe, son tenant et son statut
$class = $this->classRepository->get($newClassId);
if (!$class->tenantId->equals($tenantId)) {
throw ClasseNotFoundException::withId($newClassId);
}
if (!$class->status->peutRecevoirEleves()) {
throw ClasseNotFoundException::withId($newClassId);
}
// Trouver l'affectation existante
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
if ($assignment === null) {
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
}
$assignment->changerClasse($newClassId, $this->clock->now());
$this->classAssignmentRepository->save($assignment);
return $assignment;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateStudent;
final readonly class CreateStudentCommand
{
public function __construct(
public string $tenantId,
public string $schoolName,
public string $firstName,
public string $lastName,
public string $classId,
public string $academicYearId,
public ?string $email = null,
public ?string $dateNaissance = null,
public ?string $studentNumber = null,
) {
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\CreateStudent;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Exception\EmailDejaUtiliseeException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class CreateStudentHandler
{
public function __construct(
private UserRepository $userRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private ClassRepository $classRepository,
private Connection $connection,
private Clock $clock,
) {
}
public function __invoke(CreateStudentCommand $command): User
{
$tenantId = TenantId::fromString($command->tenantId);
$classId = ClassId::fromString($command->classId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
// Valider l'existence de la classe, son tenant et son statut
$class = $this->classRepository->get($classId);
if (!$class->tenantId->equals($tenantId)) {
throw ClasseNotFoundException::withId($classId);
}
if (!$class->status->peutRecevoirEleves()) {
throw ClasseNotFoundException::withId($classId);
}
$now = $this->clock->now();
// Vérifier l'unicité de l'email si fourni
if ($command->email !== null) {
$email = new Email($command->email);
$existingUser = $this->userRepository->findByEmail($email, $tenantId);
if ($existingUser !== null) {
throw EmailDejaUtiliseeException::dansTenant($email, $tenantId);
}
}
$this->connection->beginTransaction();
try {
// Créer l'utilisateur
$user = $command->email !== null
? User::inviter(
email: new Email($command->email),
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $command->firstName,
lastName: $command->lastName,
invitedAt: $now,
dateNaissance: $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null,
studentNumber: $command->studentNumber,
)
: User::inscrire(
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $command->firstName,
lastName: $command->lastName,
inscritAt: $now,
dateNaissance: $command->dateNaissance !== null
? new DateTimeImmutable($command->dateNaissance)
: null,
studentNumber: $command->studentNumber,
);
$this->userRepository->save($user);
// Affecter à la classe
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $user->id,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $now,
);
$this->classAssignmentRepository->save($assignment);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
return $user;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\DeleteLogo;
final readonly class DeleteLogoCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
) {
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\DeleteLogo;
use App\Administration\Application\Service\LogoUploader;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class DeleteLogoHandler
{
public function __construct(
private SchoolBrandingRepository $brandingRepository,
private LogoUploader $logoUploader,
private Clock $clock,
) {
}
public function __invoke(DeleteLogoCommand $command): SchoolBranding
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$branding = $this->brandingRepository->get($schoolId, $tenantId);
if ($branding->logoUrl !== null) {
$this->logoUploader->deleteByUrl($branding->logoUrl);
}
$branding->supprimerLogo($this->clock->now());
$this->brandingRepository->save($branding);
return $branding;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportStudents;
/**
* Commande pour lancer l'import d'élèves en batch.
*
* Dispatchée de manière asynchrone via le event bus.
*/
final readonly class ImportStudentsCommand
{
public function __construct(
public string $batchId,
public string $tenantId,
public string $schoolName,
public string $academicYearId,
public bool $createMissingClasses = false,
) {
}
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportStudents;
use App\Administration\Application\Service\Import\DateParser;
use App\Administration\Application\Service\Import\ImportRowValidator;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolClass;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use DomainException;
use Psr\Log\LoggerInterface;
use function sprintf;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Throwable;
/**
* Handler pour l'import d'élèves en batch.
*
* Traite les lignes valides du batch, crée les élèves et les affecte aux classes.
*
* @see AC5: Import validé → élèves créés en base
* @see NFR-SC6: 500 élèves importés en < 2 minutes
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ImportStudentsHandler
{
public function __construct(
private ImportBatchRepository $importBatchRepository,
private UserRepository $userRepository,
private ClassRepository $classRepository,
private ClassAssignmentRepository $classAssignmentRepository,
private SchoolIdResolver $schoolIdResolver,
private Connection $connection,
private Clock $clock,
private LoggerInterface $logger,
) {
}
public function __invoke(ImportStudentsCommand $command): void
{
$batchId = ImportBatchId::fromString($command->batchId);
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$now = $this->clock->now();
$batch = $this->importBatchRepository->get($batchId);
$batch->demarrer($now);
$this->importBatchRepository->save($batch);
$lignes = $batch->lignes();
$importedCount = 0;
$errorCount = 0;
$processedCount = 0;
$createdClasses = [];
/** @var array<string, ClassId> */
$classCache = [];
foreach ($lignes as $row) {
try {
$className = trim($row->valeurChamp(StudentImportField::CLASS_NAME) ?? '');
if (!isset($classCache[$className])) {
$classCache[$className] = $this->resolveClassId(
$className,
$tenantId,
$academicYearId,
$command->schoolName,
$command->createMissingClasses,
$now,
$createdClasses,
);
}
$classId = $classCache[$className];
$firstName = trim($row->valeurChamp(StudentImportField::FIRST_NAME) ?? '');
$lastName = trim($row->valeurChamp(StudentImportField::LAST_NAME) ?? '');
if ($firstName === '' && $lastName === '') {
$fullName = trim($row->valeurChamp(StudentImportField::FULL_NAME) ?? '');
if ($fullName !== '') {
[$lastName, $firstName] = ImportRowValidator::splitFullName($fullName);
}
}
$emailRaw = $row->valeurChamp(StudentImportField::EMAIL);
$birthDate = $row->valeurChamp(StudentImportField::BIRTH_DATE);
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
$trimmedStudentNumber = $studentNumber !== null && trim($studentNumber) !== '' ? trim($studentNumber) : null;
$this->connection->beginTransaction();
try {
if ($emailRaw !== null && trim($emailRaw) !== '') {
$emailVO = new Email(trim($emailRaw));
if ($this->userRepository->findByEmail($emailVO, $tenantId) !== null) {
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', trim($emailRaw)));
}
$user = User::inviter(
email: $emailVO,
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $firstName,
lastName: $lastName,
invitedAt: $now,
dateNaissance: DateParser::parse($birthDate),
studentNumber: $trimmedStudentNumber,
);
} else {
$user = User::inscrire(
role: Role::ELEVE,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $firstName,
lastName: $lastName,
inscritAt: $now,
dateNaissance: DateParser::parse($birthDate),
studentNumber: $trimmedStudentNumber,
);
}
$this->userRepository->save($user);
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $user->id,
classId: $classId,
academicYearId: $academicYearId,
assignedAt: $now,
);
$this->classAssignmentRepository->save($assignment);
$this->connection->commit();
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
++$importedCount;
} catch (DomainException $e) {
$this->logger->warning('Import ligne {line} échouée : {message}', [
'line' => $row->lineNumber,
'message' => $e->getMessage(),
'batch_id' => $command->batchId,
]);
++$errorCount;
}
++$processedCount;
if ($processedCount % 50 === 0) {
$batch->mettreAJourProgression($importedCount, $errorCount);
$this->importBatchRepository->save($batch);
}
}
$batch->terminer($importedCount, $errorCount, $this->clock->now());
$this->importBatchRepository->save($batch);
}
/**
* @param list<string> $createdClasses
*/
private function resolveClassId(
string $className,
TenantId $tenantId,
AcademicYearId $academicYearId,
string $schoolName,
bool $createMissingClasses,
DateTimeImmutable $now,
array &$createdClasses,
): ClassId {
$classNameVO = new ClassName($className);
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
if ($class !== null) {
return $class->id;
}
if (!$createMissingClasses) {
throw new DomainException(sprintf('La classe "%s" n\'existe pas.', $className));
}
$newClass = SchoolClass::creer(
tenantId: $tenantId,
schoolId: SchoolId::fromString($this->schoolIdResolver->resolveForTenant((string) $tenantId)),
academicYearId: $academicYearId,
name: $classNameVO,
level: null,
capacity: null,
createdAt: $now,
);
$this->classRepository->save($newClass);
$createdClasses[] = $className;
return $newClass->id;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportTeachers;
/**
* Commande pour lancer l'import d'enseignants en batch.
*
* Dispatchée de manière asynchrone via le command bus.
*/
final readonly class ImportTeachersCommand
{
public function __construct(
public string $batchId,
public string $tenantId,
public string $schoolName,
public string $academicYearId,
public bool $createMissingSubjects = false,
public bool $updateExisting = false,
) {
}
}

View File

@@ -0,0 +1,406 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ImportTeachers;
use App\Administration\Application\Service\Import\MultiValueParser;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\SchoolClass\ClassName;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Model\Subject\Subject;
use App\Administration\Domain\Model\Subject\SubjectCode;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\Subject\SubjectName;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignment;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\User;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Administration\Infrastructure\School\SchoolIdResolver;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use DomainException;
use function in_array;
use function mb_strlen;
use Psr\Log\LoggerInterface;
use function sprintf;
use function strtoupper;
use function substr;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
use Throwable;
use function trim;
/**
* Handler pour l'import d'enseignants en batch.
*
* Traite les lignes valides du batch, crée les enseignants et les affecte
* aux matières/classes via TeacherAssignment.
*
* @see AC4: Import validé → enseignants créés
*/
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ImportTeachersHandler
{
private MultiValueParser $multiValueParser;
public function __construct(
private TeacherImportBatchRepository $teacherImportBatchRepository,
private UserRepository $userRepository,
private SubjectRepository $subjectRepository,
private ClassRepository $classRepository,
private TeacherAssignmentRepository $teacherAssignmentRepository,
private SchoolIdResolver $schoolIdResolver,
private Connection $connection,
private Clock $clock,
private LoggerInterface $logger,
private MessageBusInterface $eventBus,
) {
$this->multiValueParser = new MultiValueParser();
}
public function __invoke(ImportTeachersCommand $command): void
{
$batchId = ImportBatchId::fromString($command->batchId);
$tenantId = TenantId::fromString($command->tenantId);
$academicYearId = AcademicYearId::fromString($command->academicYearId);
$schoolId = SchoolId::fromString($this->schoolIdResolver->resolveForTenant($command->tenantId));
$now = $this->clock->now();
$batch = $this->teacherImportBatchRepository->get($batchId);
$batch->demarrer($now);
$this->teacherImportBatchRepository->save($batch);
$lignes = $batch->lignes();
$importedCount = 0;
$errorCount = 0;
$processedCount = 0;
try {
/** @var array<string, SubjectId> $subjectCache */
$subjectCache = [];
/** @var list<string> $existingSubjectCodes */
$existingSubjectCodes = [];
/** @var list<string> $newlyCreatedCodes */
$newlyCreatedCodes = [];
foreach ($this->subjectRepository->findAllActiveByTenant($tenantId) as $subject) {
$subjectCache[(string) $subject->name] = $subject->id;
$existingSubjectCodes[] = (string) $subject->code;
}
/** @var array<string, ClassId> */
$classCache = [];
foreach ($lignes as $row) {
try {
$firstName = trim($row->mappedData[TeacherImportField::FIRST_NAME->value] ?? '');
$lastName = trim($row->mappedData[TeacherImportField::LAST_NAME->value] ?? '');
$emailRaw = trim($row->mappedData[TeacherImportField::EMAIL->value] ?? '');
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? '';
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? '';
$emailVO = new Email($emailRaw);
$existingUser = $this->userRepository->findByEmail($emailVO, $tenantId);
if ($existingUser !== null && !$command->updateExisting) {
throw new DomainException(sprintf('L\'email "%s" est déjà utilisé.', $emailRaw));
}
$this->connection->beginTransaction();
try {
$subjects = $this->multiValueParser->parse($subjectsRaw);
$classes = $this->multiValueParser->parse($classesRaw);
$resolvedSubjectIds = $this->resolveSubjectIds(
$subjects,
$tenantId,
$schoolId,
$command->createMissingSubjects,
$now,
$subjectCache,
$existingSubjectCodes,
$newlyCreatedCodes,
);
$resolvedClassIds = $this->resolveClassIds(
$classes,
$tenantId,
$academicYearId,
$classCache,
);
if ($existingUser !== null) {
$existingUser->mettreAJourInfos($firstName, $lastName);
$this->userRepository->save($existingUser);
$this->addMissingAssignments(
$existingUser,
$resolvedSubjectIds,
$resolvedClassIds,
$tenantId,
$academicYearId,
$now,
);
$this->connection->commit();
} else {
$user = User::inviter(
email: $emailVO,
role: Role::PROF,
tenantId: $tenantId,
schoolName: $command->schoolName,
firstName: $firstName,
lastName: $lastName,
invitedAt: $now,
);
$this->userRepository->save($user);
foreach ($resolvedSubjectIds as $subjectId) {
foreach ($resolvedClassIds as $classId) {
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $user->id,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->teacherAssignmentRepository->save($assignment);
}
}
$this->connection->commit();
foreach ($user->pullDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
} catch (Throwable $e) {
$this->connection->rollBack();
throw $e;
}
++$importedCount;
} catch (DomainException $e) {
$this->logger->warning('Import enseignant ligne {line} échouée : {message}', [
'line' => $row->lineNumber,
'message' => $e->getMessage(),
'batch_id' => $command->batchId,
]);
++$errorCount;
}
++$processedCount;
if ($processedCount % 50 === 0) {
$batch->mettreAJourProgression($importedCount, $errorCount);
$this->teacherImportBatchRepository->save($batch);
}
}
$batch->terminer($importedCount, $errorCount, $this->clock->now());
$this->teacherImportBatchRepository->save($batch);
} catch (Throwable $e) {
$batch->echouer($errorCount, $this->clock->now());
$this->teacherImportBatchRepository->save($batch);
throw $e;
}
}
/**
* Ajoute les affectations manquantes pour un enseignant existant.
*
* @param list<SubjectId> $subjectIds
* @param list<ClassId> $classIds
*/
private function addMissingAssignments(
User $teacher,
array $subjectIds,
array $classIds,
TenantId $tenantId,
AcademicYearId $academicYearId,
DateTimeImmutable $now,
): void {
foreach ($subjectIds as $subjectId) {
foreach ($classIds as $classId) {
$existing = $this->teacherAssignmentRepository->findByTeacherClassSubject(
$teacher->id,
$classId,
$subjectId,
$academicYearId,
$tenantId,
);
if ($existing !== null) {
continue;
}
$assignment = TeacherAssignment::creer(
tenantId: $tenantId,
teacherId: $teacher->id,
classId: $classId,
subjectId: $subjectId,
academicYearId: $academicYearId,
createdAt: $now,
);
$this->teacherAssignmentRepository->save($assignment);
}
}
}
/**
* @param list<string> $subjectNames
* @param array<string, SubjectId> $cache
* @param list<string> $existingCodes
* @param list<string> $newlyCreatedCodes
*
* @return list<SubjectId>
*/
private function resolveSubjectIds(
array $subjectNames,
TenantId $tenantId,
SchoolId $schoolId,
bool $createMissing,
DateTimeImmutable $now,
array &$cache,
array &$existingCodes,
array &$newlyCreatedCodes,
): array {
$ids = [];
foreach ($subjectNames as $name) {
if (isset($cache[$name])) {
$ids[] = $cache[$name];
continue;
}
if ($createMissing) {
$subjectId = $this->createSubject($name, $tenantId, $schoolId, $now, $existingCodes, $newlyCreatedCodes);
$cache[$name] = $subjectId;
$ids[] = $subjectId;
}
}
return $ids;
}
/**
* @param list<string> $classNames
* @param array<string, ClassId> $cache
*
* @return list<ClassId>
*/
private function resolveClassIds(
array $classNames,
TenantId $tenantId,
AcademicYearId $academicYearId,
array &$cache,
): array {
$ids = [];
foreach ($classNames as $name) {
if (isset($cache[$name])) {
$ids[] = $cache[$name];
continue;
}
$classNameVO = new ClassName($name);
$class = $this->classRepository->findByName($classNameVO, $tenantId, $academicYearId);
if ($class !== null) {
$cache[$name] = $class->id;
$ids[] = $class->id;
}
}
return $ids;
}
/**
* @param list<string> $existingCodes
* @param list<string> $newlyCreatedCodes
*/
private function createSubject(
string $name,
TenantId $tenantId,
SchoolId $schoolId,
DateTimeImmutable $now,
array &$existingCodes,
array &$newlyCreatedCodes,
): SubjectId {
if (trim($name) === '') {
throw new DomainException('Le nom de la matière ne peut pas être vide.');
}
$code = $this->generateUniqueSubjectCode($name, $existingCodes, $newlyCreatedCodes);
if ($code === '') {
throw new DomainException(sprintf('Impossible de générer un code pour la matière "%s".', $name));
}
$subject = Subject::creer(
tenantId: $tenantId,
schoolId: $schoolId,
name: new SubjectName($name),
code: new SubjectCode($code),
color: null,
createdAt: $now,
);
$this->subjectRepository->save($subject);
$newlyCreatedCodes[] = $code;
return $subject->id;
}
/**
* @param list<string> $existingCodes
* @param list<string> $newlyCreatedCodes
*/
private function generateUniqueSubjectCode(string $name, array $existingCodes, array $newlyCreatedCodes): string
{
$base = strtoupper(substr(trim($name), 0, 4));
if (mb_strlen($base) < 2) {
$base .= 'XX';
}
$allCodes = [...$existingCodes, ...$newlyCreatedCodes];
if (!in_array($base, $allCodes, true)) {
return $base;
}
for ($i = 2; $i <= 99; ++$i) {
$candidate = $base . $i;
if (!in_array($candidate, $allCodes, true)) {
return $candidate;
}
}
return $base;
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResendParentInvitation;
final readonly class ResendParentInvitationCommand
{
public function __construct(
public string $invitationId,
public string $tenantId,
) {
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\ResendParentInvitation;
use App\Administration\Application\Service\InvitationCodeGenerator;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class ResendParentInvitationHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private InvitationCodeGenerator $codeGenerator,
private Clock $clock,
) {
}
public function __invoke(ResendParentInvitationCommand $command): ParentInvitation
{
$tenantId = TenantId::fromString($command->tenantId);
$invitationId = ParentInvitationId::fromString($command->invitationId);
$invitation = $this->invitationRepository->get($invitationId, $tenantId);
$newCode = $this->codeGenerator->generate();
$invitation->renvoyer($newCode, $this->clock->now());
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\SendParentInvitation;
final readonly class SendParentInvitationCommand
{
public function __construct(
public string $tenantId,
public string $studentId,
public string $parentEmail,
public string $createdBy,
) {
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\SendParentInvitation;
use App\Administration\Application\Service\InvitationCodeGenerator;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Domain\Model\User\UserId;
use App\Administration\Domain\Repository\ParentInvitationRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use DomainException;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class SendParentInvitationHandler
{
public function __construct(
private ParentInvitationRepository $invitationRepository,
private UserRepository $userRepository,
private InvitationCodeGenerator $codeGenerator,
private Clock $clock,
) {
}
public function __invoke(SendParentInvitationCommand $command): ParentInvitation
{
$tenantId = TenantId::fromString($command->tenantId);
$studentId = UserId::fromString($command->studentId);
$parentEmail = new Email($command->parentEmail);
$createdBy = UserId::fromString($command->createdBy);
$now = $this->clock->now();
// Verify student exists and is actually a student
$student = $this->userRepository->findById($studentId);
if ($student === null || !$student->aLeRole(Role::ELEVE)) {
throw new DomainException('L\'élève spécifié n\'existe pas.');
}
$code = $this->codeGenerator->generate();
$invitation = ParentInvitation::creer(
tenantId: $tenantId,
studentId: $studentId,
parentEmail: $parentEmail,
code: $code,
createdAt: $now,
createdBy: $createdBy,
);
$invitation->envoyer($now);
$this->invitationRepository->save($invitation);
return $invitation;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateBranding;
final readonly class UpdateBrandingCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
public ?string $primaryColor,
public ?string $secondaryColor,
public ?string $accentColor,
) {
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UpdateBranding;
use App\Administration\Domain\Exception\ContrasteInsuffisantException;
use App\Administration\Domain\Model\SchoolBranding\BrandColor;
use App\Administration\Domain\Model\SchoolBranding\ContrastValidator;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UpdateBrandingHandler
{
private const string WHITE = '#FFFFFF';
public function __construct(
private SchoolBrandingRepository $brandingRepository,
private ContrastValidator $contrastValidator,
private Clock $clock,
) {
}
public function __invoke(UpdateBrandingCommand $command): SchoolBranding
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$now = $this->clock->now();
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
if ($branding === null) {
$branding = SchoolBranding::creer(
schoolId: $schoolId,
tenantId: $tenantId,
createdAt: $now,
);
}
$primaryColor = $command->primaryColor !== null
? new BrandColor($command->primaryColor)
: null;
if ($primaryColor !== null) {
$result = $this->contrastValidator->validate($primaryColor, new BrandColor(self::WHITE));
if (!$result->passesAA) {
throw ContrasteInsuffisantException::pourRatio($result->ratio, 4.5);
}
}
$secondaryColor = $command->secondaryColor !== null
? new BrandColor($command->secondaryColor)
: null;
$accentColor = $command->accentColor !== null
? new BrandColor($command->accentColor)
: null;
$branding->modifierCouleurs(
primaryColor: $primaryColor,
secondaryColor: $secondaryColor,
accentColor: $accentColor,
at: $now,
);
$this->brandingRepository->save($branding);
return $branding;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UploadLogo;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final readonly class UploadLogoCommand
{
public function __construct(
public string $tenantId,
public string $schoolId,
public UploadedFile $file,
) {
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Command\UploadLogo;
use App\Administration\Application\Service\LogoUploader;
use App\Administration\Domain\Model\SchoolBranding\SchoolBranding;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Administration\Domain\Repository\SchoolBrandingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'command.bus')]
final readonly class UploadLogoHandler
{
public function __construct(
private SchoolBrandingRepository $brandingRepository,
private LogoUploader $logoUploader,
private Clock $clock,
) {
}
public function __invoke(UploadLogoCommand $command): SchoolBranding
{
$tenantId = TenantId::fromString($command->tenantId);
$schoolId = SchoolId::fromString($command->schoolId);
$now = $this->clock->now();
$branding = $this->brandingRepository->findBySchoolId($schoolId, $tenantId);
if ($branding === null) {
$branding = SchoolBranding::creer(
schoolId: $schoolId,
tenantId: $tenantId,
createdAt: $now,
);
}
$logoUrl = $this->logoUploader->upload($command->file, $tenantId, $branding->logoUrl);
$branding->changerLogo($logoUrl, $now);
$this->brandingRepository->save($branding);
return $branding;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
/**
* Port pour le traitement d'images (redimensionnement).
*/
interface ImageProcessor
{
/**
* Redimensionne une image en respectant les proportions.
*
* @param string $sourcePath Chemin vers le fichier source
* @param int $maxWidth Largeur maximale
* @param int $maxHeight Hauteur maximale
*
* @return string Contenu binaire de l'image redimensionnée (PNG)
*/
public function resize(string $sourcePath, int $maxWidth, int $maxHeight): string;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
/**
* Port pour le stockage des logos d'établissement.
*/
interface LogoStorage
{
/**
* Stocke un logo et retourne son URL publique.
*
* @param string $content Contenu binaire du fichier
* @param string $key Clé de stockage (chemin)
* @param string $contentType Type MIME du fichier
*
* @return string URL publique du fichier stocké
*/
public function store(string $content, string $key, string $contentType): string;
/**
* Supprime un fichier du stockage.
*/
public function delete(string $key): void;
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetAllAssignments\AssignmentWithNamesDto;
/**
* Read-model port for paginated assignment queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<AssignmentWithNamesDto>
*/
interface PaginatedAssignmentsReader
{
/**
* @return PaginatedResult<AssignmentWithNamesDto>
*/
public function findPaginated(
string $tenantId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetClasses\ClassDto;
/**
* Read-model port for paginated class queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<ClassDto>
*/
interface PaginatedClassesReader
{
/**
* @return PaginatedResult<ClassDto>
*/
public function findPaginated(
string $tenantId,
string $academicYearId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetParentInvitations\ParentInvitationDto;
/**
* Read-model port for paginated parent invitation queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<ParentInvitationDto>
*/
interface PaginatedParentInvitationsReader
{
/**
* @return PaginatedResult<ParentInvitationDto>
*/
public function findPaginated(
string $tenantId,
?string $status,
?string $studentId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetStudentsImageRights\StudentImageRightsDto;
/**
* Read-model port for paginated student image rights queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<StudentImageRightsDto>
*/
interface PaginatedStudentImageRightsReader
{
/**
* @return PaginatedResult<StudentImageRightsDto>
*/
public function findPaginated(
string $tenantId,
?string $status,
?string $search,
int $page,
int $limit,
): PaginatedResult;
/**
* Returns all students (no pagination) for export purposes.
*
* @return StudentImageRightsDto[]
*/
public function findAll(
string $tenantId,
?string $status,
): array;
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetSubjects\SubjectDto;
/**
* Read-model port for paginated subject queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<SubjectDto>
*/
interface PaginatedSubjectsReader
{
/**
* @return PaginatedResult<SubjectDto>
*/
public function findPaginated(
string $tenantId,
string $schoolId,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Port;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Query\GetUsers\UserDto;
/**
* Read-model port for paginated user queries (CQRS read side).
*
* @phpstan-type Result = PaginatedResult<UserDto>
*/
interface PaginatedUsersReader
{
/**
* @return PaginatedResult<UserDto>
*/
public function findPaginated(
string $tenantId,
?string $role,
?string $statut,
?string $search,
int $page,
int $limit,
): PaginatedResult;
}

View File

@@ -5,27 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetAllAssignments; namespace App\Administration\Application\Query\GetAllAssignments;
use App\Administration\Application\Dto\PaginatedResult; use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Application\Port\PaginatedAssignmentsReader;
use App\Administration\Domain\Repository\SubjectRepository; use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Repository\TeacherAssignmentRepository;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class GetAllAssignmentsHandler final readonly class GetAllAssignmentsHandler
{ {
public function __construct( public function __construct(
private TeacherAssignmentRepository $assignmentRepository, private PaginatedAssignmentsReader $reader,
private UserRepository $userRepository, private PaginatedQueryCache $cache,
private ClassRepository $classRepository,
private SubjectRepository $subjectRepository,
private LoggerInterface $logger,
) { ) {
} }
@@ -34,101 +23,29 @@ final readonly class GetAllAssignmentsHandler
*/ */
public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult public function __invoke(GetAllAssignmentsQuery $query): PaginatedResult
{ {
$tenantId = TenantId::fromString($query->tenantId); /* @var PaginatedResult<AssignmentWithNamesDto> */
return $this->cache->getOrLoad(
$assignments = $this->assignmentRepository->findAllActiveByTenant($tenantId); 'assignments',
$query->tenantId,
// Build lookup maps for users, classes, and subjects $this->cacheParams($query),
$users = $this->userRepository->findAllByTenant($tenantId); fn (): PaginatedResult => $this->reader->findPaginated(
/** @var array<string, array{firstName: string, lastName: string}> $userNames */ tenantId: $query->tenantId,
$userNames = []; search: $query->search,
foreach ($users as $user) {
$userNames[(string) $user->id] = [
'firstName' => $user->firstName,
'lastName' => $user->lastName,
];
}
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $classNames */
$classNames = [];
foreach ($classes as $class) {
$classNames[(string) $class->id] = (string) $class->name;
}
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
/** @var array<string, string> $subjectNames */
$subjectNames = [];
foreach ($subjects as $subject) {
$subjectNames[(string) $subject->id] = (string) $subject->name;
}
$dtos = [];
foreach ($assignments as $assignment) {
$teacherId = (string) $assignment->teacherId;
$classId = (string) $assignment->classId;
$subjectId = (string) $assignment->subjectId;
if (!isset($userNames[$teacherId])) {
$this->logger->warning('Assignment {assignmentId} references unknown teacher {teacherId}', [
'assignmentId' => (string) $assignment->id,
'teacherId' => $teacherId,
]);
}
if (!isset($classNames[$classId])) {
$this->logger->warning('Assignment {assignmentId} references unknown class {classId}', [
'assignmentId' => (string) $assignment->id,
'classId' => $classId,
]);
}
if (!isset($subjectNames[$subjectId])) {
$this->logger->warning('Assignment {assignmentId} references unknown subject {subjectId}', [
'assignmentId' => (string) $assignment->id,
'subjectId' => $subjectId,
]);
}
$teacher = $userNames[$teacherId] ?? ['firstName' => '', 'lastName' => ''];
$dtos[] = new AssignmentWithNamesDto(
id: (string) $assignment->id,
teacherId: $teacherId,
teacherFirstName: $teacher['firstName'],
teacherLastName: $teacher['lastName'],
classId: $classId,
className: $classNames[$classId] ?? '',
subjectId: $subjectId,
subjectName: $subjectNames[$subjectId] ?? '',
academicYearId: (string) $assignment->academicYearId,
status: $assignment->status->value,
startDate: $assignment->startDate,
endDate: $assignment->endDate,
createdAt: $assignment->createdAt,
);
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$dtos = array_values(array_filter(
$dtos,
static fn (AssignmentWithNamesDto $dto) => str_contains(mb_strtolower($dto->teacherFirstName), $searchLower)
|| str_contains(mb_strtolower($dto->teacherLastName), $searchLower)
|| str_contains(mb_strtolower($dto->className), $searchLower)
|| str_contains(mb_strtolower($dto->subjectName), $searchLower),
));
}
$total = count($dtos);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($dtos, $offset, $query->limit);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page, page: $query->page,
limit: $query->limit, limit: $query->limit,
),
); );
} }
/**
* @return array<string, mixed>
*/
private function cacheParams(GetAllAssignmentsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'search' => $query->search,
];
}
} }

View File

@@ -5,20 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetClasses; namespace App\Administration\Application\Query\GetClasses;
use App\Administration\Application\Dto\PaginatedResult; use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId; use App\Administration\Application\Port\PaginatedClassesReader;
use App\Administration\Domain\Repository\ClassRepository; use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class GetClassesHandler final readonly class GetClassesHandler
{ {
public function __construct( public function __construct(
private ClassRepository $classRepository, private PaginatedClassesReader $reader,
private PaginatedQueryCache $cache,
) { ) {
} }
@@ -27,33 +23,31 @@ final readonly class GetClassesHandler
*/ */
public function __invoke(GetClassesQuery $query): PaginatedResult public function __invoke(GetClassesQuery $query): PaginatedResult
{ {
$classes = $this->classRepository->findActiveByTenantAndYear( /* @var PaginatedResult<ClassDto> */
TenantId::fromString($query->tenantId), return $this->cache->getOrLoad(
AcademicYearId::fromString($query->academicYearId), 'classes',
); $query->tenantId,
$this->cacheParams($query),
if ($query->search !== null && $query->search !== '') { fn (): PaginatedResult => $this->reader->findPaginated(
$searchLower = mb_strtolower($query->search); tenantId: $query->tenantId,
$classes = array_filter( academicYearId: $query->academicYearId,
$classes, search: $query->search,
static fn ($class) => str_contains(mb_strtolower((string) $class->name), $searchLower)
|| ($class->level !== null && str_contains(mb_strtolower($class->level->value), $searchLower)),
);
$classes = array_values($classes);
}
$total = count($classes);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($classes, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($class) => ClassDto::fromDomain($class),
$items,
),
total: $total,
page: $query->page, page: $query->page,
limit: $query->limit, limit: $query->limit,
),
); );
} }
/**
* @return array<string, mixed>
*/
private function cacheParams(GetClassesQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'academic_year_id' => $query->academicYearId,
'search' => $query->search,
];
}
} }

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Application\Port\PaginatedParentInvitationsReader;
use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetParentInvitationsHandler
{
public function __construct(
private PaginatedParentInvitationsReader $reader,
private PaginatedQueryCache $cache,
) {
}
/**
* @return PaginatedResult<ParentInvitationDto>
*/
public function __invoke(GetParentInvitationsQuery $query): PaginatedResult
{
/* @var PaginatedResult<ParentInvitationDto> */
return $this->cache->getOrLoad(
'parent_invitations',
$query->tenantId,
$this->cacheParams($query),
fn (): PaginatedResult => $this->reader->findPaginated(
tenantId: $query->tenantId,
status: $query->status,
studentId: $query->studentId,
search: $query->search,
page: $query->page,
limit: $query->limit,
),
);
}
/**
* @return array<string, mixed>
*/
private function cacheParams(GetParentInvitationsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'student_id' => $query->studentId,
'search' => $query->search,
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetParentInvitationsQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public ?string $status = null,
public ?string $studentId = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetParentInvitations;
use App\Administration\Domain\Model\Invitation\ParentInvitation;
use DateTimeImmutable;
final readonly class ParentInvitationDto
{
public function __construct(
public string $id,
public string $studentId,
public string $parentEmail,
public string $status,
public DateTimeImmutable $createdAt,
public DateTimeImmutable $expiresAt,
public ?DateTimeImmutable $sentAt,
public ?DateTimeImmutable $activatedAt,
public ?string $activatedUserId,
public ?string $studentFirstName = null,
public ?string $studentLastName = null,
) {
}
public static function fromDomain(ParentInvitation $invitation, ?string $studentFirstName = null, ?string $studentLastName = null): self
{
return new self(
id: (string) $invitation->id,
studentId: (string) $invitation->studentId,
parentEmail: (string) $invitation->parentEmail,
status: $invitation->status->value,
createdAt: $invitation->createdAt,
expiresAt: $invitation->expiresAt,
sentAt: $invitation->sentAt,
activatedAt: $invitation->activatedAt,
activatedUserId: $invitation->activatedUserId !== null ? (string) $invitation->activatedUserId : null,
studentFirstName: $studentFirstName,
studentLastName: $studentLastName,
);
}
}

View File

@@ -4,41 +4,50 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights; namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Domain\Model\User\ImageRightsStatus; use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Repository\UserRepository; use App\Administration\Application\Port\PaginatedStudentImageRightsReader;
use App\Shared\Domain\Tenant\TenantId; use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsImageRightsHandler final readonly class GetStudentsImageRightsHandler
{ {
public function __construct( public function __construct(
private UserRepository $userRepository, private PaginatedStudentImageRightsReader $reader,
private PaginatedQueryCache $cache,
) { ) {
} }
/** /**
* @return StudentImageRightsDto[] * @return PaginatedResult<StudentImageRightsDto>
*/ */
public function __invoke(GetStudentsImageRightsQuery $query): array public function __invoke(GetStudentsImageRightsQuery $query): PaginatedResult
{ {
$students = $this->userRepository->findStudentsByTenant( /* @var PaginatedResult<StudentImageRightsDto> */
TenantId::fromString($query->tenantId), return $this->cache->getOrLoad(
); 'students_image_rights',
$query->tenantId,
if ($query->status !== null) { $this->cacheParams($query),
$filterStatus = ImageRightsStatus::tryFrom($query->status); fn (): PaginatedResult => $this->reader->findPaginated(
if ($filterStatus !== null) { tenantId: $query->tenantId,
$students = array_filter( status: $query->status,
$students, search: $query->search,
static fn ($user) => $user->imageRightsStatus === $filterStatus, page: $query->page,
limit: $query->limit,
),
); );
} }
}
return array_values(array_map( /**
static fn ($user) => StudentImageRightsDto::fromDomain($user), * @return array<string, mixed>
$students, */
)); private function cacheParams(GetStudentsImageRightsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'status' => $query->status,
'search' => $query->search,
];
} }
} }

View File

@@ -4,11 +4,23 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsImageRights; namespace App\Administration\Application\Query\GetStudentsImageRights;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetStudentsImageRightsQuery final readonly class GetStudentsImageRightsQuery
{ {
public int $page;
public int $limit;
public ?string $search;
public function __construct( public function __construct(
public string $tenantId, public string $tenantId,
public ?string $status = null, public ?string $status = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) { ) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
} }
} }

View File

@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsWithClass;
use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentsWithClassHandler
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return PaginatedResult<StudentWithClassDto>
*/
public function __invoke(GetStudentsWithClassQuery $query): PaginatedResult
{
$params = [
'tenant_id' => $query->tenantId,
'academic_year_id' => $query->academicYearId,
'role' => json_encode([Role::ELEVE->value]),
];
$whereClause = 'u.tenant_id = :tenant_id AND u.roles::jsonb @> :role::jsonb';
if ($query->classId !== null) {
$whereClause .= ' AND ca.school_class_id = :class_id';
$params['class_id'] = $query->classId;
}
if ($query->search !== null && $query->search !== '') {
$whereClause .= ' AND (LOWER(u.last_name) LIKE :search OR LOWER(u.first_name) LIKE :search)';
$params['search'] = '%' . mb_strtolower($query->search) . '%';
}
// Count total
$countSql = <<<SQL
SELECT COUNT(*)
FROM users u
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
WHERE {$whereClause}
SQL;
/** @var int|string|false $totalRaw */
$totalRaw = $this->connection->fetchOne($countSql, $params);
$total = (int) $totalRaw;
// Fetch paginated results
$offset = ($query->page - 1) * $query->limit;
$selectSql = <<<SQL
SELECT
u.id,
u.first_name,
u.last_name,
u.email,
u.statut,
u.student_number,
u.date_naissance,
ca.school_class_id AS class_id,
sc.name AS class_name,
sc.level AS class_level
FROM users u
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
WHERE {$whereClause}
ORDER BY u.last_name ASC, u.first_name ASC
LIMIT :limit OFFSET :offset
SQL;
$params['limit'] = $query->limit;
$params['offset'] = $offset;
$rows = $this->connection->fetchAllAssociative($selectSql, $params);
$items = array_map(static function (array $row): StudentWithClassDto {
/** @var string $id */
$id = $row['id'];
/** @var string $firstName */
$firstName = $row['first_name'];
/** @var string $lastName */
$lastName = $row['last_name'];
/** @var string|null $email */
$email = $row['email'];
/** @var string $statut */
$statut = $row['statut'];
/** @var string|null $studentNumber */
$studentNumber = $row['student_number'];
/** @var string|null $dateNaissance */
$dateNaissance = $row['date_naissance'];
/** @var string|null $classId */
$classId = $row['class_id'];
/** @var string|null $className */
$className = $row['class_name'];
/** @var string|null $classLevel */
$classLevel = $row['class_level'];
return new StudentWithClassDto(
id: $id,
firstName: $firstName,
lastName: $lastName,
email: $email,
statut: $statut,
studentNumber: $studentNumber,
dateNaissance: $dateNaissance,
classId: $classId,
className: $className,
classLevel: $classLevel,
);
}, $rows);
return new PaginatedResult(
items: $items,
total: $total,
page: $query->page,
limit: $query->limit,
);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsWithClass;
use App\Administration\Application\Dto\PaginatedResult;
final readonly class GetStudentsWithClassQuery
{
public int $page;
public int $limit;
public ?string $search;
public function __construct(
public string $tenantId,
public string $academicYearId,
public ?string $classId = null,
int $page = PaginatedResult::DEFAULT_PAGE,
int $limit = PaginatedResult::DEFAULT_LIMIT,
?string $search = null,
) {
$this->page = max(1, $page);
$this->limit = max(1, min(PaginatedResult::MAX_LIMIT, $limit));
$this->search = $search !== null ? mb_substr(trim($search), 0, PaginatedResult::MAX_SEARCH_LENGTH) : null;
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Query\GetStudentsWithClass;
final readonly class StudentWithClassDto
{
public function __construct(
public string $id,
public string $firstName,
public string $lastName,
public ?string $email,
public string $statut,
public ?string $studentNumber,
public ?string $dateNaissance,
public ?string $classId,
public ?string $className,
public ?string $classLevel,
) {
}
}

View File

@@ -5,20 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetSubjects; namespace App\Administration\Application\Query\GetSubjects;
use App\Administration\Application\Dto\PaginatedResult; use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\SchoolClass\SchoolId; use App\Administration\Application\Port\PaginatedSubjectsReader;
use App\Administration\Domain\Repository\SubjectRepository; use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class GetSubjectsHandler final readonly class GetSubjectsHandler
{ {
public function __construct( public function __construct(
private SubjectRepository $subjectRepository, private PaginatedSubjectsReader $reader,
private PaginatedQueryCache $cache,
) { ) {
} }
@@ -27,37 +23,31 @@ final readonly class GetSubjectsHandler
*/ */
public function __invoke(GetSubjectsQuery $query): PaginatedResult public function __invoke(GetSubjectsQuery $query): PaginatedResult
{ {
$subjects = $this->subjectRepository->findActiveByTenantAndSchool( /* @var PaginatedResult<SubjectDto> */
TenantId::fromString($query->tenantId), return $this->cache->getOrLoad(
SchoolId::fromString($query->schoolId), 'subjects',
); $query->tenantId,
$this->cacheParams($query),
if ($query->search !== null && $query->search !== '') { fn (): PaginatedResult => $this->reader->findPaginated(
$searchLower = mb_strtolower($query->search); tenantId: $query->tenantId,
$subjects = array_filter( schoolId: $query->schoolId,
$subjects, search: $query->search,
static fn ($subject) => str_contains(mb_strtolower((string) $subject->name), $searchLower)
|| str_contains(mb_strtolower((string) $subject->code), $searchLower),
);
$subjects = array_values($subjects);
}
$total = count($subjects);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($subjects, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
static fn ($subject) => SubjectDto::fromDomain(
$subject,
teacherCount: 0,
classCount: 0,
),
$items,
),
total: $total,
page: $query->page, page: $query->page,
limit: $query->limit, limit: $query->limit,
),
); );
} }
/**
* @return array<string, mixed>
*/
private function cacheParams(GetSubjectsQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'school_id' => $query->schoolId,
'search' => $query->search,
];
}
} }

View File

@@ -5,23 +5,16 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\GetUsers; namespace App\Administration\Application\Query\GetUsers;
use App\Administration\Application\Dto\PaginatedResult; use App\Administration\Application\Dto\PaginatedResult;
use App\Administration\Domain\Model\User\Role; use App\Administration\Application\Port\PaginatedUsersReader;
use App\Administration\Domain\Model\User\StatutCompte; use App\Administration\Application\Service\Cache\PaginatedQueryCache;
use App\Administration\Domain\Repository\UserRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_slice;
use function count;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler(bus: 'query.bus')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class GetUsersHandler final readonly class GetUsersHandler
{ {
public function __construct( public function __construct(
private UserRepository $userRepository, private PaginatedUsersReader $reader,
private Clock $clock, private PaginatedQueryCache $cache,
) { ) {
} }
@@ -30,54 +23,33 @@ final readonly class GetUsersHandler
*/ */
public function __invoke(GetUsersQuery $query): PaginatedResult public function __invoke(GetUsersQuery $query): PaginatedResult
{ {
$users = $this->userRepository->findAllByTenant( /* @var PaginatedResult<UserDto> */
TenantId::fromString($query->tenantId), return $this->cache->getOrLoad(
); 'users',
$query->tenantId,
if ($query->role !== null) { $this->cacheParams($query),
$filterRole = Role::tryFrom($query->role); fn (): PaginatedResult => $this->reader->findPaginated(
if ($filterRole !== null) { tenantId: $query->tenantId,
$users = array_filter( role: $query->role,
$users, statut: $query->statut,
static fn ($user) => $user->aLeRole($filterRole), search: $query->search,
);
}
}
if ($query->statut !== null) {
$filterStatut = StatutCompte::tryFrom($query->statut);
if ($filterStatut !== null) {
$users = array_filter(
$users,
static fn ($user) => $user->statut === $filterStatut,
);
}
}
if ($query->search !== null && $query->search !== '') {
$searchLower = mb_strtolower($query->search);
$users = array_filter(
$users,
static fn ($user) => str_contains(mb_strtolower($user->firstName), $searchLower)
|| str_contains(mb_strtolower($user->lastName), $searchLower)
|| str_contains(mb_strtolower((string) $user->email), $searchLower),
);
}
$users = array_values($users);
$total = count($users);
$offset = ($query->page - 1) * $query->limit;
$items = array_slice($users, $offset, $query->limit);
return new PaginatedResult(
items: array_map(
fn ($user) => UserDto::fromDomain($user, $this->clock),
$items,
),
total: $total,
page: $query->page, page: $query->page,
limit: $query->limit, limit: $query->limit,
),
); );
} }
/**
* @return array<string, mixed>
*/
private function cacheParams(GetUsersQuery $query): array
{
return [
'page' => $query->page,
'limit' => $query->limit,
'role' => $query->role,
'statut' => $query->statut,
'search' => $query->search,
];
}
} }

View File

@@ -4,21 +4,22 @@ declare(strict_types=1);
namespace App\Administration\Application\Query\HasStudentsInClass; namespace App\Administration\Application\Query\HasStudentsInClass;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Repository\ClassAssignmentRepository;
use Symfony\Component\Messenger\Attribute\AsMessageHandler; use Symfony\Component\Messenger\Attribute\AsMessageHandler;
/**
* Handler pour vérifier si des élèves sont affectés à une classe.
*
* Note: L'implémentation complète sera ajoutée quand le module Élèves sera disponible.
* Pour l'instant, retourne toujours 0 (aucun élève).
*/
#[AsMessageHandler(bus: 'query.bus')] #[AsMessageHandler(bus: 'query.bus')]
final readonly class HasStudentsInClassHandler final readonly class HasStudentsInClassHandler
{ {
public function __construct(
private ClassAssignmentRepository $classAssignmentRepository,
) {
}
public function __invoke(HasStudentsInClassQuery $query): int public function __invoke(HasStudentsInClassQuery $query): int
{ {
// TODO: Implémenter la vérification réelle quand le module Élèves sera disponible return $this->classAssignmentRepository->countByClass(
// Pour l'instant, retourne 0 (permet l'archivage) ClassId::fromString($query->classId),
return 0; );
} }
} }

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Cache;
use App\Administration\Application\Dto\PaginatedResult;
use function json_encode;
use const JSON_THROW_ON_ERROR;
use function ksort;
use function md5;
use function sprintf;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* Service cache-aside pour les requêtes paginées.
*
* Chaque entrée est taguée par type d'entité + tenant,
* permettant une invalidation ciblée lors de mutations.
*/
final readonly class PaginatedQueryCache
{
public function __construct(
private TagAwareCacheInterface $paginatedQueriesCache,
) {
}
/**
* @template T
*
* @param array<string, mixed> $params Filtres + page + limit
* @param callable(): PaginatedResult<T> $loader Fonction qui exécute la requête SQL
*
* @return PaginatedResult<T>
*/
public function getOrLoad(
string $entityType,
string $tenantId,
array $params,
callable $loader,
): PaginatedResult {
$key = $this->buildKey($entityType, $tenantId, $params);
$tag = sprintf('query_%s_%s', $entityType, $tenantId);
/* @var PaginatedResult<T> */
return $this->paginatedQueriesCache->get(
$key,
static function (ItemInterface $item) use ($tag, $loader): PaginatedResult {
$item->tag([$tag]);
return $loader();
},
);
}
public function invalidate(string $entityType, string $tenantId): void
{
$this->paginatedQueriesCache->invalidateTags(
[sprintf('query_%s_%s', $entityType, $tenantId)],
);
}
/**
* @param array<string, mixed> $params
*/
private function buildKey(string $entityType, string $tenantId, array $params): string
{
ksort($params);
return sprintf('query_%s_%s_%s', $entityType, $tenantId, md5(json_encode($params, JSON_THROW_ON_ERROR)));
}
}

View File

@@ -0,0 +1,159 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportField;
use function in_array;
/**
* Suggère un mapping automatique des colonnes basé sur les noms de colonnes
* et le format détecté.
*
* @see AC3: Mapping automatique proposé basé sur noms de colonnes
*/
final readonly class ColumnMappingSuggester
{
/**
* Mappings pré-configurés pour le format Pronote.
*
* @var array<string, StudentImportField>
*/
private const array PRONOTE_MAPPING = [
'élèves' => StudentImportField::FULL_NAME,
'eleves' => StudentImportField::FULL_NAME,
'né(e) le' => StudentImportField::BIRTH_DATE,
'ne(e) le' => StudentImportField::BIRTH_DATE,
'sexe' => StudentImportField::GENDER,
'classe de rattachement' => StudentImportField::CLASS_NAME,
'adresse e mail' => StudentImportField::EMAIL,
];
private const array ECOLE_DIRECTE_MAPPING = [
'nom' => StudentImportField::LAST_NAME,
'prenom' => StudentImportField::FIRST_NAME,
'classe' => StudentImportField::CLASS_NAME,
'date naissance' => StudentImportField::BIRTH_DATE,
'sexe' => StudentImportField::GENDER,
'email' => StudentImportField::EMAIL,
'numero' => StudentImportField::STUDENT_NUMBER,
];
/**
* Mapping générique par mots-clés.
*
* @var array<string, StudentImportField>
*/
private const array GENERIC_KEYWORDS = [
'élèves' => StudentImportField::FULL_NAME,
'eleves' => StudentImportField::FULL_NAME,
'nom' => StudentImportField::LAST_NAME,
'last' => StudentImportField::LAST_NAME,
'family' => StudentImportField::LAST_NAME,
'surname' => StudentImportField::LAST_NAME,
'prénom' => StudentImportField::FIRST_NAME,
'prenom' => StudentImportField::FIRST_NAME,
'first' => StudentImportField::FIRST_NAME,
'given' => StudentImportField::FIRST_NAME,
'classe' => StudentImportField::CLASS_NAME,
'class' => StudentImportField::CLASS_NAME,
'groupe' => StudentImportField::CLASS_NAME,
'email' => StudentImportField::EMAIL,
'mail' => StudentImportField::EMAIL,
'courriel' => StudentImportField::EMAIL,
'naissance' => StudentImportField::BIRTH_DATE,
'birth' => StudentImportField::BIRTH_DATE,
'date' => StudentImportField::BIRTH_DATE,
'sexe' => StudentImportField::GENDER,
'genre' => StudentImportField::GENDER,
'gender' => StudentImportField::GENDER,
'numéro' => StudentImportField::STUDENT_NUMBER,
'numero' => StudentImportField::STUDENT_NUMBER,
'number' => StudentImportField::STUDENT_NUMBER,
'matricule' => StudentImportField::STUDENT_NUMBER,
];
/**
* Suggère un mapping pour les colonnes données.
*
* @param list<string> $columns Colonnes détectées dans le fichier
* @param KnownImportFormat $detectedFormat Format détecté
*
* @return array<string, StudentImportField> Mapping suggéré (colonne → champ)
*/
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
{
return match ($detectedFormat) {
KnownImportFormat::PRONOTE => $this->mapperAvecReference($columns, self::PRONOTE_MAPPING),
KnownImportFormat::ECOLE_DIRECTE => $this->mapperAvecReference($columns, self::ECOLE_DIRECTE_MAPPING),
KnownImportFormat::CUSTOM => $this->mapperGenerique($columns),
};
}
/**
* @param list<string> $columns
* @param array<string, StudentImportField> $reference
*
* @return array<string, StudentImportField>
*/
private function mapperAvecReference(array $columns, array $reference): array
{
$normalizedReference = [];
foreach ($reference as $key => $field) {
$normalizedReference[$this->normaliser($key)] = $field;
}
$mapping = [];
$usedFields = [];
foreach ($columns as $column) {
$normalized = $this->normaliser($column);
if (isset($normalizedReference[$normalized]) && !in_array($normalizedReference[$normalized], $usedFields, true)) {
$mapping[$column] = $normalizedReference[$normalized];
$usedFields[] = $normalizedReference[$normalized];
}
}
return $mapping;
}
/**
* @param list<string> $columns
*
* @return array<string, StudentImportField>
*/
private function mapperGenerique(array $columns): array
{
$mapping = [];
$usedFields = [];
foreach ($columns as $column) {
$normalized = $this->normaliser($column);
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
$mapping[$column] = $field;
$usedFields[] = $field;
break;
}
}
}
return $mapping;
}
private function normaliser(string $column): string
{
$normalized = mb_strtolower(trim($column));
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
/** @var string $result */
$result = preg_replace('/\s+/', ' ', $normalized);
return $result;
}
}

View File

@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use function count;
use function fclose;
use function fgetcsv;
use function fopen;
use function mb_convert_encoding;
use function mb_detect_encoding;
/**
* Service de parsing de fichiers CSV avec détection d'encoding UTF-8.
*
* Supporte les séparateurs courants (virgule, point-virgule, tabulation).
*/
final readonly class CsvParser
{
private const int MAX_LINE_LENGTH = 0;
private const array SEPARATORS = [';', ',', "\t"];
public function parse(string $filePath): FileParseResult
{
$handle = fopen($filePath, 'r');
if ($handle === false) {
throw FichierImportInvalideException::fichierIllisible($filePath);
}
try {
$content = file_get_contents($filePath);
if ($content === false) {
throw FichierImportInvalideException::fichierIllisible($filePath);
}
$content = $this->convertToUtf8($content);
$content = $this->stripBom($content);
$separator = $this->detectSeparator($content);
$lines = $this->parseContent($content, $separator);
if ($lines === []) {
throw FichierImportInvalideException::fichierVide();
}
$columns = array_shift($lines);
$rows = [];
foreach ($lines as $line) {
if ($this->isEmptyLine($line)) {
continue;
}
$row = [];
foreach ($columns as $index => $column) {
$row[$column] = $line[$index] ?? '';
}
$rows[] = $row;
}
return new FileParseResult($columns, $rows);
} finally {
fclose($handle);
}
}
private function convertToUtf8(string $content): string
{
$encoding = mb_detect_encoding($content, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true);
if ($encoding !== false && $encoding !== 'UTF-8') {
$converted = mb_convert_encoding($content, 'UTF-8', $encoding);
return $converted !== false ? $converted : $content;
}
return $content;
}
private function stripBom(string $content): string
{
if (str_starts_with($content, "\xEF\xBB\xBF")) {
return substr($content, 3);
}
return $content;
}
private function detectSeparator(string $content): string
{
$firstLine = strtok($content, "\n");
if ($firstLine === false) {
return ';';
}
$maxCount = 0;
$bestSeparator = ';';
foreach (self::SEPARATORS as $separator) {
$count = substr_count($firstLine, $separator);
if ($count > $maxCount) {
$maxCount = $count;
$bestSeparator = $separator;
}
}
return $bestSeparator;
}
/**
* @return list<list<string>>
*/
private function parseContent(string $content, string $separator): array
{
$stream = fopen('php://temp', 'r+');
if ($stream === false) {
return [];
}
fwrite($stream, $content);
rewind($stream);
$lines = [];
while (($line = fgetcsv($stream, self::MAX_LINE_LENGTH, $separator, '"', '')) !== false) {
/** @var list<string> $sanitized */
$sanitized = array_map('strval', $line);
$lines[] = $sanitized;
}
fclose($stream);
return $lines;
}
/**
* @param list<string> $line
*/
private function isEmptyLine(array $line): bool
{
return count($line) === 1 && trim((string) $line[0]) === '';
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use DateTimeImmutable;
/**
* Parse des dates dans les formats courants utilisés par les fichiers d'import.
*/
final class DateParser
{
private const array FORMATS = ['d/m/Y', 'Y-m-d', 'd-m-Y', 'd.m.Y'];
public static function parse(?string $date): ?DateTimeImmutable
{
if ($date === null || trim($date) === '') {
return null;
}
$trimmed = trim($date);
foreach (self::FORMATS as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $trimmed);
if ($parsed !== false && $parsed->format($format) === $trimmed) {
return $parsed;
}
}
return null;
}
}

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\StudentImportField;
use function mb_strtolower;
use function sprintf;
use function trim;
/**
* Détecte les doublons parmi les lignes d'import :
* - contre les élèves existants en base (email, numéro élève, nom+prénom+classe)
* - au sein du fichier lui-même (lignes identiques).
*/
final readonly class DuplicateDetector
{
/**
* @param list<ImportRow> $rows
* @param list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}> $existingStudents
*
* @return list<ImportRow>
*/
public function detecter(array $rows, array $existingStudents): array
{
$byEmail = [];
$byStudentNumber = [];
$byNameClass = [];
foreach ($existingStudents as $student) {
if ($student['email'] !== null && trim($student['email']) !== '') {
$byEmail[mb_strtolower(trim($student['email']))] = true;
}
if ($student['studentNumber'] !== null && trim($student['studentNumber']) !== '') {
$byStudentNumber[trim($student['studentNumber'])] = true;
}
$key = $this->nameClassKey(
$student['firstName'],
$student['lastName'],
$student['className'] ?? '',
);
if ($key !== null) {
$byNameClass[$key] = true;
}
}
$result = [];
foreach ($rows as $row) {
$match = $this->findMatch($row, $byEmail, $byStudentNumber, $byNameClass);
if ($match !== null) {
$row = $row->avecErreurs(new ImportRowError(
'_duplicate',
sprintf('Cet élève existe déjà (correspondance : %s).', $match),
));
} else {
$this->indexRow($row, $byEmail, $byStudentNumber, $byNameClass);
}
$result[] = $row;
}
return $result;
}
/**
* @param array<string, true> $byEmail
* @param array<string, true> $byStudentNumber
* @param array<string, true> $byNameClass
*/
private function findMatch(
ImportRow $row,
array $byEmail,
array $byStudentNumber,
array $byNameClass,
): ?string {
$email = $row->valeurChamp(StudentImportField::EMAIL);
if ($email !== null && trim($email) !== '') {
$normalized = mb_strtolower(trim($email));
if (isset($byEmail[$normalized])) {
return 'email';
}
}
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
if ($studentNumber !== null && trim($studentNumber) !== '') {
if (isset($byStudentNumber[trim($studentNumber)])) {
return 'numéro élève';
}
}
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
if ($key !== null && isset($byNameClass[$key])) {
return 'nom + classe';
}
return null;
}
/**
* @param array<string, true> $byEmail
* @param array<string, true> $byStudentNumber
* @param array<string, true> $byNameClass
*/
private function indexRow(
ImportRow $row,
array &$byEmail,
array &$byStudentNumber,
array &$byNameClass,
): void {
$email = $row->valeurChamp(StudentImportField::EMAIL);
if ($email !== null && trim($email) !== '') {
$byEmail[mb_strtolower(trim($email))] = true;
}
$studentNumber = $row->valeurChamp(StudentImportField::STUDENT_NUMBER);
if ($studentNumber !== null && trim($studentNumber) !== '') {
$byStudentNumber[trim($studentNumber)] = true;
}
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
$key = $this->nameClassKey($firstName ?? '', $lastName ?? '', $className ?? '');
if ($key !== null) {
$byNameClass[$key] = true;
}
}
private function nameClassKey(string $firstName, string $lastName, string $className): ?string
{
$first = mb_strtolower(trim($firstName));
$last = mb_strtolower(trim($lastName));
$class = mb_strtolower(trim($className));
if ($first === '' || $last === '' || $class === '') {
return null;
}
return $last . '|' . $first . '|' . $class;
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Shared\Domain\Tenant\TenantId;
use Doctrine\DBAL\Connection;
/**
* Charge les élèves existants d'un tenant pour une année scolaire,
* avec leur affectation de classe, afin de détecter les doublons à l'import.
*/
final readonly class ExistingStudentFinder
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return list<array{firstName: string, lastName: string, email: ?string, studentNumber: ?string, className: ?string}>
*/
public function findAllForTenant(TenantId $tenantId, AcademicYearId $academicYearId): array
{
$sql = <<<'SQL'
SELECT u.first_name, u.last_name, u.email, u.student_number, sc.name AS class_name
FROM users u
LEFT JOIN class_assignments ca ON ca.user_id = u.id AND ca.academic_year_id = :academic_year_id
LEFT JOIN school_classes sc ON sc.id = ca.school_class_id
WHERE u.tenant_id = :tenant_id
AND u.roles::jsonb @> :role
SQL;
/** @var list<array{first_name: string, last_name: string, email: ?string, student_number: ?string, class_name: ?string}> $rows */
$rows = $this->connection->fetchAllAssociative($sql, [
'tenant_id' => (string) $tenantId,
'academic_year_id' => (string) $academicYearId,
'role' => '"ROLE_ELEVE"',
]);
return array_map(
static fn (array $row) => [
'firstName' => $row['first_name'],
'lastName' => $row['last_name'],
'email' => $row['email'],
'studentNumber' => $row['student_number'],
'className' => $row['class_name'],
],
$rows,
);
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Shared\Domain\Tenant\TenantId;
use Doctrine\DBAL\Connection;
/**
* Charge les enseignants existants d'un tenant
* afin de détecter les doublons à l'import.
*/
final readonly class ExistingTeacherFinder
{
public function __construct(
private Connection $connection,
) {
}
/**
* @return list<array{firstName: string, lastName: string, email: string}>
*/
public function findAllForTenant(TenantId $tenantId): array
{
$sql = <<<'SQL'
SELECT u.first_name, u.last_name, u.email
FROM users u
WHERE u.tenant_id = :tenant_id
AND u.roles::jsonb @> :role
SQL;
/** @var list<array{first_name: string, last_name: string, email: string}> $rows */
$rows = $this->connection->fetchAllAssociative($sql, [
'tenant_id' => (string) $tenantId,
'role' => '"ROLE_PROF"',
]);
return array_map(
static fn (array $row) => [
'firstName' => $row['first_name'],
'lastName' => $row['last_name'],
'email' => $row['email'],
],
$rows,
);
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use function array_slice;
use function count;
/**
* Résultat du parsing d'un fichier d'import.
*
* Contient les colonnes détectées et les données brutes extraites.
*/
final readonly class FileParseResult
{
/**
* @param list<string> $columns Noms des colonnes détectées
* @param list<array<string, string>> $rows Données brutes (colonne → valeur)
*/
public function __construct(
public array $columns,
public array $rows,
) {
}
public function totalRows(): int
{
return count($this->rows);
}
/**
* @return list<array<string, string>>
*/
public function preview(int $limit = 5): array
{
return array_slice($this->rows, 0, $limit);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\KnownImportFormat;
/**
* Détecte automatiquement le format d'import (Pronote, École Directe)
* à partir des noms de colonnes.
*/
final readonly class ImportFormatDetector
{
/**
* Colonnes caractéristiques de Pronote.
*/
private const array PRONOTE_COLUMNS = [
'Élèves',
'Né(e) le',
'Sexe',
'Classe de rattachement',
'Adresse E-mail',
];
/**
* Colonnes caractéristiques d'École Directe.
*/
private const array ECOLE_DIRECTE_COLUMNS = [
'NOM',
'PRENOM',
'CLASSE',
'DATE_NAISSANCE',
'SEXE',
];
/**
* @param list<string> $columns Colonnes détectées dans le fichier
*/
public function detecter(array $columns): KnownImportFormat
{
$normalizedColumns = array_map($this->normaliser(...), $columns);
if ($this->matchesPronote($normalizedColumns)) {
return KnownImportFormat::PRONOTE;
}
if ($this->matchesEcoleDirecte($normalizedColumns)) {
return KnownImportFormat::ECOLE_DIRECTE;
}
return KnownImportFormat::CUSTOM;
}
/**
* @param list<string> $normalizedColumns
*/
private function matchesPronote(array $normalizedColumns): bool
{
$pronoteNormalized = array_map($this->normaliser(...), self::PRONOTE_COLUMNS);
return $this->matchThreshold($normalizedColumns, $pronoteNormalized, 3);
}
/**
* @param list<string> $normalizedColumns
*/
private function matchesEcoleDirecte(array $normalizedColumns): bool
{
$ecoleDirecteNormalized = array_map($this->normaliser(...), self::ECOLE_DIRECTE_COLUMNS);
return $this->matchThreshold($normalizedColumns, $ecoleDirecteNormalized, 3);
}
/**
* @param list<string> $actualColumns
* @param list<string> $expectedColumns
*/
private function matchThreshold(array $actualColumns, array $expectedColumns, int $minMatches): bool
{
$matches = 0;
foreach ($expectedColumns as $expected) {
foreach ($actualColumns as $actual) {
if ($actual === $expected) {
++$matches;
break;
}
}
}
return $matches >= $minMatches;
}
private function normaliser(string $column): string
{
$normalized = mb_strtolower(trim($column));
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
/** @var string $result */
$result = preg_replace('/\s+/', ' ', $normalized);
return $result;
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use function count;
use function sprintf;
/**
* Rapport d'import généré après validation ou exécution.
*
* @see AC5: Rapport affiché : X élèves importés, Y erreurs ignorées
*/
final readonly class ImportReport
{
/**
* @param list<ImportRow> $validRows Lignes valides importées
* @param list<ImportRow> $errorRows Lignes en erreur
* @param list<string> $createdClasses Classes créées automatiquement
*/
public function __construct(
public int $totalRows,
public int $importedCount,
public int $errorCount,
public array $validRows,
public array $errorRows,
public array $createdClasses = [],
) {
}
/**
* @param list<ImportRow> $rows Toutes les lignes validées
* @param list<string> $createdClasses Classes créées automatiquement
*/
public static function fromValidatedRows(array $rows, array $createdClasses = []): self
{
$valid = [];
$errors = [];
foreach ($rows as $row) {
if ($row->estValide()) {
$valid[] = $row;
} else {
$errors[] = $row;
}
}
return new self(
totalRows: count($rows),
importedCount: count($valid),
errorCount: count($errors),
validRows: $valid,
errorRows: $errors,
createdClasses: $createdClasses,
);
}
/**
* Génère un résumé texte du rapport.
*
* @return list<string> Lignes du rapport
*/
public function lignesRapport(): array
{
$lines = [];
$lines[] = sprintf('Import terminé : %d élèves importés, %d erreurs', $this->importedCount, $this->errorCount);
if ($this->createdClasses !== []) {
$lines[] = sprintf('Classes créées automatiquement : %s', implode(', ', $this->createdClasses));
}
foreach ($this->errorRows as $row) {
foreach ($row->errors as $error) {
$lines[] = sprintf('Ligne %d, %s', $row->lineNumber, $error);
}
}
return $lines;
}
}

View File

@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\StudentImportField;
use const FILTER_VALIDATE_EMAIL;
use function in_array;
use function sprintf;
/**
* Valide les lignes d'import après mapping.
*
* Vérifie que les champs obligatoires sont remplis,
* les formats sont corrects (email, dates), et les classes existent.
*
* @see AC4: Lignes valides en vert, lignes avec erreurs en rouge
*/
final readonly class ImportRowValidator
{
/**
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
*/
public function __construct(
private ?array $existingClassNames = null,
) {
}
public function valider(ImportRow $row): ImportRow
{
$row = $this->expanderNomComplet($row);
$errors = [];
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
$errors = [...$errors, ...$this->validerEmail($row)];
$errors = [...$errors, ...$this->validerDateNaissance($row)];
$errors = [...$errors, ...$this->validerClasse($row)];
if ($errors !== []) {
return $row->avecErreurs(...$errors);
}
return $row;
}
/**
* @param list<ImportRow> $rows
*
* @return list<ImportRow>
*/
public function validerTout(array $rows): array
{
return array_map($this->valider(...), $rows);
}
/**
* @return list<ImportRowError>
*/
private function validerChampsObligatoires(ImportRow $row): array
{
$errors = [];
foreach (StudentImportField::champsObligatoires() as $field) {
$value = $row->valeurChamp($field);
if ($value === null || trim($value) === '') {
$errors[] = new ImportRowError(
$field->value,
sprintf('Le champ "%s" est obligatoire.', $field->label()),
);
}
}
return $errors;
}
/**
* @return list<ImportRowError>
*/
private function validerEmail(ImportRow $row): array
{
$email = $row->valeurChamp(StudentImportField::EMAIL);
if ($email === null || trim($email) === '') {
return [];
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return [new ImportRowError(
StudentImportField::EMAIL->value,
sprintf('L\'adresse email "%s" est invalide.', $email),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerDateNaissance(ImportRow $row): array
{
$date = $row->valeurChamp(StudentImportField::BIRTH_DATE);
if ($date === null || trim($date) === '') {
return [];
}
if (DateParser::parse($date) === null) {
return [new ImportRowError(
StudentImportField::BIRTH_DATE->value,
sprintf('La date "%s" est invalide. Formats acceptés : JJ/MM/AAAA, AAAA-MM-JJ.', $date),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerClasse(ImportRow $row): array
{
if ($this->existingClassNames === null) {
return [];
}
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
if ($className === null || trim($className) === '') {
return [];
}
if (!in_array(trim($className), $this->existingClassNames, true)) {
return [new ImportRowError(
StudentImportField::CLASS_NAME->value,
sprintf('La classe "%s" n\'existe pas.', $className),
)];
}
return [];
}
/**
* Si FULL_NAME est renseigné et que LAST_NAME/FIRST_NAME sont vides,
* on dérive nom et prénom depuis le nom complet (format "NOM Prénom").
*/
private function expanderNomComplet(ImportRow $row): ImportRow
{
$fullName = $row->valeurChamp(StudentImportField::FULL_NAME);
if ($fullName === null || trim($fullName) === '') {
return $row;
}
$lastName = $row->valeurChamp(StudentImportField::LAST_NAME);
$firstName = $row->valeurChamp(StudentImportField::FIRST_NAME);
if (($lastName !== null && trim($lastName) !== '') || ($firstName !== null && trim($firstName) !== '')) {
return $row;
}
[$derivedLast, $derivedFirst] = self::splitFullName(trim($fullName));
$mappedData = $row->mappedData;
$mappedData[StudentImportField::LAST_NAME->value] = $derivedLast;
$mappedData[StudentImportField::FIRST_NAME->value] = $derivedFirst;
return new ImportRow($row->lineNumber, $row->rawData, $mappedData, $row->errors);
}
/**
* Sépare un nom complet au format "NOM Prénom" en [nom, prénom].
*
* Convention Pronote : le nom de famille est en majuscules, le prénom en casse mixte.
* Si la convention n'est pas détectable, on prend le premier mot comme nom.
*
* @return array{0: string, 1: string}
*/
public static function splitFullName(string $fullName): array
{
$parts = preg_split('/\s+/', trim($fullName));
if ($parts === false || $parts === []) {
return [$fullName, ''];
}
$uppercaseParts = [];
$rest = [];
$foundNonUpper = false;
foreach ($parts as $part) {
if (!$foundNonUpper && mb_strtoupper($part) === $part && preg_match('/\p{L}/u', $part)) {
$uppercaseParts[] = $part;
} else {
$foundNonUpper = true;
$rest[] = $part;
}
}
if ($uppercaseParts !== [] && $rest !== []) {
return [implode(' ', $uppercaseParts), implode(' ', $rest)];
}
$lastName = array_shift($parts);
return [$lastName ?? $fullName, implode(' ', $parts)];
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use function array_filter;
use function array_map;
use function array_values;
use function explode;
use function trim;
/**
* Parse une valeur multi-éléments séparés par un délimiteur.
*
* Utilisé pour les champs matières et classes dans l'import enseignants
* où une cellule CSV peut contenir "Mathématiques, Physique".
*
* @see FR77: Import enseignants via CSV
*/
final readonly class MultiValueParser
{
/**
* @param non-empty-string $separator
*
* @return list<string>
*/
public function parse(string $value, string $separator = ','): array
{
if (trim($value) === '') {
return [];
}
return array_values(array_filter(
array_map(
static fn (string $item): string => trim($item),
explode($separator, $value),
),
static fn (string $item): bool => $item !== '',
));
}
}

View File

@@ -0,0 +1,281 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ColumnMapping;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\StudentImportBatch;
use App\Administration\Domain\Model\Import\StudentImportField;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\ImportBatchRepository;
use App\Administration\Domain\Repository\SavedColumnMappingRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function count;
use function in_array;
use InvalidArgumentException;
/**
* Orchestre la chaîne d'import d'élèves : parse → détection → mapping → validation.
*
* Extrait la logique métier du contrôleur pour respecter l'architecture hexagonale.
*/
final readonly class StudentImportOrchestrator
{
public function __construct(
private CsvParser $csvParser,
private XlsxParser $xlsxParser,
private ImportFormatDetector $formatDetector,
private ColumnMappingSuggester $mappingSuggester,
private ClassRepository $classRepository,
private ImportBatchRepository $importBatchRepository,
private SavedColumnMappingRepository $savedMappingRepository,
private ExistingStudentFinder $existingStudentFinder,
private DuplicateDetector $duplicateDetector,
private Clock $clock,
) {
}
/**
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
* crée le batch et enregistre les lignes mappées.
*
* @return array{batch: StudentImportBatch, suggestedMapping: array<string, StudentImportField>}
*/
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
{
$parseResult = match ($extension) {
'csv', 'txt' => $this->csvParser->parse($filePath),
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
};
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
$batch = StudentImportBatch::creer(
tenantId: $tenantId,
originalFilename: $originalFilename,
totalRows: $parseResult->totalRows(),
detectedColumns: $parseResult->columns,
detectedFormat: $detectedFormat,
createdAt: $this->clock->now(),
);
$rows = $this->mapRows($parseResult, $suggestedMapping);
$batch->enregistrerLignes($rows);
$this->importBatchRepository->save($batch);
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
}
/**
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
*/
public function applyMapping(StudentImportBatch $batch, ColumnMapping $columnMapping): void
{
$batch->appliquerMapping($columnMapping);
$remapped = [];
foreach ($batch->lignes() as $row) {
$mappedData = [];
foreach ($columnMapping->mapping as $column => $field) {
$mappedData[$field->value] = $row->rawData[$column] ?? '';
}
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
}
$batch->enregistrerLignes($remapped);
$this->importBatchRepository->save($batch);
}
/**
* Valide les lignes du batch et retourne les résultats avec les classes inconnues.
*
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownClasses: list<string>}
*/
public function generatePreview(StudentImportBatch $batch, TenantId $tenantId, ?AcademicYearId $academicYearId = null): array
{
$existingClasses = $this->getExistingClassNames($tenantId);
$validator = new ImportRowValidator($existingClasses);
$validatedRows = $validator->validerTout($batch->lignes());
if ($academicYearId !== null) {
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingStudents);
}
$batch->enregistrerLignes($validatedRows);
$this->importBatchRepository->save($batch);
$report = ImportReport::fromValidatedRows($validatedRows);
$unknownClasses = $this->detectUnknownClasses($validatedRows, $existingClasses);
return [
'validatedRows' => $validatedRows,
'report' => $report,
'unknownClasses' => $unknownClasses,
];
}
/**
* Prépare le batch pour la confirmation : re-valide si nécessaire
* et filtre les lignes selon les options choisies par l'utilisateur.
*
* Quand createMissingClasses est activé, les erreurs de classe inconnue
* sont retirées en re-validant sans vérification de classe.
* La détection de doublons est ré-appliquée après re-validation
* pour ne pas perdre les erreurs _duplicate.
*/
public function prepareForConfirmation(
StudentImportBatch $batch,
bool $createMissingClasses,
bool $importValidOnly,
TenantId $tenantId,
?AcademicYearId $academicYearId = null,
): void {
if ($createMissingClasses) {
$validator = new ImportRowValidator();
// Strip old errors before re-validating — the previous validation
// may have added className errors that we no longer want.
$cleanRows = array_map(
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
$batch->lignes(),
);
$revalidated = $validator->validerTout($cleanRows);
if ($academicYearId !== null) {
$existingStudents = $this->existingStudentFinder->findAllForTenant($tenantId, $academicYearId);
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingStudents);
}
$batch->enregistrerLignes($revalidated);
}
if ($importValidOnly) {
$batch->enregistrerLignes($batch->lignesValides());
}
if ($batch->mapping !== null) {
$this->savedMappingRepository->save(
$batch->tenantId,
$batch->mapping->format,
$batch->mapping->mapping,
);
}
$this->importBatchRepository->save($batch);
}
/**
* Suggère un mapping en priorité depuis les mappings sauvegardés,
* puis en fallback depuis la détection automatique.
*
* @param list<string> $columns
*
* @return array<string, StudentImportField>
*/
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
{
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
return $saved;
}
return $this->mappingSuggester->suggerer($columns, $format);
}
/**
* Vérifie que le mapping sauvegardé correspond exactement aux colonnes du fichier.
*
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
* mais ne le sont pas par le mapping sauvegardé.
*
* @param array<string, StudentImportField> $mapping
* @param list<string> $columns
*/
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
{
foreach (array_keys($mapping) as $column) {
if (!in_array($column, $columns, true)) {
return false;
}
}
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
if (count($autoMapping) > count($mapping)) {
return false;
}
return true;
}
/**
* @param array<string, StudentImportField> $mapping
*
* @return list<ImportRow>
*/
private function mapRows(FileParseResult $parseResult, array $mapping): array
{
$rows = [];
$lineNumber = 1;
foreach ($parseResult->rows as $rawData) {
$mappedData = [];
foreach ($mapping as $column => $field) {
$mappedData[$field->value] = $rawData[$column] ?? '';
}
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
++$lineNumber;
}
return $rows;
}
/**
* @return list<string>
*/
private function getExistingClassNames(TenantId $tenantId): array
{
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
return array_values(array_map(
static fn ($class) => (string) $class->name,
$classes,
));
}
/**
* @param list<ImportRow> $rows
* @param list<string> $existingClasses
*
* @return list<string>
*/
private function detectUnknownClasses(array $rows, array $existingClasses): array
{
$unknown = [];
foreach ($rows as $row) {
$className = $row->valeurChamp(StudentImportField::CLASS_NAME);
if ($className !== null
&& trim($className) !== ''
&& !in_array(trim($className), $existingClasses, true)
&& !in_array(trim($className), $unknown, true)
) {
$unknown[] = trim($className);
}
}
return $unknown;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherImportField;
use function in_array;
/**
* Suggère un mapping automatique des colonnes pour l'import enseignants.
*
* @see AC2: Mapping spécifique enseignants
*/
final readonly class TeacherColumnMappingSuggester
{
/**
* Mapping générique par mots-clés pour les enseignants.
*
* @var array<string, TeacherImportField>
*/
private const array GENERIC_KEYWORDS = [
'nom' => TeacherImportField::LAST_NAME,
'last' => TeacherImportField::LAST_NAME,
'family' => TeacherImportField::LAST_NAME,
'surname' => TeacherImportField::LAST_NAME,
'prénom' => TeacherImportField::FIRST_NAME,
'prenom' => TeacherImportField::FIRST_NAME,
'first' => TeacherImportField::FIRST_NAME,
'given' => TeacherImportField::FIRST_NAME,
'email' => TeacherImportField::EMAIL,
'mail' => TeacherImportField::EMAIL,
'courriel' => TeacherImportField::EMAIL,
'matière' => TeacherImportField::SUBJECTS,
'matiere' => TeacherImportField::SUBJECTS,
'matières' => TeacherImportField::SUBJECTS,
'matieres' => TeacherImportField::SUBJECTS,
'subject' => TeacherImportField::SUBJECTS,
'discipline' => TeacherImportField::SUBJECTS,
'classe' => TeacherImportField::CLASSES,
'classes' => TeacherImportField::CLASSES,
'class' => TeacherImportField::CLASSES,
'groupe' => TeacherImportField::CLASSES,
];
/**
* @param list<string> $columns Colonnes détectées dans le fichier
* @param KnownImportFormat $detectedFormat Format détecté
*
* @return array<string, TeacherImportField> Mapping suggéré (colonne → champ)
*/
public function suggerer(array $columns, KnownImportFormat $detectedFormat): array
{
return $this->mapperGenerique($columns);
}
/**
* @param list<string> $columns
*
* @return array<string, TeacherImportField>
*/
private function mapperGenerique(array $columns): array
{
$mapping = [];
$usedFields = [];
foreach ($columns as $column) {
$normalized = $this->normaliser($column);
foreach (self::GENERIC_KEYWORDS as $keyword => $field) {
if (str_contains($normalized, $keyword) && !in_array($field, $usedFields, true)) {
$mapping[$column] = $field;
$usedFields[] = $field;
break;
}
}
}
return $mapping;
}
private function normaliser(string $column): string
{
$normalized = mb_strtolower(trim($column));
$normalized = str_replace(['_', '-', "'"], [' ', ' ', ' '], $normalized);
/** @var string $result */
$result = preg_replace('/\s+/', ' ', $normalized);
return $result;
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\TeacherImportField;
use function mb_strtolower;
use function trim;
/**
* Détecte les doublons parmi les lignes d'import enseignants :
* - contre les enseignants existants en base (par email)
* - au sein du fichier lui-même (lignes avec le même email).
*/
final readonly class TeacherDuplicateDetector
{
/**
* @param list<ImportRow> $rows
* @param list<array{firstName: string, lastName: string, email: string}> $existingTeachers
*
* @return list<ImportRow>
*/
public function detecter(array $rows, array $existingTeachers): array
{
/** @var array<string, true> $byEmail */
$byEmail = [];
foreach ($existingTeachers as $teacher) {
if (trim($teacher['email']) !== '') {
$byEmail[mb_strtolower(trim($teacher['email']))] = true;
}
}
$result = [];
foreach ($rows as $row) {
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
if ($email !== null && trim($email) !== '') {
$normalized = mb_strtolower(trim($email));
if (isset($byEmail[$normalized])) {
$row = $row->avecErreurs(new ImportRowError(
'_duplicate',
'Cet enseignant existe déjà (correspondance : email).',
));
} else {
$byEmail[$normalized] = true;
}
}
$result[] = $row;
}
return $result;
}
}

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\KnownImportFormat;
use App\Administration\Domain\Model\Import\TeacherColumnMapping;
use App\Administration\Domain\Model\Import\TeacherImportBatch;
use App\Administration\Domain\Model\Import\TeacherImportField;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Domain\Repository\SavedTeacherColumnMappingRepository;
use App\Administration\Domain\Repository\SubjectRepository;
use App\Administration\Domain\Repository\TeacherImportBatchRepository;
use App\Shared\Domain\Clock;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function count;
use function in_array;
use InvalidArgumentException;
use function trim;
/**
* Orchestre la chaîne d'import d'enseignants : parse → détection → mapping → validation.
*
* @see FR77: Import enseignants via CSV
*/
final readonly class TeacherImportOrchestrator
{
public function __construct(
private CsvParser $csvParser,
private XlsxParser $xlsxParser,
private ImportFormatDetector $formatDetector,
private TeacherColumnMappingSuggester $mappingSuggester,
private SubjectRepository $subjectRepository,
private ClassRepository $classRepository,
private TeacherImportBatchRepository $teacherImportBatchRepository,
private SavedTeacherColumnMappingRepository $savedMappingRepository,
private ExistingTeacherFinder $existingTeacherFinder,
private TeacherDuplicateDetector $duplicateDetector,
private Clock $clock,
) {
}
/**
* Analyse un fichier uploadé : parse, détecte le format, suggère un mapping,
* crée le batch et enregistre les lignes mappées.
*
* @return array{batch: TeacherImportBatch, suggestedMapping: array<string, TeacherImportField>}
*/
public function analyzeFile(string $filePath, string $extension, string $originalFilename, TenantId $tenantId): array
{
$parseResult = match ($extension) {
'csv', 'txt' => $this->csvParser->parse($filePath),
'xlsx', 'xls' => $this->xlsxParser->parse($filePath),
default => throw new InvalidArgumentException('Format non supporté. Utilisez CSV ou XLSX.'),
};
$detectedFormat = $this->formatDetector->detecter($parseResult->columns);
$suggestedMapping = $this->suggestMapping($parseResult->columns, $detectedFormat, $tenantId);
$batch = TeacherImportBatch::creer(
tenantId: $tenantId,
originalFilename: $originalFilename,
totalRows: $parseResult->totalRows(),
detectedColumns: $parseResult->columns,
detectedFormat: $detectedFormat,
createdAt: $this->clock->now(),
);
$rows = $this->mapRows($parseResult, $suggestedMapping);
$batch->enregistrerLignes($rows);
$this->teacherImportBatchRepository->save($batch);
return ['batch' => $batch, 'suggestedMapping' => $suggestedMapping];
}
/**
* Applique un mapping de colonnes sur un batch existant et re-mappe les lignes.
*/
public function applyMapping(TeacherImportBatch $batch, TeacherColumnMapping $columnMapping): void
{
$batch->appliquerMapping($columnMapping);
$remapped = [];
foreach ($batch->lignes() as $row) {
$mappedData = [];
foreach ($columnMapping->mapping as $column => $field) {
$mappedData[$field->value] = $row->rawData[$column] ?? '';
}
$remapped[] = new ImportRow($row->lineNumber, $row->rawData, $mappedData);
}
$batch->enregistrerLignes($remapped);
$this->teacherImportBatchRepository->save($batch);
}
/**
* Valide les lignes du batch et retourne les résultats avec les matières et classes inconnues.
*
* @return array{validatedRows: list<ImportRow>, report: ImportReport, unknownSubjects: list<string>, unknownClasses: list<string>}
*/
public function generatePreview(TeacherImportBatch $batch, TenantId $tenantId): array
{
$existingSubjects = $this->getExistingSubjectNames($tenantId);
$existingClasses = $this->getExistingClassNames($tenantId);
$validator = new TeacherImportRowValidator($existingSubjects, $existingClasses);
$validatedRows = $validator->validerTout($batch->lignes());
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
$validatedRows = $this->duplicateDetector->detecter($validatedRows, $existingTeachers);
$batch->enregistrerLignes($validatedRows);
$this->teacherImportBatchRepository->save($batch);
$report = ImportReport::fromValidatedRows($validatedRows);
$unknownSubjects = $this->detectUnknownValues($validatedRows, TeacherImportField::SUBJECTS, $existingSubjects);
$unknownClasses = $this->detectUnknownValues($validatedRows, TeacherImportField::CLASSES, $existingClasses);
return [
'validatedRows' => $validatedRows,
'report' => $report,
'unknownSubjects' => $unknownSubjects,
'unknownClasses' => $unknownClasses,
];
}
/**
* Prépare le batch pour la confirmation : re-valide si nécessaire
* et filtre les lignes selon les options choisies par l'utilisateur.
*
* La détection de doublons est ré-appliquée après re-validation
* pour ne pas perdre les erreurs _duplicate.
*/
public function prepareForConfirmation(
TeacherImportBatch $batch,
bool $createMissingSubjects,
bool $importValidOnly,
TenantId $tenantId,
): void {
if ($createMissingSubjects) {
$validator = new TeacherImportRowValidator();
// Strip old errors before re-validating — the previous validation
// may have added subject/class errors that we no longer want.
$cleanRows = array_map(
static fn (ImportRow $row) => new ImportRow($row->lineNumber, $row->rawData, $row->mappedData),
$batch->lignes(),
);
$revalidated = $validator->validerTout($cleanRows);
$existingTeachers = $this->existingTeacherFinder->findAllForTenant($tenantId);
$revalidated = $this->duplicateDetector->detecter($revalidated, $existingTeachers);
$batch->enregistrerLignes($revalidated);
}
if ($importValidOnly) {
$batch->enregistrerLignes($batch->lignesValides());
}
if ($batch->mapping !== null) {
$this->savedMappingRepository->save(
$batch->tenantId,
$batch->mapping->format,
$batch->mapping->mapping,
);
}
$this->teacherImportBatchRepository->save($batch);
}
/**
* @param list<string> $columns
*
* @return array<string, TeacherImportField>
*/
private function suggestMapping(array $columns, KnownImportFormat $format, TenantId $tenantId): array
{
$saved = $this->savedMappingRepository->findByTenantAndFormat($tenantId, $format);
if ($saved !== null && $this->savedMappingMatchesColumns($saved, $columns)) {
return $saved;
}
return $this->mappingSuggester->suggerer($columns, $format);
}
/**
* Vérifie que le mapping sauvegardé correspond exactement aux colonnes du fichier.
*
* Retourne false si le fichier contient des colonnes qui pourraient être mappées
* mais ne le sont pas par le mapping sauvegardé (ex: colonne « Matières » absente
* d'un mapping sauvegardé à 3 colonnes).
*
* @param array<string, TeacherImportField> $mapping
* @param list<string> $columns
*/
private function savedMappingMatchesColumns(array $mapping, array $columns): bool
{
foreach (array_keys($mapping) as $column) {
if (!in_array($column, $columns, true)) {
return false;
}
}
// Reject saved mapping if file has more columns than the mapping covers:
// the auto-detection might map them and the user expects to see them.
$autoMapping = $this->mappingSuggester->suggerer($columns, KnownImportFormat::CUSTOM);
if (count($autoMapping) > count($mapping)) {
return false;
}
return true;
}
/**
* @param array<string, TeacherImportField> $mapping
*
* @return list<ImportRow>
*/
private function mapRows(FileParseResult $parseResult, array $mapping): array
{
$rows = [];
$lineNumber = 1;
foreach ($parseResult->rows as $rawData) {
$mappedData = [];
foreach ($mapping as $column => $field) {
$mappedData[$field->value] = $rawData[$column] ?? '';
}
$rows[] = new ImportRow($lineNumber, $rawData, $mappedData);
++$lineNumber;
}
return $rows;
}
/**
* @return list<string>
*/
private function getExistingSubjectNames(TenantId $tenantId): array
{
$subjects = $this->subjectRepository->findAllActiveByTenant($tenantId);
return array_values(array_map(
static fn ($subject) => (string) $subject->name,
$subjects,
));
}
/**
* @return list<string>
*/
private function getExistingClassNames(TenantId $tenantId): array
{
$classes = $this->classRepository->findAllActiveByTenant($tenantId);
return array_values(array_map(
static fn ($class) => (string) $class->name,
$classes,
));
}
/**
* @param list<ImportRow> $rows
* @param list<string> $existingValues
*
* @return list<string>
*/
private function detectUnknownValues(array $rows, TeacherImportField $field, array $existingValues): array
{
$parser = new MultiValueParser();
$unknown = [];
foreach ($rows as $row) {
$raw = $row->mappedData[$field->value] ?? null;
if ($raw === null || trim($raw) === '') {
continue;
}
foreach ($parser->parse($raw) as $value) {
if (!in_array($value, $existingValues, true)
&& !in_array($value, $unknown, true)
) {
$unknown[] = $value;
}
}
}
return $unknown;
}
}

View File

@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Model\Import\ImportRow;
use App\Administration\Domain\Model\Import\ImportRowError;
use App\Administration\Domain\Model\Import\TeacherImportField;
use const FILTER_VALIDATE_EMAIL;
use function in_array;
use function sprintf;
use function trim;
/**
* Valide les lignes d'import enseignants après mapping.
*
* Vérifie les champs obligatoires (nom, prénom, email),
* le format email, et l'existence des matières/classes référencées.
*
* @see AC2: Mapping spécifique enseignants
* @see AC3: Gestion matières inexistantes
* @see AC5: Gestion doublons email
*/
final readonly class TeacherImportRowValidator
{
private MultiValueParser $multiValueParser;
/**
* @param list<string>|null $existingSubjectNames Noms des matières existantes. null = pas de vérification.
* @param list<string>|null $existingClassNames Noms des classes existantes. null = pas de vérification.
*/
public function __construct(
private ?array $existingSubjectNames = null,
private ?array $existingClassNames = null,
) {
$this->multiValueParser = new MultiValueParser();
}
public function valider(ImportRow $row): ImportRow
{
$errors = [];
$errors = [...$errors, ...$this->validerChampsObligatoires($row)];
$errors = [...$errors, ...$this->validerEmail($row)];
$errors = [...$errors, ...$this->validerMatieres($row)];
$errors = [...$errors, ...$this->validerClasses($row)];
if ($errors !== []) {
return $row->avecErreurs(...$errors);
}
return $row;
}
/**
* @param list<ImportRow> $rows
*
* @return list<ImportRow>
*/
public function validerTout(array $rows): array
{
return array_map($this->valider(...), $rows);
}
/**
* @return list<ImportRowError>
*/
private function validerChampsObligatoires(ImportRow $row): array
{
$errors = [];
foreach (TeacherImportField::champsObligatoires() as $field) {
$value = $row->mappedData[$field->value] ?? null;
if ($value === null || trim($value) === '') {
$errors[] = new ImportRowError(
$field->value,
sprintf('Le champ "%s" est obligatoire.', $field->label()),
);
}
}
return $errors;
}
/**
* @return list<ImportRowError>
*/
private function validerEmail(ImportRow $row): array
{
$email = $row->mappedData[TeacherImportField::EMAIL->value] ?? null;
if ($email === null || trim($email) === '') {
return [];
}
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
return [new ImportRowError(
TeacherImportField::EMAIL->value,
sprintf('L\'adresse email "%s" est invalide.', $email),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerMatieres(ImportRow $row): array
{
if ($this->existingSubjectNames === null) {
return [];
}
$subjectsRaw = $row->mappedData[TeacherImportField::SUBJECTS->value] ?? null;
if ($subjectsRaw === null || trim($subjectsRaw) === '') {
return [];
}
$subjects = $this->multiValueParser->parse($subjectsRaw);
$unknown = [];
foreach ($subjects as $subject) {
if (!in_array($subject, $this->existingSubjectNames, true)) {
$unknown[] = $subject;
}
}
if ($unknown !== []) {
return [new ImportRowError(
TeacherImportField::SUBJECTS->value,
sprintf('Matière(s) inexistante(s) : %s', implode(', ', $unknown)),
)];
}
return [];
}
/**
* @return list<ImportRowError>
*/
private function validerClasses(ImportRow $row): array
{
if ($this->existingClassNames === null) {
return [];
}
$classesRaw = $row->mappedData[TeacherImportField::CLASSES->value] ?? null;
if ($classesRaw === null || trim($classesRaw) === '') {
return [];
}
$classes = $this->multiValueParser->parse($classesRaw);
$unknown = [];
foreach ($classes as $class) {
if (!in_array($class, $this->existingClassNames, true)) {
$unknown[] = $class;
}
}
if ($unknown !== []) {
return [new ImportRowError(
TeacherImportField::CLASSES->value,
sprintf('Classe(s) inexistante(s) : %s', implode(', ', $unknown)),
)];
}
return [];
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service\Import;
use App\Administration\Domain\Exception\FichierImportInvalideException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Reader\Exception as SpreadsheetException;
/**
* Service de parsing de fichiers XLSX (Excel) via PhpSpreadsheet.
*/
final readonly class XlsxParser
{
public function parse(string $filePath): FileParseResult
{
try {
$spreadsheet = IOFactory::load($filePath);
} catch (SpreadsheetException $e) {
throw FichierImportInvalideException::formatInvalide($filePath, $e->getMessage());
}
$sheet = $spreadsheet->getActiveSheet();
$data = $sheet->toArray('', true, true, false);
if ($data === []) {
throw FichierImportInvalideException::fichierVide();
}
/** @var list<string|int|float|bool|null> $headerRow */
$headerRow = array_shift($data);
$columns = array_values(array_map(static fn (string|int|float|bool|null $v): string => (string) $v, $headerRow));
$rows = [];
foreach ($data as $line) {
/** @var list<mixed> $cells */
$cells = $line;
if ($this->isEmptyLine($cells)) {
continue;
}
$row = [];
foreach ($columns as $index => $column) {
/** @var string|int|float|bool|null $cellValue */
$cellValue = $cells[$index] ?? '';
$row[$column] = (string) $cellValue;
}
$rows[] = $row;
}
return new FileParseResult($columns, $rows);
}
/**
* @param list<mixed> $line
*/
private function isEmptyLine(array $line): bool
{
foreach ($line as $cell) {
if ($cell !== null && $cell !== '') {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Domain\Model\Invitation\InvitationCode;
use function bin2hex;
use function random_bytes;
final readonly class InvitationCodeGenerator
{
/**
* Generates a cryptographically secure 32-character hexadecimal invitation code.
*/
public function generate(): InvitationCode
{
$code = bin2hex(random_bytes(16));
return new InvitationCode($code);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Administration\Application\Service;
use App\Administration\Application\Port\ImageProcessor;
use App\Administration\Application\Port\LogoStorage;
use App\Administration\Domain\Exception\LogoFormatInvalideException;
use App\Administration\Domain\Exception\LogoTropGrosException;
use App\Administration\Domain\Model\SchoolBranding\LogoUrl;
use App\Shared\Domain\Tenant\TenantId;
use function bin2hex;
use function in_array;
use function random_bytes;
use function strpos;
use function substr;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* Service applicatif pour l'upload et le traitement des logos.
*
* Responsabilités :
* - Validation du fichier (type MIME, taille)
* - Redimensionnement via port ImageProcessor (max 200x200px)
* - Stockage via port LogoStorage
* - Suppression des anciens fichiers lors du remplacement
*/
final readonly class LogoUploader
{
private const int MAX_SIZE = 2 * 1024 * 1024; // 2 Mo
private const int MAX_DIMENSION = 200;
/** @var string[] */
private const array ALLOWED_TYPES = ['image/png', 'image/jpeg'];
private const string KEY_PREFIX = 'logos/';
public function __construct(
private LogoStorage $storage,
private ImageProcessor $imageProcessor,
) {
}
public function upload(UploadedFile $file, TenantId $tenantId, ?LogoUrl $oldLogoUrl = null): LogoUrl
{
$this->validerFichier($file);
$content = $this->imageProcessor->resize(
$file->getPathname(),
self::MAX_DIMENSION,
self::MAX_DIMENSION,
);
$key = self::KEY_PREFIX . $tenantId . '/' . bin2hex(random_bytes(8)) . '.png';
$url = $this->storage->store($content, $key, 'image/png');
if ($oldLogoUrl !== null) {
$this->deleteByUrl($oldLogoUrl);
}
return new LogoUrl($url);
}
public function deleteByUrl(LogoUrl $logoUrl): void
{
$url = $logoUrl->value;
$pos = strpos($url, self::KEY_PREFIX);
if ($pos !== false) {
$this->storage->delete(substr($url, $pos));
}
}
private function validerFichier(UploadedFile $file): void
{
$size = $file->getSize();
if ($size > self::MAX_SIZE) {
throw LogoTropGrosException::pourTaille($size, self::MAX_SIZE);
}
$mimeType = $file->getMimeType() ?? 'unknown';
if (!in_array($mimeType, self::ALLOWED_TYPES, true)) {
throw LogoFormatInvalideException::pourType($mimeType, self::ALLOWED_TYPES);
}
}
}

View File

@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId; use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent; use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
use Override; use Override;
use Ramsey\Uuid\UuidInterface; use Ramsey\Uuid\UuidInterface;
@@ -20,6 +21,7 @@ final readonly class AffectationRetiree implements DomainEvent
public UserId $teacherId, public UserId $teacherId,
public ClassId $classId, public ClassId $classId,
public SubjectId $subjectId, public SubjectId $subjectId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn, private DateTimeImmutable $occurredOn,
) { ) {
} }

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\SchoolClass\SchoolId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lors de la modification du branding d'un établissement.
*/
final readonly class BrandingModifie implements DomainEvent
{
public function __construct(
public SchoolId $schoolId,
public TenantId $tenantId,
public string $champ,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->schoolId->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignmentId;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EleveAffecteAClasse implements DomainEvent
{
public function __construct(
public ClassAssignmentId $assignmentId,
public UserId $studentId,
public ClassId $classId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->assignmentId->value;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class EleveInscrit implements DomainEvent
{
public function __construct(
public UserId $userId,
public string $firstName,
public string $lastName,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->userId->value;
}
}

View File

@@ -9,6 +9,7 @@ use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId; use App\Administration\Domain\Model\TeacherAssignment\TeacherAssignmentId;
use App\Administration\Domain\Model\User\UserId; use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent; use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable; use DateTimeImmutable;
use Override; use Override;
use Ramsey\Uuid\UuidInterface; use Ramsey\Uuid\UuidInterface;
@@ -20,6 +21,7 @@ final readonly class EnseignantAffecte implements DomainEvent
public UserId $teacherId, public UserId $teacherId,
public ClassId $classId, public ClassId $classId,
public SubjectId $subjectId, public SubjectId $subjectId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn, private DateTimeImmutable $occurredOn,
) { ) {
} }

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'élèves échoue.
*/
final readonly class ImportElevesEchoue implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'élèves est lancé.
*/
final readonly class ImportElevesLance implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $totalRows,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'élèves est terminé avec succès.
*/
final readonly class ImportElevesTermine implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $importedCount,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'enseignants échoue.
*/
final readonly class ImportEnseignantsEchoue implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'enseignants est lancé.
*/
final readonly class ImportEnseignantsLance implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $totalRows,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
/**
* Événement émis lorsqu'un import d'enseignants est terminé avec succès.
*/
final readonly class ImportEnseignantsTermine implements DomainEvent
{
public function __construct(
public ImportBatchId $batchId,
public TenantId $tenantId,
public int $importedCount,
public int $errorCount,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->batchId->value;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class InvitationParentActivee implements DomainEvent
{
public function __construct(
public ParentInvitationId $invitationId,
public UserId $studentId,
public UserId $parentUserId,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->invitationId->value;
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Event;
use App\Administration\Domain\Model\Invitation\ParentInvitationId;
use App\Administration\Domain\Model\User\Email;
use App\Administration\Domain\Model\User\UserId;
use App\Shared\Domain\DomainEvent;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use Override;
use Ramsey\Uuid\UuidInterface;
final readonly class InvitationParentEnvoyee implements DomainEvent
{
public function __construct(
public ParentInvitationId $invitationId,
public UserId $studentId,
public Email $parentEmail,
public TenantId $tenantId,
private DateTimeImmutable $occurredOn,
) {
}
#[Override]
public function occurredOn(): DateTimeImmutable
{
return $this->occurredOn;
}
#[Override]
public function aggregateId(): UuidInterface
{
return $this->invitationId->value;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class AffectationEleveNonTrouveeException extends DomainException
{
public static function pourEleve(UserId $studentId): self
{
return new self(sprintf(
'Aucune affectation trouvée pour l\'élève "%s" cette année scolaire.',
$studentId,
));
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class BrandColorInvalideException extends DomainException
{
public static function pourFormat(string $value): self
{
return new self(sprintf(
'La couleur "%s" doit être au format hexadécimal #RRGGBB (ex: "#3B82F6").',
$value,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class ContrasteInsuffisantException extends DomainException
{
public static function pourRatio(float $ratio, float $minimum): self
{
return new self(sprintf(
'Le contraste de %.1f:1 est insuffisant. Le minimum requis pour la conformité WCAG AA est de %.1f:1. Choisissez une couleur plus foncée.',
$ratio,
$minimum,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class EleveDejaAffecteException extends DomainException
{
public static function pourAnneeScolaire(UserId $studentId): self
{
return new self(sprintf(
'L\'élève "%s" est déjà affecté à une classe pour cette année scolaire.',
$studentId,
));
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use DomainException;
use function sprintf;
final class FichierImportInvalideException extends DomainException
{
public static function fichierIllisible(string $path): self
{
return new self(sprintf(
'Le fichier "%s" ne peut pas être lu.',
$path,
));
}
public static function fichierVide(): self
{
return new self('Le fichier est vide ou ne contient aucune donnée.');
}
public static function formatInvalide(string $path, string $reason): self
{
return new self(sprintf(
'Le fichier "%s" a un format invalide : %s',
$path,
$reason,
));
}
public static function fichierTropGros(int $sizeBytes, int $maxBytes): self
{
return new self(sprintf(
'Le fichier fait %d Mo mais la limite est de %d Mo.',
(int) ($sizeBytes / 1024 / 1024),
(int) ($maxBytes / 1024 / 1024),
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\ImportBatchId;
use DomainException;
use function sprintf;
final class ImportBatchNotFoundException extends DomainException
{
public static function withId(ImportBatchId $id): self
{
return new self(sprintf(
'Le lot d\'import "%s" n\'existe pas.',
$id,
));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\ImportBatchId;
use DomainException;
use function sprintf;
final class ImportDejaEnCoursException extends DomainException
{
public static function pourBatch(ImportBatchId $batchId): self
{
return new self(sprintf(
'L\'import "%s" est déjà en cours de traitement.',
$batchId,
));
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\Import\ImportBatchId;
use App\Administration\Domain\Model\Import\ImportStatus;
use DomainException;
use function sprintf;
final class ImportNonDemarrableException extends DomainException
{
public static function pourStatut(ImportBatchId $batchId, ImportStatus $status): self
{
return new self(sprintf(
'L\'import "%s" ne peut pas être démarré depuis le statut "%s".',
$batchId,
$status->value,
));
}
public static function mappingManquant(ImportBatchId $batchId): self
{
return new self(sprintf(
'L\'import "%s" ne peut pas être démarré sans mapping de colonnes.',
$batchId,
));
}
}

Some files were not shown because too many files have changed in this diff Show More