Quellcode durchsuchen

merge feature/V8-6489-dvelopperloutil-de-cration-des-

Olivier Massot vor 10 Monaten
Ursprung
Commit
32f3b28f9f
86 geänderte Dateien mit 5538 neuen und 313 gelöschten Zeilen
  1. 1 1
      composer.json
  2. 56 0
      config/packages/monolog.yaml
  3. 0 12
      config/packages/staging/monolog.yaml
  4. 0 12
      config/packages/test/monolog.yaml
  5. 2 0
      config/services.yaml
  6. 18 17
      doc/internal_requests.md
  7. 3 1
      env/.env.staging
  8. 5 1
      env/.env.test
  9. 5 1
      env/.env.test1
  10. 5 1
      env/.env.test2
  11. 5 1
      env/.env.test3
  12. 5 1
      env/.env.test4
  13. 5 1
      env/.env.test5
  14. 5 1
      env/.env.test6
  15. 5 1
      env/.env.test7
  16. 5 1
      env/.env.test8
  17. 5 1
      env/.env.test9
  18. 1 1
      phpstan.neon.dist
  19. 4 0
      readme.md
  20. 20 0
      sql/schema-extensions/003-view_organization_identification.sql
  21. 469 0
      src/ApiResources/Organization/OrganizationCreationRequest.php
  22. 110 0
      src/ApiResources/Organization/OrganizationDeletionRequest.php
  23. 228 0
      src/ApiResources/Organization/OrganizationMemberCreationRequest.php
  24. 62 45
      src/Entity/Access/Access.php
  25. 2 0
      src/Entity/Access/OrganizationFunction.php
  26. 3 0
      src/Entity/Core/AddressPostal.php
  27. 3 0
      src/Entity/Core/ContactPoint.php
  28. 3 0
      src/Entity/Network/NetworkOrganization.php
  29. 51 36
      src/Entity/Organization/Organization.php
  30. 4 1
      src/Entity/Organization/OrganizationAddressPostal.php
  31. 204 0
      src/Entity/Organization/OrganizationIdentification.php
  32. 8 3
      src/Entity/Organization/Parameters.php
  33. 15 0
      src/Entity/Organization/Settings.php
  34. 52 10
      src/Entity/Person/Person.php
  35. 4 1
      src/Entity/Person/PersonAddressPostal.php
  36. 43 0
      src/Entity/Traits/CreatedOnAndByTrait.php
  37. 52 52
      src/Enum/Cotisation/TypeOfPracticeEnum.php
  38. 1 0
      src/Enum/Organization/OrganizationIdsEnum.php
  39. 1 1
      src/Message/Handler/ExportHandler.php
  40. 1 1
      src/Message/Handler/MailerHandler.php
  41. 56 0
      src/Message/Handler/OrganizationCreationHandler.php
  42. 60 0
      src/Message/Handler/OrganizationDeletionHandler.php
  43. 3 3
      src/Message/Handler/Typo3/Typo3DeleteHandler.php
  44. 3 3
      src/Message/Handler/Typo3/Typo3UndeleteHandler.php
  45. 3 3
      src/Message/Handler/Typo3/Typo3UpdateHandler.php
  46. 1 1
      src/Message/Message/Export.php
  47. 1 1
      src/Message/Message/MailerCommand.php
  48. 28 0
      src/Message/Message/OrganizationCreation.php
  49. 29 0
      src/Message/Message/OrganizationDeletion.php
  50. 2 2
      src/Message/Message/Typo3/Typo3Delete.php
  51. 2 2
      src/Message/Message/Typo3/Typo3Undelete.php
  52. 2 2
      src/Message/Message/Typo3/Typo3Update.php
  53. 20 0
      src/Repository/Core/FileRepository.php
  54. 21 0
      src/Repository/Organization/OrganizationIdentificationRepository.php
  55. 1 1
      src/Repository/Person/PersonRepository.php
  56. 1 1
      src/Security/Voter/InternalRequestsVoter.php
  57. 42 0
      src/Service/Dolibarr/DolibarrApiService.php
  58. 36 0
      src/Service/File/FileManager.php
  59. 24 0
      src/Service/File/Storage/ApiLegacyStorage.php
  60. 4 0
      src/Service/File/Storage/FileStorageInterface.php
  61. 49 7
      src/Service/File/Storage/LocalStorage.php
  62. 7 7
      src/Service/OnChange/Organization/OnParametersChange.php
  63. 933 0
      src/Service/Organization/OrganizationFactory.php
  64. 19 0
      src/Service/Organization/Utils.php
  65. 8 2
      src/Service/Rest/ApiRequestInterface.php
  66. 19 2
      src/Service/Rest/ApiRequestService.php
  67. 18 3
      src/Service/Security/InternalRequestsService.php
  68. 5 0
      src/Service/ServiceIterator/StorageIterator.php
  69. 14 14
      src/Service/Typo3/SubdomainService.php
  70. 5 2
      src/Service/Typo3/Typo3Service.php
  71. 5 1
      src/Service/Utils/EntityUtils.php
  72. 1 1
      src/Service/Utils/Path.php
  73. 23 0
      src/Service/Utils/SecurityUtils.php
  74. 1 2
      src/Service/Utils/UrlBuilder.php
  75. 1 2
      src/State/Processor/Export/LicenceCmf/ExportRequestProcessor.php
  76. 64 0
      src/State/Processor/Organization/OrganizationCreationRequestProcessor.php
  77. 55 0
      src/State/Processor/Organization/OrganizationDeletionRequestProcessor.php
  78. 125 0
      tests/Unit/Service/Dolibarr/DolibarrApiServiceTest.php
  79. 11 11
      tests/Unit/Service/OnChange/Organization/OnParametersChangeTest.php
  80. 2144 0
      tests/Unit/Service/Organization/OrganizationFactoryTest.php
  81. 62 0
      tests/Unit/Service/Organization/UtilsTest.php
  82. 100 23
      tests/Unit/Service/Rest/ApiRequestServiceTest.php
  83. 44 2
      tests/Unit/Service/Security/InternalRequestsServiceTest.php
  84. 8 8
      tests/Unit/Service/Typo3/SubdomainServiceTest.php
  85. 1 1
      tests/Unit/Service/Typo3/Typo3ServiceTest.php
  86. 6 2
      tests/Unit/Service/Utils/UrlBuilderTest.php

+ 1 - 1
composer.json

@@ -12,7 +12,7 @@
     }
   ],
   "require": {
-    "php": ">=8.1",
+    "php": ">=8.2",
     "ext-ctype": "*",
     "ext-iconv": "*",
     "api-platform/core": "3.1.7",

+ 56 - 0
config/packages/monolog.yaml

@@ -2,6 +2,7 @@ monolog:
     channels:
         - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
         - cron
+        - admin
 
     handlers:
         # sorties standards (stdout, stderr, console)
@@ -35,6 +36,12 @@ monolog:
             level: debug
             max_files: 3
             channels: [security]
+        file_depreciation:
+            type: rotating_file
+            path: "%kernel.logs_dir%/%kernel.environment%.deprecation.log"
+            level: debug
+            max_files: 3
+            channels: [deprecation]
 
         # email en cas d'erreurs critiques, sauf erreurs 404 / 405
         #        critical:
@@ -103,6 +110,55 @@ monolog:
             formatter:      monolog.formatter.html
             content_type:   text/html
 
+        ### --- Admin operations ---
+        # Log fichier (niveau debug)
+        admin_file:
+            type: rotating_file
+            path: "%kernel.logs_dir%/%kernel.environment%.admin.log"
+            level: debug
+            max_files: 7
+            formatter: monolog.formatter.message
+            channels: ['admin']
+
+        # Rapport par mail
+        admin_info:
+            type:           fingers_crossed
+            action_level:   info
+            handler:        admin_info_deduplicated
+            channels: ['admin']
+        admin_info_deduplicated:
+            type: deduplication
+            # the time in seconds during which duplicate entries are discarded (default: 60)
+            time: 10
+            handler: admin_info_mailer
+        admin_info_mailer:
+            type:           symfony_mailer
+            from_email:     "mail.report@opentalent.fr"
+            to_email:       "exploitation@opentalent.fr"
+            subject:        "Administration - Execution Report"
+            level:          info
+            content_type:   text/html
+
+        # Log par mail en cas d'erreur critique
+        admin_critical:
+            type:           fingers_crossed
+            action_level:   critical
+            handler:        admin_critical_deduplicated
+            channels: ['admin']
+        admin_critical_deduplicated:
+            type: deduplication
+            # the time in seconds during which duplicate entries are discarded (default: 60)
+            time: 10
+            handler: admin_critical_mailer
+        admin_critical_mailer:
+            type:           symfony_mailer
+            from_email:     "mail.report@opentalent.fr"
+            to_email:       "exploitation@opentalent.fr"
+            subject:        "Administration - Critical Error"
+            level:          critical
+            formatter:      monolog.formatter.html
+            content_type:   text/html
+
         # uncomment to get logging in your browser
         # you may have to allow bigger header sizes in your Web server configuration
         #firephp:

+ 0 - 12
config/packages/staging/monolog.yaml

@@ -1,12 +0,0 @@
-monolog:
-    handlers:
-        main:
-            type: fingers_crossed
-            action_level: error
-            handler: nested
-            excluded_http_codes: [404, 405]
-            channels: ["!event"]
-        nested:
-            type: stream
-            path: "%kernel.logs_dir%/%kernel.environment%.log"
-            level: debug

+ 0 - 12
config/packages/test/monolog.yaml

@@ -1,12 +0,0 @@
-monolog:
-    handlers:
-        main:
-            type: fingers_crossed
-            action_level: error
-            handler: nested
-            excluded_http_codes: [404, 405]
-            channels: ["!event"]
-        nested:
-            type: stream
-            path: "%kernel.logs_dir%/%kernel.environment%.log"
-            level: debug

+ 2 - 0
config/services.yaml

@@ -25,6 +25,8 @@ services:
             $publicLegacyBaseUrl: '%env(PUBLIC_API_LEG_BASE_URL)%'
             $baseUrl: '%env(API_BASE_URL)%'
             $publicBaseUrl: '%env(PUBLIC_API_BASE_URL)%'
+            $opentalentMailReport: 'mail.report@opentalent.fr'
+            $fileStorageDir: '%kernel.project_dir%/var/files/storage'
 
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name

+ 18 - 17
doc/internal_requests.md

@@ -2,25 +2,25 @@
 
 ### Principe général
 
-Les requêtes internes sont des requêtes envoyées de ap2i vers opentalent-platform ou dans le sens inverse, par 
-exemple pour demander un fichier.
+Les requêtes internes sont des requêtes échangées entre les services internes à l'entreprise (maestro, ap2i, 
+opentalent-platform...), par exemple pour demander un fichier.
 
-Ces requêtes ne sont pas protégées par l'authentification Symfony standard, car elles doivent pouvoir être exécutées 
-en dehors du cadre d'une requête utilisateur, par exemple lors d'une exécution en ligne de commande ou lors 
-d'un processus asynchrone exécuté par messenger.
+Ces requêtes ne sont pas systématiquement protégées par l'authentification Symfony standard, car elles doivent 
+pouvoir être exécutées en dehors du cadre d'une requête utilisateur, par exemple lors d'une exécution en ligne 
+de commande ou lors d'un processus asynchrone exécuté par messenger.
 
 Pour éviter tout risque de sécurité lié à ces routes :
 
 * on restreint leur accès aux ips internes
-* on conditionne l'autorisation à la présence d'un token
+* on conditionne l'autorisation soit à la présence d'un token, soit à une authentification en tant que super-admin.
 * on limite les routes concernées
 
-Ainsi, si l'on prend l'exemple d'une requête `/internal/download/123` sur ap2i :
-
-* Un utilisateur dans le VPN qui ferait un curl à cette adresse recevra une erreur 500 à cause du token manquant
-* Un utilisateur hors VPN, même s'il connaissait le token, recevra une erreur 500, car n'ayant pas une ip autorisée
-* Une requête issue de la V1 sera autorisée sans authentification
+Ainsi, si l'on prend l'exemple d'une requête `/internal/download/123` envoyée à ap2i :
 
+* Un utilisateur dans le VPN sans le token qui ferait un CURL à cette adresse recevra une erreur 403
+* Un utilisateur hors VPN, même s'il connaissait le token, recevra une erreur 403, car n'ayant pas une ip autorisée
+* Une requête issue de la V1 avec le bon token et provenant d'une ip interne sera autorisée sans authentification
+* Une requête d'un utilisateur connecté en tant que super admin et à l'intérieur du VPN pourra aussi aboutir.
 ### Ip internes 
 
 Les ips considérées comme interne sont :
@@ -36,13 +36,14 @@ Les ips considérées comme interne sont :
 
 ### Mise en oeuvre
 
-On met en place un pattern de routes de la forme `/api/internal/*` qui sera uniquement dédié aux requêtes internes entre
-les deux API ou à d'autres éventuels échanges entre systèmes.
+On met en place un pattern de routes de la forme `/api/internal/*` qui sera uniquement dédié aux requêtes internes 
+entre les services opentalent.
 
 Les appels à cette route ne sont autorisés que si :
 
 1. Que l'ip du client dont émet la requête fait partie d'un pool autorisé d'ips internes
-2. Qu'un header 'internal-requests-token' est défini et que sa valeur correspond à la valeur attendue.
+2. Qu'un header 'internal-requests-token' est défini et que sa valeur correspond à la valeur attendue OU que 
+   l'utilisateur est connecté en tant que super-admin.
 
 Si ces deux conditions ne sont pas remplies, la requête est rejetée, et ce même si l'utilisateur est authentifié.
 
@@ -52,20 +53,20 @@ Les routes internal sont configurées ici : `config/packages/security.yaml`
 
 ### Valider le fonctionnement
 
-Soit `$id` l'id d'un fichier stocké sur l'environnement V2
+Exemple avec la route dédié au téléchargement des fichiers, `$id` étant l'id d'un fichier stocké sur l'environnement V2.
 On part du principe que l'utilisateur authentifié a des droits suffisants pour voir ce fichier.
 
 
 Côté ap2i, les requêtes suivantes doivent donner les résultats correspondants :
 
-| query                      | header défini | authentifié | VPN activé | Résultat attendu |
+| query                      | header défini | super-admin | VPN activé | Résultat attendu |
 |----------------------------|---------------|-------------|------------|------------------|
 | /api/internal/download/$id | NON           | NON         | NON        | 401 Unauthorized |
 | /api/internal/download/$id | OUI           | NON         | NON        | 401 Unauthorized |
 | /api/internal/download/$id | OUI           | NON         | OUI        | 200 OK           |
 | /api/internal/download/$id | OUI           | OUI         | OUI        | 200 OK           |
+| /api/internal/download/$id | NON           | OUI         | OUI        | 200 OK           |
 | /api/internal/download/$id | OUI           | OUI         | NON        | 403 Forbidden    |
-| /api/internal/download/$id | NON           | OUI         | OUI        | 403 Forbidden    |
 | /api/download/$id          | *             | NON         | *          | 401 Unauthorized |
 | /api/download/$id          | *             | OUI         | *          | 200 OK           |
 

+ 3 - 1
env/.env.staging

@@ -35,4 +35,6 @@ INTERNAL_FILES_DOWNLOAD_URI=https://none
 LOG_FILE_NAME=staging
 ###< filename log ###
 
-
+###> mailcatcher ###
+MAILER_DSN=none
+###< symfony/mercure-bundle ###

+ 5 - 1
env/.env.test

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test1

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test1.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test1.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test1.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test2

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test2.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test2.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test2.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test3

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test3.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test3.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test3.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test4

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test4.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test4.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test4.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test5

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test5.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test5.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test5.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test6

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test6.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test6.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test6.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test7

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test7.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test7.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test7.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test8

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test8.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test8.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test8.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 5 - 1
env/.env.test9

@@ -14,7 +14,7 @@ PUBLIC_API_BASE_URL=https://ap2i.test9.opentalent.fr/api
 ###< api v2 ###
 
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test9.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test9.opentalent.fr
 ###< typo3 client ###
 
 ###> symfony/mercure-bundle ###
@@ -25,6 +25,10 @@ MERCURE_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 LOG_FILE_NAME=test
 ###< filename log ###

+ 1 - 1
phpstan.neon.dist

@@ -2,7 +2,6 @@
 parameters:
     level: 6
     treatPhpDocTypesAsCertain: false
-    checkGenericClassInNonGenericObjectType: false
     paths:
         - src
 
@@ -10,4 +9,5 @@ parameters:
     # on ignore les erreurs qui imposent d'indiquer le type d'un iterable dans la phpDoc (cf: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type)
     ignoreErrors :
         - '#Attribute class JetBrains\\PhpStorm\\[a-zA-Z]+ does not exist.#'
+        - identifier: 'missingType.generics'
 

+ 4 - 0
readme.md

@@ -52,3 +52,7 @@ Pour vider le cache (utiles entre chaque analyse si les modifications ne sont pa
 ## Corriger le formatage du code automatiquement
 
     php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php
+
+# Phpunit
+
+    XDEBUG_MODE=coverage php -d memory_limit=-1 /var/www/html/vendor/phpunit/phpunit/phpunit --testsuite unit --configuration /var/www/html/phpunit.xml.dist

+ 20 - 0
sql/schema-extensions/003-view_organization_identification.sql

@@ -0,0 +1,20 @@
+CREATE OR REPLACE VIEW view_organization_identification AS
+    SELECT o.id, o.name, o.identifier, o.siretNumber, o.waldecNumber,
+           a.streetAddress, a.streetAddressSecond, a.streetAddressThird, a.addressCity, a.postalCode,
+           c.email, c.telphone,
+           REGEXP_REPLACE(
+                   REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(LOWER(trim(o.name)), '[éèê]', 'e'), '[à]', 'a'), '[ç]', 'c'),
+                   '[^a-z0-9]+',
+                   '+'
+           ) as normalizedName,
+           REGEXP_REPLACE(
+                   LOWER(REGEXP_REPLACE(REGEXP_REPLACE(REGEXP_REPLACE(trim(concat(a.streetAddress, ' ', a.streetAddressSecond, ' ', a.streetAddressThird)), '[éèê]', 'e'), '[à]', 'a'), '[ç]', 'c')),
+                   '[^a-z0-9]+',
+                   '+'
+           ) as normalizedAddress
+    FROM opentalent.Organization o
+             INNER JOIN opentalent.OrganizationAddressPostal oa ON oa.organization_id = o.id
+             INNER JOIN opentalent.AddressPostal a ON a.id = oa.addressPostal_id
+             INNER JOIN opentalent.organization_contactpoint oc ON oc.organization_id = o.id
+             INNER JOIN opentalent.ContactPoint c ON oc.contactPoint_id = c.id
+    WHERE oa.type='ADDRESS_HEAD_OFFICE' AND c.contactType='PRINCIPAL';

+ 469 - 0
src/ApiResources/Organization/OrganizationCreationRequest.php

@@ -0,0 +1,469 @@
+<?php
+
+namespace App\ApiResources\Organization;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\OrganizationIdsEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\State\Processor\Organization\OrganizationCreationRequestProcessor;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Requête de création d'une nouvelle organisation.
+ */
+#[ApiResource(
+    operations: [
+        new Post(
+            uriTemplate: '/internal/organization/create',
+        ),
+    ],
+    processor: OrganizationCreationRequestProcessor::class
+)]
+class OrganizationCreationRequest
+{
+    private const FRANCE_COUNTRY_INTERNAL_ID = 72;
+
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_OK = 'ok';
+    public const STATUS_OK_WITH_ERRORS = 'ok with errors';
+
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 0;
+
+    /**
+     * A quelle adresse email notifier la création de l'organisation, ou d'éventuelles erreurs ?
+     */
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private ?string $sendConfirmationEmailAt = null;
+
+    private string $name;
+
+    /**
+     * Matricule (obligatoire dans le réseau CMF).
+     */
+    #[Assert\Regex(pattern: '/^|(FR\d{12})$/')]
+    private string $identifier = '';
+
+    #[Assert\Regex(pattern: '/^|(\d+)$/')]
+    private string $siretNumber = '';
+
+    #[Assert\Regex(pattern: '/^|(W\d+)$/')]
+    private string $waldecNumber = '';
+
+    private ?LegalEnum $legalStatus = null;
+
+    private SettingsProductEnum $product;
+
+    #[Assert\Length(
+        min: 1,
+        minMessage: 'Street address must be at least {{ limit }} characters long',
+    )]
+    private string $streetAddress1;
+
+    private ?string $streetAddress2 = null;
+
+    private ?string $streetAddress3 = null;
+
+    #[Assert\Length(
+        min: 3,
+        minMessage: 'Postal code must be at least {{ limit }} characters long',
+    )]
+    private string $postalCode;
+
+    #[Assert\Length(
+        min: 1,
+        minMessage: 'City must be at least {{ limit }} characters long',
+    )]
+    private string $city;
+
+    private int $countryId = self::FRANCE_COUNTRY_INTERNAL_ID;
+
+    #[Assert\Length(
+        min: 10,
+        minMessage: 'Phone number must be at least {{ limit }} characters long',
+    )]
+    private string $phoneNumber;
+
+    #[Assert\Email(
+        message: 'The email {{ value }} is not a valid email.',
+    )]
+    private string $email;
+
+    #[Assert\Length(
+        min: 2,
+        minMessage: 'Subdomain must be at least {{ limit }} characters long',
+    )]
+    private string $subdomain;
+
+    #[Assert\Positive]
+    private int $parentId = OrganizationIdsEnum::_2IOS->value;
+
+    private ?PrincipalTypeEnum $principalType = null;
+
+    /**
+     * Id d'une Person existante ou requête de création d'un nouvel access qui aura le
+     * rôle de président(e) de la nouvelle structure.
+     */
+    private int|OrganizationMemberCreationRequest|null $president = null;
+
+    /**
+     * Id d'une Person existante ou requête de création d'un nouvel access qui aura le
+     * rôle de directeur / directrice de la nouvelle structure.
+     */
+    private int|OrganizationMemberCreationRequest|null $director = null;
+
+    /**
+     * Faut-il créer un site typo3 pour la nouvelle structure.
+     */
+    private bool $createWebsite = true;
+
+    /**
+     * La structure est-elle cliente de Opentalent.
+     */
+    private bool $client = false;
+
+    /**
+     * Statut de l'opération.
+     */
+    private string $status = self::STATUS_PENDING;
+
+    private ?\DateTime $creationDate = null;
+    private ?int $authorId = null;
+
+    /**
+     * For testing purposes only.
+     */
+    private bool $async = true;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getSendConfirmationEmailAt(): ?string
+    {
+        return $this->sendConfirmationEmailAt;
+    }
+
+    public function setSendConfirmationEmailAt(?string $sendConfirmationEmailAt): self
+    {
+        $this->sendConfirmationEmailAt = $sendConfirmationEmailAt;
+
+        return $this;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): self
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    public function getIdentifier(): string
+    {
+        return $this->identifier;
+    }
+
+    public function setIdentifier(string $identifier): self
+    {
+        $this->identifier = $identifier;
+
+        return $this;
+    }
+
+    public function getSiretNumber(): string
+    {
+        return $this->siretNumber;
+    }
+
+    public function setSiretNumber(string $siretNumber): self
+    {
+        $this->siretNumber = $siretNumber;
+
+        return $this;
+    }
+
+    public function getWaldecNumber(): string
+    {
+        return $this->waldecNumber;
+    }
+
+    public function setWaldecNumber(string $waldecNumber): self
+    {
+        $this->waldecNumber = $waldecNumber;
+
+        return $this;
+    }
+
+    public function getLegalStatus(): LegalEnum
+    {
+        return $this->legalStatus;
+    }
+
+    public function setLegalStatus(LegalEnum $legalStatus): self
+    {
+        $this->legalStatus = $legalStatus;
+
+        return $this;
+    }
+
+    public function getProduct(): SettingsProductEnum
+    {
+        return $this->product;
+    }
+
+    public function setProduct(SettingsProductEnum $product): self
+    {
+        $this->product = $product;
+
+        return $this;
+    }
+
+    public function getStreetAddress1(): string
+    {
+        return $this->streetAddress1;
+    }
+
+    public function setStreetAddress1(string $streetAddress1): self
+    {
+        $this->streetAddress1 = $streetAddress1;
+
+        return $this;
+    }
+
+    public function getStreetAddress2(): ?string
+    {
+        return $this->streetAddress2;
+    }
+
+    public function setStreetAddress2(?string $streetAddress2): self
+    {
+        $this->streetAddress2 = $streetAddress2;
+
+        return $this;
+    }
+
+    public function getStreetAddress3(): ?string
+    {
+        return $this->streetAddress3;
+    }
+
+    public function setStreetAddress3(?string $streetAddress3): self
+    {
+        $this->streetAddress3 = $streetAddress3;
+
+        return $this;
+    }
+
+    public function getPostalCode(): string
+    {
+        return $this->postalCode;
+    }
+
+    public function setPostalCode(string $postalCode): self
+    {
+        $this->postalCode = $postalCode;
+
+        return $this;
+    }
+
+    public function getCity(): string
+    {
+        return $this->city;
+    }
+
+    public function setCity(string $city): self
+    {
+        $this->city = $city;
+
+        return $this;
+    }
+
+    public function getCountryId(): int
+    {
+        return $this->countryId;
+    }
+
+    public function setCountryId(int $countryId): self
+    {
+        $this->countryId = $countryId;
+
+        return $this;
+    }
+
+    public function getPhoneNumber(): string
+    {
+        return $this->phoneNumber;
+    }
+
+    public function setPhoneNumber(string $phoneNumber): self
+    {
+        $this->phoneNumber = $phoneNumber;
+
+        return $this;
+    }
+
+    public function getEmail(): string
+    {
+        return $this->email;
+    }
+
+    public function setEmail(string $email): self
+    {
+        $this->email = $email;
+
+        return $this;
+    }
+
+    public function getSubdomain(): string
+    {
+        return $this->subdomain;
+    }
+
+    public function setSubdomain(string $subdomain): self
+    {
+        $this->subdomain = $subdomain;
+
+        return $this;
+    }
+
+    public function getParentId(): int
+    {
+        return $this->parentId;
+    }
+
+    public function setParentId(int $parentId): self
+    {
+        $this->parentId = $parentId;
+
+        return $this;
+    }
+
+    public function getPrincipalType(): PrincipalTypeEnum
+    {
+        return $this->principalType;
+    }
+
+    public function setPrincipalType(PrincipalTypeEnum $principalType): self
+    {
+        $this->principalType = $principalType;
+
+        return $this;
+    }
+
+    public function getPresident(): ?OrganizationMemberCreationRequest
+    {
+        return $this->president;
+    }
+
+    public function setPresident(?OrganizationMemberCreationRequest $president): self
+    {
+        $this->president = $president;
+
+        return $this;
+    }
+
+    public function getDirector(): ?OrganizationMemberCreationRequest
+    {
+        return $this->director;
+    }
+
+    public function setDirector(?OrganizationMemberCreationRequest $director): self
+    {
+        $this->director = $director;
+
+        return $this;
+    }
+
+    public function getCreateWebsite(): bool
+    {
+        return $this->createWebsite;
+    }
+
+    public function setCreateWebsite(bool $createWebsite): self
+    {
+        $this->createWebsite = $createWebsite;
+
+        return $this;
+    }
+
+    public function isClient(): bool
+    {
+        return $this->client;
+    }
+
+    public function setClient(bool $client): self
+    {
+        $this->client = $client;
+
+        return $this;
+    }
+
+    public function getStatus(): string
+    {
+        return $this->status;
+    }
+
+    public function setStatus(string $status): self
+    {
+        $this->status = $status;
+
+        return $this;
+    }
+
+    public function getCreationDate(): \DateTime
+    {
+        return $this->creationDate;
+    }
+
+    public function setCreationDate(?\DateTime $creationDate): self
+    {
+        $this->creationDate = $creationDate;
+
+        return $this;
+    }
+
+    public function getAuthorId(): ?int
+    {
+        return $this->authorId;
+    }
+
+    public function setAuthorId(?int $authorId): self
+    {
+        $this->authorId = $authorId;
+
+        return $this;
+    }
+
+    public function isAsync(): bool
+    {
+        return $this->async;
+    }
+
+    public function setAsync(bool $async): self
+    {
+        $this->async = $async;
+
+        return $this;
+    }
+}

+ 110 - 0
src/ApiResources/Organization/OrganizationDeletionRequest.php

@@ -0,0 +1,110 @@
+<?php
+
+namespace App\ApiResources\Organization;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\State\Processor\Organization\OrganizationDeletionRequestProcessor;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Requête de création d'une nouvelle organisation
+ */
+#[ApiResource(
+    operations: [
+        new Post(
+            uriTemplate: '/internal/organization/delete',
+        ),
+    ],
+    processor: OrganizationDeletionRequestProcessor::class
+)]
+class OrganizationDeletionRequest
+{
+    public const STATUS_PENDING = 'pending';
+    public const STATUS_OK = 'ok';
+    public const STATUS_OK_WITH_ERRORS = 'ok with errors';
+
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 0;
+
+    private int $organizationId;
+
+    /**
+     * A quelle adresse email notifier la création de l'organisation, ou d'éventuelles erreurs ?
+     * @var string|null
+     */
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private ?string $sendConfirmationEmailAt = null;
+
+    /**
+     * Statut de l'opération
+     * @var string
+     */
+    private string $status = self::STATUS_PENDING;
+
+    /**
+     * For testing purposes only
+     * @var bool
+     */
+    private bool $async = true;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    public function setOrganizationId(int $organizationId): self
+    {
+        $this->organizationId = $organizationId;
+        return $this;
+    }
+
+    public function getSendConfirmationEmailAt(): ?string
+    {
+        return $this->sendConfirmationEmailAt;
+    }
+
+    public function setSendConfirmationEmailAt(?string $sendConfirmationEmailAt): self
+    {
+        $this->sendConfirmationEmailAt = $sendConfirmationEmailAt;
+        return $this;
+    }
+
+    public function getStatus(): string
+    {
+        return $this->status;
+    }
+
+    public function setStatus(string $status): self
+    {
+        $this->status = $status;
+        return $this;
+    }
+
+    public function isAsync(): bool
+    {
+        return $this->async;
+    }
+
+    public function setAsync(bool $async): self
+    {
+        $this->async = $async;
+        return $this;
+    }
+}

+ 228 - 0
src/ApiResources/Organization/OrganizationMemberCreationRequest.php

@@ -0,0 +1,228 @@
+<?php
+
+namespace App\ApiResources\Organization;
+
+use App\Enum\Core\FileTypeEnum;
+use App\Enum\Person\GenderEnum;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Requête de création d'un nouvel access.
+ */
+class OrganizationMemberCreationRequest
+{
+    #[Assert\Type(type: FileTypeEnum::class)]
+    private GenderEnum $gender = GenderEnum::MISTER;
+
+    #[Assert\Regex(pattern: '/^[a-z0-9\-]{3,}$/')]
+    private string $username;
+
+    #[Assert\Length(
+        min: 1,
+        minMessage: 'Name must be at least {{ limit }} characters long',
+    )]
+    private string $name;
+
+    #[Assert\Length(
+        min: 1,
+        minMessage: 'Given name must be at least {{ limit }} characters long',
+    )]
+    private string $givenName;
+
+    #[Assert\Length(
+        min: 1,
+        minMessage: 'Street address must be at least {{ limit }} characters long',
+    )]
+    private string $streetAddress1;
+
+    private ?string $streetAddress2 = null;
+
+    private ?string $streetAddress3 = null;
+
+    #[Assert\Length(
+        min: 3,
+        minMessage: 'Postal code must be at least {{ limit }} characters long',
+    )]
+    private string $postalCode;
+
+    #[Assert\Length(
+        min: 1,
+        minMessage: 'City must be at least {{ limit }} characters long',
+    )]
+    private string $city;
+
+    private int $countryId = 72; // France's id
+
+    #[Assert\Length(
+        min: 10,
+        minMessage: 'Phone number must be at least {{ limit }} characters long',
+    )]
+    private string $phone;
+
+    #[Assert\Length(
+        min: 10,
+        minMessage: 'Mobile phone number must be at least {{ limit }} characters long',
+    )]
+    private ?string $mobile = null;
+
+    #[Assert\Email(
+        message: 'The email {{ value }} is not a valid email.',
+    )]
+    private string $email;
+
+    public function getUsername(): string
+    {
+        return $this->username;
+    }
+
+    public function setUsername(string $username): self
+    {
+        $this->username = $username;
+
+        return $this;
+    }
+
+    public function getGender(): GenderEnum
+    {
+        return $this->gender;
+    }
+
+    public function setGender(GenderEnum $gender): self
+    {
+        $this->gender = $gender;
+
+        return $this;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): self
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    public function getGivenName(): string
+    {
+        return $this->givenName;
+    }
+
+    public function setGivenName(string $givenName): self
+    {
+        $this->givenName = $givenName;
+
+        return $this;
+    }
+
+    public function getStreetAddress1(): string
+    {
+        return $this->streetAddress1;
+    }
+
+    public function setStreetAddress1(string $streetAddress1): self
+    {
+        $this->streetAddress1 = $streetAddress1;
+
+        return $this;
+    }
+
+    public function getStreetAddress2(): ?string
+    {
+        return $this->streetAddress2;
+    }
+
+    public function setStreetAddress2(?string $streetAddress2): self
+    {
+        $this->streetAddress2 = $streetAddress2;
+
+        return $this;
+    }
+
+    public function getStreetAddress3(): ?string
+    {
+        return $this->streetAddress3;
+    }
+
+    public function setStreetAddress3(?string $streetAddress3): self
+    {
+        $this->streetAddress3 = $streetAddress3;
+
+        return $this;
+    }
+
+    public function getPostalCode(): string
+    {
+        return $this->postalCode;
+    }
+
+    public function setPostalCode(string $postalCode): self
+    {
+        $this->postalCode = $postalCode;
+
+        return $this;
+    }
+
+    public function getCity(): string
+    {
+        return $this->city;
+    }
+
+    public function setCity(string $city): self
+    {
+        $this->city = $city;
+
+        return $this;
+    }
+
+    public function getCountryId(): int
+    {
+        return $this->countryId;
+    }
+
+    public function setCountryId(int $countryId): self
+    {
+        $this->countryId = $countryId;
+
+        return $this;
+    }
+
+    public function getPhone(): string
+    {
+        return $this->phone;
+    }
+
+    public function setPhone(string $phone): self
+    {
+        $this->phone = $phone;
+
+        return $this;
+    }
+
+    public function getMobile(): ?string
+    {
+        return $this->mobile;
+    }
+
+    public function setMobile(?string $mobile): self
+    {
+        $this->mobile = $mobile;
+
+        return $this;
+    }
+
+    public function getEmail(): string
+    {
+        return $this->email;
+    }
+
+    public function setEmail(string $email): self
+    {
+        $this->email = $email;
+
+        return $this;
+    }
+}

+ 62 - 45
src/Entity/Access/Access.php

@@ -50,6 +50,7 @@ use App\Entity\Product\Equipment;
 use App\Entity\Product\EquipmentLoan;
 use App\Entity\Product\EquipmentRepair;
 use App\Entity\Reward\AccessReward;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Filter\ApiPlatform\Person\FullNameFilter;
 use App\Filter\ApiPlatform\Utils\InFilter;
 use App\Repository\Access\AccessRepository;
@@ -79,6 +80,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ApiFilter(GroupFilter::class, arguments: ['whitelist' => ['access_people_ref']])]
 class Access implements UserInterface, PasswordAuthenticatedUserInterface
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
@@ -109,6 +112,9 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column(type: 'json', length: 4294967295, nullable: true)]
     private ?array $roles = [];
 
+    #[ORM\Column(options: ['default' => false])]
+    private bool $loginEnabled = false;
+
     /** @var mixed[]|null */
     #[Groups(['my_access:input'])]
     #[ORM\Column(type: 'json', length: 4294967295, nullable: true)]
@@ -117,22 +123,22 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\OneToOne(mappedBy: 'access', cascade: ['persist'], orphanRemoval: true)]
     private AccessBilling $accessBilling;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: PersonActivity::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: PersonActivity::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $personActivity;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: OrganizationFunction::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: OrganizationFunction::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationFunction;
 
-    #[ORM\OneToMany(mappedBy: 'licensee', targetEntity: OrganizationLicence::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'licensee', targetEntity: OrganizationLicence::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationLicences;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: PersonalizedList::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: PersonalizedList::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $personalizedLists;
 
-    #[ORM\OneToMany(mappedBy: 'recipientAccess', targetEntity: Notification::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'recipientAccess', targetEntity: Notification::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $notifications;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: NotificationUser::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: NotificationUser::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $notificationUsers;
 
     #[ORM\ManyToMany(targetEntity: Access::class, mappedBy: 'children', cascade: ['persist'])]
@@ -144,13 +150,13 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\InverseJoinColumn(name: 'children_id', referencedColumnName: 'id')]
     private Collection $children;
 
-    #[ORM\OneToMany(mappedBy: 'accessPayer', targetEntity: AccessPayer::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'accessPayer', targetEntity: AccessPayer::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $billingPayers;
 
-    #[ORM\OneToMany(mappedBy: 'accessReceiver', targetEntity: AccessPayer::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'accessReceiver', targetEntity: AccessPayer::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $billingReceivers;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AccessIntangible::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AccessIntangible::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $accessIntangibles;
 
     #[ORM\ManyToOne(inversedBy: 'publicationDirectors')]
@@ -161,22 +167,22 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     private ?EducationNotationConfig $educationNotationConfig;
 
-    #[ORM\OneToMany(mappedBy: 'company', targetEntity: CompanyPerson::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'company', targetEntity: CompanyPerson::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $companyPersonAccesses;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: CompanyPerson::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: CompanyPerson::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $companyPersonCompany;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: EducationStudent::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: EducationStudent::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationStudent;
 
-    #[ORM\ManyToMany(targetEntity: EducationStudent::class, mappedBy: 'teachers', cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\ManyToMany(targetEntity: EducationStudent::class, mappedBy: 'teachers', cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationStudentByTeacher;
 
-    #[ORM\OneToMany(mappedBy: 'teacher', targetEntity: EducationTeacher::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'teacher', targetEntity: EducationTeacher::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationTeachers;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: PersonHoliday::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: PersonHoliday::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $holidays;
 
     #[ORM\ManyToMany(targetEntity: Course::class, mappedBy: 'students', cascade: ['persist'])]
@@ -201,97 +207,97 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column]
     private bool $ielEnabled = false;
 
-    #[ORM\OneToMany(mappedBy: 'educationalProjectPayer', targetEntity: EducationalProjectPayer::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'educationalProjectPayer', targetEntity: EducationalProjectPayer::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $billingEducationalProjectPayers;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Bill::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Bill::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $bills;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: BillLine::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: BillLine::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $billLines;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: BillCredit::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: BillCredit::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $billCredits;
 
-    #[ORM\OneToMany(mappedBy: 'silentPartner', targetEntity: EducationalProject::class)]
+    #[ORM\OneToMany(mappedBy: 'silentPartner', targetEntity: EducationalProject::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $silentPartners;
 
-    #[ORM\OneToMany(mappedBy: 'operationalPartner', targetEntity: EducationalProject::class)]
+    #[ORM\OneToMany(mappedBy: 'operationalPartner', targetEntity: EducationalProject::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $operationalPartners;
 
-    #[ORM\OneToMany(mappedBy: 'guest', targetEntity: EventUser::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'guest', targetEntity: EventUser::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $eventUsers;
 
-    #[ORM\OneToMany(mappedBy: 'student', targetEntity: ExamenConvocation::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'student', targetEntity: ExamenConvocation::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $examenConvocations;
 
-    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: EquipmentRepair::class)]
+    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: EquipmentRepair::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipmentRepairProviders;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Attendance::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Attendance::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $attendances;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AttendanceBooking::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AttendanceBooking::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $attendanceBookings;
 
-    #[ORM\OneToMany(mappedBy: 'replacement', targetEntity: Attendance::class)]
+    #[ORM\OneToMany(mappedBy: 'replacement', targetEntity: Attendance::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $attendanceReplacements;
 
-    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: RoomRepair::class)]
+    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: RoomRepair::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $roomRepairProviders;
 
-    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: PlaceRepair::class)]
+    #[ORM\OneToMany(mappedBy: 'provider', targetEntity: PlaceRepair::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $placeRepairProviders;
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Email::class)]
+    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Email::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $emails;
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Mail::class)]
+    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Mail::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $mails;
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Sms::class)]
+    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Sms::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $sms;
 
     #[ORM\ManyToMany(targetEntity: Jury::class, mappedBy: 'members', orphanRemoval: true)]
     private Collection $juryMembers;
 
-    #[ORM\OneToMany(mappedBy: 'contactPerson', targetEntity: Organization::class)]
+    #[ORM\OneToMany(mappedBy: 'contactPerson', targetEntity: Organization::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationContacts;
 
-    #[ORM\OneToMany(mappedBy: 'member', targetEntity: CommissionMember::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'member', targetEntity: CommissionMember::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $commissionMembers;
 
-    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: Equipment::class)]
+    #[ORM\OneToMany(mappedBy: 'supplier', targetEntity: Equipment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipmentSuppliers;
 
-    #[ORM\OneToMany(mappedBy: 'controlManager', targetEntity: Equipment::class)]
+    #[ORM\OneToMany(mappedBy: 'controlManager', targetEntity: Equipment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipmentControlManagers;
 
-    #[ORM\OneToMany(mappedBy: 'editor', targetEntity: Equipment::class)]
+    #[ORM\OneToMany(mappedBy: 'editor', targetEntity: Equipment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipmentEditors;
 
-    #[ORM\OneToMany(mappedBy: 'borrower', targetEntity: EquipmentLoan::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'borrower', targetEntity: EquipmentLoan::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipmentLoans;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Equipment::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Equipment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipments;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AccessFictionalIntangible::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AccessFictionalIntangible::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $accessFictionalIntangibles;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Donor::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: Donor::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $donors;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AccessReward::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: AccessReward::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $accessRewards;
 
-    #[ORM\OneToMany(mappedBy: 'access', targetEntity: OrganizationResponsability::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'access', targetEntity: OrganizationResponsability::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationResponsabilities;
 
-    #[ORM\OneToMany(mappedBy: 'accessOriginal', targetEntity: AccessWish::class, cascade: ['persist', 'remove'], fetch: 'EAGER', orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'accessOriginal', targetEntity: AccessWish::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $accessWishes;
 
-    #[ORM\OneToMany(mappedBy: 'student', targetEntity: WorkByUser::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'student', targetEntity: WorkByUser::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $workByUsers;
 
     #[ORM\ManyToMany(targetEntity: Tagg::class, inversedBy: 'accesses', cascade: ['persist'])]
@@ -493,6 +499,17 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
         return array_unique($roles ?? []);
     }
 
+    public function isLoginEnabled(): bool
+    {
+        return $this->loginEnabled;
+    }
+
+    public function setLoginEnabled(bool $loginEnabled): self
+    {
+        $this->loginEnabled = $loginEnabled;
+        return $this;
+    }
+
     public function getPersonActivity(): Collection
     {
         return $this->personActivity;

+ 2 - 0
src/Entity/Access/OrganizationFunction.php

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\ApiResource;
 use App\Attribute\DateTimeConstraintAware;
 use App\Entity\Organization\Activity;
 use App\Entity\Traits\ActivityPeriodTrait;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Access\DeparturesCauseEnum;
 use App\Repository\Access\OrganizationFunctionRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -23,6 +24,7 @@ use Doctrine\ORM\Mapping as ORM;
 class OrganizationFunction
 {
     use ActivityPeriodTrait;
+    use CreatedOnAndByTrait;
 
     #[ORM\Id]
     #[ORM\GeneratedValue]

+ 3 - 0
src/Entity/Core/AddressPostal.php

@@ -9,6 +9,7 @@ use App\Entity\Organization\OrganizationAddressPostal;
 use App\Entity\Person\PersonAddressPostal;
 use App\Entity\Place\Place;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Repository\Core\AddressPostalRepository;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
@@ -27,6 +28,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ORM\Entity(repositoryClass: AddressPostalRepository::class)]
 class AddressPostal
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]

+ 3 - 0
src/Entity/Core/ContactPoint.php

@@ -9,6 +9,7 @@ use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Entity\Place\Place;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Core\ContactPointTypeEnum;
 use App\Repository\Core\ContactPointRepository;
 use App\Validator\Core as OpentalentAssert;
@@ -32,6 +33,8 @@ use Symfony\Component\Validator\Constraints as Assert;
 #[OpentalentAssert\ContactPoint]
 class ContactPoint
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]

+ 3 - 0
src/Entity/Network/NetworkOrganization.php

@@ -7,6 +7,7 @@ namespace App\Entity\Network;
 use ApiPlatform\Metadata\ApiResource;
 use App\Attribute\DateTimeConstraintAware;
 use App\Entity\Organization\Organization;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Repository\Network\NetworkOrganizationRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use Doctrine\ORM\Mapping as ORM;
@@ -25,6 +26,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[DateTimeConstraintAware(startDateFieldName: 'startDate', endDateFieldName: 'endDate')]
 class NetworkOrganization
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]

+ 51 - 36
src/Entity/Organization/Organization.php

@@ -35,6 +35,7 @@ use App\Entity\Place\Place;
 use App\Entity\Product\Equipment;
 use App\Entity\Product\Intangible;
 use App\Entity\Reward\Reward;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Organization\CategoryEnum;
 use App\Enum\Organization\LegalEnum;
 use App\Enum\Organization\OpcaEnum;
@@ -63,6 +64,8 @@ use JetBrains\PhpStorm\Pure;
 #[ORM\Entity(repositoryClass: OrganizationRepository::class)]
 class Organization
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
@@ -77,25 +80,25 @@ class Organization
     #[ORM\Column(length: 50, nullable: true, enumType: LegalEnum::class)]
     private ?LegalEnum $legalStatus = null;
 
-    #[ORM\Column(length: 255, nullable: true, enumType: PrincipalTypeEnum::class)]
-    private ?PrincipalTypeEnum $principalType = null;
+    #[ORM\Column(length: 255, nullable: false, enumType: PrincipalTypeEnum::class)]
+    private PrincipalTypeEnum $principalType;
 
     #[ORM\OneToOne(mappedBy: 'organization', cascade: ['persist', 'remove'])]
     private Settings $settings;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Access::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Access::class, cascade: ['persist'])]
     private Collection $accesses;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: NetworkOrganization::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: NetworkOrganization::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $networkOrganizations;
 
-    #[ORM\OneToMany(mappedBy: 'parent', targetEntity: NetworkOrganization::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'parent', targetEntity: NetworkOrganization::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $networkOrganizationChildren;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationNotationConfig::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationNotationConfig::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationNotationConfigs;
 
-    #[ORM\OneToOne(inversedBy: 'organization', targetEntity: Parameters::class)]
+    #[ORM\OneToOne(inversedBy: 'organization', targetEntity: Parameters::class, cascade: ['persist'])]
     #[ORM\JoinColumn(nullable: false)]
     private Parameters $parameters;
 
@@ -105,6 +108,7 @@ class Organization
     #[ORM\Column(type: 'text', nullable: true)]
     private ?string $description = null;
 
+    // TODO: utile ce champs? ou c'est juste un doublon du champs 'createDate'?
     #[ORM\Column(type: 'date', nullable: true)]
     private ?\DateTimeInterface $creationDate = null;
 
@@ -218,7 +222,7 @@ class Organization
     #[ORM\Column(nullable: true)]
     private ?string $otherPractice = null;
 
-    #[ORM\ManyToMany(targetEntity: ContactPoint::class, mappedBy: 'organization')]
+    #[ORM\ManyToMany(targetEntity: ContactPoint::class, mappedBy: 'organization', cascade: ['persist'], orphanRemoval: true)]
     private Collection $contactPoints;
 
     #[ORM\ManyToMany(targetEntity: BankAccount::class, inversedBy: 'organization')]
@@ -226,92 +230,92 @@ class Organization
     #[ORM\InverseJoinColumn(name: 'bankAccount_id', referencedColumnName: 'id')]
     private Collection $bankAccounts;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationAddressPostal::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationAddressPostal::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationAddressPostals;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationLicence::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationLicence::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationLicences;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationArticle::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationArticle::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $organizationArticles;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Cycle::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Cycle::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $cycles;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationTiming::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationTiming::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationTimings;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Subdomain::class)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Subdomain::class, cascade: ['persist', 'remove'])]
     private Collection $subdomains;
 
     #[ORM\ManyToOne(inversedBy: 'organizationContacts')]
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     private ?Access $contactPerson;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationHoliday::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: OrganizationHoliday::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $holidays;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Course::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Course::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $courses;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationalProject::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationalProject::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationalProjects;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Event::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Event::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $events;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Examen::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Examen::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $examens;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: CriteriaNotation::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: CriteriaNotation::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $critereNotations;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationCategory::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: EducationCategory::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $educationCategories;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: PeriodNotation::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: PeriodNotation::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $periodNotations;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: File::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: File::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $files;
 
-    #[ORM\OneToMany(mappedBy: 'recipientOrganization', targetEntity: Notification::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'recipientOrganization', targetEntity: Notification::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $notifications;
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Email::class)]
+    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Email::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $emails;
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Mail::class)]
+    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Mail::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $mails;
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Sms::class)]
+    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Sms::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $sms;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Activity::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Activity::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $activities;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Jury::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Jury::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $juries;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Commission::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Commission::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $commissions;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Place::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Place::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $places;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Attendance::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Attendance::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $attendances;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Equipment::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Equipment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $equipments;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Intangible::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Intangible::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $intangibles;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Donor::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Donor::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $donors;
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Reward::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Reward::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $rewards;
 
     //    #[ORM\OneToOne()]
@@ -428,6 +432,7 @@ class Organization
         if ($settings->getOrganization() !== $this) {
             $settings->setOrganization($this);
         }
+
         $this->settings = $settings;
 
         return $this;
@@ -1197,6 +1202,16 @@ class Organization
         return $this->subdomains;
     }
 
+    public function addSubdomain(Subdomain $subdomain): self
+    {
+        if (!$this->subdomains->contains($subdomain)) {
+            $this->subdomains[] = $subdomain;
+            $subdomain->setOrganization($this);
+        }
+
+        return $this;
+    }
+
     public function addAccess(Access $access): self
     {
         if (!$this->accesses->contains($access)) {

+ 4 - 1
src/Entity/Organization/OrganizationAddressPostal.php

@@ -7,6 +7,7 @@ namespace App\Entity\Organization;
 use ApiPlatform\Metadata\ApiResource;
 use App\Attribute\OrganizationDefaultValue;
 use App\Entity\Core\AddressPostal;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
 use App\Repository\Organization\OrganizationAddressPostalRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -33,6 +34,8 @@ use Symfony\Component\Validator\Constraints as Assert;
 #[OpentalentAssert\OrganizationAddressPostal]
 class OrganizationAddressPostal
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
@@ -43,7 +46,7 @@ class OrganizationAddressPostal
     #[ORM\JoinColumn(nullable: false)]
     private Organization $organization;
 
-    #[ORM\OneToOne(inversedBy: 'organizationAddressPostal', cascade: ['persist', 'remove'])]
+    #[ORM\OneToOne(inversedBy: 'organizationAddressPostal', cascade: ['persist', 'remove'], orphanRemoval: true)]
     #[ORM\JoinColumn(nullable: false)]
     #[Assert\Valid]
     #[Groups('address')]

+ 204 - 0
src/Entity/Organization/OrganizationIdentification.php

@@ -0,0 +1,204 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entity\Organization;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use App\Repository\Organization\OrganizationIdentificationRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * Cette entité regroupe les informations permettant d'identifier une organisation, et est utilisée entre autre
+ * pour la création d'organisation.
+ *
+ * Fichier source de la view : ./sql/schema-extensions/003-view_organization_identification.sql
+ */
+#[ApiResource(
+    operations: [
+        new Get(
+            uriTemplate: '/public/organization_identification/{id}',
+            requirements: ['id' => '\\d+']
+        ),
+    ]
+)]
+#[ORM\Table(name: 'view_organization_identification')]
+#[ORM\Entity(repositoryClass: OrganizationIdentificationRepository::class, readOnly: true)]
+class OrganizationIdentification
+{
+    #[ORM\Id]
+    #[ORM\Column]
+    private int $id;
+
+    #[ORM\Column]
+    private string $name;
+
+    #[ORM\Column]
+    private string $normalizedName;
+
+    #[ORM\Column]
+    private ?string $identifier;
+
+    #[ORM\Column]
+    private ?string $siretNumber;
+
+    #[ORM\Column]
+    private ?string $waldecNumber;
+
+    #[ORM\Column]
+    private ?string $normalizedAddress;
+
+    #[ORM\Column]
+    private ?string $streetAddress;
+
+    #[ORM\Column]
+    private ?string $streetAddressSecond;
+
+    #[ORM\Column]
+    private ?string $streetAddressThird;
+
+    #[ORM\Column]
+    private ?string $addressCity;
+
+    #[ORM\Column]
+    private ?string $postalCode;
+
+    #[ORM\Column]
+    private ?string $email;
+
+    #[ORM\Column]
+    private ?string $telphone;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getName(): string
+    {
+        return $this->name;
+    }
+
+    public function setName(string $name): self
+    {
+        $this->name = $name;
+
+        return $this;
+    }
+
+    public function getNormalizedName(): string
+    {
+        return $this->normalizedName;
+    }
+
+    public function setNormalizedName(string $normalizedName): self
+    {
+        $this->normalizedName = $normalizedName;
+
+        return $this;
+    }
+
+    public function getIdentifier(): ?string
+    {
+        return $this->identifier;
+    }
+
+    public function setIdentifier(?string $identifier): self
+    {
+        $this->identifier = $identifier;
+
+        return $this;
+    }
+
+    public function getSiretNumber(): ?string
+    {
+        return $this->siretNumber;
+    }
+
+    public function setSiretNumber(?string $siretNumber): self
+    {
+        $this->siretNumber = $siretNumber;
+
+        return $this;
+    }
+
+    public function getWaldecNumber(): ?string
+    {
+        return $this->waldecNumber;
+    }
+
+    public function setWaldecNumber(?string $waldecNumber): self
+    {
+        $this->waldecNumber = $waldecNumber;
+
+        return $this;
+    }
+
+    public function getNormalizedAddress(): ?string
+    {
+        return $this->normalizedAddress;
+    }
+
+    public function setNormalizedAddress(?string $normalizedAddress): self
+    {
+        $this->normalizedAddress = $normalizedAddress;
+
+        return $this;
+    }
+
+    public function getAddressCity(): ?string
+    {
+        return $this->addressCity;
+    }
+
+    public function setAddressCity(?string $addressCity): self
+    {
+        $this->addressCity = $addressCity;
+
+        return $this;
+    }
+
+    public function getPostalCode(): ?string
+    {
+        return $this->postalCode;
+    }
+
+    public function setPostalCode(?string $postalCode): self
+    {
+        $this->postalCode = $postalCode;
+
+        return $this;
+    }
+
+    public function getEmail(): ?string
+    {
+        return $this->email;
+    }
+
+    public function setEmail(?string $email): self
+    {
+        $this->email = $email;
+
+        return $this;
+    }
+
+    public function getTelphone(): ?string
+    {
+        return $this->telphone;
+    }
+
+    public function setTelphone(?string $telphone): self
+    {
+        $this->telphone = $telphone;
+
+        return $this;
+    }
+}

+ 8 - 3
src/Entity/Organization/Parameters.php

@@ -150,13 +150,13 @@ class Parameters
     private ?File $qrCode = null;
 
     #[ORM\Column(length: 255, enumType: TimeZoneEnum::class, options: ['default' => TimeZoneEnum::EUROPE_PARIS])]
-    private ?TimeZoneEnum $timezone = null;
+    private ?TimeZoneEnum $timezone = TimeZoneEnum::EUROPE_PARIS;
 
     #[ORM\Column(length: 255, nullable: false, enumType: PeriodicityEnum::class, options: ['default' => PeriodicityEnum::ANNUAL])]
-    private ?PeriodicityEnum $educationPeriodicity = null;
+    private PeriodicityEnum $educationPeriodicity = PeriodicityEnum::ANNUAL;
 
     #[ORM\Column(length: 255, nullable: true, enumType: AdvancedEducationNotationTypeEnum::class, options: ['default' => AdvancedEducationNotationTypeEnum::BY_EDUCATION])]
-    private ?AdvancedEducationNotationTypeEnum $advancedEducationNotationType = null;
+    private AdvancedEducationNotationTypeEnum $advancedEducationNotationType = AdvancedEducationNotationTypeEnum::BY_EDUCATION;
 
     #[ORM\Column(options: ['default' => false])]
     private bool $sendAttendanceEmail = false;
@@ -225,6 +225,11 @@ class Parameters
 
     public function setOrganization(Organization $organization): self
     {
+        // set the owning side of the relation if necessary
+        if ($organization->getParameters() !== $this) {
+            $organization->setParameters($this);
+        }
+
         $this->organization = $organization;
 
         return $this;

+ 15 - 0
src/Entity/Organization/Settings.php

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Entity\Organization;
 
 use ApiPlatform\Metadata\ApiResource;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Organization\SettingsProductEnum;
 use App\Repository\Organization\SettingsRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -18,6 +19,8 @@ use Doctrine\ORM\Mapping as ORM;
 #[ORM\Entity(repositoryClass: SettingsRepository::class)]
 class Settings
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
@@ -121,4 +124,16 @@ class Settings
 
         return $this;
     }
+
+    public function getCreateDate(): ?\DateTimeInterface
+    {
+        return $this->createDate;
+    }
+
+    public function setCreateDate(?\DateTimeInterface $createDate): self
+    {
+        $this->createDate = $createDate;
+
+        return $this;
+    }
 }

+ 52 - 10
src/Entity/Person/Person.php

@@ -11,6 +11,7 @@ use App\Entity\Core\BankAccount;
 use App\Entity\Core\ContactPoint;
 use App\Entity\Core\Country;
 use App\Entity\Core\File;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Person\GenderEnum;
 use App\Repository\Person\PersonRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -30,6 +31,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ORM\Entity(repositoryClass: PersonRepository::class)]
 class Person implements UserInterface, PasswordAuthenticatedUserInterface
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
@@ -39,7 +42,13 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column(length: 180, unique: true, nullable: true)]
     private ?string $username = null;
 
-    /** @var string[]|null */
+    #[ORM\Column(options: ['default' => false])]
+    private bool $enabled = false;
+
+    /**
+     * @var array<mixed>|null
+     */
+    #[ORM\Column(type: "array", nullable: true)]
     private ?array $roles = [];
 
     #[ORM\Column(nullable: true)]
@@ -53,7 +62,7 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[Groups(['access_people_ref', 'access_address'])]
     private ?string $givenName = null;
 
-    #[ORM\ManyToMany(targetEntity: ContactPoint::class, mappedBy: 'person')]
+    #[ORM\ManyToMany(targetEntity: ContactPoint::class, mappedBy: 'person', cascade: ['persist'], orphanRemoval: true)]
     private Collection $contactPoints;
 
     #[ORM\ManyToMany(targetEntity: BankAccount::class, inversedBy: 'person', cascade: ['persist'], orphanRemoval: true)]
@@ -61,7 +70,7 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\InverseJoinColumn(name: 'bankAccount_id', referencedColumnName: 'id')]
     private Collection $bankAccount;
 
-    #[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonAddressPostal::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonAddressPostal::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     #[Groups('access_address')]
     private Collection $personAddressPostal;
 
@@ -84,25 +93,29 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\OneToMany(mappedBy: 'person', targetEntity: File::class, orphanRemoval: true)]
     private Collection $files;
 
-    #[ORM\OneToMany(mappedBy: 'person', targetEntity: DisciplineOtherEstablishment::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'person', targetEntity: DisciplineOtherEstablishment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $disciplineotherestablishments;
 
-    #[ORM\OneToMany(mappedBy: 'person', targetEntity: Qualification::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'person', targetEntity: Qualification::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $qualifications;
 
-    #[ORM\OneToMany(mappedBy: 'person', targetEntity: SchoolingInEstablishment::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'person', targetEntity: SchoolingInEstablishment::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $schoolingEstablisments;
 
-    #[ORM\OneToMany(mappedBy: 'person', targetEntity: TeacherSchoolingHistory::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'person', targetEntity: TeacherSchoolingHistory::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $teacherSchoolingHistories;
 
-    #[ORM\ManyToMany(targetEntity: File::class, mappedBy: 'accessPersons', cascade: ['persist'])]
+    #[ORM\ManyToMany(targetEntity: File::class, mappedBy: 'accessPersons', cascade: ['persist', 'remove'])]
     #[ORM\OrderBy(['id' => 'DESC'])]
     private Collection $personFiles;
 
-    #[ORM\OneToMany(mappedBy: 'personOwner', targetEntity: DocumentWish::class, cascade: ['persist'], orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'personOwner', targetEntity: DocumentWish::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $documentWishes;
 
+    /** @var array<string, string> */
+    #[ORM\Column(type: "json", nullable: true)]
+    private array $confidentiality = [];
+
     #[Pure]
     public function __construct()
     {
@@ -141,6 +154,17 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
         return $this;
     }
 
+    public function isEnabled(): bool
+    {
+        return $this->enabled;
+    }
+
+    public function setEnabled(bool $enabled): self
+    {
+        $this->enabled = $enabled;
+        return $this;
+    }
+
     /**
      * @return string[]
      */
@@ -355,7 +379,7 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     /**
      * @return Collection<int, Access>
      */
-    public function getAccess(): Collection
+    public function getAccesses(): Collection
     {
         return $this->access;
     }
@@ -588,4 +612,22 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
 
         return $this;
     }
+
+    /**
+     * @return array<string, string>
+     */
+    public function getConfidentiality(): array
+    {
+        return $this->confidentiality;
+    }
+
+    /**
+     * @param array<string, string> $confidentiality
+     * @return $this
+     */
+    public function setConfidentiality(array $confidentiality): self
+    {
+        $this->confidentiality = $confidentiality;
+        return $this;
+    }
 }

+ 4 - 1
src/Entity/Person/PersonAddressPostal.php

@@ -6,6 +6,7 @@ namespace App\Entity\Person;
 
 use ApiPlatform\Metadata\ApiResource;
 use App\Entity\Core\AddressPostal;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Person\AddressPostalPersonTypeEnum;
 use App\Repository\Person\PersonAddressPostalRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -20,6 +21,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ORM\Entity(repositoryClass: PersonAddressPostalRepository::class)]
 class PersonAddressPostal
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\GeneratedValue]
@@ -29,7 +32,7 @@ class PersonAddressPostal
     #[ORM\JoinColumn(nullable: false)]
     private Person $person;
 
-    #[ORM\OneToOne(inversedBy: 'personAddressPostal', cascade: ['persist', 'remove'])]
+    #[ORM\OneToOne(inversedBy: 'personAddressPostal', cascade: ['persist', 'remove'], orphanRemoval: true)]
     #[ORM\JoinColumn(nullable: false)]
     #[Groups('access_address')]
     private AddressPostal $addressPostal;

+ 43 - 0
src/Entity/Traits/CreatedOnAndByTrait.php

@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entity\Traits;
+
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * Add the 'createDate' and 'createdBy' properties to an entity.
+ */
+trait CreatedOnAndByTrait
+{
+    #[ORM\Column(type: 'datetime', nullable: true)]
+    private ?\DateTimeInterface $createDate = null;
+
+    #[ORM\Column(nullable: true)]
+    private ?int $createdBy = null;
+
+    public function getCreateDate(): ?\DateTimeInterface
+    {
+        return $this->createDate;
+    }
+
+    public function setCreateDate(?\DateTimeInterface $createDate): self
+    {
+        $this->createDate = $createDate;
+
+        return $this;
+    }
+
+    public function getCreatedBy(): ?int
+    {
+        return $this->createdBy;
+    }
+
+    public function setCreatedBy(?int $createdBy): self
+    {
+        $this->createdBy = $createdBy;
+
+        return $this;
+    }
+}

+ 52 - 52
src/Enum/Cotisation/TypeOfPracticeEnum.php

@@ -13,56 +13,56 @@ enum TypeOfPracticeEnum: string
 {
     use EnumMethodsTrait;
 
-    public const BATTERY_FANFARE = 'BATTERY_FANFARE';
-    public const BIG_BAND = 'BIG_BAND';
-    public const BRASS_BAND = 'BRASS_BAND';
-    public const MIXED_CHORUS = 'MIXED_CHORUS';
-    public const FEMAL_CHOIR = 'FEMAL_CHOIR';
-    public const MENS_CHOIR = 'MENS_CHOIR';
-    public const CHILDRENS_CHOIR = 'CHILDRENS_CHOIR';
-    public const ORCHESTRA_CLASS = 'ORCHESTRA_CLASS'; // Non
-    public const COPPER_BAND = 'COPPER_BAND';
-    public const JAZZ_BAND = 'JAZZ_BAND';
-    public const PERCUSSION_BAND = 'PERCUSSION_BAND';
-    public const PLUCKED_ORCHESTRA = 'PLUCKED_ORCHESTRA'; // Done
-    public const FOLKLORIC_BAND = 'FOLKLORIC_BAND';
-    public const VOCAL_BAND_UP_16 = 'VOCAL_BAND_UP_16';
-    public const FIFE_AND_DRUM = 'FIFE_AND_DRUM';
-    public const CURRENT_MUSIC_GROUP = 'CURRENT_MUSIC_GROUP';
-    public const CHAMBER_MUSIC_ENSEMBLE = 'CHAMBER_MUSIC_ENSEMBLE';
-    public const TRADITIONAL_MUSIC_ENSEMBLE = 'TRADITIONAL_MUSIC_ENSEMBLE';
-    public const VARIOUS_ORCHESTRA = 'VARIOUS_ORCHESTRA';
-    public const ACCORDION_ORCHESTRA = 'ACCORDION_ORCHESTRA';
-    public const HARMONY_ORCHESTRA = 'HARMONY_ORCHESTRA';
-    public const FANFARE_BAND = 'FANFARE_BAND';
-    public const SYMPHONY_ORCHESTRA = 'SYMPHONY_ORCHESTRA';
-    public const VIOLIN_BAND = 'VIOLIN_BAND';
-    public const SAXOPHONES_BAND = 'SAXOPHONES_BAND';
-    public const HUNTING_HORNS = 'HUNTING_HORNS';
-    public const STRING_ORCHESTRA = 'STRING_ORCHESTRA';
-    public const FLUTE_ENSEMBLE = 'FLUTE_ENSEMBLE';
-    public const CLARINET_CHOIR = 'CLARINET_CHOIR';
-    public const PHILHARMONIC_ORCHESTRA = 'PHILHARMONIC_ORCHESTRA';
-    public const BANDAS = 'BANDAS';
-    public const BAGAD = 'BAGAD';
-    public const BATTUCADA = 'BATTUCADA';
-    public const MARCHING_BAND = 'MARCHING_BAND';
-    public const EDUCATION = 'EDUCATION';
-    public const MAJORETTE_AND_TWIRLING = 'MAJORETTE_AND_TWIRLING';
-    public const TROOP = 'TROOP';
-    public const OTHER_TYPE = 'OTHER_TYPE';
-    public const COMPANIES = 'COMPANIES';
-    public const TAP_DANCE = 'TAP_DANCE';
-    public const CLASSICAL_DANCE = 'CLASSICAL_DANCE';
-    public const CONTEMPORARY_DANCE = 'CONTEMPORARY_DANCE';
-    public const BALLROOM_DANCE = 'BALLROOM_DANCE';
-    public const JAZZ_DANCE = 'JAZZ_DANCE';
-    public const FOLK_DANCE = 'FOLK_DANCE';
-    public const BREAK_DANCING = 'BREAK_DANCING';
-    public const LATIN_DANCE = 'LATIN_DANCE';
-    public const ART_TEACHING = 'ART_TEACHING';
-    public const CIRCUS_TRAINING = 'CIRCUS_TRAINING';
-    public const DANCE_LESSONS = 'DANCE_LESSONS';
-    public const MUSIC_TEACHING = 'MUSIC_TEACHING';
-    public const DRAMATIC_ARTS = 'DRAMATIC_ARTS';
+    case BATTERY_FANFARE = 'BATTERY_FANFARE';
+    case BIG_BAND = 'BIG_BAND';
+    case BRASS_BAND = 'BRASS_BAND';
+    case MIXED_CHORUS = 'MIXED_CHORUS';
+    case FEMAL_CHOIR = 'FEMAL_CHOIR';
+    case MENS_CHOIR = 'MENS_CHOIR';
+    case CHILDRENS_CHOIR = 'CHILDRENS_CHOIR';
+    case ORCHESTRA_CLASS = 'ORCHESTRA_CLASS'; // Non
+    case COPPER_BAND = 'COPPER_BAND';
+    case JAZZ_BAND = 'JAZZ_BAND';
+    case PERCUSSION_BAND = 'PERCUSSION_BAND';
+    case PLUCKED_ORCHESTRA = 'PLUCKED_ORCHESTRA'; // Done
+    case FOLKLORIC_BAND = 'FOLKLORIC_BAND';
+    case VOCAL_BAND_UP_16 = 'VOCAL_BAND_UP_16';
+    case FIFE_AND_DRUM = 'FIFE_AND_DRUM';
+    case CURRENT_MUSIC_GROUP = 'CURRENT_MUSIC_GROUP';
+    case CHAMBER_MUSIC_ENSEMBLE = 'CHAMBER_MUSIC_ENSEMBLE';
+    case TRADITIONAL_MUSIC_ENSEMBLE = 'TRADITIONAL_MUSIC_ENSEMBLE';
+    case VARIOUS_ORCHESTRA = 'VARIOUS_ORCHESTRA';
+    case ACCORDION_ORCHESTRA = 'ACCORDION_ORCHESTRA';
+    case HARMONY_ORCHESTRA = 'HARMONY_ORCHESTRA';
+    case FANFARE_BAND = 'FANFARE_BAND';
+    case SYMPHONY_ORCHESTRA = 'SYMPHONY_ORCHESTRA';
+    case VIOLIN_BAND = 'VIOLIN_BAND';
+    case SAXOPHONES_BAND = 'SAXOPHONES_BAND';
+    case HUNTING_HORNS = 'HUNTING_HORNS';
+    case STRING_ORCHESTRA = 'STRING_ORCHESTRA';
+    case FLUTE_ENSEMBLE = 'FLUTE_ENSEMBLE';
+    case CLARINET_CHOIR = 'CLARINET_CHOIR';
+    case PHILHARMONIC_ORCHESTRA = 'PHILHARMONIC_ORCHESTRA';
+    case BANDAS = 'BANDAS';
+    case BAGAD = 'BAGAD';
+    case BATTUCADA = 'BATTUCADA';
+    case MARCHING_BAND = 'MARCHING_BAND';
+    case EDUCATION = 'EDUCATION';
+    case MAJORETTE_AND_TWIRLING = 'MAJORETTE_AND_TWIRLING';
+    case TROOP = 'TROOP';
+    case OTHER_TYPE = 'OTHER_TYPE';
+    case COMPANIES = 'COMPANIES';
+    case TAP_DANCE = 'TAP_DANCE';
+    case CLASSICAL_DANCE = 'CLASSICAL_DANCE';
+    case CONTEMPORARY_DANCE = 'CONTEMPORARY_DANCE';
+    case BALLROOM_DANCE = 'BALLROOM_DANCE';
+    case JAZZ_DANCE = 'JAZZ_DANCE';
+    case FOLK_DANCE = 'FOLK_DANCE';
+    case BREAK_DANCING = 'BREAK_DANCING';
+    case LATIN_DANCE = 'LATIN_DANCE';
+    case ART_TEACHING = 'ART_TEACHING';
+    case CIRCUS_TRAINING = 'CIRCUS_TRAINING';
+    case DANCE_LESSONS = 'DANCE_LESSONS';
+    case MUSIC_TEACHING = 'MUSIC_TEACHING';
+    case DRAMATIC_ARTS = 'DRAMATIC_ARTS';
 }

+ 1 - 0
src/Enum/Organization/OrganizationIdsEnum.php

@@ -17,4 +17,5 @@ enum OrganizationIdsEnum: int
     case _2IOS = 32366;
     case FFEC = 91295;
     case OPENTALENT_BASE = 13;
+    case OUTOFNET_PARENT = 93931;
 }

+ 1 - 1
src/Message/Handler/ExportHandler.php

@@ -4,7 +4,7 @@ declare(strict_types=1);
 
 namespace App\Message\Handler;
 
-use App\Message\Command\Export;
+use App\Message\Message\Export;
 use App\Repository\Access\AccessRepository;
 use App\Service\MercureHub;
 use App\Service\Notifier;

+ 1 - 1
src/Message/Handler/MailerHandler.php

@@ -5,7 +5,7 @@ declare(strict_types=1);
 namespace App\Message\Handler;
 
 use App\Entity\Message\Email;
-use App\Message\Command\MailerCommand;
+use App\Message\Message\MailerCommand;
 use App\Repository\Access\AccessRepository;
 use App\Service\Mailer\Mailer;
 use App\Service\Notifier;

+ 56 - 0
src/Message/Handler/OrganizationCreationHandler.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Handler;
+
+use App\Message\Message\OrganizationCreation;
+use App\Service\Organization\OrganizationFactory;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Email as SymfonyEmail;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+
+#[AsMessageHandler(priority: 1)]
+class OrganizationCreationHandler
+{
+    public function __construct(
+        private readonly OrganizationFactory $organizationFactory,
+        private readonly MailerInterface $symfonyMailer,
+    ) {
+    }
+
+    /**
+     * @throws \Throwable
+     * @throws TransportExceptionInterface
+     * @throws \Symfony\Component\Mailer\Exception\TransportExceptionInterface
+     */
+    public function __invoke(OrganizationCreation $organizationCreationCommand): void
+    {
+        $organizationCreationRequest = $organizationCreationCommand->getOrganizationCreationRequest();
+        $mail = ['subject' => '', 'content' => ''];
+
+        try {
+            $organization = $this->organizationFactory->create($organizationCreationRequest);
+
+            $mail['subject'] = 'New organization created';
+            $mail['content'] = 'The organization "'.$organization->getName().'" has been created successfully.';
+        } catch (\Exception $e) {
+            $mail['subject'] = 'Organization creation : an error occured';
+            $mail['content'] = 'An error occured while creating the new organization : \n'.$e->getMessage();
+            throw $e;
+        } finally {
+            if ($organizationCreationRequest->getSendConfirmationEmailAt() !== null) {
+                $symfonyMail = (new SymfonyEmail())
+                    ->from('mail.report@opentalent.fr')
+                    ->replyTo('mail.report@opentalent.fr')
+                    ->returnPath(Address::create('mail.report@opentalent.fr'))
+                    ->to($organizationCreationRequest->getSendConfirmationEmailAt())
+                    ->subject($mail['subject'])
+                    ->text($mail['content']);
+                $this->symfonyMailer->send($symfonyMail);
+            }
+        }
+    }
+}

+ 60 - 0
src/Message/Handler/OrganizationDeletionHandler.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Handler;
+
+use App\Message\Message\OrganizationCreation;
+use App\Message\Message\OrganizationDeletion;
+use App\Service\Organization\OrganizationFactory;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Email as SymfonyEmail;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Throwable;
+
+#[AsMessageHandler(priority: 1)]
+class OrganizationDeletionHandler
+{
+    public function __construct(
+        private readonly OrganizationFactory $organizationFactory,
+        private readonly MailerInterface $symfonyMailer,
+        private readonly string $opentalentMailReport
+    ) {}
+
+    /**
+     * @throws Throwable
+     * @throws TransportExceptionInterface
+     * @throws \Symfony\Component\Mailer\Exception\TransportExceptionInterface
+     */
+    public function __invoke(OrganizationDeletion $organizationDeletionCommand): void
+    {
+        $organizationCreationRequest = $organizationDeletionCommand->getOrganizationDeletionRequest();
+        $mail = ['subject' => '', 'content' => ''];
+
+        try {
+            $this->organizationFactory->delete($organizationCreationRequest);
+
+            $mail['subject'] = 'Organization deleted';
+            $mail['content'] = 'The organization n° ' . $organizationCreationRequest->getOrganizationId() . ' has been deleted successfully.';
+
+        } catch (\Exception $e) {
+            $mail['subject'] = 'Organization deletion : an error occured';
+            $mail['content'] = 'An error occured while deleting the new organization : \n' . $e->getMessage();
+            throw $e;
+
+        } finally {
+            if ($organizationCreationRequest->getSendConfirmationEmailAt() !== null) {
+                $symfonyMail = (new SymfonyEmail())
+                    ->from($this->opentalentMailReport)
+                    ->replyTo($this->opentalentMailReport)
+                    ->returnPath(Address::create($this->opentalentMailReport))
+                    ->to($organizationCreationRequest->getSendConfirmationEmailAt())
+                    ->subject($mail['subject'])
+                    ->text($mail['content']);
+                $this->symfonyMailer->send($symfonyMail);
+            }
+        }
+    }
+}

+ 3 - 3
src/Message/Handler/Typo3/Typo3DeleteCommandHandler.php → src/Message/Handler/Typo3/Typo3DeleteHandler.php

@@ -2,12 +2,12 @@
 
 namespace App\Message\Handler\Typo3;
 
-use App\Message\Command\Typo3\Typo3DeleteCommand;
+use App\Message\Message\Typo3\Typo3Delete;
 use App\Service\Typo3\Typo3Service;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 
 #[AsMessageHandler(priority: 1)]
-class Typo3DeleteCommandHandler
+class Typo3DeleteHandler
 {
     public function __construct(
         private Typo3Service $typo3Service,
@@ -17,7 +17,7 @@ class Typo3DeleteCommandHandler
     /**
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      */
-    public function __invoke(Typo3DeleteCommand $command): void
+    public function __invoke(Typo3Delete $command): void
     {
         $this->typo3Service->deleteSite($command->getOrganizationId());
     }

+ 3 - 3
src/Message/Handler/Typo3/Typo3UndeleteCommandHandler.php → src/Message/Handler/Typo3/Typo3UndeleteHandler.php

@@ -2,12 +2,12 @@
 
 namespace App\Message\Handler\Typo3;
 
-use App\Message\Command\Typo3\Typo3UndeleteCommand;
+use App\Message\Message\Typo3\Typo3Undelete;
 use App\Service\Typo3\Typo3Service;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 
 #[AsMessageHandler(priority: 1)]
-class Typo3UndeleteCommandHandler
+class Typo3UndeleteHandler
 {
     public function __construct(
         private Typo3Service $typo3Service,
@@ -17,7 +17,7 @@ class Typo3UndeleteCommandHandler
     /**
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      */
-    public function __invoke(Typo3UndeleteCommand $command): void
+    public function __invoke(Typo3Undelete $command): void
     {
         $this->typo3Service->undeleteSite($command->getOrganizationId());
     }

+ 3 - 3
src/Message/Handler/Typo3/Typo3UpdateCommandHandler.php → src/Message/Handler/Typo3/Typo3UpdateHandler.php

@@ -2,12 +2,12 @@
 
 namespace App\Message\Handler\Typo3;
 
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Service\Typo3\Typo3Service;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 
 #[AsMessageHandler(priority: 1)]
-class Typo3UpdateCommandHandler
+class Typo3UpdateHandler
 {
     public function __construct(
         private Typo3Service $typo3Service,
@@ -17,7 +17,7 @@ class Typo3UpdateCommandHandler
     /**
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      */
-    public function __invoke(Typo3UpdateCommand $command): void
+    public function __invoke(Typo3Update $command): void
     {
         $this->typo3Service->updateSite($command->getOrganizationId());
     }

+ 1 - 1
src/Message/Command/Export.php → src/Message/Message/Export.php

@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Message\Command;
+namespace App\Message\Message;
 
 use App\ApiResources\Export\ExportRequest;
 

+ 1 - 1
src/Message/Command/MailerCommand.php → src/Message/Message/MailerCommand.php

@@ -2,7 +2,7 @@
 
 declare(strict_types=1);
 
-namespace App\Message\Command;
+namespace App\Message\Message;
 
 use App\Service\Mailer\Model\MailerModelInterface;
 

+ 28 - 0
src/Message/Message/OrganizationCreation.php

@@ -0,0 +1,28 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Message;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+
+/**
+ * Transmission d'une requête de création d'organisation au service dédié.
+ */
+class OrganizationCreation
+{
+    public function __construct(
+        private OrganizationCreationRequest $organizationCreationRequest,
+    ) {
+    }
+
+    public function getOrganizationCreationRequest(): OrganizationCreationRequest
+    {
+        return $this->organizationCreationRequest;
+    }
+
+    public function setOrganizationCreationRequest(OrganizationCreationRequest $organizationCreationRequest): void
+    {
+        $this->organizationCreationRequest = $organizationCreationRequest;
+    }
+}

+ 29 - 0
src/Message/Message/OrganizationDeletion.php

@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Message;
+
+use App\ApiResources\Organization\OrganizationDeletionRequest;
+
+/**
+ * Transmission d'une requête de création d'organisation au service dédié.
+ */
+class OrganizationDeletion
+{
+    public function __construct(
+        private OrganizationDeletionRequest $organizationDeletionRequest
+    ) {
+    }
+
+    public function getOrganizationDeletionRequest(): OrganizationDeletionRequest
+    {
+        return $this->organizationDeletionRequest;
+    }
+
+    public function setOrganizationDeletionRequest(OrganizationDeletionRequest $organizationDeletionRequest): self
+    {
+        $this->organizationDeletionRequest = $organizationDeletionRequest;
+        return $this;
+    }
+}

+ 2 - 2
src/Message/Command/Typo3/Typo3DeleteCommand.php → src/Message/Message/Typo3/Typo3Delete.php

@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace App\Message\Command\Typo3;
+namespace App\Message\Message\Typo3;
 
 /**
  * Envoi d'une requête Delete à l'api Typo3.
  */
-class Typo3DeleteCommand
+class Typo3Delete
 {
     public function __construct(
         private int $organizationId,

+ 2 - 2
src/Message/Command/Typo3/Typo3UndeleteCommand.php → src/Message/Message/Typo3/Typo3Undelete.php

@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace App\Message\Command\Typo3;
+namespace App\Message\Message\Typo3;
 
 /**
  * Envoi d'une requête Undelete à l'api Typo3.
  */
-class Typo3UndeleteCommand
+class Typo3Undelete
 {
     public function __construct(
         private int $organizationId,

+ 2 - 2
src/Message/Command/Typo3/Typo3UpdateCommand.php → src/Message/Message/Typo3/Typo3Update.php

@@ -2,12 +2,12 @@
 
 declare(strict_types=1);
 
-namespace App\Message\Command\Typo3;
+namespace App\Message\Message\Typo3;
 
 /**
  * Envoi d'une requête Update à l'api Typo3.
  */
-class Typo3UpdateCommand
+class Typo3Update
 {
     public function __construct(
         private int $organizationId,

+ 20 - 0
src/Repository/Core/FileRepository.php

@@ -14,4 +14,24 @@ class FileRepository extends ServiceEntityRepository
     {
         parent::__construct($registry, File::class);
     }
+
+    public function deleteByOrganization(int $organizationId): void
+    {
+        $this->createQueryBuilder('f')
+            ->delete()
+            ->where('f.organization = :organizationId')
+            ->setParameter('organizationId', $organizationId)
+            ->getQuery()
+            ->execute();
+    }
+
+    public function deleteByPerson(int $personId): void
+    {
+        $this->createQueryBuilder('f')
+            ->delete()
+            ->where('f.person = :personId')
+            ->setParameter('personId', $personId)
+            ->getQuery()
+            ->execute();
+    }
 }

+ 21 - 0
src/Repository/Organization/OrganizationIdentificationRepository.php

@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repository\Organization;
+
+use App\Entity\Organization\OrganizationIdentification;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+/**
+ * @method OrganizationIdentification|null find($id, $lockMode = null, $lockVersion = null)
+ * @method OrganizationIdentification|null findOneBy(array $criteria, array $orderBy = null)
+ */
+class OrganizationIdentificationRepository extends ServiceEntityRepository
+{
+    public function __construct(ManagerRegistry $registry)
+    {
+        parent::__construct($registry, OrganizationIdentification::class);
+    }
+}

+ 1 - 1
src/Repository/Person/PersonRepository.php

@@ -17,7 +17,7 @@ use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
  * @method Person[]    findAll()
  * @method Person[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
  */
-final class PersonRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
+class PersonRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
 {
     public function __construct(ManagerRegistry $registry)
     {

+ 1 - 1
src/Security/Voter/InternalRequestsVoter.php

@@ -37,6 +37,6 @@ class InternalRequestsVoter extends Voter
         $clientIp = $request->server->get('REMOTE_ADDR');
         $internalRequestsToken = $request->headers->get('internal-requests-token') ?? null;
 
-        return $internalRequestsToken && $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
+        return $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
     }
 }

+ 42 - 0
src/Service/Dolibarr/DolibarrApiService.php

@@ -4,9 +4,12 @@ declare(strict_types=1);
 
 namespace App\Service\Dolibarr;
 
+use App\Entity\Organization\Organization;
 use App\Service\Rest\ApiRequestService;
 use JetBrains\PhpStorm\Pure;
+use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 
 /**
@@ -178,4 +181,43 @@ class DolibarrApiService extends ApiRequestService
             throw $e;
         }
     }
+
+    /**
+     * Créé une société dans la DB dolibarr, et retourne l'id de celle-ci.
+     */
+    public function createSociety(Organization $organization, bool $client = false): mixed
+    {
+        $body = [
+            'name' => $organization->getName(),
+            'client' => $client ? 1 : 2,
+            'code_client' => -1,
+            'import_key' => 'crm',
+            'array_options' => ['options_2iopen_organization_id' => $organization->getId()],
+        ];
+
+        /** @var Response $response */
+        $response = $this->post('/thirdparties', $body);
+
+        return json_decode($response->getContent(), true);
+    }
+
+    /**
+     * Delete the organization from Dolibarr.
+     *
+     * @throws \JsonException
+     * @throws TransportExceptionInterface
+     */
+    public function switchSocietyToProspect(int $organizationId): void
+    {
+        $socId = $this->getSociety($organizationId)['id'];
+
+        $res = $this->put(
+            "thirdparties/$socId",
+            ['client' => 2],
+        );
+
+        if ($res->getStatusCode() !== 200) {
+            throw new HttpException($res->getStatusCode(), 'Error while updating the society in Dolibarr');
+        }
+    }
 }

+ 36 - 0
src/Service/File/FileManager.php

@@ -13,11 +13,13 @@ use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
+use App\Repository\Core\FileRepository;
 use App\Service\File\Exception\FileNotFoundException;
 use App\Service\File\Factory\ImageFactory;
 use App\Service\File\Storage\FileStorageInterface;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\ServiceIterator\StorageIterator;
+use Doctrine\ORM\EntityManagerInterface;
 
 /**
  * Le gestionnaire de fichiers permet d'effectuer de nombreuses opérations sur les fichiers stockés dans les différents
@@ -30,6 +32,8 @@ class FileManager
         protected readonly StorageIterator $storageIterator,
         protected readonly ImageFactory $imageFactory,
         protected readonly LocalStorage $localStorage,
+        protected readonly EntityManagerInterface $entityManager,
+        protected readonly FileRepository $fileRepository,
     ) {
     }
 
@@ -137,4 +141,36 @@ class FileManager
             ['fileId' => $file->getId()]
         );
     }
+
+    /**
+     * Permanently delete the organization's files from each storage, and remove any reference
+     * in the DB
+     *
+     * @param int $organizationId
+     * @return void
+     */
+    public function deleteOrganizationFiles(int $organizationId): void
+    {
+        foreach ($this->storageIterator->getStorages() as $storageService) {
+            $storageService->deleteOrganizationFiles($organizationId);
+        }
+
+        $this->fileRepository->deleteByOrganization($organizationId);
+    }
+
+    /**
+     * Permanently delete the person's files from each storage, and remove any reference
+     * * in the DB
+ *
+     * @param int $personId
+     * @return void
+     */
+    public function deletePersonFiles(int $personId): void
+    {
+        foreach ($this->storageIterator->getStorages() as $storageService) {
+            $storageService->deletePersonFiles($personId);
+        }
+
+        $this->fileRepository->deleteByPerson($personId);
+    }
 }

+ 24 - 0
src/Service/File/Storage/ApiLegacyStorage.php

@@ -56,4 +56,28 @@ class ApiLegacyStorage implements FileStorageInterface
     {
         return $file->getHost() === FileHostEnum::API1;
     }
+
+    /**
+     * Permanently delete the entire file storage of the given Organization
+     *
+     * @param int $organizationId
+     * @return void
+     */
+    public function deleteOrganizationFiles(int $organizationId): void
+    {
+        $url = sprintf('/_internal/request/organization-files/delete/%s', $organizationId);
+        $this->apiLegacyRequestService->get($url);
+    }
+
+    /**
+     * Permanently delete the entire file storage of the given Person
+     *
+     * @param int $personId
+     * @return void
+     */
+    public function deletePersonFiles(int $personId): void
+    {
+        $url = sprintf('/_internal/request/person-files/delete/%s', $personId);
+        $this->apiLegacyRequestService->get($url);
+    }
 }

+ 4 - 0
src/Service/File/Storage/FileStorageInterface.php

@@ -15,4 +15,8 @@ interface FileStorageInterface
     public function getImageUrl(File $file, string $size, bool $relativePath): string;
 
     public function support(File $file): bool;
+
+    public function deleteOrganizationFiles(int $organizationId): void;
+
+    public function deletePersonFiles(int $personId): void;
 }

+ 49 - 7
src/Service/File/Storage/LocalStorage.php

@@ -47,14 +47,15 @@ class LocalStorage implements FileStorageInterface
     protected FilesystemInterface $filesystem;
 
     public function __construct(
-        protected readonly FilesystemMap $filesystemMap,
+        protected readonly FilesystemMap          $filesystemMap,
         protected readonly EntityManagerInterface $entityManager,
-        protected readonly AccessRepository $accessRepository,
-        protected readonly DataManager $dataManager,
-        protected readonly CacheManager $cacheManager,
-        protected readonly ImageFactory $imageFactory,
-        protected readonly FileUtils $fileUtils,
-        protected readonly UrlBuilder $urlBuilder,
+        protected readonly AccessRepository       $accessRepository,
+        protected readonly DataManager            $dataManager,
+        protected readonly CacheManager           $cacheManager,
+        protected readonly ImageFactory           $imageFactory,
+        protected readonly FileUtils              $fileUtils,
+        protected readonly UrlBuilder             $urlBuilder,
+        protected readonly string                 $fileStorageDir
     ) {
         $this->filesystem = $filesystemMap->get(static::FS_KEY);
     }
@@ -294,6 +295,47 @@ class LocalStorage implements FileStorageInterface
         }
     }
 
+    /**
+     * Permanently delete the entire file storage of the given Organization
+     *
+     * @param int $organizationId
+     * @return void
+     */
+    public function deleteOrganizationFiles(int $organizationId): void
+    {
+        $this->rrmDir('organization/' . $organizationId);
+        $this->rrmDir('temp/organization/' . $organizationId);
+    }
+
+    /**
+     * Permanently delete the entire file storage of the given Person
+     *
+     * @param int $personId
+     * @return void
+     */
+    public function deletePersonFiles(int $personId): void
+    {
+        $this->rrmDir('person/' . $personId);
+        $this->rrmDir('temp/person/' . $personId);
+    }
+
+    /**
+     * Supprime récursivement un répertoire
+     *
+     * (Au moment du développement, Gaufrette ne permet pas la suppression de répertoire, on laissera
+     * le soin à un cron de supprimer les répertoires vides du storage)
+     *
+     * @param string $dirKey
+     * @return void
+     */
+    protected function rrmDir(string $dirKey): void {
+        if (!$this->filesystem->isDirectory($dirKey)) {
+            throw new \RuntimeException('Directory `'.$dirKey.'` does not exist');
+        }
+        $dir = Path::join($this->fileStorageDir, $dirKey);
+        Path::rmtree($dir);
+    }
+
     /**
      * If an organization owns the file, the prefix will be '(_temp_/)organization/{id}(/{type})'.
      * If a person owns it, the prefix will be '(_temp_/)person/{id}(/{type})'

+ 7 - 7
src/Service/OnChange/Organization/OnParametersChange.php

@@ -8,9 +8,9 @@ use App\Entity\Booking\Course;
 use App\Entity\Education\EducationNotationConfig;
 use App\Entity\Organization\Parameters;
 use App\Enum\Education\AdvancedEducationNotationTypeEnum;
-use App\Message\Command\Typo3\Typo3DeleteCommand;
-use App\Message\Command\Typo3\Typo3UndeleteCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\Typo3\Typo3Delete;
+use App\Message\Message\Typo3\Typo3Undelete;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Repository\Booking\CourseRepository;
 use App\Service\Network\Utils as NetworkUtils;
 use App\Service\OnChange\OnChangeContext;
@@ -86,7 +86,7 @@ class OnParametersChange extends OnChangeDefault
             && $context->previousData()->getCustomDomain() !== $parameters->getCustomDomain()
         ) {
             $this->messageBus->dispatch(
-                new Typo3UpdateCommand($parameters->getOrganization()->getId())
+                new Typo3Update($parameters->getOrganization()->getId())
             );
         }
 
@@ -97,14 +97,14 @@ class OnParametersChange extends OnChangeDefault
         ) {
             if ($parameters->getDesactivateOpentalentSiteWeb()) {
                 $this->messageBus->dispatch(
-                    new Typo3DeleteCommand($parameters->getOrganization()->getId())
+                    new Typo3Delete($parameters->getOrganization()->getId())
                 );
             } else {
                 $this->messageBus->dispatch(
-                    new Typo3UndeleteCommand($parameters->getOrganization()->getId())
+                    new Typo3Undelete($parameters->getOrganization()->getId())
                 );
                 $this->messageBus->dispatch(
-                    new Typo3UpdateCommand($parameters->getOrganization()->getId())
+                    new Typo3Update($parameters->getOrganization()->getId())
                 );
             }
         }

+ 933 - 0
src/Service/Organization/OrganizationFactory.php

@@ -0,0 +1,933 @@
+<?php
+
+namespace App\Service\Organization;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Organization\OrganizationDeletionRequest;
+use App\ApiResources\Organization\OrganizationMemberCreationRequest;
+use App\Entity\Access\Access;
+use App\Entity\Access\OrganizationFunction;
+use App\Entity\Core\AddressPostal;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Education\Cycle;
+use App\Entity\Network\NetworkOrganization;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\OrganizationAddressPostal;
+use App\Entity\Organization\Parameters;
+use App\Entity\Organization\Settings;
+use App\Entity\Organization\Subdomain;
+use App\Entity\Person\Person;
+use App\Entity\Person\PersonAddressPostal;
+use App\Enum\Access\FunctionEnum;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Education\CycleEnum;
+use App\Enum\Network\NetworkEnum;
+use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Enum\Person\AddressPostalPersonTypeEnum;
+use App\Repository\Access\FunctionTypeRepository;
+use App\Repository\Core\CountryRepository;
+use App\Repository\Organization\OrganizationIdentificationRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Repository\Person\PersonRepository;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\File\FileManager;
+use App\Service\Organization\Utils as OrganizationUtils;
+use App\Service\Typo3\BindFileService;
+use App\Service\Typo3\SubdomainService;
+use App\Service\Typo3\Typo3Service;
+use App\Service\Utils\DatesUtils;
+use App\Service\Utils\UrlBuilder;
+use App\Service\Utils\SecurityUtils;
+use Doctrine\ORM\EntityManagerInterface;
+use Elastica\Param;
+use libphonenumber\NumberParseException;
+use libphonenumber\PhoneNumberUtil;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\String\ByteString;
+use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\Service\Attribute\Required;
+
+class OrganizationFactory
+{
+    private LoggerInterface $logger;
+
+    protected PhoneNumberUtil $phoneNumberUtil;
+
+    public function __construct(
+        private readonly SubdomainService $subdomainService,
+        private readonly OrganizationRepository $organizationRepository,
+        private readonly CountryRepository $countryRepository,
+        private readonly OrganizationUtils $organizationUtils,
+        private readonly Typo3Service $typo3Service,
+        private readonly DolibarrApiService $dolibarrApiService,
+        private readonly EntityManagerInterface $entityManager,
+        private readonly PersonRepository $personRepository,
+        private readonly BindFileService $bindFileService,
+        private readonly OrganizationIdentificationRepository $organizationIdentificationRepository,
+        private readonly ApiLegacyRequestService $apiLegacyRequestService,
+        private readonly FunctionTypeRepository $functionTypeRepository,
+    ) {
+        $this->phoneNumberUtil = PhoneNumberUtil::getInstance();
+    }
+
+    #[Required]
+    /** @see https://symfony.com/doc/current/logging/channels_handlers.html#how-to-autowire-logger-channels */
+    public function setLoggerInterface(LoggerInterface $adminLogger): void
+    {
+        $this->logger = $adminLogger;
+    }
+
+    /**
+     * Créé une nouvelle organisation à partir des données contenues dans une OrganizationCreationRequest.
+     *
+     * @throws TransportExceptionInterface
+     * @throws \Throwable
+     */
+    public function create(OrganizationCreationRequest $organizationCreationRequest): Organization
+    {
+        $this->logger->info(
+            "Start the creation of a new organization named '".$organizationCreationRequest->getName()."'"
+        );
+
+        $this->entityManager->beginTransaction();
+
+        try {
+            // On vérifie si cette organisation n'existe pas déjà
+            $this->interruptIfOrganizationExists($organizationCreationRequest);
+
+            // On vérifie la validité et la disponibilité du sous domaine
+            $this->validateSubdomain($organizationCreationRequest->getSubdomain());
+            $this->logger->info("Subdomain is valid and available : '".$organizationCreationRequest->getSubdomain()."'");
+
+            // On construit l'organisation et ses relations
+            $organization = $this->makeOrganizationWithRelations($organizationCreationRequest);
+            $this->logger->info('Organization created with all its relations');
+
+            // On persiste et on commit, les objets liés seront persistés en cascade
+            $this->entityManager->persist($organization);
+
+            $this->entityManager->flush();
+
+            $this->entityManager->commit();
+            $this->logger->debug(' - New entities committed in DB');
+
+            $this->logger->info('Organization persisted in the DB');
+        } catch (\Throwable $e) {
+            $this->logger->critical("An error happened, operation cancelled\n".$e);
+            $this->entityManager->rollback();
+            throw $e;
+        }
+
+        $withError = false;
+
+        // Création de la société Dolibarr
+        try {
+            $dolibarrId = $this->dolibarrApiService->createSociety(
+                $organization,
+                $organizationCreationRequest->isClient()
+            );
+            $this->logger->info('New dolibarr structure created (uid : '.$dolibarrId.')');
+        } catch (\Throwable $e) {
+            $this->logger->critical('An error happened while creating the dolibarr society, please proceed manually.');
+            $this->logger->debug($e);
+            $withError = true;
+        }
+
+        // Register the subdomain into the BindFile (takes up to 5min to take effect)
+        try {
+            $this->bindFileService->registerSubdomain($organizationCreationRequest->getSubdomain());
+            $this->logger->info('Subdomain registered');
+        } catch (\Throwable $e) {
+            $this->logger->critical('An error happened while updating the bind file, please proceed manually.');
+            $this->logger->debug($e);
+            $withError = true;
+        }
+
+        // Création du site typo3 (on est obligé d'attendre que l'organisation soit persistée en base)
+        if ($organizationCreationRequest->getCreateWebsite()) {
+            try {
+                $rootUid = $this->createTypo3Website($organization);
+                $this->logger->info('Typo3 website created (root uid: '.$rootUid.')');
+            } catch (\Throwable $e) {
+                $this->logger->critical('An error happened while creating the typo3 website, please proceed manually.');
+                $this->logger->debug($e);
+                $withError = true;
+            }
+        } else {
+            $this->logger->warning('Typo3 website creation was not required');
+        }
+
+        // Création de l'organisation dans la base adminassos (géré par la V1)
+        try {
+            $this->updateAdminassosDb($organization);
+            $this->logger->info('Adminassos db updated');
+        } catch (\Throwable $e) {
+            $this->logger->critical('An error happened while updating the adminassos db, please proceed manually.');
+            $this->logger->debug($e);
+            $withError = true;
+        }
+
+        if ($withError) {
+            $organizationCreationRequest->setStatus(OrganizationCreationRequest::STATUS_OK_WITH_ERRORS);
+            $this->logger->warning('-- Operation ended with errors, check the logs for more information --');
+        } else {
+            $organizationCreationRequest->setStatus(OrganizationCreationRequest::STATUS_OK);
+        }
+
+        return $organization;
+    }
+
+    /**
+     * Lève une exception si cette organisation existe déjà.
+     */
+    protected function interruptIfOrganizationExists(OrganizationCreationRequest $organizationCreationRequest): void
+    {
+        if (
+            $organizationCreationRequest->getSiretNumber()
+            && $this->organizationIdentificationRepository->findOneBy(
+                ['siretNumber' => $organizationCreationRequest->getSiretNumber()]
+            )
+        ) {
+            throw new \RuntimeException("This siret number is already registered : '".$organizationCreationRequest->getSiretNumber()."'");
+        }
+
+        if (
+            $organizationCreationRequest->getWaldecNumber()
+            && $this->organizationIdentificationRepository->findOneBy(
+                ['waldecNumber' => $organizationCreationRequest->getWaldecNumber()]
+            )
+        ) {
+            throw new \RuntimeException("This RNA identifier (waldec number) is already registered : '".$organizationCreationRequest->getWaldecNumber()."'");
+        }
+
+        if (
+            $organizationCreationRequest->getIdentifier()
+            && $this->organizationIdentificationRepository->findOneBy(
+                ['identifier' => $organizationCreationRequest->getIdentifier()]
+            )
+        ) {
+            throw new \RuntimeException("This CMF identifier is already registered : '".$organizationCreationRequest->getIdentifier()."'");
+        }
+
+        $normalizedName = $this->normalizeIdentificationField($organizationCreationRequest->getName());
+
+        if (
+            $this->organizationIdentificationRepository->findOneBy(
+                ['normalizedName' => $normalizedName, 'addressCity' => $organizationCreationRequest->getCity()]
+            )
+        ) {
+            throw new \RuntimeException("An organization named '".$organizationCreationRequest->getName()."' already exists in ".$organizationCreationRequest->getCity());
+        }
+
+        $address = $this->normalizeIdentificationField(implode(' ', [
+            $organizationCreationRequest->getStreetAddress1(),
+            $organizationCreationRequest->getStreetAddress2(),
+            $organizationCreationRequest->getStreetAddress3(),
+        ]));
+
+        if (
+            $this->organizationIdentificationRepository->findOneBy(
+                [
+                    'normalizedAddress' => $address,
+                    'addressCity' => $organizationCreationRequest->getCity(),
+                    'postalCode' => $organizationCreationRequest->getPostalCode(),
+                ]
+            )
+        ) {
+            throw new \RuntimeException('An organization already exists at this address.');
+        }
+    }
+
+    /**
+     * Vérifie la disponibilité et la validité d'un sous domaine.
+     *
+     * @throws \Exception
+     */
+    protected function validateSubdomain(string $subdomainValue): void
+    {
+        if (!$this->subdomainService->isValidSubdomain($subdomainValue)) {
+            throw new \RuntimeException('Not a valid subdomain : '.$subdomainValue);
+        }
+
+        if ($this->subdomainService->isReservedSubdomain($subdomainValue)) {
+            throw new \RuntimeException('This subdomain is not available : '.$subdomainValue);
+        }
+
+        if ($this->subdomainService->isRegistered($subdomainValue)) {
+            throw new \RuntimeException('This subdomain is already registered : '.$subdomainValue);
+        }
+    }
+
+    /**
+     * Créé une nouvelle instance d'organisation, et toutes les instances liées (paramètres, contact, adresses, ...),
+     * selon le contenu de la requête de création.
+     *
+     * @throws \Throwable
+     */
+    protected function makeOrganizationWithRelations(
+        OrganizationCreationRequest $organizationCreationRequest,
+    ): Organization {
+        // Création de l'organisation
+        $organization = $this->makeOrganization($organizationCreationRequest);
+        $this->logger->debug(' - Organization created');
+
+        // Création des Parameters
+        $parameters = $this->makeParameters($organizationCreationRequest);
+        $organization->setParameters($parameters);
+        $this->logger->debug(' - Parameters created');
+
+        // Création des Settings
+        $settings = $this->makeSettings($organizationCreationRequest);
+        $organization->setSettings($settings);
+        $this->logger->debug(' - Settings created');
+
+        // Création de l'adresse postale
+        $organizationAddressPostal = $this->makePostalAddress($organizationCreationRequest);
+        $organization->addOrganizationAddressPostal($organizationAddressPostal);
+        $this->logger->debug(' - OrganizationAddressPostal created');
+
+        // Création du point de contact
+        $contactPoint = $this->makeContactPoint($organizationCreationRequest);
+        $organization->addContactPoint($contactPoint);
+        $this->logger->debug(' - ContactPoint created');
+
+        // Rattachement au réseau
+        $networkOrganization = $this->makeNetworkOrganization($organizationCreationRequest);
+        $organization->addNetworkOrganization($networkOrganization);
+        $this->logger->debug(' - NetworkOrganization created');
+
+        // Créé l'admin
+        $adminAccess = $this->makeAdminAccess($organizationCreationRequest);
+        $organization->addAccess($adminAccess);
+        $this->logger->debug(' - Admin access created');
+
+        // Création des cycles
+        foreach ($this->makeCycles() as $cycle) {
+            $organization->addCycle($cycle);
+        }
+        $this->logger->debug(' - Cycles created');
+
+        // Création du président (si renseigné)
+        $presidentCreationRequest = $organizationCreationRequest->getPresident();
+        if ($presidentCreationRequest !== null) {
+            $presidentAccess = $this->makeAccess(
+                $presidentCreationRequest,
+                FunctionEnum::PRESIDENT,
+                $organizationCreationRequest->getCreationDate(),
+                $organizationCreationRequest->getAuthorId()
+            );
+
+            $organization->addAccess($presidentAccess);
+            $this->logger->debug(' - President access created');
+        }
+
+        // Création du directeur (si renseigné)
+        $directorCreationRequest = $organizationCreationRequest->getDirector();
+        if ($directorCreationRequest !== null) {
+            $directorAccess = $this->makeAccess(
+                $directorCreationRequest,
+                FunctionEnum::DIRECTOR,
+                $organizationCreationRequest->getCreationDate(),
+                $organizationCreationRequest->getAuthorId()
+            );
+
+            $organization->addAccess($directorAccess);
+            $this->logger->debug(' - Director access created');
+        }
+
+        // Création du sous-domaine
+        $subdomain = $this->makeSubdomain($organizationCreationRequest);
+        $organization->addSubdomain($subdomain);
+
+        // <--- Pour la rétrocompatibilité avec la v1 ; pourra être supprimé lorsque la migration sera achevée
+        $parameters = $organization->getParameters();
+        $parameters->setSubDomain($organizationCreationRequest->getSubdomain());
+        $parameters->setOtherWebsite('https://'.$organizationCreationRequest->getSubdomain().'.opentalent.fr');
+        // --->
+        $this->logger->debug(' - Subdomain created');
+
+        return $organization;
+    }
+
+    /**
+     * Créé une nouvelle instance d'organisation.
+     */
+    protected function makeOrganization(OrganizationCreationRequest $organizationCreationRequest): Organization
+    {
+        // Création de l'organisation
+        $organization = new Organization();
+        $organization->setName($organizationCreationRequest->getName());
+        $organization->setLegalStatus($organizationCreationRequest->getLegalStatus());
+        $organization->setPrincipalType($organizationCreationRequest->getPrincipalType());
+        $organization->setIdentifier($organizationCreationRequest->getIdentifier());
+        $organization->setCreationDate($organizationCreationRequest->getCreationDate());
+        $organization->setCreateDate($organizationCreationRequest->getCreationDate());
+        $organization->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        return $organization;
+    }
+
+    /**
+     * Create a new Parameters object from the data in an OrganizationCreationRequest.
+     *
+     * @param OrganizationCreationRequest $organizationCreationRequest The organization creation request
+     *
+     * @return Parameters The created Parameters object
+     *
+     * @throws \Throwable If there is an error
+     */
+    protected function makeParameters(OrganizationCreationRequest $organizationCreationRequest): Parameters
+    {
+        $parameters = new Parameters();
+
+        return $parameters;
+    }
+
+    /**
+     * Creates a new instance of the Settings class based on the given OrganizationCreationRequest object.
+     *
+     * @param OrganizationCreationRequest $organizationCreationRequest the OrganizationCreationRequest object containing the required data
+     *
+     * @return Settings the newly created instance of the Settings class
+     */
+    protected function makeSettings(OrganizationCreationRequest $organizationCreationRequest): Settings
+    {
+        $settings = new Settings();
+        $settings->setProduct($organizationCreationRequest->getProduct());
+
+        // TODO: à revoir, pour étendre à d'autres pays (voir à remplacer le champs 'country' par un champs 'currency'?)
+        $settings->setCountry(
+            $organizationCreationRequest->getCountryId() === 41 ? 'SWITZERLAND' : 'FRANCE'
+        );
+
+        $settings->setCreateDate($organizationCreationRequest->getCreationDate());
+        $settings->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        return $settings;
+    }
+
+    /**
+     * Creates a new instance of the OrganizationAddressPostal class based on the given OrganizationCreationRequest object.
+     *
+     * @param OrganizationCreationRequest $organizationCreationRequest the OrganizationCreationRequest object containing the required data
+     *
+     * @return OrganizationAddressPostal the newly created instance of the OrganizationAddressPostal class
+     */
+    protected function makePostalAddress(OrganizationCreationRequest $organizationCreationRequest): OrganizationAddressPostal
+    {
+        $country = $this->countryRepository->find($organizationCreationRequest->getCountryId());
+        if (!$country) {
+            throw new \RuntimeException('No country found for id '.$organizationCreationRequest->getCountryId());
+        }
+
+        $addressPostal = new AddressPostal();
+        $addressPostal->setStreetAddress($organizationCreationRequest->getStreetAddress1());
+        $addressPostal->setStreetAddressSecond($organizationCreationRequest->getStreetAddress2());
+        $addressPostal->setStreetAddressThird($organizationCreationRequest->getStreetAddress3());
+        $addressPostal->setPostalCode($organizationCreationRequest->getPostalCode());
+        $addressPostal->setAddressCity($organizationCreationRequest->getCity());
+        $addressPostal->setAddressCountry($country);
+        $addressPostal->setCreateDate($organizationCreationRequest->getCreationDate());
+        $addressPostal->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        $organizationAddressPostal = new OrganizationAddressPostal();
+        $organizationAddressPostal->setAddressPostal($addressPostal);
+        $organizationAddressPostal->setType(AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE);
+        $organizationAddressPostal->setCreateDate($organizationCreationRequest->getCreationDate());
+        $organizationAddressPostal->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        return $organizationAddressPostal;
+    }
+
+    /**
+     * Creates a new instance of the ContactPoint class based on the given OrganizationCreationRequest object.
+     *
+     * @param OrganizationCreationRequest $organizationCreationRequest the OrganizationCreationRequest object containing the required data
+     *
+     * @return ContactPoint the newly created instance of the ContactPoint class
+     *
+     * @throws NumberParseException
+     */
+    protected function makeContactPoint(OrganizationCreationRequest $organizationCreationRequest): ContactPoint
+    {
+        if (!$this->phoneNumberUtil->isPossibleNumber($organizationCreationRequest->getPhoneNumber())) {
+            throw new \RuntimeException('Phone number is invalid or missing');
+        }
+        $phoneNumber = $this->phoneNumberUtil->parse($organizationCreationRequest->getPhoneNumber());
+
+        $contactPoint = new ContactPoint();
+        $contactPoint->setContactType(ContactPointTypeEnum::PRINCIPAL);
+        $contactPoint->setEmail($organizationCreationRequest->getEmail());
+        $contactPoint->setTelphone($phoneNumber);
+        $contactPoint->setCreateDate($organizationCreationRequest->getCreationDate());
+        $contactPoint->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        return $contactPoint;
+    }
+
+    /**
+     * Creates a new instance of the NetworkOrganization class based on the given OrganizationCreationRequest object.
+     *
+     * @param OrganizationCreationRequest $organizationCreationRequest the OrganizationCreationRequest object containing the required data
+     *
+     * @return NetworkOrganization the newly created instance of the NetworkOrganization class
+     *
+     * @throws \RuntimeException|\Exception if no parent organization is found for the given parent ID or if no network is found for the given network ID
+     */
+    protected function makeNetworkOrganization(OrganizationCreationRequest $organizationCreationRequest): NetworkOrganization
+    {
+        $parent = $this->organizationRepository->find($organizationCreationRequest->getParentId());
+        if (!$parent) {
+            throw new \RuntimeException('No parent organization found for id '.$organizationCreationRequest->getParentId());
+        }
+
+        if ($parent->getSettings()->getProduct() !== SettingsProductEnum::MANAGER) {
+            throw new \RuntimeException("Parent organization must have the product 'manager' (actual product: '".$parent->getSettings()->getProduct()->value."')");
+        }
+
+        $networkOrganization = $this->organizationUtils->getActiveNetworkOrganization($parent);
+        if (!$networkOrganization) {
+            throw new \RuntimeException('No network found for parent '.$organizationCreationRequest->getParentId());
+        }
+
+        $network = $networkOrganization->getNetwork();
+
+        // Si réseau CMF, on vérifie que le matricule est valide
+        if ($network->getId() === NetworkEnum::CMF->value) {
+            if (!preg_match("/FR\d{12}/", $organizationCreationRequest->getIdentifier())) {
+                throw new \RuntimeException('CMF identifier is missing or invalid.');
+            }
+        }
+
+        $networkOrganization = new NetworkOrganization();
+        $networkOrganization->setParent($parent);
+        $networkOrganization->setNetwork($network);
+        $networkOrganization->setStartDate(DatesUtils::new());
+        $networkOrganization->setCreateDate($organizationCreationRequest->getCreationDate());
+        $networkOrganization->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        return $networkOrganization;
+    }
+
+    /**
+     * Creates a new instance of the Access class with admin access based on the given OrganizationCreationRequest object.
+     *
+     * @param OrganizationCreationRequest $organizationCreationRequest the OrganizationCreationRequest object containing the required data
+     *
+     * @return Access the newly created instance of the Access class with admin access
+     */
+    protected function makeAdminAccess(OrganizationCreationRequest $organizationCreationRequest): Access
+    {
+        $admin = new Person();
+        $admin->setUsername('admin'.strtolower($organizationCreationRequest->getSubdomain()));
+        $randomString = ByteString::fromRandom(32)->toString();
+        $admin->setPassword($randomString);
+        $admin->setEnabled(true);
+
+        $adminAccess = new Access();
+        $adminAccess->setAdminAccess(true);
+        $adminAccess->setPerson($admin);
+        $adminAccess->setLoginEnabled(true);
+        $adminAccess->setRoles(['ROLE_ADMIN', 'ROLE_ADMIN_CORE']);
+        $adminAccess->setCreateDate($organizationCreationRequest->getCreationDate());
+        $adminAccess->setCreatedBy($organizationCreationRequest->getAuthorId());
+
+        $contactPoint = new ContactPoint();
+        $contactPoint->setContactType(ContactPointTypeEnum::PRINCIPAL);
+        $contactPoint->setEmail($organizationCreationRequest->getEmail());
+        $admin->addContactPoint($contactPoint);
+
+        return $adminAccess;
+    }
+
+    /**
+     * Creates an array of Cycle objects based on a predefined set of data.
+     *
+     * @return Cycle[] an array of Cycle objects
+     */
+    protected function makeCycles(): array
+    {
+        $cyclesData = [
+            ['Cycle initiation', 10, CycleEnum::INITIATION_CYCLE],
+            ['Cycle 1', 20, CycleEnum::CYCLE_1],
+            ['Cycle 2', 30, CycleEnum::CYCLE_2],
+            ['Cycle 3', 40, CycleEnum::CYCLE_3],
+            ['Cycle 4', 50, CycleEnum::CYCLE_4],
+            ['Hors cycle', 60, CycleEnum::OUT_CYCLE],
+        ];
+
+        $cycles = [];
+
+        foreach ($cyclesData as $cycleData) {
+            $cycle = new Cycle();
+            $cycle->setLabel($cycleData[0]);
+            $cycle->setOrder($cycleData[1]);
+            $cycle->setCycleEnum($cycleData[2]);
+            $cycle->setIsSystem(false);
+            $cycles[] = $cycle;
+        }
+
+        return $cycles;
+    }
+
+    /**
+     * Creates an Access object based on the given OrganizationMemberCreationRequest.
+     *
+     * @param int|OrganizationMemberCreationRequest $creationRequestData the request object containing the
+     *                                                                   necessary data for creating a Person object,
+     *                                                                   or the id of an existing one
+     *
+     * @return Access the created Access object
+     *
+     * @throws NumberParseException
+     */
+    protected function makeAccess(
+        int|OrganizationMemberCreationRequest $creationRequestData,
+        FunctionEnum $function,
+        \DateTime $creationDate,
+        ?int $authorId,
+    ): Access {
+        if (is_int($creationRequestData)) {
+            $person = $this->personRepository->find($creationRequestData);
+        } else {
+            $person = new Person();
+
+            if ($this->personRepository->findOneBy(['username' => $creationRequestData->getUsername()])) {
+                throw new \RuntimeException('Username already in use : '.$creationRequestData->getUsername());
+            }
+
+            $person->setUsername($creationRequestData->getUsername());
+            $person->setPassword(ByteString::fromRandom(32)->toString());
+            $person->setGender($creationRequestData->getGender());
+            $person->setName(
+                ucfirst(strtolower($creationRequestData->getName()))
+            );
+            $person->setGivenName(
+                ucfirst(strtolower($creationRequestData->getGivenName()))
+            );
+
+            $personPostalAddress = $this->makePersonPostalAddress($creationRequestData, $creationDate, $authorId);
+            $person->addPersonAddressPostal($personPostalAddress);
+
+            $contactPoint = $this->makePersonContactPoint($creationRequestData, $creationDate, $authorId);
+            $person->addContactPoint($contactPoint);
+
+            $person->setCreateDate($creationDate);
+            $person->setCreatedBy($authorId);
+        }
+
+        $access = new Access();
+        $access->setPerson($person);
+
+        $functionType = $this->functionTypeRepository->findOneBy(['mission' => $function]);
+
+        $organizationFunction = new OrganizationFunction();
+        $organizationFunction->setFunctionType($functionType);
+        $organizationFunction->setStartDate($creationDate);
+        $organizationFunction->setCreateDate($creationDate);
+        $organizationFunction->setCreatedBy($authorId);
+
+        $access->addOrganizationFunction($organizationFunction);
+
+        $access->setCreateDate($creationDate);
+        $access->setCreatedBy($authorId);
+
+        return $access;
+    }
+
+    /**
+     * Creates a PersonAddressPostal object based on the given OrganizationMemberCreationRequest.
+     *
+     * @param OrganizationMemberCreationRequest $organizationMemberCreationRequest the request object containing the
+     *                                                                             necessary data for creating a
+     *                                                                             PersonAddressPostal object
+     *
+     * @return PersonAddressPostal the created PersonAddressPostal object
+     */
+    protected function makePersonPostalAddress(
+        OrganizationMemberCreationRequest $organizationMemberCreationRequest,
+        \DateTime $creationDate,
+        ?int $authorId,
+    ): PersonAddressPostal {
+        $addressPostal = new AddressPostal();
+        $addressPostal->setStreetAddress($organizationMemberCreationRequest->getStreetAddress1());
+        $addressPostal->setStreetAddressSecond($organizationMemberCreationRequest->getStreetAddress2());
+        $addressPostal->setStreetAddressThird($organizationMemberCreationRequest->getStreetAddress3());
+        $addressPostal->setPostalCode($organizationMemberCreationRequest->getPostalCode());
+        $addressPostal->setAddressCity($organizationMemberCreationRequest->getCity());
+        $addressPostal->setCreateDate($creationDate);
+        $addressPostal->setCreatedBy($authorId);
+
+        $country = $this->countryRepository->find($organizationMemberCreationRequest->getCountryId());
+        $addressPostal->setAddressCountry($country);
+
+        $personAddressPostal = new PersonAddressPostal();
+        $personAddressPostal->setAddressPostal($addressPostal);
+        $personAddressPostal->setType(AddressPostalPersonTypeEnum::ADDRESS_PRINCIPAL);
+        $personAddressPostal->setCreateDate($creationDate);
+        $personAddressPostal->setCreatedBy($authorId);
+
+        return $personAddressPostal;
+    }
+
+    /**
+     * Creates a new instance of the ContactPoint class based on the given OrganizationCreationRequest object.
+     *
+     * @param OrganizationMemberCreationRequest $organizationMemberCreationRequest the OrganizationMemberCreationRequest object containing the required data
+     *
+     * @return ContactPoint the newly created instance of the ContactPoint class
+     *
+     * @throws NumberParseException
+     */
+    protected function makePersonContactPoint(
+        OrganizationMemberCreationRequest $organizationMemberCreationRequest,
+        \DateTime $creationDate,
+        ?int $authorId,
+    ): ContactPoint {
+        if (!$this->phoneNumberUtil->isPossibleNumber($organizationMemberCreationRequest->getPhone())) {
+            throw new \RuntimeException('Phone number is invalid or missing (person: '.$organizationMemberCreationRequest->getUsername().')');
+        }
+        if (
+            $organizationMemberCreationRequest->getMobile() !== null
+            && !$this->phoneNumberUtil->isPossibleNumber($organizationMemberCreationRequest->getMobile())) {
+            throw new \RuntimeException('Mobile phone number is invalid (person: '.$organizationMemberCreationRequest->getUsername().')');
+        }
+
+        $phoneNumber = $this->phoneNumberUtil->parse($organizationMemberCreationRequest->getPhone());
+
+        $contactPoint = new ContactPoint();
+        $contactPoint->setContactType(ContactPointTypeEnum::PRINCIPAL);
+        $contactPoint->setEmail($organizationMemberCreationRequest->getEmail());
+        $contactPoint->setTelphone($phoneNumber);
+
+        if ($organizationMemberCreationRequest->getMobile() !== null) {
+            $mobileNumber = $this->phoneNumberUtil->parse($organizationMemberCreationRequest->getMobile());
+            $contactPoint->setMobilPhone($mobileNumber);
+        }
+
+        $contactPoint->setCreateDate($creationDate);
+        $contactPoint->setCreatedBy($authorId);
+
+        return $contactPoint;
+    }
+
+    protected function makeSubdomain(OrganizationCreationRequest $organizationCreationRequest): Subdomain
+    {
+        $subdomain = new Subdomain();
+        $subdomain->setSubdomain($organizationCreationRequest->getSubdomain());
+        $subdomain->setActive(true);
+
+        return $subdomain;
+    }
+
+    /**
+     * Créé le site Typo3 et retourne l'id de la page racine du site nouvellement créé, ou null en cas d'erreur.
+     *
+     * @throws RedirectionExceptionInterface
+     * @throws ClientExceptionInterface
+     * @throws TransportExceptionInterface
+     * @throws ServerExceptionInterface
+     */
+    protected function createTypo3Website(Organization $organization): ?int
+    {
+        $response = $this->typo3Service->createSite($organization->getId());
+        $content = json_decode($response->getContent(), true);
+        $rootPageUid = $content['root_uid'];
+
+        if ($response->getStatusCode() === Response::HTTP_OK && $rootPageUid > 0) {
+            // TODO: revoir l'utilité du champs cmsId
+            $organization->setCmsId($rootPageUid);
+            $this->entityManager->persist($organization);
+            $this->entityManager->flush();
+
+            return $rootPageUid;
+        } else {
+            $this->logger->critical("/!\ A critical error happened while creating the Typo3 website");
+            $this->logger->debug($response->getContent());
+        }
+
+        return null;
+    }
+
+    protected function updateAdminassosDb(Organization $organization): void
+    {
+        $response = $this->apiLegacyRequestService->get(
+            UrlBuilder::concatPath('/_internal/request/adminassos/create/organization/', [(string)$organization->getId()])
+        );
+
+        if ($response->getStatusCode() !== Response::HTTP_OK) {
+            throw new \RuntimeException('An error happened while updating the adminassos database: '.$response->getContent());
+        }
+    }
+
+    /**
+     * Normalise la chaine comme sont normalisées les champs de l'entité OrganizationIdentification.
+     *
+     * @øee sql/schema-extensions/003-view_organization_identification.sql
+     */
+    protected function normalizeIdentificationField(string $value): string
+    {
+        $value = strtolower(trim($value));
+        $value = preg_replace('/[éèê]/u', 'e', $value);
+        $value = preg_replace('/[à]/u', 'a', $value);
+        $value = preg_replace('/[ç]/u', 'c', $value);
+
+        return preg_replace('/[^a-z0-9]+/u', '+', $value);
+    }
+
+    /**
+     * /!\ Danger zone /!\.
+     *
+     * Supprime définitivement une organisation, ses données, ses fichiers, son site internet, et son profil Dolibarr.
+     *
+     * Pour éviter une suppression accidentelle, cette méthode ne doit pouvoir être exécutée que si la requête a été
+     * envoyée depuis le localhost.
+     *
+     * @throws \Exception
+     */
+    public function delete(OrganizationDeletionRequest $organizationDeletionRequest): OrganizationDeletionRequest
+    {
+        SecurityUtils::preventIfNotLocalhost();
+
+        $organization = $this->organizationRepository->find($organizationDeletionRequest->getOrganizationId());
+        if (!$organization) {
+            throw new \RuntimeException("No organization was found for id : " . $organizationDeletionRequest->getOrganizationId());
+        }
+
+        $this->logger->info(
+            "Start the deletion of organization '".$organization->getName()."' [".$organization->getId().']'
+        );
+
+        $this->entityManager->beginTransaction();
+
+        $withError = false;
+
+        try {
+            $orphanPersons = $this->getOrphansToBePersons($organization);
+
+            // On est obligé de supprimer manuellement les paramètres, car c'est l'entité Parameters qui est
+            // propriétaire de la relation Organization ↔ Parameters.
+            $this->entityManager->remove($organization->getParameters());
+
+            // Toutes les autres entités liées seront supprimées en cascade
+            $this->entityManager->remove($organization);
+
+            // Supprime les personnes qui n'avaient pas d'autre Access attaché
+            $deletedPersonIds = [];
+            foreach ($orphanPersons as $person) {
+                $deletedPersonIds[] = $person->getId();
+                $this->entityManager->remove($person);
+            }
+
+            $this->entityManager->flush();
+            $this->entityManager->commit();
+        } catch (\Exception $e) {
+            $this->logger->critical("An error happened, operation cancelled\n".$e);
+            $this->entityManager->rollback();
+            throw $e;
+        }
+
+        try {
+            $this->deleteTypo3Website($organizationDeletionRequest->getOrganizationId());
+        } catch (\Exception $e) {
+            $this->logger->critical('An error happened while deleting the Typo3 website, please proceed manually.');
+            $this->logger->debug($e);
+            $withError = true;
+        }
+
+        try {
+            $this->switchDolibarrSocietyToProspect($organization);
+        } catch (\Exception $e) {
+            $this->logger->critical('An error happened while updating the Dolibarr society, please proceed manually.');
+            $this->logger->debug($e);
+            $withError = true;
+        }
+
+        try {
+            $this->fileManager->deleteOrganizationFiles($organizationDeletionRequest->getOrganizationId());
+        } catch (\Exception $e) {
+            $this->logger->critical("An error happened while deleting the organization's files, please proceed manually.");
+            $this->logger->debug($e);
+            $withError = true;
+        }
+
+        foreach ($deletedPersonIds as $personId) {
+            try {
+                $this->fileManager->deletePersonFiles($personId);
+            } catch (\Exception $e) {
+                $this->logger->critical("An error happened while deleting the person's files, please proceed manually (id=" . $person->getId() . ").");
+                $this->logger->debug($e);
+                $withError = true;
+            }
+        }
+
+        if ($withError) {
+            $organizationDeletionRequest->setStatus(OrganizationDeletionRequest::STATUS_OK_WITH_ERRORS);
+            $this->logger->warning('-- Operation ended with errors, check the logs for more information --');
+        } else {
+            $organizationDeletionRequest->setStatus(OrganizationDeletionRequest::STATUS_OK);
+        }
+
+        return $organizationDeletionRequest;
+    }
+
+    /**
+     * Supprime tous les Access d'une organisation, ainsi que la Person
+     * rattachée (si celle-ci n'est pas liée à d'autres Access).
+     *
+     * @param Organization $organization
+     * @return array<Person>
+     */
+    protected function getOrphansToBePersons(Organization $organization): array
+    {
+        $orphans = [];
+
+        foreach ($organization->getAccesses() as $access) {
+            $person = $access->getPerson();
+            if ($person->getAccesses()->count() === 1) {
+                $orphans[] = $person;
+            }
+        }
+        return $orphans;
+    }
+
+    // TODO: à revoir, c'est du many to many
+    //    protected function removeTypeOfPractices(Organization $organization): void {
+    //        foreach ($organization->getTypeOfPractices() as $typeOfPractice) {
+    //            $organization->removeTypeOfPractice($typeOfPractice);
+    //        }
+    //    }
+
+    // TODO: à revoir, c'est du many to many
+    //    protected function deleteContactPoints(Organization $organization): void
+    //    {
+    //        foreach ($organization->getContactPoints() as $contactPoint) {
+    //            $this->entityManager->remove($contactPoint);
+    //        }
+    //    }
+
+    // TODO: à revoir, c'est du many to many
+    //    protected function deleteBankAccounts(Organization $organization): void {
+    //        foreach ($organization->getBankAccounts() as $bankAccount) {
+    //            $this->entityManager->remove($bankAccount);
+    //        }
+    //    }
+
+
+    protected function deleteTypo3Website(int $organizationId): void
+    {
+        // TODO: implement
+        //        $this->typo3Service->deleteSite($organization->getId());
+    }
+
+    protected function switchDolibarrSocietyToProspect(Organization $organization): void
+    {
+        $this->dolibarrApiService->switchSocietyToProspect($organization->getId());
+    }
+}

+ 19 - 0
src/Service/Organization/Utils.php

@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace App\Service\Organization;
 
+use App\Entity\Network\NetworkOrganization;
 use App\Entity\Organization\Organization;
 use App\Enum\Organization\OrganizationIdsEnum;
 use App\Enum\Organization\SettingsProductEnum;
@@ -290,4 +291,22 @@ class Utils
 
         return !empty($modules) && in_array($module, $modules);
     }
+
+    /**
+     * Retourne le premier NetworkOrganization actif de la structure
+     *
+     * @param Organization $organization
+     * @return NetworkOrganization|null
+     * @throws \Exception
+     */
+    public function getActiveNetworkOrganization(Organization $organization): ?NetworkOrganization
+    {
+        foreach ($organization->getNetworkOrganizations() as $networkOrganization) {
+            if ($this->networkUtils->isNetworkOrganizationActiveNow($networkOrganization)) {
+                return $networkOrganization;
+            }
+        }
+
+        return null;
+    }
 }

+ 8 - 2
src/Service/Rest/ApiRequestInterface.php

@@ -47,22 +47,28 @@ interface ApiRequestInterface
     /**
      * Sends a POST request and returns the response.
      *
+     * @param string $path
+     * @param array<mixed>| string $body
      * @param array<mixed> $parameters
      * @param array<mixed> $options
      *
+     * @return ResponseInterface
      * @throws HttpException
      */
-    public function post(string $path, array $parameters = [], array $options = []): ResponseInterface;
+    public function post(string $path, array | string $body, array $parameters = [], array $options = []): ResponseInterface;
 
     /**
      * Sends a PUT request and returns the response.
      *
+     * @param string $path
+     * @param array<mixed> | string $body
      * @param array<mixed> $parameters
      * @param array<mixed> $options
      *
+     * @return ResponseInterface
      * @throws HttpException
      */
-    public function put(string $path, array $parameters = [], array $options = []): ResponseInterface;
+    public function put(string $path, array | string $body, array $parameters = [], array $options = []): ResponseInterface;
 
     /**
      * Sends a DELETE request and returns the response.

+ 19 - 2
src/Service/Rest/ApiRequestService.php

@@ -71,29 +71,46 @@ class ApiRequestService implements ApiRequestInterface
         return $this->request('GET', $path, $parameters, $options);
     }
 
+    /**
+     * Complète les options en y ajoutant le body
+     *
+     * @param array<mixed> $options
+     * @param array<mixed>|string $body
+     * @return array<mixed>
+     */
+    protected function addBodyOption(array $options, array | string $body): array
+    {
+        $option = is_array($body) ? ['json' => $body] : ['body' => $body];
+        return array_merge($options, $option);
+    }
+
     /**
      * Sends a POST request and returns the response.
      *
+     * @param array<mixed>|string $body
      * @param array<mixed> $parameters
      * @param array<mixed> $options
      *
      * @throws HttpException
      */
-    public function post(string $path, array $parameters = [], array $options = []): ResponseInterface
+    public function post(string $path, array | string $body, array $parameters = [], array $options = []): ResponseInterface
     {
+        $options = $this->addBodyOption($options, $body);
         return $this->request('POST', $path, $parameters, $options);
     }
 
     /**
      * Sends a PUT request and returns the response.
      *
+     * @param array<mixed>|string $body
      * @param array<mixed> $parameters
      * @param array<mixed> $options
      *
      * @throws HttpException
      */
-    public function put(string $path, array $parameters = [], array $options = []): ResponseInterface
+    public function put(string $path, array | string $body, array $parameters = [], array $options = []): ResponseInterface
     {
+        $options = $this->addBodyOption($options, $body);
         return $this->request('PUT', $path, $parameters, $options);
     }
 

+ 18 - 3
src/Service/Security/InternalRequestsService.php

@@ -2,6 +2,10 @@
 
 namespace App\Service\Security;
 
+use App\Entity\Access\Access;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\User\UserInterface;
+
 /**
  * Identify and allow internal requests between api v1 and v2.
  *
@@ -22,6 +26,7 @@ class InternalRequestsService
 
     public function __construct(
         readonly private string $internalRequestsToken,
+        private Security $security
     ) {
     }
 
@@ -43,16 +48,26 @@ class InternalRequestsService
      * Compare the given token to the expected one, and return true if they are identical
      * An empty token can not be valid.
      */
-    protected function tokenIsValid(string $token): bool
+    protected function tokenIsValid(?string $token): bool
     {
         return $token && $token === $this->internalRequestsToken;
     }
 
+    public function isSuperAdmin(): bool
+    {
+        /** @var Access $user */
+        $user = $this->security->getUser();
+        if (!$user instanceof UserInterface) {
+            return false;
+        }
+        return $user->getSuperAdminAccess();
+    }
+
     /**
      * Is the given request a valid internal request, which shall be responded even without authentication.
      */
-    public function isAllowed(string $ip, string $token): bool
+    public function isAllowed(string $ip, ?string $token): bool
     {
-        return $this->isInternalIp($ip) && $this->tokenIsValid($token);
+        return $this->isInternalIp($ip) && ($this->isSuperAdmin() || $this->tokenIsValid($token));
     }
 }

+ 5 - 0
src/Service/ServiceIterator/StorageIterator.php

@@ -22,6 +22,11 @@ class StorageIterator
     ) {
     }
 
+    public function getStorages(): iterable
+    {
+        return $this->storageServices;
+    }
+
     /**
      * Itère sur les services de storage disponibles et
      * retourne le premier qui supporte ce type de requête.

+ 14 - 14
src/Service/Typo3/SubdomainService.php

@@ -4,8 +4,8 @@ namespace App\Service\Typo3;
 
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Subdomain;
-use App\Message\Command\MailerCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\MailerCommand;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Organization\SubdomainRepository;
 use App\Service\Mailer\Model\SubdomainChangeModel;
@@ -117,20 +117,13 @@ class SubdomainService
             throw new \RuntimeException('This subdomain is already registered');
         }
 
-        $subdomain = new Subdomain();
-        $subdomain->setSubdomain($subdomainValue);
-        $subdomain->setOrganization($organization);
-        $subdomain->setActive(false);
-
         $this->em->beginTransaction();
 
         try {
-            // <--- Pour la rétrocompatibilité avec la v1; pourra être supprimé lorsque la migration sera achevée
-            $parameters = $organization->getParameters();
-            $parameters->setSubDomain($subdomainValue);
-            $parameters->setOtherWebsite('https://'.$subdomainValue.'.opentalent.fr');
-            $this->em->persist($parameters);
-            // --->
+            $subdomain = new Subdomain();
+            $subdomain->setSubdomain($subdomainValue);
+            $subdomain->setOrganization($organization);
+            $subdomain->setActive(false);
 
             $this->em->persist($subdomain);
             $this->em->flush();
@@ -191,6 +184,13 @@ class SubdomainService
         }
         $subdomain->setActive(true);
 
+        // <--- Pour la rétrocompatibilité avec la v1; pourra être supprimé lorsque la migration sera achevée
+        $parameters = $subdomain->getOrganization()->getParameters();
+        $parameters->setSubDomain($subdomain->getSubdomain());
+        $parameters->setOtherWebsite('https://'.$subdomain->getSubdomain().'.opentalent.fr');
+        $this->em->persist($parameters);
+        // --->
+
         // TODO: comprendre pourquoi ce refresh est indispensable pour que l'organisation soit à jour
         $this->em->flush();
         $this->em->refresh($subdomain->getOrganization());
@@ -215,7 +215,7 @@ class SubdomainService
     protected function updateTypo3Website(Organization $organization): void
     {
         $this->messageBus->dispatch(
-            new Typo3UpdateCommand($organization->getId())
+            new Typo3Update($organization->getId())
         );
     }
 

+ 5 - 2
src/Service/Typo3/Typo3Service.php

@@ -26,8 +26,11 @@ class Typo3Service
      */
     protected function sendCommand(string $route, array $parameters): ResponseInterface
     {
-        $url = UrlBuilder::concatParameters('/typo3/index.php?route='.$route, $parameters);
-
+        $url = UrlBuilder::concat(
+            '/typo3',
+            [$route],
+            $parameters
+        );
         return $this->typo3_client->request('GET', $url);
     }
 

+ 5 - 1
src/Service/Utils/EntityUtils.php

@@ -16,8 +16,12 @@ class EntityUtils
     /**
      * @throws \ReflectionException
      */
-    public function defaultValueSettersByAccess(mixed $entity, Access $access): void
+    public function defaultValueSettersByAccess(mixed $entity, ?Access $access): void
     {
+        if ($access === null) {
+            // Cas des internal requests (ex: création d'organisation)
+            return;
+        }
         $this->organizationDefaultValue($entity, $access);
         $this->billingSettingDefaultValueDefaultValue($entity, $access);
     }

+ 1 - 1
src/Service/Utils/Path.php

@@ -96,7 +96,7 @@ class Path
      *
      * @return bool returns true if the directory was successfully removed, false otherwise
      */
-    protected static function rmtree(string $path): bool
+    public static function rmtree(string $path): bool
     {
         if (!file_exists($path)) {
             return true;

+ 23 - 0
src/Service/Utils/SecurityUtils.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Utils;
+
+class SecurityUtils
+{
+    /**
+     * Lève une exception si la méthode a été appelée dans le cadre d'un appel API originaire d'un hôte
+     * différent de localhost.
+     */
+    public static function preventIfNotLocalhost(): void
+    {
+        if (
+            $_SERVER
+            && $_SERVER['APP_ENV'] !== 'docker'
+            && $_SERVER['SERVER_ADDR'] !== $_SERVER['REMOTE_ADDR']
+        ) {
+            throw new \RuntimeException('This operation is restricted to localhost');
+        }
+    }
+}

+ 1 - 2
src/Service/Utils/UrlBuilder.php

@@ -23,9 +23,8 @@ class UrlBuilder
     {
         $url = $base;
         foreach ($tails as $tail) {
-            $url = trim($url, '/').'/'.trim(strval($tail), '/');
+            $url = rtrim($url, '/').'/'.ltrim(strval($tail), '/');
         }
-
         return $url;
     }
 

+ 1 - 2
src/State/Processor/Export/LicenceCmf/ExportRequestProcessor.php

@@ -10,8 +10,7 @@ use ApiPlatform\State\ProcessorInterface;
 use App\ApiResources\Export\ExportRequest;
 use App\Entity\Access\Access;
 use App\Entity\Core\File;
-use App\Message\Command\Export;
-use App\Service\Network\Utils as NetworkUtils;
+use App\Message\Message\Export;
 use App\Service\ServiceIterator\ExporterIterator;
 use Symfony\Bundle\SecurityBundle\Security;
 use Symfony\Component\HttpFoundation\Response;

+ 64 - 0
src/State/Processor/Organization/OrganizationCreationRequestProcessor.php

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Organization;
+
+use App\Entity\Access\Access;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\Message\Message\OrganizationCreation;
+use App\Service\Organization\OrganizationFactory;
+use App\Service\Utils\DatesUtils;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Messenger\MessageBusInterface;
+
+class OrganizationCreationRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly MessageBusInterface $messageBus,
+        private readonly OrganizationFactory $organizationFactory,
+        private Security $security,
+    ) {
+    }
+
+    /**
+     * @param OrganizationCreationRequest $data
+     * @param mixed[]       $uriVariables
+     * @param mixed[]       $context
+     *
+     * @throws \Exception
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrganizationCreationRequest
+    {
+        /**
+         * @var OrganizationCreationRequest $organizationCreationRequest
+         */
+        $organizationCreationRequest = $data;
+
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        /** @var Access|null $access */
+        $access = $this->security->getUser();
+
+        $organizationCreationRequest->setCreationDate(DatesUtils::new());
+        $organizationCreationRequest->setAuthorId($access?->getId());
+
+        if ($organizationCreationRequest->isAsync()) {
+            // Send the export request to Messenger (@see App\Message\Handler\OrganizationCreationHandler)
+            $this->messageBus->dispatch(
+                new OrganizationCreation($organizationCreationRequest)
+            );
+        } else {
+            // For testing purposes only
+            $this->organizationFactory->create($organizationCreationRequest);
+        }
+
+        return $organizationCreationRequest;
+    }
+}

+ 55 - 0
src/State/Processor/Organization/OrganizationDeletionRequestProcessor.php

@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Organization;
+
+use ApiPlatform\Metadata\Delete;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Organization\OrganizationDeletionRequest;
+use App\Message\Message\OrganizationCreation;
+use App\Message\Message\OrganizationDeletion;
+use App\Service\Organization\OrganizationFactory;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Messenger\MessageBusInterface;
+
+class OrganizationDeletionRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly MessageBusInterface $messageBus,
+        private readonly OrganizationFactory $organizationFactory,
+    ) {}
+
+    /**
+     * @param OrganizationDeletionRequest $data
+     * @param mixed[]       $uriVariables
+     * @param mixed[]       $context
+     *
+     * @throws \Exception
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrganizationDeletionRequest
+    {
+        /**
+         * @var OrganizationDeletionRequest $organizationDeletionRequest
+         */
+        $organizationDeletionRequest = $data;
+
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        if ($organizationDeletionRequest->isAsync()) {
+            // Send the export request to Messenger (@see App\Message\Handler\OrganizationCreationHandler)
+            $this->messageBus->dispatch(
+                new OrganizationDeletion($organizationDeletionRequest)
+            );
+        } else {
+            // For testing purposes only
+            $this->organizationFactory->delete($organizationDeletionRequest);
+        }
+        return $organizationDeletionRequest;
+    }
+}

+ 125 - 0
tests/Unit/Service/Dolibarr/DolibarrApiServiceTest.php

@@ -2,11 +2,13 @@
 
 namespace App\Tests\Unit\Service\Dolibarr;
 
+use App\Entity\Organization\Organization;
 use App\Service\Dolibarr\DolibarrApiService;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
 
 class DolibarrApiServiceTest extends TestCase
 {
@@ -439,4 +441,127 @@ class DolibarrApiServiceTest extends TestCase
 
         $dolibarrApiService->getSocietyTagsIds($socId);
     }
+
+    /**
+     * @see DolibarrApiService::createSociety
+     */
+    public function testCreateSociety(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createSociety'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+        $organization->method('getName')->willReturn('Foo');
+
+        $expectedPostBody = [
+            'name' => 'Foo',
+            'client' => 2,
+            'code_client' => -1,
+            'import_key' => 'crm',
+            'array_options' => ['options_2iopen_organization_id' => 123],
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getContent')->willReturn('456');
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('post')
+            ->with('/thirdparties', $expectedPostBody)
+            ->willReturn($response);
+
+        $result = $dolibarrApiService->createSociety($organization);
+
+        $this->assertEquals(456, $result);
+    }
+
+    /**
+     * @see DolibarrApiService::createSociety
+     */
+    public function testCreateSocietyIsClient(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['createSociety'])
+            ->getMock();
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+        $organization->method('getName')->willReturn('Foo');
+
+        $expectedPostBody = [
+            'name' => 'Foo',
+            'client' => 1,
+            'code_client' => -1,
+            'import_key' => 'crm',
+            'array_options' => ['options_2iopen_organization_id' => 123],
+        ];
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getContent')->willReturn('456');
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('post')
+            ->with('/thirdparties', $expectedPostBody)
+            ->willReturn($response);
+
+        $result = $dolibarrApiService->createSociety($organization, true);
+
+        $this->assertEquals(456, $result);
+    }
+
+    public function testSwitchSocietyToProspect(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['switchSocietyToProspect'])
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getStatusCode')->willReturn(200);
+
+        $dolibarrApiService
+            ->method('getSociety')
+            ->with(123)
+            ->willReturn(['id' => 456]);
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('put')
+            ->with('thirdparties/456', ['client' => 2])
+            ->willReturn($response);
+
+        $dolibarrApiService->switchSocietyToProspect(123);
+    }
+
+    public function testSwitchSocietyToProspectWithError(): void
+    {
+        $dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['switchSocietyToProspect'])
+            ->getMock();
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->getMock();
+        $response->method('getStatusCode')->willReturn(500);
+
+        $dolibarrApiService
+            ->method('getSociety')
+            ->with(123)
+            ->willReturn(['id' => 456]);
+
+        $dolibarrApiService
+            ->expects(self::once())
+            ->method('put')
+            ->with('thirdparties/456', ['client' => 2])
+            ->willReturn($response);
+
+        $this->expectException(HttpException::class);
+        $this->expectExceptionMessage('Error while updating the society in Dolibarr');
+
+        $dolibarrApiService->switchSocietyToProspect(123);
+    }
 }

+ 11 - 11
tests/Unit/Service/OnChange/Organization/OnParametersChangeTest.php

@@ -11,9 +11,9 @@ use App\Entity\Education\EducationNotationConfig;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Enum\Education\AdvancedEducationNotationTypeEnum;
-use App\Message\Command\Typo3\Typo3DeleteCommand;
-use App\Message\Command\Typo3\Typo3UndeleteCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\Typo3\Typo3Delete;
+use App\Message\Message\Typo3\Typo3Undelete;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Repository\Booking\CourseRepository;
 use App\Service\Network\Utils as NetworkUtils;
 use App\Service\OnChange\OnChangeContext;
@@ -206,8 +206,8 @@ class OnParametersChangeTest extends TestCase
         $this->messageBus
             ->expects(self::once())
             ->method('dispatch')
-            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
-            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
+            ->with(self::isInstanceOf(Typo3Update::class))
+            ->willReturn(new Envelope(new Typo3Update(1)));
 
         $previousParameters = $this->getMockBuilder(Parameters::class)->getMock();
         $previousParameters->method('getId')->willReturn(1);
@@ -245,8 +245,8 @@ class OnParametersChangeTest extends TestCase
         $this->messageBus
             ->expects(self::once())
             ->method('dispatch')
-            ->with(self::isInstanceOf(Typo3DeleteCommand::class))
-            ->willReturn(new Envelope(new Typo3DeleteCommand(1)));
+            ->with(self::isInstanceOf(Typo3Delete::class))
+            ->willReturn(new Envelope(new Typo3Delete(1)));
 
         $previousParameters = $this->getMockBuilder(Parameters::class)->getMock();
         $previousParameters->method('getId')->willReturn(1);
@@ -285,11 +285,11 @@ class OnParametersChangeTest extends TestCase
             ->expects(self::exactly(2))
             ->method('dispatch')
             ->willReturnCallback(function ($message) {
-                if ($message instanceof Typo3UndeleteCommand) {
-                    return new Envelope(new Typo3UndeleteCommand(1));
+                if ($message instanceof Typo3Undelete) {
+                    return new Envelope(new Typo3Undelete(1));
                 }
-                if ($message instanceof Typo3UpdateCommand) {
-                    return new Envelope(new Typo3UpdateCommand(1));
+                if ($message instanceof Typo3Update) {
+                    return new Envelope(new Typo3Update(1));
                 }
                 throw new \AssertionError('unexpected message : '.$message::class);
             });

+ 2144 - 0
tests/Unit/Service/Organization/OrganizationFactoryTest.php

@@ -0,0 +1,2144 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Organization;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Organization\OrganizationDeletionRequest;
+use App\ApiResources\Organization\OrganizationMemberCreationRequest;
+use App\Entity\Access\Access;
+use App\Entity\Access\FunctionType;
+use App\Entity\Core\ContactPoint;
+use App\Entity\Core\Country;
+use App\Entity\Education\Cycle;
+use App\Entity\Network\Network;
+use App\Entity\Network\NetworkOrganization;
+use App\Entity\Organization\Organization;
+use App\Entity\Organization\OrganizationAddressPostal;
+use App\Entity\Organization\Parameters;
+use App\Entity\Organization\Settings;
+use App\Entity\Organization\Subdomain;
+use App\Entity\Person\Person;
+use App\Entity\Person\PersonAddressPostal;
+use App\Enum\Access\FunctionEnum;
+use App\Enum\Core\ContactPointTypeEnum;
+use App\Enum\Education\CycleEnum;
+use App\Enum\Network\NetworkEnum;
+use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Enum\Person\AddressPostalPersonTypeEnum;
+use App\Enum\Person\GenderEnum;
+use App\Repository\Access\FunctionTypeRepository;
+use App\Repository\Core\CountryRepository;
+use App\Repository\Organization\OrganizationIdentificationRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Repository\Person\PersonRepository;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use App\Service\Dolibarr\DolibarrApiService;
+use App\Service\Organization\OrganizationFactory;
+use App\Service\Organization\Utils as OrganizationUtils;
+use App\Service\Typo3\BindFileService;
+use App\Service\Typo3\SubdomainService;
+use App\Service\Typo3\Typo3Service;
+use App\Service\Utils\DatesUtils;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+use libphonenumber\PhoneNumber;
+use libphonenumber\PhoneNumberUtil;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+class TestableOrganizationFactory extends OrganizationFactory
+{
+    public function setPhoneNumberUtil(PhoneNumberUtil $phoneNumberUtil): void
+    {
+        $this->phoneNumberUtil = $phoneNumberUtil;
+    }
+
+    public function interruptIfOrganizationExists(OrganizationCreationRequest $organizationCreationRequest): void
+    {
+        parent::interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function validateSubdomain(string $subdomainValue): void
+    {
+        parent::validateSubdomain($subdomainValue);
+    }
+
+    public function makeOrganizationWithRelations(
+        OrganizationCreationRequest $organizationCreationRequest,
+    ): Organization {
+        return parent::makeOrganizationWithRelations($organizationCreationRequest);
+    }
+
+    public function makeOrganization(OrganizationCreationRequest $organizationCreationRequest): Organization
+    {
+        return parent::makeOrganization($organizationCreationRequest);
+    }
+
+    public function makeParameters(OrganizationCreationRequest $organizationCreationRequest): Parameters
+    {
+        return parent::makeParameters($organizationCreationRequest);
+    }
+
+    public function makeSettings(OrganizationCreationRequest $organizationCreationRequest): Settings
+    {
+        return parent::makeSettings($organizationCreationRequest);
+    }
+
+    public function makePostalAddress(OrganizationCreationRequest $organizationCreationRequest): OrganizationAddressPostal
+    {
+        return parent::makePostalAddress($organizationCreationRequest);
+    }
+
+    public function makeContactPoint(OrganizationCreationRequest $organizationCreationRequest): ContactPoint
+    {
+        return parent::makeContactPoint($organizationCreationRequest);
+    }
+
+    public function makeNetworkOrganization(OrganizationCreationRequest $organizationCreationRequest): NetworkOrganization
+    {
+        return parent::makeNetworkOrganization($organizationCreationRequest);
+    }
+
+    public function makeAdminAccess(OrganizationCreationRequest $organizationCreationRequest): Access
+    {
+        return parent::makeAdminAccess($organizationCreationRequest);
+    }
+
+    public function makeCycles(): array
+    {
+        return parent::makeCycles();
+    }
+
+    public function makeAccess(int|OrganizationMemberCreationRequest $creationRequestData, FunctionEnum $function, \DateTime $creationDate, ?int $authorId): Access
+    {
+        return parent::makeAccess($creationRequestData, $function, $creationDate, $authorId);
+    }
+
+    public function makePersonPostalAddress(OrganizationMemberCreationRequest $organizationMemberCreationRequest, \DateTime $creationDate, ?int $authorId): PersonAddressPostal
+    {
+        return parent::makePersonPostalAddress($organizationMemberCreationRequest, $creationDate, $authorId);
+    }
+
+    public function makePersonContactPoint(OrganizationMemberCreationRequest $organizationMemberCreationRequest, \DateTime $creationDate, ?int $authorId): ContactPoint
+    {
+        return parent::makePersonContactPoint($organizationMemberCreationRequest, $creationDate, $authorId);
+    }
+
+    public function makeSubdomain(OrganizationCreationRequest $organizationCreationRequest): Subdomain
+    {
+        return parent::makeSubdomain($organizationCreationRequest);
+    }
+
+    public function createTypo3Website(Organization $organization): ?int
+    {
+        return parent::createTypo3Website($organization);
+    }
+
+    public function updateAdminassosDb(Organization $organization): void
+    {
+        parent::updateAdminassosDb($organization);
+    }
+
+    public function normalizeIdentificationField(string $value): string
+    {
+        return parent::normalizeIdentificationField($value);
+    }
+
+    public function deleteOrganizationAccesses(Organization $organization): void
+    {
+        parent::deleteOrganizationAccesses($organization);
+    }
+
+    public function deleteTypo3Website(Organization $organization): void
+    {
+        parent::deleteTypo3Website($organization);
+    }
+
+    public function switchDolibarrSocietyToProspect(Organization $organization): void
+    {
+        parent::switchDolibarrSocietyToProspect($organization);
+    }
+
+    public function deleteOrganizationFiles(Organization $organization): void
+    {
+        parent::deleteOrganizationFiles($organization);
+    }
+
+    public function deleteDirectoriesV1(Organization $organization): void
+    {
+        parent::deleteDirectoriesV1($organization);
+    }
+
+    public function deleteDirectories59(Organization $organization): void
+    {
+        parent::deleteDirectories59($organization);
+    }
+}
+
+class OrganizationFactoryTest extends TestCase
+{
+    private readonly MockObject|SubdomainService $subdomainService;
+    private readonly MockObject|OrganizationRepository $organizationRepository;
+    private readonly MockObject|CountryRepository $countryRepository;
+    private readonly MockObject|OrganizationUtils $organizationUtils;
+    private readonly MockObject|Typo3Service $typo3Service;
+    private readonly MockObject|DolibarrApiService $dolibarrApiService;
+    private readonly MockObject|EntityManagerInterface $entityManager;
+    private readonly MockObject|PersonRepository $personRepository;
+    private readonly MockObject|BindFileService $bindFileService;
+    private readonly MockObject|LoggerInterface $logger;
+    private readonly MockObject|OrganizationIdentificationRepository $organizationIdentificationRepository;
+    private readonly MockObject|ApiLegacyRequestService $apiLegacyRequestService;
+    private readonly MockObject|PhoneNumberUtil $phoneNumberUtil;
+    private readonly MockObject|FunctionTypeRepository $functionTypeRepository;
+
+    public function setUp(): void
+    {
+        $this->subdomainService = $this->getMockBuilder(SubdomainService::class)->disableOriginalConstructor()->getMock();
+        $this->organizationRepository = $this->getMockBuilder(OrganizationRepository::class)->disableOriginalConstructor()->getMock();
+        $this->countryRepository = $this->getMockBuilder(CountryRepository::class)->disableOriginalConstructor()->getMock();
+        $this->organizationUtils = $this->getMockBuilder(OrganizationUtils::class)->disableOriginalConstructor()->getMock();
+        $this->typo3Service = $this->getMockBuilder(Typo3Service::class)->disableOriginalConstructor()->getMock();
+        $this->dolibarrApiService = $this->getMockBuilder(DolibarrApiService::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->personRepository = $this->getMockBuilder(PersonRepository::class)->disableOriginalConstructor()->getMock();
+        $this->bindFileService = $this->getMockBuilder(BindFileService::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->organizationIdentificationRepository = $this->getMockBuilder(OrganizationIdentificationRepository::class)->disableOriginalConstructor()->getMock();
+        $this->apiLegacyRequestService = $this->getMockBuilder(ApiLegacyRequestService::class)->disableOriginalConstructor()->getMock();
+        $this->phoneNumberUtil = $this->getMockBuilder(PhoneNumberUtil::class)->disableOriginalConstructor()->getMock();
+        $this->functionTypeRepository = $this->getMockBuilder(FunctionTypeRepository::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function tearDown(): void
+    {
+        DatesUtils::clearFakeDatetime();
+    }
+
+    private function getOrganizationFactoryMockFor(string $methodName): TestableOrganizationFactory|MockObject
+    {
+        $organizationFactory = $this
+            ->getMockBuilder(TestableOrganizationFactory::class)
+            ->setConstructorArgs(
+                [
+                    $this->subdomainService,
+                    $this->organizationRepository,
+                    $this->countryRepository,
+                    $this->organizationUtils,
+                    $this->typo3Service,
+                    $this->dolibarrApiService,
+                    $this->entityManager,
+                    $this->personRepository,
+                    $this->bindFileService,
+                    $this->organizationIdentificationRepository,
+                    $this->apiLegacyRequestService,
+                    $this->functionTypeRepository,
+                ])
+            ->setMethodsExcept(['setLoggerInterface', 'setPhoneNumberUtil', $methodName])
+            ->getMock();
+
+        $organizationFactory->setLoggerInterface($this->logger);
+        $organizationFactory->setPhoneNumberUtil($this->phoneNumberUtil);
+
+        return $organizationFactory;
+    }
+
+    public function testCreate(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('create');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSubdomain')->willReturn('subdomain');
+        $organizationCreationRequest->method('isClient')->willReturn(false);
+        $organizationCreationRequest->method('getCreateWebsite')->willReturn(true);
+
+        $this->entityManager->expects(self::once())->method('beginTransaction');
+
+        $organizationFactory->expects(self::once())->method('interruptIfOrganizationExists');
+        $organizationFactory->expects(self::once())->method('validateSubdomain')->with('subdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeOrganizationWithRelations')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        $this->entityManager->expects(self::once())->method('persist')->with($organization);
+        $this->entityManager->expects(self::once())->method('flush');
+        $this->entityManager->expects(self::once())->method('commit');
+
+        $this->dolibarrApiService
+            ->expects(self::once())
+            ->method('createSociety')
+            ->with($organization, false)
+            ->willReturn(456);
+
+        $this->bindFileService
+            ->expects(self::once())
+            ->method('registerSubdomain')
+            ->with('subdomain');
+
+        $organizationFactory
+            ->expects(self::once())
+            ->method('createTypo3Website')
+            ->with($organization)
+            ->willReturn(789);
+
+        $this->logger
+            ->method('info')
+            ->withConsecutive(
+                ["Start the creation of a new organization named 'foo'"],
+                ["Subdomain is valid and available : 'subdomain'"],
+                ['Organization created with all its relations'],
+                ['Organization persisted in the DB'],
+                ['New dolibarr structure created (uid : 456)'],
+                ['Subdomain registered'],
+                ['Typo3 website created (root uid: 789)'],
+                ['Adminassos db updated']
+            );
+
+        $organizationCreationRequest
+            ->expects(self::once())
+            ->method('setStatus')
+            ->with(OrganizationCreationRequest::STATUS_OK);
+
+        $result = $organizationFactory->create($organizationCreationRequest);
+
+        $this->assertEquals(
+            $organization,
+            $result
+        );
+    }
+
+    public function testCreateWithRollback(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('create');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSubdomain')->willReturn('subdomain');
+        $organizationCreationRequest->method('isClient')->willReturn(false);
+        $organizationCreationRequest->method('getCreateWebsite')->willReturn(true);
+
+        $this->entityManager->expects(self::once())->method('beginTransaction');
+
+        $organizationFactory->expects(self::once())->method('interruptIfOrganizationExists');
+        $organizationFactory->expects(self::once())->method('validateSubdomain')->with('subdomain');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeOrganizationWithRelations')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        $this->entityManager->expects(self::once())->method('persist')->with($organization);
+        $this->entityManager->expects(self::once())->method('flush')->willThrowException(new \RuntimeException('some error'));
+        $this->entityManager->expects(self::once())->method('rollback');
+
+        $this->dolibarrApiService
+            ->expects(self::never())
+            ->method('createSociety');
+
+        $this->bindFileService
+            ->expects(self::never())
+            ->method('registerSubdomain');
+
+        $organizationFactory
+            ->expects(self::never())
+            ->method('createTypo3Website');
+
+        $this->logger
+            ->method('info')
+            ->withConsecutive(
+                ["Start the creation of a new organization named 'foo'"],
+                ["Subdomain is valid and available : 'subdomain'"],
+                ['Organization created with all its relations']
+            );
+
+        $this->logger
+            ->method('critical')
+            ->with(
+                $this->matchesRegularExpression('/^An error happened, operation cancelled\nRuntimeException: some error.*/')
+            );
+
+        $this->expectException(\RuntimeException::class);
+
+        $result = $organizationFactory->create($organizationCreationRequest);
+    }
+
+    public function testCreateWithExistingOrganization(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('create');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+
+        $this->entityManager->expects(self::once())->method('beginTransaction');
+
+        $organizationFactory->expects(self::once())->method('interruptIfOrganizationExists')->willThrowException(new \RuntimeException('An organization named foo already exists'));
+
+        $organizationFactory->expects(self::never())->method('validateSubdomain');
+
+        $organizationFactory
+            ->expects(self::never())
+            ->method('makeOrganizationWithRelations');
+
+        $this->entityManager->expects(self::never())->method('persist');
+        $this->entityManager->expects(self::never())->method('flush');
+        $this->entityManager->expects(self::once())->method('rollback');
+
+        $this->dolibarrApiService
+            ->expects(self::never())
+            ->method('createSociety');
+
+        $this->bindFileService
+            ->expects(self::never())
+            ->method('registerSubdomain');
+
+        $organizationFactory
+            ->expects(self::never())
+            ->method('createTypo3Website');
+
+        $this->logger
+            ->method('info')
+            ->withConsecutive(
+                ["Start the creation of a new organization named 'foo'"],
+            );
+
+        $this->logger
+            ->method('critical')
+            ->with(
+                $this->matchesRegularExpression("/^An error happened, operation cancelled\nRuntimeException: An organization named foo already exists.*/")
+            );
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('An organization named foo already exists');
+
+        $result = $organizationFactory->create($organizationCreationRequest);
+    }
+
+    public function testCreateNoWebsiteAndIsClient(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('create');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSubdomain')->willReturn('subdomain');
+        $organizationCreationRequest->method('isClient')->willReturn(true);
+        $organizationCreationRequest->method('getCreateWebsite')->willReturn(false);
+
+        $organizationFactory->expects(self::once())->method('interruptIfOrganizationExists');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeOrganizationWithRelations')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        $this->dolibarrApiService
+            ->method('createSociety')
+            ->with($organization, true)
+            ->willReturn(456);
+
+        $organizationFactory
+            ->expects(self::never())
+            ->method('createTypo3Website');
+
+        $this->logger
+            ->expects(self::once())
+            ->method('warning')
+            ->with('Typo3 website creation was not required');
+
+        $result = $organizationFactory->create($organizationCreationRequest);
+
+        $this->assertEquals(
+            $organization,
+            $result
+        );
+    }
+
+    public function testCreateWithErrors(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('create');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSubdomain')->willReturn('subdomain');
+        $organizationCreationRequest->method('isClient')->willReturn(true);
+        $organizationCreationRequest->method('getCreateWebsite')->willReturn(true);
+
+        $organizationFactory->expects(self::once())->method('interruptIfOrganizationExists');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeOrganizationWithRelations')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        $this->dolibarrApiService
+            ->method('createSociety')
+            ->with($organization, true)
+            ->willThrowException(new \RuntimeException('An error happened'));
+
+        $this->bindFileService
+            ->method('registerSubdomain')
+            ->with('subdomain')
+            ->willThrowException(new \RuntimeException('An error happened'));
+
+        $organizationFactory
+            ->method('createTypo3Website')
+            ->with($organization)
+            ->willThrowException(new \RuntimeException('An error happened'));
+
+        $organizationFactory
+            ->method('updateAdminassosDb')
+            ->with($organization)
+            ->willThrowException(new \RuntimeException('An error happened'));
+
+        $this->logger
+            ->expects(self::exactly(4))
+            ->method('critical')
+            ->willReturnOnConsecutiveCalls([
+                'An error happened while creating the dolibarr society, please proceed manually.',
+                'An error happened while updating the bind file, please proceed manually.',
+                'An error happened while creating the typo3 website, please proceed manually.',
+                'An error happened while updating the adminassos db, please proceed manually.',
+            ]);
+
+        $this->logger
+            ->expects(self::once())
+            ->method('warning')
+            ->with('-- Operation ended with errors, check the logs for more information --');
+
+        $organizationCreationRequest
+            ->expects(self::once())
+            ->method('setStatus')
+            ->with(OrganizationCreationRequest::STATUS_OK_WITH_ERRORS);
+
+        $result = $organizationFactory->create($organizationCreationRequest);
+
+        $this->assertEquals(
+            $organization,
+            $result
+        );
+    }
+
+    public function testInterruptIfOrganizationExistsNotExisting(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $this->organizationIdentificationRepository
+            ->expects(self::exactly(2))
+            ->method('findOneBy')
+            ->willReturn(null);
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testInterruptIfOrganizationExistsNotExistingNonExistingWithIdentifiers(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('123456');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('W123456');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('FR000000000000');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $this->organizationIdentificationRepository
+            ->expects(self::exactly(5))
+            ->method('findOneBy')
+            ->willReturnMap([
+                [['siretNumber' => '123456'], null, null],
+                [['waldecNumber' => 'W123456'], null, null],
+                [['identifier' => 'FR000000000000'], null, null],
+                [['normalizedName' => 'foo', 'addressCity' => 'Paris'], null, null],
+                [['normalizedAddress' => 'part1 part2 part3', 'addressCity' => 'Paris', 'postalCode' => '75000'], null, null],
+            ]);
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testInterruptIfOrganizationExistsExistingWithSiret(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('123456');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('W123456');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('FR000000000000');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $this->organizationIdentificationRepository
+            ->method('findOneBy')
+            ->willReturnMap([
+                [['siretNumber' => '123456'], null, $organization],
+                [['waldecNumber' => 'W123456'], null, null],
+                [['identifier' => 'FR000000000000'], null, null],
+                [['normalizedName' => 'foo', 'addressCity' => 'Paris'], null, null],
+                [['normalizedAddress' => 'part1 part2 part3', 'addressCity' => 'Paris', 'postalCode' => '75000'], null, null],
+            ]);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("This siret number is already registered : '123456'");
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testInterruptIfOrganizationExistsExistingWithWaldec(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('123456');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('W123456');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('FR000000000000');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $this->organizationIdentificationRepository
+            ->method('findOneBy')
+            ->willReturnMap([
+                [['siretNumber' => '123456'], null, null],
+                [['waldecNumber' => 'W123456'], null, $organization],
+                [['identifier' => 'FR000000000000'], null, null],
+                [['normalizedName' => 'foo', 'addressCity' => 'Paris'], null, null],
+                [['normalizedAddress' => 'part1 part2 part3', 'addressCity' => 'Paris', 'postalCode' => '75000'], null, null],
+            ]);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("This RNA identifier (waldec number) is already registered : 'W123456'");
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testInterruptIfOrganizationExistsExistingWithCMFIdentifier(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('123456');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('W123456');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('FR000000000000');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $this->organizationIdentificationRepository
+            ->method('findOneBy')
+            ->willReturnMap([
+                [['siretNumber' => '123456'], null, null],
+                [['waldecNumber' => 'W123456'], null, null],
+                [['identifier' => 'FR000000000000'], null, $organization],
+                [['normalizedName' => 'foo', 'addressCity' => 'Paris'], null, null],
+                [['normalizedAddress' => 'part1 part2 part3', 'addressCity' => 'Paris', 'postalCode' => '75000'], null, null],
+            ]);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("This CMF identifier is already registered : 'FR000000000000'");
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testInterruptIfOrganizationExistsExistingWithSameNameAndCity(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('123456');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('W123456');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('FR000000000000');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $this->organizationIdentificationRepository
+            ->method('findOneBy')
+            ->willReturnMap([
+                [['siretNumber' => '123456'], null, null],
+                [['waldecNumber' => 'W123456'], null, null],
+                [['identifier' => 'FR000000000000'], null, null],
+                [['normalizedName' => 'foo', 'addressCity' => 'Paris'], null, $organization],
+                [['normalizedAddress' => 'part1 part2 part3', 'addressCity' => 'Paris', 'postalCode' => '75000'], null, null],
+            ]);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("An organization named 'foo' already exists in Paris");
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testInterruptIfOrganizationExistsExistingWithSameAddress(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('interruptIfOrganizationExists');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getName')->willReturn('foo');
+        $organizationCreationRequest->method('getSiretNumber')->willReturn('123456');
+        $organizationCreationRequest->method('getWaldecNumber')->willReturn('W123456');
+        $organizationCreationRequest->method('getIdentifier')->willReturn('FR000000000000');
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('part1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('part2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('part3');
+        $organizationCreationRequest->method('getCity')->willReturn('Paris');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('75000');
+
+        $organizationFactory
+            ->method('normalizeIdentificationField')
+            ->willReturnMap([
+                ['foo', 'foo'],
+                ['part1 part2 part3', 'part1 part2 part3'],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $this->organizationIdentificationRepository
+            ->method('findOneBy')
+            ->willReturnMap([
+                [['siretNumber' => '123456'], null, null],
+                [['waldecNumber' => 'W123456'], null, null],
+                [['identifier' => 'FR000000000000'], null, null],
+                [['normalizedName' => 'foo', 'addressCity' => 'Paris'], null, null],
+                [['normalizedAddress' => 'part1 part2 part3', 'addressCity' => 'Paris', 'postalCode' => '75000'], null, $organization],
+            ]);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('An organization already exists at this address.');
+
+        $organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+    }
+
+    public function testValidateSubdomain(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('validateSubdomain');
+
+        $this->subdomainService->expects(self::once())->method('isValidSubdomain')->willReturn(true);
+        $this->subdomainService->expects(self::once())->method('isReservedSubdomain')->willReturn(false);
+        $this->subdomainService->expects(self::once())->method('isRegistered')->willReturn(false);
+
+        $organizationFactory->validateSubdomain('foo');
+    }
+
+    public function testValidateSubdomainIsNotValid(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('validateSubdomain');
+
+        $this->subdomainService->method('isValidSubdomain')->willReturn(false);
+        $this->subdomainService->method('isReservedSubdomain')->willReturn(false);
+        $this->subdomainService->method('isRegistered')->willReturn(false);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Not a valid subdomain : foo');
+
+        $organizationFactory->validateSubdomain('foo');
+    }
+
+    public function testValidateSubdomainIsReserved(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('validateSubdomain');
+
+        $this->subdomainService->method('isValidSubdomain')->willReturn(true);
+        $this->subdomainService->method('isReservedSubdomain')->willReturn(true);
+        $this->subdomainService->method('isRegistered')->willReturn(false);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('This subdomain is not available : foo');
+
+        $organizationFactory->validateSubdomain('foo');
+    }
+
+    public function testValidateSubdomainIsRegistered(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('validateSubdomain');
+
+        $this->subdomainService->method('isValidSubdomain')->willReturn(true);
+        $this->subdomainService->method('isReservedSubdomain')->willReturn(false);
+        $this->subdomainService->method('isRegistered')->willReturn(true);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('This subdomain is already registered : foo');
+
+        $organizationFactory->validateSubdomain('foo');
+    }
+
+    public function testMakeOrganizationWithRelations(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeOrganizationWithRelations');
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getCreationDate')->willReturn($creationDate);
+        $organizationCreationRequest->method('getAuthorId')->willReturn(1);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Création de l'organisation
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeOrganization')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        // Création des Parameters
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeParameters')
+            ->with($organizationCreationRequest)
+            ->willReturn($parameters);
+
+        $organization->expects(self::once())->method('setParameters')->with($parameters);
+
+        // Création des Settings
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeSettings')
+            ->with($organizationCreationRequest)
+            ->willReturn($settings);
+
+        $organization->expects(self::once())->method('setSettings')->with($settings);
+
+        // Création de l'adresse postale
+        $organizationAddressPostal = $this->getMockBuilder(OrganizationAddressPostal::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makePostalAddress')
+            ->with($organizationCreationRequest)
+            ->willReturn($organizationAddressPostal);
+
+        $organization->expects(self::once())->method('addOrganizationAddressPostal')->with($organizationAddressPostal);
+
+        // Création du point de contact
+        $contactPoint = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeContactPoint')
+            ->with($organizationCreationRequest)
+            ->willReturn($contactPoint);
+
+        $organization->expects(self::once())->method('addContactPoint')->with($contactPoint);
+
+        // Rattachement au réseau
+        $networkOrganization = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeNetworkOrganization')
+            ->with($organizationCreationRequest)
+            ->willReturn($networkOrganization);
+
+        $organization->expects(self::once())->method('addNetworkOrganization')->with($networkOrganization);
+
+        // Créé l'admin
+        $adminAccess = $this->getMockBuilder(Access::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeAdminAccess')
+            ->with($organizationCreationRequest)
+            ->willReturn($adminAccess);
+
+        // Le `$organization->expects(...)->method('addAccess')` est implémenté plus loin,
+        // après la création du président et du directeur.
+
+        // Création des cycles
+        $cycle1 = $this->getMockBuilder(Cycle::class)->getMock();
+        $cycle2 = $this->getMockBuilder(Cycle::class)->getMock();
+        $cycle3 = $this->getMockBuilder(Cycle::class)->getMock();
+
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeCycles')
+            ->willReturn([$cycle1, $cycle2, $cycle3]);
+
+        $organization->expects(self::exactly(3))->method('addCycle')->withConsecutive([$cycle1], [$cycle2], [$cycle3]);
+
+        // Création du président et du directeur
+        $organizationMemberCreationRequest1 = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getPresident')->willReturn($organizationMemberCreationRequest1);
+
+        $organizationMemberCreationRequest2 = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getDirector')->willReturn($organizationMemberCreationRequest2);
+
+        $access1 = $this->getMockBuilder(Access::class)->getMock();
+        $access2 = $this->getMockBuilder(Access::class)->getMock();
+        $organizationFactory
+            ->expects(self::exactly(2))
+            ->method('makeAccess')
+            ->willReturnMap([
+                [$organizationMemberCreationRequest1, FunctionEnum::PRESIDENT, $creationDate, 1, $access1],
+                [$organizationMemberCreationRequest2, FunctionEnum::DIRECTOR, $creationDate, 1, $access2],
+            ]);
+
+        $organization
+            ->expects(self::exactly(3))
+            ->method('addAccess')
+            ->withConsecutive([$adminAccess], [$access1], [$access2]);
+
+        // Création du sous-domaine
+        $subdomain = $this->getMockBuilder(Subdomain::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeSubdomain')
+            ->with($organizationCreationRequest)
+            ->willReturn($subdomain);
+
+        $organization->expects(self::once())->method('addSubdomain')->with($subdomain);
+
+        // Enregistrement du sous domaine dans Parameters (retrocompatibilité v1)
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $organizationCreationRequest->method('getSubdomain')->willReturn('foo');
+        $parameters->expects(self::once())->method('setSubDomain')->with('foo');
+        $parameters->expects(self::once())->method('setOtherWebsite')->with('https://foo.opentalent.fr');
+
+        $result = $organizationFactory->makeOrganizationWithRelations($organizationCreationRequest);
+
+        $this->assertEquals(
+            $result,
+            $organization
+        );
+    }
+
+    public function testMakeOrganizationWithRelationsNoPresidentNoDirector(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeOrganizationWithRelations');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        // Création de l'organisation
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makeOrganization')
+            ->with($organizationCreationRequest)
+            ->willReturn($organization);
+
+        $organizationCreationRequest->method('getPresident')->willReturn(null);
+        $organizationCreationRequest->method('getDirector')->willReturn(null);
+
+        // Un seul appel, pour l'adminAccess
+        $organization
+            ->expects(self::once())
+            ->method('addAccess');
+
+        $organizationFactory->makeOrganizationWithRelations($organizationCreationRequest);
+    }
+
+    public function testMakeOrganization()
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeOrganization');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $organizationCreationRequest->method('getName')->willReturn('My Organization');
+        $organizationCreationRequest->method('getLegalStatus')->willReturn(LegalEnum::ASSOCIATION_LAW_1901);
+        $organizationCreationRequest->method('getPrincipalType')->willReturn(PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY);
+
+        $organization = $organizationFactory->makeOrganization($organizationCreationRequest);
+
+        $this->assertEquals(
+            'My Organization',
+            $organization->getName()
+        );
+
+        $this->assertEquals(
+            LegalEnum::ASSOCIATION_LAW_1901,
+            $organization->getLegalStatus()
+        );
+
+        $this->assertEquals(
+            PrincipalTypeEnum::ARTISTIC_EDUCATION_ONLY,
+            $organization->getPrincipalType()
+        );
+    }
+
+    public function testMakeParameters(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeParameters');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $parameters = $organizationFactory->makeParameters($organizationCreationRequest);
+
+        $this->assertInstanceOf(Parameters::class, $parameters);
+    }
+
+    public function testMakeSettings(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeSettings');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $organizationCreationRequest->method('getProduct')->willReturn(SettingsProductEnum::ARTIST_PREMIUM);
+
+        $settings = $organizationFactory->makeSettings($organizationCreationRequest);
+
+        $this->assertEquals(
+            SettingsProductEnum::ARTIST_PREMIUM,
+            $settings->getProduct()
+        );
+    }
+
+    public function testMakePostalAddress(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePostalAddress');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getStreetAddress1')->willReturn('address1');
+        $organizationCreationRequest->method('getStreetAddress2')->willReturn('address2');
+        $organizationCreationRequest->method('getStreetAddress3')->willReturn('address3');
+        $organizationCreationRequest->method('getPostalCode')->willReturn('00000');
+        $organizationCreationRequest->method('getCity')->willReturn('city');
+        $organizationCreationRequest->method('getCountryId')->willReturn(1);
+
+        $country = $this->getMockBuilder(Country::class)->getMock();
+
+        $this->countryRepository->expects(self::once())->method('find')->with(1)->willReturn($country);
+
+        $organizationAddressPostal = $organizationFactory->makePostalAddress($organizationCreationRequest);
+
+        $this->assertEquals(
+            AddressPostalOrganizationTypeEnum::ADDRESS_HEAD_OFFICE,
+            $organizationAddressPostal->getType()
+        );
+
+        $addressPostal = $organizationAddressPostal->getAddressPostal();
+
+        $this->assertEquals(
+            'address1',
+            $addressPostal->getStreetAddress()
+        );
+
+        $this->assertEquals(
+            'address2',
+            $addressPostal->getStreetAddressSecond()
+        );
+
+        $this->assertEquals(
+            'address3',
+            $addressPostal->getStreetAddressThird()
+        );
+
+        $this->assertEquals(
+            '00000',
+            $addressPostal->getPostalCode()
+        );
+
+        $this->assertEquals(
+            'city',
+            $addressPostal->getAddressCity()
+        );
+
+        $this->assertEquals(
+            $country,
+            $addressPostal->getAddressCountry()
+        );
+    }
+
+    public function testMakePostalAddressUnexistingCountry(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePostalAddress');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getCountryId')->willReturn(1);
+
+        $this->countryRepository->expects(self::once())->method('find')->with(1)->willReturn(null);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('No country found for id 1');
+
+        $organizationFactory->makePostalAddress($organizationCreationRequest);
+    }
+
+    public function testMakeContactPoint(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeContactPoint');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $organizationCreationRequest->method('getPhoneNumber')->willReturn('+33102030405');
+        $organizationCreationRequest->method('getEmail')->willReturn('contact@domain.net');
+
+        $this->phoneNumberUtil
+            ->method('isPossibleNumber')
+            ->with('+33102030405')
+            ->willReturn(true);
+
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+
+        $this->phoneNumberUtil
+            ->expects(self::once())
+            ->method('parse')
+            ->with('+33102030405')
+            ->willReturn($phoneNumber);
+
+        $contactPoint = $organizationFactory->makeContactPoint($organizationCreationRequest);
+
+        $this->assertEquals(
+            'contact@domain.net',
+            $contactPoint->getEmail()
+        );
+
+        $this->assertEquals(
+            $phoneNumber,
+            $contactPoint->getTelphone()
+        );
+    }
+
+    public function testMakeContactPointInvalidPhoneNumber(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeContactPoint');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $organizationCreationRequest->method('getPhoneNumber')->willReturn('invalid');
+        $organizationCreationRequest->method('getEmail')->willReturn('contact@domain.net');
+
+        $this->phoneNumberUtil
+            ->method('isPossibleNumber')
+            ->with('invalid')
+            ->willReturn(false);
+
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+
+        $this->phoneNumberUtil
+            ->expects(self::never())
+            ->method('parse');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Phone number is invalid or missing');
+
+        $organizationFactory->makeContactPoint($organizationCreationRequest);
+    }
+
+    public function testMakeNetworkOrganization(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
+
+        DatesUtils::setFakeDatetime('2024-01-01');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getParentId')->willReturn(123);
+
+        $parent = $this->getMockBuilder(Organization::class)->getMock();
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::MANAGER);
+        $parent->method('getSettings')->willReturn($settings);
+        $this->organizationRepository->expects(self::once())->method('find')->with(123)->willReturn($parent);
+
+        $network = $this->getMockBuilder(Network::class)->getMock();
+        $networkOrganization = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization->method('getNetwork')->willReturn($network);
+        $this->organizationUtils
+            ->expects(self::once())
+            ->method('getActiveNetworkOrganization')
+            ->with($parent)
+            ->willReturn($networkOrganization);
+
+        $networkOrganization = $organizationFactory->makeNetworkOrganization($organizationCreationRequest);
+
+        $this->assertEquals(
+            $parent,
+            $networkOrganization->getParent()
+        );
+
+        $this->assertEquals(
+            $network,
+            $networkOrganization->getNetwork()
+        );
+
+        $this->assertEquals(
+            '2024-01-01',
+            $networkOrganization->getStartDate()->format('Y-m-d')
+        );
+    }
+
+    public function testMakeNetworkOrganizationMissingParent(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getParentId')->willReturn(123);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('No parent organization found for id 123');
+
+        $this->organizationRepository->expects(self::once())->method('find')->with(123)->willReturn(null);
+
+        $organizationFactory->makeNetworkOrganization($organizationCreationRequest);
+    }
+
+    public function testMakeNetworkOrganizationParentIsNotManager(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
+
+        DatesUtils::setFakeDatetime('2024-01-01');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getParentId')->willReturn(123);
+
+        $parent = $this->getMockBuilder(Organization::class)->getMock();
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::SCHOOL);
+        $parent->method('getSettings')->willReturn($settings);
+        $this->organizationRepository->expects(self::once())->method('find')->with(123)->willReturn($parent);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage("Parent organization must have the product 'manager' (actual product: 'school')");
+
+        $organizationFactory->makeNetworkOrganization($organizationCreationRequest);
+    }
+
+    public function testMakeNetworkOrganizationMissingNetwork(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getParentId')->willReturn(123);
+
+        $parent = $this->getMockBuilder(Organization::class)->getMock();
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::MANAGER);
+        $parent->method('getSettings')->willReturn($settings);
+        $this->organizationRepository->expects(self::once())->method('find')->with(123)->willReturn($parent);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('No network found for parent 123');
+
+        $this->organizationRepository->expects(self::once())->method('find')->with(123)->willReturn($parent);
+
+        $organizationFactory->makeNetworkOrganization($organizationCreationRequest);
+    }
+
+    public function testMakeNetworkOrganizationIsCMFInvalidIdentifier(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getParentId')->willReturn(123);
+        $organizationCreationRequest->method('getIdentifier')->willReturn('invalid');
+
+        $parent = $this->getMockBuilder(Organization::class)->getMock();
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::MANAGER);
+        $parent->method('getSettings')->willReturn($settings);
+        $this->organizationRepository->method('find')->with(123)->willReturn($parent);
+
+        $network = $this->getMockBuilder(Network::class)->getMock();
+        $network->method('getId')->willReturn(NetworkEnum::CMF->value);
+
+        $networkOrganization = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization->method('getNetwork')->willReturn($network);
+
+        $this->organizationUtils
+            ->method('getActiveNetworkOrganization')
+            ->with($parent)
+            ->willReturn($networkOrganization);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('CMF identifier is missing or invalid.');
+
+        $organizationFactory->makeNetworkOrganization($organizationCreationRequest);
+    }
+
+    public function testMakeNetworkOrganizationIsNotCMFInvalidIdentifier(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeNetworkOrganization');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+        $organizationCreationRequest->method('getParentId')->willReturn(123);
+        $organizationCreationRequest->method('getIdentifier')->willReturn('invalid');
+
+        $parent = $this->getMockBuilder(Organization::class)->getMock();
+        $settings = $this->getMockBuilder(Settings::class)->getMock();
+        $settings->method('getProduct')->willReturn(SettingsProductEnum::MANAGER);
+        $parent->method('getSettings')->willReturn($settings);
+        $this->organizationRepository->method('find')->with(123)->willReturn($parent);
+
+        $network = $this->getMockBuilder(Network::class)->getMock();
+        $network->method('getId')->willReturn(NetworkEnum::OUTOFNET->value);
+
+        $networkOrganization = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization->method('getNetwork')->willReturn($network);
+
+        $this->organizationUtils
+            ->method('getActiveNetworkOrganization')
+            ->with($parent)
+            ->willReturn($networkOrganization);
+
+        $result = $organizationFactory->makeNetworkOrganization($organizationCreationRequest);
+
+        $this->assertEquals(
+            $network,
+            $result->getNetwork()
+        );
+    }
+
+    public function testMakeAdminAccess(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeAdminAccess');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $organizationCreationRequest->method('getSubdomain')->willReturn('foo');
+
+        $adminAccess = $organizationFactory->makeAdminAccess($organizationCreationRequest);
+
+        $this->assertTrue(
+            $adminAccess->getAdminAccess()
+        );
+
+        $this->assertEquals(
+            'adminfoo',
+            $adminAccess->getPerson()->getUsername()
+        );
+
+        $this->assertEquals(
+            32,
+            strlen($adminAccess->getPerson()->getPassword())
+        );
+    }
+
+    public function testMakeCycles(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeCycles');
+
+        $cycles = $organizationFactory->makeCycles();
+
+        $cyclesExpectedData = [
+            ['Cycle initiation', 10, CycleEnum::INITIATION_CYCLE],
+            ['Cycle 1', 20, CycleEnum::CYCLE_1],
+            ['Cycle 2', 30, CycleEnum::CYCLE_2],
+            ['Cycle 3', 40, CycleEnum::CYCLE_3],
+            ['Cycle 4', 50, CycleEnum::CYCLE_4],
+            ['Hors cycle', 60, CycleEnum::OUT_CYCLE],
+        ];
+
+        $i = 0;
+        foreach ($cycles as $cycle) {
+            $this->assertEquals(
+                $cyclesExpectedData[$i][0],
+                $cycle->getLabel()
+            );
+            $this->assertEquals(
+                $cyclesExpectedData[$i][1],
+                $cycle->getOrder()
+            );
+            $this->assertEquals(
+                $cyclesExpectedData[$i][2],
+                $cycle->getCycleEnum()
+            );
+            $this->assertFalse(
+                $cycle->getIsSystem()
+            );
+            ++$i;
+        }
+    }
+
+    public function testMakeAccessNewPerson(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeAccess');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getUsername')->willReturn('bob');
+        $organizationMemberCreationRequest->method('getName')->willReturn('Bob');
+        $organizationMemberCreationRequest->method('getGivenName')->willReturn('bOBBy');
+        $organizationMemberCreationRequest->method('getGender')->willReturn(GenderEnum::MISTER);
+
+        $this->personRepository
+            ->method('findOneBy')
+            ->with(['username' => 'bob'], null)
+            ->willReturn(null);
+
+        $personAddressPostal = $this->getMockBuilder(PersonAddressPostal::class)->getMock();
+        $organizationFactory
+            ->expects(self::once())
+            ->method('makePersonPostalAddress')
+            ->with($organizationMemberCreationRequest)
+            ->willReturn($personAddressPostal);
+
+        $contactPoint = $this->getMockBuilder(ContactPoint::class)->getMock();
+        $organizationFactory
+                ->expects(self::once())
+                ->method('makePersonContactPoint')
+                ->with($organizationMemberCreationRequest)
+                ->willReturn($contactPoint);
+
+        $functionType = $this->getMockBuilder(FunctionType::class)->getMock();
+
+        $this->functionTypeRepository
+            ->expects(self::once())
+            ->method('findOneBy')
+            ->with(['mission' => FunctionEnum::ACCOUNTANT])
+            ->willReturn($functionType);
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $access = $organizationFactory->makeAccess(
+            $organizationMemberCreationRequest,
+            FunctionEnum::ACCOUNTANT,
+            $creationDate,
+            1
+        );
+
+        $this->assertInstanceOf(Access::class, $access);
+        $this->assertEquals(
+            'bob',
+            $access->getPerson()->getUsername()
+        );
+        $this->assertTrue(
+            strlen($access->getPerson()->getPassword()) === 32
+        );
+        $this->assertEquals(
+            'Bob',
+            $access->getPerson()->getName()
+        );
+        $this->assertEquals(
+            'Bobby',
+            $access->getPerson()->getGivenName()
+        );
+        $this->assertEquals(
+            [$personAddressPostal],
+            $access->getPerson()->getPersonAddressPostal()->toArray()
+        );
+        $this->assertEquals(
+            [$contactPoint],
+            $access->getPerson()->getContactPoints()->toArray()
+        );
+        $this->assertEquals(
+            $functionType,
+            $access->getOrganizationFunction()->first()->getFunctionType()
+        );
+        $this->assertEquals(
+            $creationDate,
+            $access->getOrganizationFunction()->first()->getStartDate()
+        );
+    }
+
+    public function testMakeAccessNewPersonUsernameAlreadyInUse(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeAccess');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getUsername')->willReturn('bob');
+        $organizationMemberCreationRequest->method('getName')->willReturn('Bob');
+        $organizationMemberCreationRequest->method('getGivenName')->willReturn('bOBBy');
+        $organizationMemberCreationRequest->method('getGender')->willReturn(GenderEnum::MISTER);
+
+        $person = $this->getMockBuilder(Person::class)->getMock();
+        $this->personRepository
+            ->method('findOneBy')
+            ->with(['username' => 'bob'], null)
+            ->willReturn($person);
+
+        $organizationFactory
+            ->expects(self::never())
+            ->method('makePersonPostalAddress');
+
+        $organizationFactory
+                ->expects(self::never())
+                ->method('makePersonContactPoint');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Username already in use : bob');
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $organizationFactory->makeAccess(
+            $organizationMemberCreationRequest,
+            FunctionEnum::ADHERENT,
+            $creationDate,
+            1
+        );
+    }
+
+    public function testMakeAccessExistingPerson(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeAccess');
+
+        $person = $this->getMockBuilder(Person::class)->getMock();
+
+        $this->personRepository
+            ->expects(self::once())
+            ->method('find')
+            ->with(123)
+            ->willReturn($person);
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $functionType = $this->getMockBuilder(FunctionType::class)->getMock();
+
+        $this->functionTypeRepository
+            ->expects(self::once())
+            ->method('findOneBy')
+            ->with(['mission' => FunctionEnum::ACCOUNTANT])
+            ->willReturn($functionType);
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $access = $organizationFactory->makeAccess(
+            123,
+            FunctionEnum::ACCOUNTANT,
+            $creationDate,
+            1
+        );
+
+        $this->assertInstanceOf(Access::class, $access);
+        $this->assertEquals(
+            $person,
+            $access->getPerson()
+        );
+
+        $this->assertEquals(
+            $functionType,
+            $access->getOrganizationFunction()->first()->getFunctionType()
+        );
+        $this->assertEquals(
+            $creationDate,
+            $access->getOrganizationFunction()->first()->getStartDate()
+        );
+    }
+
+    public function testMakeAccessPostalAddress()
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePersonPostalAddress');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getStreetAddress1')->willReturn('Aaa');
+        $organizationMemberCreationRequest->method('getStreetAddress2')->willReturn('Bbb');
+        $organizationMemberCreationRequest->method('getStreetAddress3')->willReturn(null);
+        $organizationMemberCreationRequest->method('getPostalCode')->willReturn('00000');
+        $organizationMemberCreationRequest->method('getCity')->willReturn('city');
+        $organizationMemberCreationRequest->method('getCountryId')->willReturn(123);
+
+        $country = $this->getMockBuilder(Country::class)->getMock();
+        $this->countryRepository
+            ->expects(self::once())
+            ->method('find')
+            ->with(123)
+            ->willReturn($country);
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $personPostalAddress = $organizationFactory->makePersonPostalAddress(
+            $organizationMemberCreationRequest,
+            $creationDate,
+            1
+        );
+
+        $this->assertEquals(
+            AddressPostalPersonTypeEnum::ADDRESS_PRINCIPAL,
+            $personPostalAddress->getType()
+        );
+
+        $postalAddress = $personPostalAddress->getAddressPostal();
+        $this->assertEquals('Aaa', $postalAddress->getStreetAddress());
+        $this->assertEquals('Bbb', $postalAddress->getStreetAddressSecond());
+        $this->assertEquals(null, $postalAddress->getStreetAddressThird());
+        $this->assertEquals('00000', $postalAddress->getPostalCode());
+        $this->assertEquals('city', $postalAddress->getAddressCity());
+        $this->assertEquals($country, $postalAddress->getAddressCountry());
+    }
+
+    public function testMakeAccessContactPoint(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePersonContactPoint');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getPhone')->willReturn('+33102030405');
+        $organizationMemberCreationRequest->method('getEmail')->willReturn('email@domain.com');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn('+33607080910');
+
+        $this->phoneNumberUtil
+            ->expects(self::exactly(2))
+            ->method('isPossibleNumber')
+            ->willReturnMap([
+                ['+33102030405', null, true],
+                ['+33607080910', null, true],
+            ]);
+
+        $phoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+        $mobilePhoneNumber = $this->getMockBuilder(PhoneNumber::class)->getMock();
+
+        $this->phoneNumberUtil
+            ->expects(self::exactly(2))
+            ->method('parse')
+            ->willReturnMap([
+                ['+33102030405', null, null, false, $phoneNumber],
+                ['+33607080910', null, null, false, $mobilePhoneNumber],
+            ]);
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $contactPoint = $organizationFactory->makePersonContactPoint(
+            $organizationMemberCreationRequest,
+            $creationDate,
+            1
+        );
+
+        $this->assertEquals(
+            ContactPointTypeEnum::PRINCIPAL,
+            $contactPoint->getContactType()
+        );
+
+        $this->assertEquals(
+            'email@domain.com',
+            $contactPoint->getEmail()
+        );
+
+        $this->assertEquals(
+            $phoneNumber,
+            $contactPoint->getTelphone()
+        );
+
+        $this->assertEquals(
+            $mobilePhoneNumber,
+            $contactPoint->getMobilPhone()
+        );
+    }
+
+    public function testMakeAccessContactPointInvalidPhone(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePersonContactPoint');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getUsername')->willReturn('bob');
+        $organizationMemberCreationRequest->method('getPhone')->willReturn('invalid');
+        $organizationMemberCreationRequest->method('getEmail')->willReturn('email@domain.com');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn('+33607080910');
+
+        $this->phoneNumberUtil
+            ->expects(self::once())
+            ->method('isPossibleNumber')
+            ->with('invalid')
+            ->willReturn(false);
+
+        $this->phoneNumberUtil
+            ->expects(self::never())
+            ->method('parse');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Phone number is invalid or missing (person: bob)');
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $organizationFactory->makePersonContactPoint(
+            $organizationMemberCreationRequest,
+            $creationDate,
+            1
+        );
+    }
+
+    public function testMakeAccessContactPointInvalidMobilePhone(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePersonContactPoint');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getUsername')->willReturn('bob');
+        $organizationMemberCreationRequest->method('getPhone')->willReturn('+33102030405');
+        $organizationMemberCreationRequest->method('getEmail')->willReturn('email@domain.com');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn('invalid');
+
+        $this->phoneNumberUtil
+            ->expects(self::exactly(2))
+            ->method('isPossibleNumber')
+            ->willReturnMap([
+                ['+33102030405', null, true],
+                ['invalid', null, false],
+            ]);
+
+        $this->phoneNumberUtil
+            ->expects(self::never())
+            ->method('parse');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('Mobile phone number is invalid (person: bob)');
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $organizationFactory->makePersonContactPoint(
+            $organizationMemberCreationRequest,
+            $creationDate,
+            1
+        );
+    }
+
+    public function testMakePersonContactPointNoMobile(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makePersonContactPoint');
+
+        $organizationMemberCreationRequest = $this->getMockBuilder(OrganizationMemberCreationRequest::class)->getMock();
+
+        $organizationMemberCreationRequest->method('getPhone')->willReturn('+33102030405');
+        $organizationMemberCreationRequest->method('getMobile')->willReturn(null);
+
+        $this->phoneNumberUtil
+            ->expects(self::once())
+            ->method('isPossibleNumber')
+            ->with('+33102030405')
+            ->willReturn(true);
+
+        $creationDate = $this->getMockBuilder(\DateTime::class)->getMock();
+
+        $contactPoint = $organizationFactory->makePersonContactPoint(
+            $organizationMemberCreationRequest,
+            $creationDate,
+            1
+        );
+
+        $this->assertEquals(
+            null,
+            $contactPoint->getMobilPhone()
+        );
+    }
+
+    public function testMakeSubdomain(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('makeSubdomain');
+
+        $organizationCreationRequest = $this->getMockBuilder(OrganizationCreationRequest::class)->getMock();
+
+        $organizationCreationRequest->method('getSubdomain')->willReturn('subdomain');
+
+        $subdomain = $organizationFactory->makeSubdomain($organizationCreationRequest);
+
+        $this->assertEquals(
+            'subdomain',
+            $subdomain->getSubdomain()
+        );
+
+        $this->assertTrue(
+            $subdomain->isActive()
+        );
+    }
+
+    public function testCreateTypo3Website(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('createTypo3Website');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $response->method('getStatusCode')->willReturn(Response::HTTP_OK);
+        $response->method('getContent')->willReturn('{"root_uid": 456}');
+
+        $this->typo3Service->expects(self::once())->method('createSite')->with(123)->willReturn($response);
+
+        $organization->expects(self::once())->method('setCmsId')->with(456);
+        $this->entityManager->expects(self::once())->method('persist')->with($organization);
+        $this->entityManager->expects(self::once())->method('flush');
+
+        $result = $organizationFactory->createTypo3Website($organization);
+
+        $this->assertEquals(
+            456,
+            $result
+        );
+    }
+
+    public function testCreateTypo3WebsiteWithError(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('createTypo3Website');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $response->method('getStatusCode')->willReturn(Response::HTTP_FORBIDDEN);
+        $response->method('getContent')->willReturn('');
+
+        $this->typo3Service->expects(self::once())->method('createSite')->with(123)->willReturn($response);
+
+        $this->logger
+            ->expects(self::once())
+            ->method('critical')
+            ->with('/!\ A critical error happened while creating the Typo3 website');
+
+        $result = $organizationFactory->createTypo3Website($organization);
+
+        $this->assertNull($result);
+    }
+
+    public function testCreateTypo3WebsiteWithInvalidResponse(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('createTypo3Website');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $response->method('getStatusCode')->willReturn(Response::HTTP_OK);
+        $response->method('getContent')->willReturn('<html lang="fr">Login page</html>');
+
+        $this->typo3Service->expects(self::once())->method('createSite')->with(123)->willReturn($response);
+
+        $this->logger
+            ->expects(self::once())
+            ->method('critical')
+            ->with('/!\ A critical error happened while creating the Typo3 website');
+
+        $result = $organizationFactory->createTypo3Website($organization);
+
+        $this->assertNull($result);
+    }
+
+    public function testUpdateAdminassosDb(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('updateAdminassosDb');
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $response->method('getStatusCode')->willReturn(Response::HTTP_OK);
+
+        $this->apiLegacyRequestService
+            ->expects(self::once())
+            ->method('post')
+            ->with('/_internal/secure/organization/creation-event', ['organizationId' => 123])
+            ->willReturn($response);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+
+        $organizationFactory->updateAdminassosDb($organization);
+    }
+
+    public function testUpdateAdminassosDbError(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('updateAdminassosDb');
+
+        $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
+        $response->method('getStatusCode')->willReturn(Response::HTTP_BAD_REQUEST);
+        $response->method('getContent')->willReturn('some error');
+
+        $this->apiLegacyRequestService
+            ->expects(self::once())
+            ->method('post')
+            ->with('/_internal/secure/organization/creation-event', ['organizationId' => 123])
+            ->willReturn($response);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('An error happened while updating the adminassos database: some error');
+
+        $organizationFactory->updateAdminassosDb($organization);
+    }
+
+    public function testNormalizeIdentificationField(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('normalizeIdentificationField');
+
+        $this->assertEquals(
+            'c+est+une+phrase+normalisee+',
+            $organizationFactory->normalizeIdentificationField("C'est une phrase normalisée.")
+        );
+    }
+    public function testDelete(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('delete');
+
+        $organizationDeletionRequest = $this->getMockBuilder(OrganizationDeletionRequest::class)->getMock();
+
+        $organizationDeletionRequest->method('getOrganizationId')->willReturn(123);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $this->organizationRepository
+            ->expects(self::once())
+            ->method('find')
+            ->with(123)
+            ->willReturn($organization);
+
+        $this->entityManager->expects(self::once())->method('beginTransaction');
+        $this->entityManager->expects(self::once())->method('flush');
+        $this->entityManager->expects(self::once())->method('commit');
+        $this->entityManager->expects(self::never())->method('rollback');
+
+        $organizationFactory->expects(self::once())->method('deleteOrganizationAccesses')->with($organization);
+
+        $this->entityManager->expects(self::exactly(2))->method('remove')->withConsecutive(
+            [$parameters],
+            [$organization]
+        );
+
+        $organizationFactory->expects(self::once())->method('deleteTypo3Website')->with($organization);
+        $organizationFactory->expects(self::once())->method('switchDolibarrSocietyToProspect')->with($organization);
+        $organizationFactory->expects(self::once())->method('deleteOrganizationFiles')->with($organization);
+        $organizationFactory->expects(self::once())->method('deleteDirectoriesV1')->with($organization);
+        $organizationFactory->expects(self::once())->method('deleteDirectories59')->with($organization);
+
+        $organizationDeletionRequest
+            ->expects(self::once())
+            ->method('setStatus')
+            ->with(OrganizationDeletionRequest::STATUS_OK);
+
+        $result = $organizationFactory->delete($organizationDeletionRequest);
+
+        $this->assertEquals(
+            $organizationDeletionRequest,
+            $result
+        );
+    }
+
+    public function testDeleteWithRollback(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('delete');
+
+        $organizationDeletionRequest = $this->getMockBuilder(OrganizationDeletionRequest::class)->getMock();
+
+        $organizationDeletionRequest->method('getOrganizationId')->willReturn(123);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $this->organizationRepository
+            ->expects(self::once())
+            ->method('find')
+            ->with(123)
+            ->willReturn($organization);
+
+        $this->entityManager->expects(self::once())->method('beginTransaction');
+        $this->entityManager->expects(self::never())->method('flush');
+        $this->entityManager->expects(self::never())->method('commit');
+        $this->entityManager->expects(self::once())->method('rollback');
+
+        $organizationFactory->expects(self::once())->method('deleteOrganizationAccesses')->with($organization);
+
+        $this->entityManager->method('remove')->willThrowException(new \Exception('some error'));
+
+        $organizationFactory->expects(self::never())->method('deleteTypo3Website');
+        $organizationFactory->expects(self::never())->method('switchDolibarrSocietyToProspect');
+        $organizationFactory->expects(self::never())->method('deleteOrganizationFiles');
+        $organizationFactory->expects(self::never())->method('deleteDirectoriesV1');
+        $organizationFactory->expects(self::never())->method('deleteDirectories59');
+
+        $organizationDeletionRequest
+            ->expects(self::never())
+            ->method('setStatus');
+
+        $this->logger
+            ->expects(self::once())
+            ->method('critical')
+            ->with($this->callback(function ($arg) {
+                return is_string($arg) && str_contains($arg, 'An error happened, operation cancelled') && str_contains($arg, 'some error');
+            }));
+
+        $this->expectException(\Exception::class);
+
+        $organizationFactory->delete($organizationDeletionRequest);
+    }
+
+    public function testDeleteWithNonBlockingErrors(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('delete');
+
+        $organizationDeletionRequest = $this->getMockBuilder(OrganizationDeletionRequest::class)->getMock();
+
+        $organizationDeletionRequest->method('getOrganizationId')->willReturn(123);
+
+        $parameters = $this->getMockBuilder(Parameters::class)->getMock();
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+        $organization->method('getParameters')->willReturn($parameters);
+
+        $this->organizationRepository
+            ->expects(self::once())
+            ->method('find')
+            ->with(123)
+            ->willReturn($organization);
+
+        $this->entityManager->expects(self::once())->method('beginTransaction');
+        $this->entityManager->expects(self::once())->method('flush');
+        $this->entityManager->expects(self::once())->method('commit');
+        $this->entityManager->expects(self::never())->method('rollback');
+
+        $organizationFactory->expects(self::once())->method('deleteOrganizationAccesses')->with($organization);
+
+        $this->entityManager->expects(self::exactly(2))->method('remove')->withConsecutive(
+            [$parameters],
+            [$organization]
+        );
+
+        $organizationFactory->expects(self::once())->method('deleteTypo3Website')->willThrowException(new \Exception('some error'));
+        $organizationFactory->expects(self::once())->method('switchDolibarrSocietyToProspect')->willThrowException(new \Exception('some error'));
+        $organizationFactory->expects(self::once())->method('deleteOrganizationFiles')->willThrowException(new \Exception('some error'));
+        $organizationFactory->expects(self::once())->method('deleteDirectoriesV1')->willThrowException(new \Exception('some error'));
+        $organizationFactory->expects(self::once())->method('deleteDirectories59')->willThrowException(new \Exception('some error'));
+
+        $organizationDeletionRequest
+            ->expects(self::once())
+            ->method('setStatus')
+            ->with(OrganizationDeletionRequest::STATUS_OK_WITH_ERRORS);
+
+        $this->logger
+            ->expects(self::exactly(5))
+            ->method('critical')
+        ->withConsecutive(
+            ['An error happened while deleting the Typo3 website, please proceed manually.'],
+            ['An error happened while updating the Dolibarr society, please proceed manually.'],
+            ['An error happened while deleting the local directories, please proceed manually.'],
+            ['An error happened while deleting the V1 directories, please proceed manually.'],
+            ['An error happened while deleting the 5.9 directories, please proceed manually.'],
+        );
+
+        $result = $organizationFactory->delete($organizationDeletionRequest);
+
+        $this->assertEquals(
+            $organizationDeletionRequest,
+            $result
+        );
+    }
+
+    public function testDeleteOrganizationAccesses(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('deleteOrganizationAccesses');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $access1 = $this->getMockBuilder(Access::class)->getMock();
+        $access2 = $this->getMockBuilder(Access::class)->getMock();
+        $access_other = $this->getMockBuilder(Access::class)->getMock();
+
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $person1->method('getAccesses')->willReturn(new ArrayCollection([$access1]));
+        $access1->method('getPerson')->willReturn($person1);
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->method('getAccesses')->willReturn(new ArrayCollection([$access2, $access_other]));
+        $access2->method('getPerson')->willReturn($person2);
+
+        $organization->method('getAccesses')->willReturn(new ArrayCollection([$access1, $access2]));
+
+        $this->entityManager
+            ->expects(self::exactly(3))
+            ->method('remove')
+            ->withConsecutive(
+                [$person1],
+                [$access1],
+                [$access2],
+            );
+
+        $organizationFactory->deleteOrganizationAccesses($organization);
+    }
+
+    public function testSwitchDolibarrSocietyToProspect(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('switchDolibarrSocietyToProspect');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization->method('getId')->willReturn(123);
+
+        $this->dolibarrApiService
+            ->expects(self::once())
+            ->method('switchSocietyToProspect')
+            ->with(123);
+
+        $organizationFactory->switchDolibarrSocietyToProspect($organization);
+    }
+}

+ 62 - 0
tests/Unit/Service/Organization/UtilsTest.php

@@ -4,6 +4,7 @@
 
 namespace App\Tests\Unit\Service\Organization;
 
+use App\Entity\Network\NetworkOrganization;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Settings;
@@ -653,4 +654,65 @@ class UtilsTest extends TestCase
         $this->assertTrue($organizationUtils->hasModule($organization, 'foo'));
         $this->assertFalse($organizationUtils->hasModule($organization, 'other'));
     }
+
+    /**
+     * @see OrganizationUtils::getActiveNetworkOrganization()
+     */
+    public function testGetActiveNetworkOrganization(): void
+    {
+        $organizationUtils = $this->getOrganizationUtilsMockFor('getActiveNetworkOrganization');
+
+        $networkOrganization1 = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization2 = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization3 = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+
+        $this
+            ->networkUtils
+            ->expects(self::exactly(2))
+            ->method('isNetworkOrganizationActiveNow')
+            ->willReturnMap([
+                [$networkOrganization1, false],
+                [$networkOrganization2, true],
+                [$networkOrganization3, false],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization
+            ->method('getNetworkOrganizations')
+            ->willReturn(new ArrayCollection([$networkOrganization1, $networkOrganization2, $networkOrganization3]));
+
+        $result = $organizationUtils->getActiveNetworkOrganization($organization);
+
+        $this->assertEquals($networkOrganization2, $result);
+    }
+
+    /**
+     * @see OrganizationUtils::getActiveNetworkOrganization()
+     */
+    public function testGetActiveNetworkOrganizationNoActive(): void
+    {
+        $organizationUtils = $this->getOrganizationUtilsMockFor('getActiveNetworkOrganization');
+
+        $networkOrganization1 = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+        $networkOrganization2 = $this->getMockBuilder(NetworkOrganization::class)->getMock();
+
+        $this
+            ->networkUtils
+            ->expects(self::exactly(2))
+            ->method('isNetworkOrganizationActiveNow')
+            ->willReturnMap([
+                [$networkOrganization1, false],
+                [$networkOrganization2, false],
+            ]);
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+        $organization
+            ->method('getNetworkOrganizations')
+            ->willReturn(new ArrayCollection([$networkOrganization1, $networkOrganization2]));
+
+        $result = $organizationUtils->getActiveNetworkOrganization($organization);
+
+        $this->assertEquals(null, $result);
+    }
+
 }

+ 100 - 23
tests/Unit/Service/Rest/ApiRequestServiceTest.php

@@ -9,6 +9,15 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
 
+class TestableApiRequestService extends ApiRequestService {
+    public function addBodyOption(array $options, array|string $body): array
+    {
+        return parent::addBodyOption($options, $body);
+    }
+}
+
+
+
 class ApiRequestServiceTest extends TestCase
 {
     private HttpClientInterface $client;
@@ -19,11 +28,11 @@ class ApiRequestServiceTest extends TestCase
     }
 
     /**
-     * @see ApiRequestService::getJsonContent()
+     * @see TestableApiRequestService::getJsonContent()
      */
     public function testGetJsonContent(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['getJsonContent'])
             ->getMock();
@@ -39,11 +48,11 @@ class ApiRequestServiceTest extends TestCase
     }
 
     /**
-     * @see ApiRequestService::getJsonContent()
+     * @see TestableApiRequestService::getJsonContent()
      */
     public function testGetContent(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['getContent'])
             ->getMock();
@@ -62,11 +71,11 @@ class ApiRequestServiceTest extends TestCase
     }
 
     /**
-     * @see ApiRequestService::getContent()
+     * @see TestableApiRequestService::getContent()
      */
     public function testGetContentWithError(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['getContent'])
             ->getMock();
@@ -80,11 +89,11 @@ class ApiRequestServiceTest extends TestCase
     }
 
     /**
-     * @see ApiRequestService::get()
+     * @see TestableApiRequestService::get()
      */
     public function testGet(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['get'])
             ->getMock();
@@ -101,34 +110,96 @@ class ApiRequestServiceTest extends TestCase
         $this->assertEquals($response, $actualResponse);
     }
 
+    public function testAddBodyOptionBodyIsString()
+    {
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['addBodyOption'])
+            ->getMock();
+
+        $options = ['some_option' => 'some_value'];
+        $body = 'foo';
+
+        $result = $apiRequestService->addBodyOption($options, $body);
+
+        $this->assertEquals(
+            ['some_option' => 'some_value', 'body' => 'foo'],
+            $result
+        );
+    }
+
+    public function testAddBodyOptionBodyIsArray()
+    {
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['addBodyOption'])
+            ->getMock();
+
+        $options = ['some_option' => 'some_value'];
+        $body = ['foo' => 'bar'];
+
+        $result = $apiRequestService->addBodyOption($options, $body);
+
+        $this->assertEquals(
+            ['some_option' => 'some_value', 'json' => ['foo' => 'bar']],
+            $result
+        );
+    }
+
+
+    public function testAddBodyOptionKeyExists()
+    {
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
+            ->setConstructorArgs([$this->client])
+            ->setMethodsExcept(['addBodyOption'])
+            ->getMock();
+
+        $options = ['some_option' => 'some_value', 'body' => 'exists'];
+        $body = 'foo';
+
+        $result = $apiRequestService->addBodyOption($options, $body);
+
+        $this->assertEquals(
+            ['some_option' => 'some_value', 'body' => 'foo'],
+            $result
+        );
+    }
+
     /**
-     * @see ApiRequestService::post()
+     * @see TestableApiRequestService::post()
      */
     public function testPost(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['post'])
             ->getMock();
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
 
-        $apiRequestService->expects(self::once())
+        $apiRequestService
+            ->expects(self::once())
             ->method('request')
-            ->with('POST', 'path/to/data', [], [])
+            ->with('POST', 'path/to/data', [], ['option' => 2, 'json' => ['foo' => 1]])
             ->willReturn($response);
 
-        $actualResponse = $apiRequestService->post('path/to/data');
+        $apiRequestService
+            ->expects(self::once())
+            ->method('addBodyOption')
+            ->with(['option' => 2, 'json' => 3], ['foo' => 1])
+            ->willReturn(['option' => 2, 'json' => ['foo' => 1]]);
+
+        $actualResponse = $apiRequestService->post('path/to/data', ['foo' => 1], [], ['option' => 2, 'json' => 3]);
 
         $this->assertEquals($response, $actualResponse);
     }
 
     /**
-     * @see ApiRequestService::put()
+     * @see TestableApiRequestService::put()
      */
     public function testPut(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['put'])
             ->getMock();
@@ -137,20 +208,26 @@ class ApiRequestServiceTest extends TestCase
 
         $apiRequestService->expects(self::once())
             ->method('request')
-            ->with('PUT', 'path/to/data', [], [])
+            ->with('PUT', 'path/to/data', [], ['option' => 2, 'body' => 'foo'])
             ->willReturn($response);
 
-        $actualResponse = $apiRequestService->put('path/to/data');
+        $apiRequestService
+            ->expects(self::once())
+            ->method('addBodyOption')
+            ->with([], 'foo')
+            ->willReturn(['option' => 2, 'body' => 'foo']);
+
+        $actualResponse = $apiRequestService->put('path/to/data', 'foo');
 
         $this->assertEquals($response, $actualResponse);
     }
 
     /**
-     * @see ApiRequestService::delete()
+     * @see TestableApiRequestService::delete()
      */
     public function testDelete(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['delete'])
             ->getMock();
@@ -168,11 +245,11 @@ class ApiRequestServiceTest extends TestCase
     }
 
     /**
-     * @see ApiRequestService::request()
+     * @see TestableApiRequestService::request()
      */
     public function testRequest(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['request'])
             ->getMock();
@@ -186,11 +263,11 @@ class ApiRequestServiceTest extends TestCase
     }
 
     /**
-     * @see ApiRequestService::request()
+     * @see TestableApiRequestService::request()
      */
     public function testRequestWithError(): void
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['request'])
             ->getMock();

+ 44 - 2
tests/Unit/Service/Security/InternalRequestsServiceTest.php

@@ -2,9 +2,12 @@
 
 namespace App\Tests\Unit\Service\Security;
 
+use App\Entity\Access\Access;
 use App\Service\Security\InternalRequestsService;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\Security\Core\User\UserInterface;
 
 class TestableInternalRequestsService extends InternalRequestsService
 {
@@ -13,7 +16,7 @@ class TestableInternalRequestsService extends InternalRequestsService
         return parent::isInternalIp($ip);
     }
 
-    public function tokenIsValid(string $token): bool
+    public function tokenIsValid(string|null $token): bool
     {
         return parent::tokenIsValid($token);
     }
@@ -22,15 +25,17 @@ class TestableInternalRequestsService extends InternalRequestsService
 class InternalRequestsServiceTest extends TestCase
 {
     public const internalRequestsToken = 'azerty';
+    private Security $security;
 
     public function setUp(): void
     {
+        $this->security = $this->getMockBuilder(Security::class)->disableOriginalConstructor()->getMock();
     }
 
     private function getInternalRequestsServiceMockFor(string $methodName, ?string $token = null): TestableInternalRequestsService|MockObject
     {
         return $this->getMockBuilder(TestableInternalRequestsService::class)
-            ->setConstructorArgs([$token ?? self::internalRequestsToken])
+            ->setConstructorArgs([$token ?? self::internalRequestsToken, $this->security])
             ->setMethodsExcept([$methodName])
             ->getMock();
     }
@@ -71,6 +76,43 @@ class InternalRequestsServiceTest extends TestCase
         $this->assertFalse($internalRequestsService->tokenIsValid(''));
     }
 
+    public function testIsSuperAdmin(): void
+    {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isSuperAdmin', '');
+
+        $user = $this->getMockBuilder(Access::class)->getMock();
+        $user->method('getSuperAdminAccess')->willReturn(true);
+        $this->security->expects($this->once())->method('getUser')->willReturn($user);
+
+        $this->assertTrue(
+            $internalRequestsService->isSuperAdmin()
+        );
+    }
+
+    public function testIsSuperAdminIsNot(): void
+    {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isSuperAdmin', '');
+
+        $user = $this->getMockBuilder(Access::class)->getMock();
+        $user->method('getSuperAdminAccess')->willReturn(false);
+        $this->security->expects($this->once())->method('getUser')->willReturn($user);
+
+        $this->assertFalse(
+            $internalRequestsService->isSuperAdmin()
+        );
+    }
+
+    public function testIsSuperAdminNoUser(): void
+    {
+        $internalRequestsService = $this->getInternalRequestsServiceMockFor('isSuperAdmin', '');
+
+        $this->security->expects($this->once())->method('getUser')->willReturn(null);
+
+        $this->assertFalse(
+            $internalRequestsService->isSuperAdmin()
+        );
+    }
+
     public function testIsAllowed(): void
     {
         $internalRequestsService = $this->getInternalRequestsServiceMockFor('isAllowed');

+ 8 - 8
tests/Unit/Service/Typo3/SubdomainServiceTest.php

@@ -7,8 +7,8 @@ use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Subdomain;
 use App\Entity\Person\Person;
-use App\Message\Command\MailerCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\MailerCommand;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Organization\SubdomainRepository;
 use App\Service\Mailer\Model\SubdomainChangeModel;
@@ -184,14 +184,11 @@ class SubdomainServiceTest extends TestCase
         $subdomainService->expects(self::once())->method('isReservedSubdomain')->with('sub')->willReturn(false);
         $subdomainService->expects(self::once())->method('isRegistered')->with('sub')->willReturn(false);
 
-        $this->entityManager->expects(self::exactly(2))->method('persist');
+        $this->entityManager->expects(self::once())->method('persist');
         $this->entityManager->expects(self::once())->method('flush');
 
         $this->bindFileService->expects(self::once())->method('registerSubdomain')->with('sub');
 
-        $parameters->expects(self::once())->method('setSubDomain')->with('sub');
-        $parameters->expects(self::once())->method('setOtherWebsite')->with('https://sub.opentalent.fr');
-
         // Subdomain is not activated by default
         $subdomainService->expects(self::never())->method('activateSubdomain');
 
@@ -391,6 +388,9 @@ class SubdomainServiceTest extends TestCase
         $subdomainService->expects(self::never())->method('updateTypo3Website');
         $subdomainService->expects(self::never())->method('sendConfirmationEmail');
 
+//        $parameters->expects(self::once())->method('setSubDomain')->with('sub');
+//        $parameters->expects(self::once())->method('setOtherWebsite')->with('https://sub.opentalent.fr');
+
         $this->expectException(\RuntimeException::class);
         $this->expectExceptionMessage('Can not activate a non-persisted subdomain');
 
@@ -471,8 +471,8 @@ class SubdomainServiceTest extends TestCase
         $this->messageBus
             ->expects(self::once())
             ->method('dispatch')
-            ->with(self::isInstanceOf(Typo3UpdateCommand::class))
-            ->willReturn(new Envelope(new Typo3UpdateCommand(1)));
+            ->with(self::isInstanceOf(Typo3Update::class))
+            ->willReturn(new Envelope(new Typo3Update(1)));
 
         $subdomainService->updateTypo3Website($organization);
     }

+ 1 - 1
tests/Unit/Service/Typo3/Typo3ServiceTest.php

@@ -36,7 +36,7 @@ class Typo3ServiceTest extends TestCase
         $this->typo3Client
             ->expects(self::once())
             ->method('request')
-            ->with('GET', '/typo3/index.php?route=foo&param=bar')
+            ->with('GET', '/typo3/foo?param=bar')
             ->willReturn($response);
 
         $typo3Service = $this->getMockBuilder(TestableTypo3Service::class)

+ 6 - 2
tests/Unit/Service/Utils/UrlBuilderTest.php

@@ -23,8 +23,12 @@ class UrlBuilderTest extends TestCase
             UrlBuilder::concatPath('https://domain.org/', ['/abc/def'])
         );
         $this->assertEquals(
-            'https://domain.org/abc/def/ghi/jkl',
-            UrlBuilder::concatPath('https://domain.org/', ['/abc/def', 'ghi', '//jkl'])
+            'https://domain.org/abc/def/ghi/jkl/',
+            UrlBuilder::concatPath('https://domain.org/', ['/abc/def', 'ghi', '//jkl/'])
+        );
+        $this->assertEquals(
+            '/abc/def',
+            UrlBuilder::concatPath('/abc/', ['/def'])
         );
         $this->assertEquals(
             'https://domain.org/',