浏览代码

Merge branch 'feature/V8-7041-mise--disposition-du-bon-de-comm' into feature/boutique

# Conflicts:
#	src/Entity/Organization/Settings.php
Vincent 8 月之前
父节点
当前提交
39f0c33068
共有 100 个文件被更改,包括 6927 次插入603 次删除
  1. 1 1
      composer.json
  2. 1 0
      config/opentalent/products.yaml
  3. 5 5
      config/packages/messenger.yaml
  4. 56 0
      config/packages/monolog.yaml
  5. 0 12
      config/packages/staging/monolog.yaml
  6. 0 12
      config/packages/test/monolog.yaml
  7. 2 0
      config/services.yaml
  8. 18 16
      doc/internal_requests.md
  9. 3 1
      env/.env.staging
  10. 5 1
      env/.env.test
  11. 5 1
      env/.env.test1
  12. 5 1
      env/.env.test2
  13. 5 1
      env/.env.test3
  14. 5 1
      env/.env.test4
  15. 5 1
      env/.env.test5
  16. 5 1
      env/.env.test6
  17. 5 1
      env/.env.test7
  18. 5 1
      env/.env.test8
  19. 5 1
      env/.env.test9
  20. 1 1
      phpstan.neon.dist
  21. 4 0
      readme.md
  22. 20 0
      sql/schema-extensions/003-view_organization_identification.sql
  23. 88 0
      src/ApiResources/Dolibarr/DolibarrDocDownload.php
  24. 469 0
      src/ApiResources/Organization/OrganizationCreationRequest.php
  25. 112 0
      src/ApiResources/Organization/OrganizationDeletionRequest.php
  26. 228 0
      src/ApiResources/Organization/OrganizationMemberCreationRequest.php
  27. 4 2
      src/ApiResources/Profile/AccessProfile.php
  28. 63 45
      src/Entity/Access/Access.php
  29. 2 0
      src/Entity/Access/OrganizationFunction.php
  30. 3 0
      src/Entity/Core/AddressPostal.php
  31. 3 0
      src/Entity/Core/ContactPoint.php
  32. 3 0
      src/Entity/Network/NetworkOrganization.php
  33. 51 36
      src/Entity/Organization/Organization.php
  34. 4 1
      src/Entity/Organization/OrganizationAddressPostal.php
  35. 204 0
      src/Entity/Organization/OrganizationIdentification.php
  36. 8 3
      src/Entity/Organization/Parameters.php
  37. 15 0
      src/Entity/Organization/Settings.php
  38. 55 10
      src/Entity/Person/Person.php
  39. 4 1
      src/Entity/Person/PersonAddressPostal.php
  40. 43 0
      src/Entity/Traits/CreatedOnAndByTrait.php
  41. 52 52
      src/Enum/Cotisation/TypeOfPracticeEnum.php
  42. 19 0
      src/Enum/Dolibarr/DolibarrDocTypeEnum.php
  43. 1 0
      src/Enum/Organization/OrganizationIdsEnum.php
  44. 1 1
      src/Message/Handler/ExportHandler.php
  45. 2 2
      src/Message/Handler/MailerHandler.php
  46. 56 0
      src/Message/Handler/OrganizationCreationHandler.php
  47. 69 0
      src/Message/Handler/OrganizationDeletionHandler.php
  48. 3 3
      src/Message/Handler/Typo3/Typo3DeleteHandler.php
  49. 3 3
      src/Message/Handler/Typo3/Typo3UndeleteHandler.php
  50. 3 3
      src/Message/Handler/Typo3/Typo3UpdateHandler.php
  51. 1 1
      src/Message/Message/Export.php
  52. 2 2
      src/Message/Message/Mailer.php
  53. 28 0
      src/Message/Message/OrganizationCreation.php
  54. 30 0
      src/Message/Message/OrganizationDeletion.php
  55. 2 2
      src/Message/Message/Typo3/Typo3Delete.php
  56. 2 2
      src/Message/Message/Typo3/Typo3Undelete.php
  57. 2 2
      src/Message/Message/Typo3/Typo3Update.php
  58. 20 0
      src/Repository/Core/FileRepository.php
  59. 21 0
      src/Repository/Organization/OrganizationIdentificationRepository.php
  60. 1 1
      src/Repository/Person/PersonRepository.php
  61. 1 1
      src/Security/Voter/InternalRequestsVoter.php
  62. 13 13
      src/Service/Cron/Job/CleanDb.php
  63. 26 51
      src/Service/Cron/Job/CleanTempFiles.php
  64. 2 1
      src/Service/Doctrine/FiltersConfigurationService.php
  65. 66 0
      src/Service/Dolibarr/DolibarrApiService.php
  66. 30 0
      src/Service/File/FileManager.php
  67. 18 0
      src/Service/File/Storage/ApiLegacyStorage.php
  68. 6 0
      src/Service/File/Storage/FileStorageInterface.php
  69. 37 4
      src/Service/File/Storage/LocalStorage.php
  70. 7 7
      src/Service/OnChange/Organization/OnParametersChange.php
  71. 924 0
      src/Service/Organization/OrganizationFactory.php
  72. 17 0
      src/Service/Organization/Utils.php
  73. 8 6
      src/Service/Rest/ApiRequestInterface.php
  74. 27 6
      src/Service/Rest/ApiRequestService.php
  75. 19 3
      src/Service/Security/InternalRequestsService.php
  76. 8 0
      src/Service/ServiceIterator/StorageIterator.php
  77. 15 15
      src/Service/Typo3/SubdomainService.php
  78. 27 3
      src/Service/Typo3/Typo3Service.php
  79. 5 1
      src/Service/Utils/EntityUtils.php
  80. 1 1
      src/Service/Utils/Path.php
  81. 23 0
      src/Service/Utils/SecurityUtils.php
  82. 1 1
      src/Service/Utils/UrlBuilder.php
  83. 1 2
      src/State/Processor/Export/LicenceCmf/ExportRequestProcessor.php
  84. 64 0
      src/State/Processor/Organization/OrganizationCreationRequestProcessor.php
  85. 54 0
      src/State/Processor/Organization/OrganizationDeletionRequestProcessor.php
  86. 54 0
      src/State/Provider/Dolibarr/DolibarrDocDownloadProvider.php
  87. 1 0
      tests/Fixture/Factory/Organization/OrganizationFactory.php
  88. 301 0
      tests/Unit/Service/Cron/Job/CleanDbTest.php
  89. 190 81
      tests/Unit/Service/Cron/Job/CleanTempFilesTest.php
  90. 0 90
      tests/Unit/Service/Doctrine/FiltersConfigurationService.php
  91. 213 0
      tests/Unit/Service/Doctrine/FiltersConfigurationServiceTest.php
  92. 1 1
      tests/Unit/Service/Dolibarr/DolibarrAccountCreatorTest.php
  93. 125 0
      tests/Unit/Service/Dolibarr/DolibarrApiServiceTest.php
  94. 80 34
      tests/Unit/Service/File/FileManagerTest.php
  95. 99 18
      tests/Unit/Service/File/Storage/ApiLegacyStorageTest.php
  96. 262 0
      tests/Unit/Service/File/Storage/LocalStorageTest.php
  97. 11 11
      tests/Unit/Service/OnChange/Organization/OnParametersChangeTest.php
  98. 2193 0
      tests/Unit/Service/Organization/OrganizationFactoryTest.php
  99. 61 0
      tests/Unit/Service/Organization/UtilsTest.php
  100. 98 23
      tests/Unit/Service/Rest/ApiRequestServiceTest.php

+ 1 - 1
composer.json

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

+ 1 - 0
config/opentalent/products.yaml

@@ -18,6 +18,7 @@ parameters:
           - LicenceCmfOrganizationER
           - LicenceCmfOrganizationER
           - UploadRequest
           - UploadRequest
           - SubdomainAvailability
           - SubdomainAvailability
+          - DolibarrDocDownload
         roles:
         roles:
           - ROLE_IMPORT
           - ROLE_IMPORT
           - ROLE_TAGG
           - ROLE_TAGG

+ 5 - 5
config/packages/messenger.yaml

@@ -11,8 +11,8 @@ framework:
 
 
         routing:
         routing:
             # Route your messages to the transports
             # Route your messages to the transports
-            'App\Message\Command\MailerCommand': async
-            'App\Message\Command\Export': async
-            'App\Message\Command\Typo3\Typo3UpdateCommand': async
-            'App\Message\Command\Typo3\Typo3DeleteCommand': async
-            'App\Message\Command\Typo3\Typo3UndeleteCommand': async
+            'App\Message\Message\Mailer': async
+            'App\Message\Message\Export': async
+            'App\Message\Message\Typo3\Typo3Update': async
+            'App\Message\Message\Typo3\Typo3Delete': async
+            'App\Message\Message\Typo3\Typo3Undelete': async

+ 56 - 0
config/packages/monolog.yaml

@@ -2,6 +2,7 @@ monolog:
     channels:
     channels:
         - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
         - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
         - cron
         - cron
+        - admin
 
 
     handlers:
     handlers:
         # sorties standards (stdout, stderr, console)
         # sorties standards (stdout, stderr, console)
@@ -35,6 +36,12 @@ monolog:
             level: debug
             level: debug
             max_files: 3
             max_files: 3
             channels: [security]
             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
         # email en cas d'erreurs critiques, sauf erreurs 404 / 405
         #        critical:
         #        critical:
@@ -103,6 +110,55 @@ monolog:
             formatter:      monolog.formatter.html
             formatter:      monolog.formatter.html
             content_type:   text/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
         # uncomment to get logging in your browser
         # you may have to allow bigger header sizes in your Web server configuration
         # you may have to allow bigger header sizes in your Web server configuration
         #firephp:
         #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)%'
             $publicLegacyBaseUrl: '%env(PUBLIC_API_LEG_BASE_URL)%'
             $baseUrl: '%env(API_BASE_URL)%'
             $baseUrl: '%env(API_BASE_URL)%'
             $publicBaseUrl: '%env(PUBLIC_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
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name
     # this creates a service per class whose id is the fully-qualified class name

+ 18 - 16
doc/internal_requests.md

@@ -2,24 +2,25 @@
 
 
 ### Principe général
 ### 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 :
 Pour éviter tout risque de sécurité lié à ces routes :
 
 
 * on restreint leur accès aux ips internes
 * 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
 * on limite les routes concernées
 
 
-Ainsi, si l'on prend l'exemple d'une requête `/internal/download/123` sur ap2i :
+Ainsi, si l'on prend l'exemple d'une requête `/internal/download/123` envoyée à 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
+* 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 
 ### Ip internes 
 
 
@@ -36,13 +37,14 @@ Les ips considérées comme interne sont :
 
 
 ### Mise en oeuvre
 ### 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 :
 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
 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é.
 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 +54,20 @@ Les routes internal sont configurées ici : `config/packages/security.yaml`
 
 
 ### Valider le fonctionnement
 ### 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.
 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 :
 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 | NON           | NON         | NON        | 401 Unauthorized |
 | /api/internal/download/$id | OUI           | 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           | NON         | OUI        | 200 OK           |
 | /api/internal/download/$id | OUI           | OUI         | 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 | OUI           | OUI         | NON        | 403 Forbidden    |
-| /api/internal/download/$id | NON           | OUI         | OUI        | 403 Forbidden    |
 | /api/download/$id          | *             | NON         | *          | 401 Unauthorized |
 | /api/download/$id          | *             | NON         | *          | 401 Unauthorized |
 | /api/download/$id          | *             | OUI         | *          | 200 OK           |
 | /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
 LOG_FILE_NAME=staging
 ###< filename log ###
 ###< 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 ###
 ###< api v2 ###
 
 
 ###> typo3 client ###
 ###> typo3 client ###
-TYPO3_BASE_URI=http://test.opentalent.fr/ohcluses
+TYPO3_BASE_URI=https://sub.test.opentalent.fr
 ###< typo3 client ###
 ###< typo3 client ###
 
 
 ###> symfony/mercure-bundle ###
 ###> 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
 MERCURE_PUBLIC_URL=https://mercure.test.opentalent.fr/.well-known/mercure
 ###< symfony/mercure-bundle ###
 ###< symfony/mercure-bundle ###
 
 
+###> bindfile populate buffer file
+BIND_FILE_BUFFER_FILE=/env/subdomain.txt
+###< bindfile populate buffer file
+
 ###> filename log ###
 ###> filename log ###
 LOG_FILE_NAME=test
 LOG_FILE_NAME=test
 ###< filename log ###
 ###< filename log ###

+ 5 - 1
env/.env.test1

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

+ 5 - 1
env/.env.test2

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

+ 5 - 1
env/.env.test3

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

+ 5 - 1
env/.env.test4

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

+ 5 - 1
env/.env.test5

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

+ 5 - 1
env/.env.test6

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

+ 5 - 1
env/.env.test7

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

+ 5 - 1
env/.env.test8

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

+ 5 - 1
env/.env.test9

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

+ 1 - 1
phpstan.neon.dist

@@ -2,7 +2,6 @@
 parameters:
 parameters:
     level: 6
     level: 6
     treatPhpDocTypesAsCertain: false
     treatPhpDocTypesAsCertain: false
-    checkGenericClassInNonGenericObjectType: false
     paths:
     paths:
         - src
         - 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)
     # 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 :
     ignoreErrors :
         - '#Attribute class JetBrains\\PhpStorm\\[a-zA-Z]+ does not exist.#'
         - '#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
 ## Corriger le formatage du code automatiquement
 
 
     php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php
     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';

+ 88 - 0
src/ApiResources/Dolibarr/DolibarrDocDownload.php

@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\Dolibarr;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use App\Enum\Dolibarr\DolibarrDocTypeEnum;
+use App\State\Provider\Dolibarr\DolibarrDocDownloadProvider;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Demande de téléchargement d'un fichier depuis Dolibarr (facture, bon de commande, etc).
+ */
+#[ApiResource(
+    operations: [
+        new Get(
+            uriTemplate: '/dolibarr/download/{dolibarrDocType}/{ref}',
+            requirements: [
+                'ref' => '[\w-]+',
+            ],
+            security: '(is_granted("ROLE_ADMIN_CORE") or 
+                            is_granted("ROLE_ADMINISTRATIF_MANAGER_CORE") or 
+                            is_granted("ROLE_PEDAGOGICS_MANAGER_CORE") or 
+                            is_granted("ROLE_FINANCIAL_MANAGER_CORE"))',
+            provider: DolibarrDocDownloadProvider::class
+        ),
+    ]
+)]
+class DolibarrDocDownload
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    protected int $id = 0;
+
+    /**
+     * Type de fichier à télécharger.
+     */
+    #[Assert\Type(type: DolibarrDocTypeEnum::class)]
+    protected DolibarrDocTypeEnum $dolibarrDocType;
+
+    /**
+     * The dolibarr reference of the document (ex: CO2502-0380, FA2101-02988, ...)
+     * Must be URL Encoded.
+     */
+    protected string $ref;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getDolibarrDocType(): DolibarrDocTypeEnum
+    {
+        return $this->dolibarrDocType;
+    }
+
+    public function setDolibarrDocType(DolibarrDocTypeEnum $dolibarrDocType): self
+    {
+        $this->dolibarrDocType = $dolibarrDocType;
+
+        return $this;
+    }
+
+    public function getRef(): string
+    {
+        return $this->ref;
+    }
+
+    public function setRef(string $ref): self
+    {
+        $this->ref = $ref;
+
+        return $this;
+    }
+}

+ 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;
+    }
+}

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

@@ -0,0 +1,112 @@
+<?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 ?
+     */
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private ?string $sendConfirmationEmailAt = null;
+
+    /**
+     * Statut de l'opération.
+     */
+    private string $status = self::STATUS_PENDING;
+
+    /**
+     * 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 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;
+    }
+}

+ 4 - 2
src/ApiResources/Profile/AccessProfile.php

@@ -302,7 +302,8 @@ class AccessProfile implements ApiResourcesInterface
     }
     }
 
 
     /**
     /**
-     * return required for PHP Stan
+     * return required for PHP Stan.
+     *
      * @return bool[]
      * @return bool[]
      */
      */
     public function getHistorical(): array
     public function getHistorical(): array
@@ -311,7 +312,8 @@ class AccessProfile implements ApiResourcesInterface
     }
     }
 
 
     /**
     /**
-     * param require for PHP Stan
+     * param require for PHP Stan.
+     *
      * @param bool[] $historical
      * @param bool[] $historical
      *
      *
      * @return $this
      * @return $this

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

@@ -50,6 +50,7 @@ use App\Entity\Product\Equipment;
 use App\Entity\Product\EquipmentLoan;
 use App\Entity\Product\EquipmentLoan;
 use App\Entity\Product\EquipmentRepair;
 use App\Entity\Product\EquipmentRepair;
 use App\Entity\Reward\AccessReward;
 use App\Entity\Reward\AccessReward;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Filter\ApiPlatform\Person\FullNameFilter;
 use App\Filter\ApiPlatform\Person\FullNameFilter;
 use App\Filter\ApiPlatform\Utils\InFilter;
 use App\Filter\ApiPlatform\Utils\InFilter;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Access\AccessRepository;
@@ -79,6 +80,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ApiFilter(GroupFilter::class, arguments: ['whitelist' => ['access_people_ref']])]
 #[ApiFilter(GroupFilter::class, arguments: ['whitelist' => ['access_people_ref']])]
 class Access implements UserInterface, PasswordAuthenticatedUserInterface
 class Access implements UserInterface, PasswordAuthenticatedUserInterface
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]
@@ -109,6 +112,9 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column(type: 'json', length: 4294967295, nullable: true)]
     #[ORM\Column(type: 'json', length: 4294967295, nullable: true)]
     private ?array $roles = [];
     private ?array $roles = [];
 
 
+    #[ORM\Column(options: ['default' => false])]
+    private bool $loginEnabled = false;
+
     /** @var mixed[]|null */
     /** @var mixed[]|null */
     #[Groups(['my_access:input'])]
     #[Groups(['my_access:input'])]
     #[ORM\Column(type: 'json', length: 4294967295, nullable: true)]
     #[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)]
     #[ORM\OneToOne(mappedBy: 'access', cascade: ['persist'], orphanRemoval: true)]
     private AccessBilling $accessBilling;
     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;
     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;
     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;
     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;
     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;
     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;
     private Collection $notificationUsers;
 
 
     #[ORM\ManyToMany(targetEntity: Access::class, mappedBy: 'children', cascade: ['persist'])]
     #[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')]
     #[ORM\InverseJoinColumn(name: 'children_id', referencedColumnName: 'id')]
     private Collection $children;
     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;
     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;
     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;
     private Collection $accessIntangibles;
 
 
     #[ORM\ManyToOne(inversedBy: 'publicationDirectors')]
     #[ORM\ManyToOne(inversedBy: 'publicationDirectors')]
@@ -161,22 +167,22 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     private ?EducationNotationConfig $educationNotationConfig;
     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;
     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;
     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;
     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;
     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;
     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;
     private Collection $holidays;
 
 
     #[ORM\ManyToMany(targetEntity: Course::class, mappedBy: 'students', cascade: ['persist'])]
     #[ORM\ManyToMany(targetEntity: Course::class, mappedBy: 'students', cascade: ['persist'])]
@@ -201,97 +207,97 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column]
     #[ORM\Column]
     private bool $ielEnabled = false;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     private Collection $sms;
 
 
     #[ORM\ManyToMany(targetEntity: Jury::class, mappedBy: 'members', orphanRemoval: true)]
     #[ORM\ManyToMany(targetEntity: Jury::class, mappedBy: 'members', orphanRemoval: true)]
     private Collection $juryMembers;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     private Collection $workByUsers;
 
 
     #[ORM\ManyToMany(targetEntity: Tagg::class, inversedBy: 'accesses', cascade: ['persist'])]
     #[ORM\ManyToMany(targetEntity: Tagg::class, inversedBy: 'accesses', cascade: ['persist'])]
@@ -493,6 +499,18 @@ class Access implements UserInterface, PasswordAuthenticatedUserInterface
         return array_unique($roles ?? []);
         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
     public function getPersonActivity(): Collection
     {
     {
         return $this->personActivity;
         return $this->personActivity;

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

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\ApiResource;
 use App\Attribute\DateTimeConstraintAware;
 use App\Attribute\DateTimeConstraintAware;
 use App\Entity\Organization\Activity;
 use App\Entity\Organization\Activity;
 use App\Entity\Traits\ActivityPeriodTrait;
 use App\Entity\Traits\ActivityPeriodTrait;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Access\DeparturesCauseEnum;
 use App\Enum\Access\DeparturesCauseEnum;
 use App\Repository\Access\OrganizationFunctionRepository;
 use App\Repository\Access\OrganizationFunctionRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -23,6 +24,7 @@ use Doctrine\ORM\Mapping as ORM;
 class OrganizationFunction
 class OrganizationFunction
 {
 {
     use ActivityPeriodTrait;
     use ActivityPeriodTrait;
+    use CreatedOnAndByTrait;
 
 
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\GeneratedValue]
     #[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\Person\PersonAddressPostal;
 use App\Entity\Place\Place;
 use App\Entity\Place\Place;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Repository\Core\AddressPostalRepository;
 use App\Repository\Core\AddressPostalRepository;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\Common\Collections\Collection;
@@ -27,6 +28,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ORM\Entity(repositoryClass: AddressPostalRepository::class)]
 #[ORM\Entity(repositoryClass: AddressPostalRepository::class)]
 class AddressPostal
 class AddressPostal
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[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\Person\Person;
 use App\Entity\Place\Place;
 use App\Entity\Place\Place;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Core\ContactPointTypeEnum;
 use App\Enum\Core\ContactPointTypeEnum;
 use App\Repository\Core\ContactPointRepository;
 use App\Repository\Core\ContactPointRepository;
 use App\Validator\Core as OpentalentAssert;
 use App\Validator\Core as OpentalentAssert;
@@ -32,6 +33,8 @@ use Symfony\Component\Validator\Constraints as Assert;
 #[OpentalentAssert\ContactPoint]
 #[OpentalentAssert\ContactPoint]
 class ContactPoint
 class ContactPoint
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]

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

@@ -7,6 +7,7 @@ namespace App\Entity\Network;
 use ApiPlatform\Metadata\ApiResource;
 use ApiPlatform\Metadata\ApiResource;
 use App\Attribute\DateTimeConstraintAware;
 use App\Attribute\DateTimeConstraintAware;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Organization;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Repository\Network\NetworkOrganizationRepository;
 use App\Repository\Network\NetworkOrganizationRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use Doctrine\ORM\Mapping as ORM;
 use Doctrine\ORM\Mapping as ORM;
@@ -25,6 +26,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[DateTimeConstraintAware(startDateFieldName: 'startDate', endDateFieldName: 'endDate')]
 #[DateTimeConstraintAware(startDateFieldName: 'startDate', endDateFieldName: 'endDate')]
 class NetworkOrganization
 class NetworkOrganization
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[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\Equipment;
 use App\Entity\Product\Intangible;
 use App\Entity\Product\Intangible;
 use App\Entity\Reward\Reward;
 use App\Entity\Reward\Reward;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Organization\CategoryEnum;
 use App\Enum\Organization\CategoryEnum;
 use App\Enum\Organization\LegalEnum;
 use App\Enum\Organization\LegalEnum;
 use App\Enum\Organization\OpcaEnum;
 use App\Enum\Organization\OpcaEnum;
@@ -63,6 +64,8 @@ use JetBrains\PhpStorm\Pure;
 #[ORM\Entity(repositoryClass: OrganizationRepository::class)]
 #[ORM\Entity(repositoryClass: OrganizationRepository::class)]
 class Organization
 class Organization
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]
@@ -77,25 +80,25 @@ class Organization
     #[ORM\Column(length: 50, nullable: true, enumType: LegalEnum::class)]
     #[ORM\Column(length: 50, nullable: true, enumType: LegalEnum::class)]
     private ?LegalEnum $legalStatus = null;
     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'])]
     #[ORM\OneToOne(mappedBy: 'organization', cascade: ['persist', 'remove'])]
     private Settings $settings;
     private Settings $settings;
 
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Access::class, orphanRemoval: true)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Access::class, cascade: ['persist'])]
     private Collection $accesses;
     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;
     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;
     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;
     private Collection $educationNotationConfigs;
 
 
-    #[ORM\OneToOne(inversedBy: 'organization', targetEntity: Parameters::class)]
+    #[ORM\OneToOne(inversedBy: 'organization', targetEntity: Parameters::class, cascade: ['persist'])]
     #[ORM\JoinColumn(nullable: false)]
     #[ORM\JoinColumn(nullable: false)]
     private Parameters $parameters;
     private Parameters $parameters;
 
 
@@ -105,6 +108,7 @@ class Organization
     #[ORM\Column(type: 'text', nullable: true)]
     #[ORM\Column(type: 'text', nullable: true)]
     private ?string $description = null;
     private ?string $description = null;
 
 
+    // TODO: utile ce champs? ou c'est juste un doublon du champs 'createDate'?
     #[ORM\Column(type: 'date', nullable: true)]
     #[ORM\Column(type: 'date', nullable: true)]
     private ?\DateTimeInterface $creationDate = null;
     private ?\DateTimeInterface $creationDate = null;
 
 
@@ -218,7 +222,7 @@ class Organization
     #[ORM\Column(nullable: true)]
     #[ORM\Column(nullable: true)]
     private ?string $otherPractice = null;
     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;
     private Collection $contactPoints;
 
 
     #[ORM\ManyToMany(targetEntity: BankAccount::class, inversedBy: 'organization')]
     #[ORM\ManyToMany(targetEntity: BankAccount::class, inversedBy: 'organization')]
@@ -226,92 +230,92 @@ class Organization
     #[ORM\InverseJoinColumn(name: 'bankAccount_id', referencedColumnName: 'id')]
     #[ORM\InverseJoinColumn(name: 'bankAccount_id', referencedColumnName: 'id')]
     private Collection $bankAccounts;
     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;
     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;
     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;
     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;
     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;
     private Collection $educationTimings;
 
 
-    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Subdomain::class)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Subdomain::class, cascade: ['persist', 'remove'])]
     private Collection $subdomains;
     private Collection $subdomains;
 
 
     #[ORM\ManyToOne(inversedBy: 'organizationContacts')]
     #[ORM\ManyToOne(inversedBy: 'organizationContacts')]
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     #[ORM\JoinColumn(referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
     private ?Access $contactPerson;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     private Collection $notifications;
 
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Email::class)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Email::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $emails;
     private Collection $emails;
 
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Mail::class)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Mail::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $mails;
     private Collection $mails;
 
 
-    #[ORM\OneToMany(mappedBy: 'author', targetEntity: Sms::class)]
+    #[ORM\OneToMany(mappedBy: 'organization', targetEntity: Sms::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
     private Collection $sms;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     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;
     private Collection $rewards;
 
 
     //    #[ORM\OneToOne()]
     //    #[ORM\OneToOne()]
@@ -428,6 +432,7 @@ class Organization
         if ($settings->getOrganization() !== $this) {
         if ($settings->getOrganization() !== $this) {
             $settings->setOrganization($this);
             $settings->setOrganization($this);
         }
         }
+
         $this->settings = $settings;
         $this->settings = $settings;
 
 
         return $this;
         return $this;
@@ -1197,6 +1202,16 @@ class Organization
         return $this->subdomains;
         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
     public function addAccess(Access $access): self
     {
     {
         if (!$this->accesses->contains($access)) {
         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 ApiPlatform\Metadata\ApiResource;
 use App\Attribute\OrganizationDefaultValue;
 use App\Attribute\OrganizationDefaultValue;
 use App\Entity\Core\AddressPostal;
 use App\Entity\Core\AddressPostal;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
 use App\Enum\Organization\AddressPostalOrganizationTypeEnum;
 use App\Repository\Organization\OrganizationAddressPostalRepository;
 use App\Repository\Organization\OrganizationAddressPostalRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -33,6 +34,8 @@ use Symfony\Component\Validator\Constraints as Assert;
 #[OpentalentAssert\OrganizationAddressPostal]
 #[OpentalentAssert\OrganizationAddressPostal]
 class OrganizationAddressPostal
 class OrganizationAddressPostal
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]
@@ -43,7 +46,7 @@ class OrganizationAddressPostal
     #[ORM\JoinColumn(nullable: false)]
     #[ORM\JoinColumn(nullable: false)]
     private Organization $organization;
     private Organization $organization;
 
 
-    #[ORM\OneToOne(inversedBy: 'organizationAddressPostal', cascade: ['persist', 'remove'])]
+    #[ORM\OneToOne(inversedBy: 'organizationAddressPostal', cascade: ['persist', 'remove'], orphanRemoval: true)]
     #[ORM\JoinColumn(nullable: false)]
     #[ORM\JoinColumn(nullable: false)]
     #[Assert\Valid]
     #[Assert\Valid]
     #[Groups('address')]
     #[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;
     private ?File $qrCode = null;
 
 
     #[ORM\Column(length: 255, enumType: TimeZoneEnum::class, options: ['default' => TimeZoneEnum::EUROPE_PARIS])]
     #[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])]
     #[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])]
     #[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])]
     #[ORM\Column(options: ['default' => false])]
     private bool $sendAttendanceEmail = false;
     private bool $sendAttendanceEmail = false;
@@ -225,6 +225,11 @@ class Parameters
 
 
     public function setOrganization(Organization $organization): self
     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;
         $this->organization = $organization;
 
 
         return $this;
         return $this;

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

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

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

@@ -11,6 +11,7 @@ use App\Entity\Core\BankAccount;
 use App\Entity\Core\ContactPoint;
 use App\Entity\Core\ContactPoint;
 use App\Entity\Core\Country;
 use App\Entity\Core\Country;
 use App\Entity\Core\File;
 use App\Entity\Core\File;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Person\GenderEnum;
 use App\Enum\Person\GenderEnum;
 use App\Repository\Person\PersonRepository;
 use App\Repository\Person\PersonRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -30,6 +31,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ORM\Entity(repositoryClass: PersonRepository::class)]
 #[ORM\Entity(repositoryClass: PersonRepository::class)]
 class Person implements UserInterface, PasswordAuthenticatedUserInterface
 class Person implements UserInterface, PasswordAuthenticatedUserInterface
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]
@@ -39,7 +42,13 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\Column(length: 180, unique: true, nullable: true)]
     #[ORM\Column(length: 180, unique: true, nullable: true)]
     private ?string $username = null;
     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 = [];
     private ?array $roles = [];
 
 
     #[ORM\Column(nullable: true)]
     #[ORM\Column(nullable: true)]
@@ -53,7 +62,7 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[Groups(['access_people_ref', 'access_address'])]
     #[Groups(['access_people_ref', 'access_address'])]
     private ?string $givenName = null;
     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;
     private Collection $contactPoints;
 
 
     #[ORM\ManyToMany(targetEntity: BankAccount::class, inversedBy: 'person', cascade: ['persist'], orphanRemoval: true)]
     #[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')]
     #[ORM\InverseJoinColumn(name: 'bankAccount_id', referencedColumnName: 'id')]
     private Collection $bankAccount;
     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')]
     #[Groups('access_address')]
     private Collection $personAddressPostal;
     private Collection $personAddressPostal;
 
 
@@ -84,25 +93,29 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     #[ORM\OneToMany(mappedBy: 'person', targetEntity: File::class, orphanRemoval: true)]
     #[ORM\OneToMany(mappedBy: 'person', targetEntity: File::class, orphanRemoval: true)]
     private Collection $files;
     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;
     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;
     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;
     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;
     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'])]
     #[ORM\OrderBy(['id' => 'DESC'])]
     private Collection $personFiles;
     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;
     private Collection $documentWishes;
 
 
+    /** @var array<string, string> */
+    #[ORM\Column(type: 'json', nullable: true)]
+    private array $confidentiality = [];
+
     #[Pure]
     #[Pure]
     public function __construct()
     public function __construct()
     {
     {
@@ -141,6 +154,18 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
         return $this;
         return $this;
     }
     }
 
 
+    public function isEnabled(): bool
+    {
+        return $this->enabled;
+    }
+
+    public function setEnabled(bool $enabled): self
+    {
+        $this->enabled = $enabled;
+
+        return $this;
+    }
+
     /**
     /**
      * @return string[]
      * @return string[]
      */
      */
@@ -355,7 +380,7 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
     /**
     /**
      * @return Collection<int, Access>
      * @return Collection<int, Access>
      */
      */
-    public function getAccess(): Collection
+    public function getAccesses(): Collection
     {
     {
         return $this->access;
         return $this->access;
     }
     }
@@ -588,4 +613,24 @@ class Person implements UserInterface, PasswordAuthenticatedUserInterface
 
 
         return $this;
         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 ApiPlatform\Metadata\ApiResource;
 use App\Entity\Core\AddressPostal;
 use App\Entity\Core\AddressPostal;
+use App\Entity\Traits\CreatedOnAndByTrait;
 use App\Enum\Person\AddressPostalPersonTypeEnum;
 use App\Enum\Person\AddressPostalPersonTypeEnum;
 use App\Repository\Person\PersonAddressPostalRepository;
 use App\Repository\Person\PersonAddressPostalRepository;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 // use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
@@ -20,6 +21,8 @@ use Symfony\Component\Serializer\Annotation\Groups;
 #[ORM\Entity(repositoryClass: PersonAddressPostalRepository::class)]
 #[ORM\Entity(repositoryClass: PersonAddressPostalRepository::class)]
 class PersonAddressPostal
 class PersonAddressPostal
 {
 {
+    use CreatedOnAndByTrait;
+
     #[ORM\Id]
     #[ORM\Id]
     #[ORM\Column]
     #[ORM\Column]
     #[ORM\GeneratedValue]
     #[ORM\GeneratedValue]
@@ -29,7 +32,7 @@ class PersonAddressPostal
     #[ORM\JoinColumn(nullable: false)]
     #[ORM\JoinColumn(nullable: false)]
     private Person $person;
     private Person $person;
 
 
-    #[ORM\OneToOne(inversedBy: 'personAddressPostal', cascade: ['persist', 'remove'])]
+    #[ORM\OneToOne(inversedBy: 'personAddressPostal', cascade: ['persist', 'remove'], orphanRemoval: true)]
     #[ORM\JoinColumn(nullable: false)]
     #[ORM\JoinColumn(nullable: false)]
     #[Groups('access_address')]
     #[Groups('access_address')]
     private AddressPostal $addressPostal;
     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;
     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';
 }
 }

+ 19 - 0
src/Enum/Dolibarr/DolibarrDocTypeEnum.php

@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Dolibarr;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Formats de sortie des fichiers exportés.
+ */
+enum DolibarrDocTypeEnum: string
+{
+    use EnumMethodsTrait;
+
+    case ORDER = 'order'; // Bon de commande
+    case INVOICE = 'invoice'; // Facture
+    case CONTRACT = 'contract'; // Contrat
+}

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

@@ -17,4 +17,5 @@ enum OrganizationIdsEnum: int
     case _2IOS = 32366;
     case _2IOS = 32366;
     case FFEC = 91295;
     case FFEC = 91295;
     case OPENTALENT_BASE = 13;
     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;
 namespace App\Message\Handler;
 
 
-use App\Message\Command\Export;
+use App\Message\Message\Export;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Access\AccessRepository;
 use App\Service\MercureHub;
 use App\Service\MercureHub;
 use App\Service\Notifier;
 use App\Service\Notifier;

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

@@ -5,7 +5,7 @@ declare(strict_types=1);
 namespace App\Message\Handler;
 namespace App\Message\Handler;
 
 
 use App\Entity\Message\Email;
 use App\Entity\Message\Email;
-use App\Message\Command\MailerCommand;
+use App\Message\Message\Mailer as MailerMessage;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Access\AccessRepository;
 use App\Service\Mailer\Mailer;
 use App\Service\Mailer\Mailer;
 use App\Service\Notifier;
 use App\Service\Notifier;
@@ -21,7 +21,7 @@ class MailerHandler
     ) {
     ) {
     }
     }
 
 
-    public function __invoke(MailerCommand $mailerCommand): void
+    public function __invoke(MailerMessage $mailerCommand): void
     {
     {
         $mailerModel = $mailerCommand->getMailerModel();
         $mailerModel = $mailerCommand->getMailerModel();
         $emails = $this->mailer->main($mailerModel);
         $emails = $this->mailer->main($mailerModel);

+ 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);
+            }
+        }
+    }
+}

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

@@ -0,0 +1,69 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Handler;
+
+use App\Message\Message\OrganizationDeletion;
+use App\Service\Organization\OrganizationFactory;
+use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use Symfony\Component\Mailer\MailerInterface;
+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Email as SymfonyEmail;
+
+#[AsMessageHandler(priority: 1)]
+readonly class OrganizationDeletionHandler
+{
+    public function __construct(
+        private OrganizationFactory $organizationFactory,
+        private MailerInterface     $symfonyMailer,
+        private string              $opentalentMailReport,
+    ) {
+    }
+
+    /**
+     * @throws \Throwable
+     * @throws TransportExceptionInterface
+     */
+    public function __invoke(OrganizationDeletion $organizationDeletionCommand): void
+    {
+        $organizationCreationRequest = $organizationDeletionCommand->getOrganizationDeletionRequest();
+
+        try {
+            $this->organizationFactory->delete($organizationCreationRequest);
+
+            $this->sendMail(
+                $organizationCreationRequest->getSendConfirmationEmailAt() ?? $this->opentalentMailReport,
+                'Organization deleted',
+                'The organization n° '.$organizationCreationRequest->getOrganizationId().' has been deleted successfully.',
+            );
+        } catch (\Exception $e) {
+            $this->sendMail(
+                $organizationCreationRequest->getSendConfirmationEmailAt() ?? $this->opentalentMailReport,
+                'Organization deletion : an error occurred',
+                'An error occurred while deleting the new organization : \n'.$e->getMessage()
+            );
+            throw $e;
+        }
+    }
+
+    /**
+     * @throws TransportExceptionInterface
+     */
+    private function sendMail(
+        string $to,
+        string $subject,
+        string $content,
+    ): void
+    {
+        $symfonyMail = (new SymfonyEmail())
+            ->from($this->opentalentMailReport)
+            ->replyTo($this->opentalentMailReport)
+            ->returnPath(Address::create($this->opentalentMailReport))
+            ->to($to)
+            ->subject($subject)
+            ->text($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;
 namespace App\Message\Handler\Typo3;
 
 
-use App\Message\Command\Typo3\Typo3DeleteCommand;
+use App\Message\Message\Typo3\Typo3Delete;
 use App\Service\Typo3\Typo3Service;
 use App\Service\Typo3\Typo3Service;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 
 
 #[AsMessageHandler(priority: 1)]
 #[AsMessageHandler(priority: 1)]
-class Typo3DeleteCommandHandler
+class Typo3DeleteHandler
 {
 {
     public function __construct(
     public function __construct(
         private Typo3Service $typo3Service,
         private Typo3Service $typo3Service,
@@ -17,7 +17,7 @@ class Typo3DeleteCommandHandler
     /**
     /**
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      */
      */
-    public function __invoke(Typo3DeleteCommand $command): void
+    public function __invoke(Typo3Delete $command): void
     {
     {
         $this->typo3Service->deleteSite($command->getOrganizationId());
         $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;
 namespace App\Message\Handler\Typo3;
 
 
-use App\Message\Command\Typo3\Typo3UndeleteCommand;
+use App\Message\Message\Typo3\Typo3Undelete;
 use App\Service\Typo3\Typo3Service;
 use App\Service\Typo3\Typo3Service;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 
 
 #[AsMessageHandler(priority: 1)]
 #[AsMessageHandler(priority: 1)]
-class Typo3UndeleteCommandHandler
+class Typo3UndeleteHandler
 {
 {
     public function __construct(
     public function __construct(
         private Typo3Service $typo3Service,
         private Typo3Service $typo3Service,
@@ -17,7 +17,7 @@ class Typo3UndeleteCommandHandler
     /**
     /**
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      */
      */
-    public function __invoke(Typo3UndeleteCommand $command): void
+    public function __invoke(Typo3Undelete $command): void
     {
     {
         $this->typo3Service->undeleteSite($command->getOrganizationId());
         $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;
 namespace App\Message\Handler\Typo3;
 
 
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Service\Typo3\Typo3Service;
 use App\Service\Typo3\Typo3Service;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 use Symfony\Component\Messenger\Attribute\AsMessageHandler;
 
 
 #[AsMessageHandler(priority: 1)]
 #[AsMessageHandler(priority: 1)]
-class Typo3UpdateCommandHandler
+class Typo3UpdateHandler
 {
 {
     public function __construct(
     public function __construct(
         private Typo3Service $typo3Service,
         private Typo3Service $typo3Service,
@@ -17,7 +17,7 @@ class Typo3UpdateCommandHandler
     /**
     /**
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
      */
      */
-    public function __invoke(Typo3UpdateCommand $command): void
+    public function __invoke(Typo3Update $command): void
     {
     {
         $this->typo3Service->updateSite($command->getOrganizationId());
         $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);
 declare(strict_types=1);
 
 
-namespace App\Message\Command;
+namespace App\Message\Message;
 
 
 use App\ApiResources\Export\ExportRequest;
 use App\ApiResources\Export\ExportRequest;
 
 

+ 2 - 2
src/Message/Command/MailerCommand.php → src/Message/Message/Mailer.php

@@ -2,14 +2,14 @@
 
 
 declare(strict_types=1);
 declare(strict_types=1);
 
 
-namespace App\Message\Command;
+namespace App\Message\Message;
 
 
 use App\Service\Mailer\Model\MailerModelInterface;
 use App\Service\Mailer\Model\MailerModelInterface;
 
 
 /**
 /**
  * Classe ... qui ...
  * Classe ... qui ...
  */
  */
-class MailerCommand
+class Mailer
 {
 {
     public function __construct(
     public function __construct(
         private MailerModelInterface $model,
         private MailerModelInterface $model,

+ 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;
+    }
+}

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

@@ -0,0 +1,30 @@
+<?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);
 declare(strict_types=1);
 
 
-namespace App\Message\Command\Typo3;
+namespace App\Message\Message\Typo3;
 
 
 /**
 /**
  * Envoi d'une requête Delete à l'api Typo3.
  * Envoi d'une requête Delete à l'api Typo3.
  */
  */
-class Typo3DeleteCommand
+class Typo3Delete
 {
 {
     public function __construct(
     public function __construct(
         private int $organizationId,
         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);
 declare(strict_types=1);
 
 
-namespace App\Message\Command\Typo3;
+namespace App\Message\Message\Typo3;
 
 
 /**
 /**
  * Envoi d'une requête Undelete à l'api Typo3.
  * Envoi d'une requête Undelete à l'api Typo3.
  */
  */
-class Typo3UndeleteCommand
+class Typo3Undelete
 {
 {
     public function __construct(
     public function __construct(
         private int $organizationId,
         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);
 declare(strict_types=1);
 
 
-namespace App\Message\Command\Typo3;
+namespace App\Message\Message\Typo3;
 
 
 /**
 /**
  * Envoi d'une requête Update à l'api Typo3.
  * Envoi d'une requête Update à l'api Typo3.
  */
  */
-class Typo3UpdateCommand
+class Typo3Update
 {
 {
     public function __construct(
     public function __construct(
         private int $organizationId,
         private int $organizationId,

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

@@ -14,4 +14,24 @@ class FileRepository extends ServiceEntityRepository
     {
     {
         parent::__construct($registry, File::class);
         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[]    findAll()
  * @method Person[]    findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
  * @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)
     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');
         $clientIp = $request->server->get('REMOTE_ADDR');
         $internalRequestsToken = $request->headers->get('internal-requests-token') ?? null;
         $internalRequestsToken = $request->headers->get('internal-requests-token') ?? null;
 
 
-        return $internalRequestsToken && $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
+        return $this->internalRequestsService->isAllowed($clientIp, $internalRequestsToken);
     }
     }
 }
 }

+ 13 - 13
src/Service/Cron/Job/CleanDb.php

@@ -76,7 +76,7 @@ class CleanDb extends BaseCronJob
 
 
             if ($commit) {
             if ($commit) {
                 $this->connection->commit();
                 $this->connection->commit();
-                $this->ui->print('DB purged - '.$purged.' records permanently deleted');
+                $this->logger->info('DB purged - '.$purged.' records permanently deleted');
 
 
                 return;
                 return;
             } else {
             } else {
@@ -98,7 +98,7 @@ class CleanDb extends BaseCronJob
      */
      */
     protected function purgeAuditTables(\DateTime $maxDate): int
     protected function purgeAuditTables(\DateTime $maxDate): int
     {
     {
-        $this->ui->print('Purge Audit_* tables from the records created before the '.$maxDate->format('c'));
+        $this->logger->info('Purge Audit_* tables from the records created before the '.$maxDate->format('c'));
 
 
         $tableNames = $this->connection->getSchemaManager()->listTableNames();
         $tableNames = $this->connection->getSchemaManager()->listTableNames();
 
 
@@ -115,7 +115,7 @@ class CleanDb extends BaseCronJob
                 $stmt = $this->connection->prepare($sql);
                 $stmt = $this->connection->prepare($sql);
                 $purged = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
                 $purged = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
 
 
-                $this->ui->print('* '.$tableName.' : '.$purged.' lines to delete');
+                $this->logger->debug('* '.$tableName.' : '.$purged.' lines to delete');
 
 
                 $total += $purged;
                 $total += $purged;
             }
             }
@@ -131,7 +131,7 @@ class CleanDb extends BaseCronJob
      */
      */
     protected function purgeMessages(\DateTime $maxDate): int
     protected function purgeMessages(\DateTime $maxDate): int
     {
     {
-        $this->ui->print('Purge the DB from the messages created before the '.$maxDate->format('c'));
+        $this->logger->info('Purge the DB from the messages created before the '.$maxDate->format('c'));
 
 
         $sql = 'DELETE r
         $sql = 'DELETE r
                 FROM opentalent.Message m
                 FROM opentalent.Message m
@@ -139,18 +139,18 @@ class CleanDb extends BaseCronJob
                 where (m.dateSent < :maxDate or (m.dateSent is null and m.createDate < :maxDate)) and m.isSystem = true and m.id > 0;';
                 where (m.dateSent < :maxDate or (m.dateSent is null and m.createDate < :maxDate)) and m.isSystem = true and m.id > 0;';
 
 
         $stmt = $this->connection->prepare($sql);
         $stmt = $this->connection->prepare($sql);
-        $purgedReportMessage = $stmt->executeStatement(['maxDate' => $maxDate->format('c')]);
+        $purgedReportMessage = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
 
 
-        $this->ui->print('* ReportMessage : '.$purgedReportMessage.' lines to delete');
+        $this->logger->debug('* ReportMessage : '.$purgedReportMessage.' lines to delete');
 
 
         $sql = 'DELETE
         $sql = 'DELETE
                 FROM opentalent.Message
                 FROM opentalent.Message
                 where (dateSent < :maxDate or (dateSent is null and createDate < :maxDate)) and isSystem = true and id > 0;';
                 where (dateSent < :maxDate or (dateSent is null and createDate < :maxDate)) and isSystem = true and id > 0;';
 
 
         $stmt = $this->connection->prepare($sql);
         $stmt = $this->connection->prepare($sql);
-        $purgedMessage = $stmt->executeStatement(['maxDate' => $maxDate->format('c')]);
+        $purgedMessage = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
 
 
-        $this->ui->print('* Message : '.$purgedMessage.' lines to delete');
+        $this->logger->debug('* Message : '.$purgedMessage.' lines to delete');
 
 
         return $purgedReportMessage + $purgedMessage;
         return $purgedReportMessage + $purgedMessage;
     }
     }
@@ -162,7 +162,7 @@ class CleanDb extends BaseCronJob
      */
      */
     protected function purgeNotifications(\DateTime $maxDate): int
     protected function purgeNotifications(\DateTime $maxDate): int
     {
     {
-        $this->ui->print('Purge the DB from the notifications created before the '.$maxDate->format('c'));
+        $this->logger->info('Purge the DB from the notifications created before the '.$maxDate->format('Y-m-d'));
 
 
         $sql = "DELETE u
         $sql = "DELETE u
                 FROM opentalent.Information i
                 FROM opentalent.Information i
@@ -170,18 +170,18 @@ class CleanDb extends BaseCronJob
                 where i.createDate < :maxDate and i.discr = 'notification';";
                 where i.createDate < :maxDate and i.discr = 'notification';";
 
 
         $stmt = $this->connection->prepare($sql);
         $stmt = $this->connection->prepare($sql);
-        $purgedNotificationUser = $stmt->executeStatement(['maxDate' => $maxDate->format('c')]);
+        $purgedNotificationUser = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
 
 
-        $this->ui->print('* NotificationUser : '.$purgedNotificationUser.' lines to delete');
+        $this->logger->debug('* NotificationUser : '.$purgedNotificationUser.' lines to delete');
 
 
         $sql = "DELETE
         $sql = "DELETE
                 FROM opentalent.Information
                 FROM opentalent.Information
                 where createDate < :maxDate and discr = 'notification';";
                 where createDate < :maxDate and discr = 'notification';";
 
 
         $stmt = $this->connection->prepare($sql);
         $stmt = $this->connection->prepare($sql);
-        $purgedNotification = $stmt->executeStatement(['maxDate' => $maxDate->format('c')]);
+        $purgedNotification = $stmt->executeStatement(['maxDate' => $maxDate->format('Y-m-d')]);
 
 
-        $this->ui->print('* Information : '.$purgedNotification.' lines to delete');
+        $this->logger->debug('* Information : '.$purgedNotification.' lines to delete');
 
 
         return $purgedNotificationUser + $purgedNotification;
         return $purgedNotificationUser + $purgedNotification;
     }
     }

+ 26 - 51
src/Service/Cron/Job/CleanTempFiles.php

@@ -10,7 +10,6 @@ use App\Service\Cron\BaseCronJob;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\Utils\DatesUtils;
 use App\Service\Utils\DatesUtils;
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\Connection;
-use Doctrine\DBAL\ConnectionException;
 use Doctrine\ORM\QueryBuilder;
 use Doctrine\ORM\QueryBuilder;
 use JetBrains\PhpStorm\Pure;
 use JetBrains\PhpStorm\Pure;
 
 
@@ -62,8 +61,6 @@ class CleanTempFiles extends BaseCronJob
             }
             }
             $this->ui->print('  * '.$file->getPath());
             $this->ui->print('  * '.$file->getPath());
         }
         }
-
-        $this->purgeDb($maxDate, false);
     }
     }
 
 
     /**
     /**
@@ -77,8 +74,9 @@ class CleanTempFiles extends BaseCronJob
         $maxDate->sub(new \DateInterval('P'.self::DELETE_OLDER_THAN.'D'));
         $maxDate->sub(new \DateInterval('P'.self::DELETE_OLDER_THAN.'D'));
 
 
         $files = $this->listFilesToDelete($maxDate);
         $files = $this->listFilesToDelete($maxDate);
+        $this->logger->info(count($files).' temporary files to be removed');
+
         $this->deleteFiles($files);
         $this->deleteFiles($files);
-        $this->purgeDb($maxDate);
     }
     }
 
 
     /**
     /**
@@ -99,46 +97,6 @@ class CleanTempFiles extends BaseCronJob
         return $queryBuilder->getQuery()->getResult();
         return $queryBuilder->getQuery()->getResult();
     }
     }
 
 
-    /**
-     * Purge the DB from temporary file records older than N days.
-     *
-     * @throws ConnectionException
-     */
-    protected function purgeDb(\DateTime $maxDate, bool $commit = true): void
-    {
-        $this->connection->beginTransaction();
-        $this->connection->setAutoCommit(false);
-
-        $purged = 0;
-
-        try {
-            $purged += $this->purgeFiles($maxDate);
-
-            if ($commit) {
-                $this->connection->commit();
-                $this->ui->print('DB purged - '.$purged.' records permanently deleted');
-            } else {
-                $this->connection->rollback();
-                $this->ui->print('DB purged - '.$purged.' records would be permanently deleted');
-            }
-        } catch (\Exception $exception) {
-            $this->connection->rollback();
-            throw $exception;
-        }
-    }
-
-    /**
-     * Purge File table and returns the number of deleted records.
-     */
-    protected function purgeFiles(\DateTime $maxDate): int
-    {
-        $queryBuilder = $this->fileRepository->createQueryBuilder('f');
-        $queryBuilder->delete();
-        $this->getQueryConditions($queryBuilder, $maxDate);
-
-        return $queryBuilder->getQuery()->execute();
-    }
-
     /**
     /**
      * Delete the files.
      * Delete the files.
      *
      *
@@ -147,24 +105,41 @@ class CleanTempFiles extends BaseCronJob
     protected function deleteFiles(array $files): void
     protected function deleteFiles(array $files): void
     {
     {
         $total = count($files);
         $total = count($files);
-        $this->ui->print($total.' temporary files to be removed');
+        $this->logger->info($total.' temporary files to be removed');
+
+        $this->connection->setAutoCommit(false);
+        $queryBuilder = $this->fileRepository->createQueryBuilder('f');
 
 
-        $this->ui->print('Deleting files...');
+        $this->logger->info('Deleting files...');
         $i = 0;
         $i = 0;
         $deleted = 0;
         $deleted = 0;
         $this->ui->progress(0, $total);
         $this->ui->progress(0, $total);
+
         foreach ($files as $file) {
         foreach ($files as $file) {
+            $this->connection->beginTransaction();
+            $this->ui->progress($i, $total);
+            ++$i;
+
             try {
             try {
-                ++$i;
-                $this->ui->progress($i, $total);
+                // Delete from disk
                 $this->storage->hardDelete($file);
                 $this->storage->hardDelete($file);
+
+                // Remove from DB
+                $queryBuilder->delete()->where('f.id = :id')->setParameter('id', $file->getId());
+
+                $this->connection->commit();
                 ++$deleted;
                 ++$deleted;
-            } catch (\RuntimeException $e) {
-                $this->ui->print('ERROR : '.$e->getMessage());
+            } catch (\RuntimeException|\InvalidArgumentException $e) {
+                // Non blocking errors
+                $this->connection->rollback();
+                $this->logger->error('ERROR : '.$e->getMessage());
+            } catch (\Exception $exception) {
+                $this->connection->rollback();
+                throw $exception;
             }
             }
         }
         }
 
 
-        $this->ui->print($deleted.' files deleted');
+        $this->logger->info($deleted.' files deleted');
     }
     }
 
 
     protected function getQueryConditions(QueryBuilder $queryBuilder, \DateTime $maxDate): void
     protected function getQueryConditions(QueryBuilder $queryBuilder, \DateTime $maxDate): void

+ 2 - 1
src/Service/Doctrine/FiltersConfigurationService.php

@@ -22,7 +22,7 @@ class FiltersConfigurationService
      * Si $previousTimeConstraintState est `true`, les filtres étaient activés, et si c'est `false`, les filtres
      * Si $previousTimeConstraintState est `true`, les filtres étaient activés, et si c'est `false`, les filtres
      * étaient désactivés. Si les filtres ne sont pas suspendus, $previousTimeConstraintState est null.
      * étaient désactivés. Si les filtres ne sont pas suspendus, $previousTimeConstraintState est null.
      */
      */
-    private ?bool $previousTimeConstraintState = null;
+    protected ?bool $previousTimeConstraintState = null;
 
 
     public function __construct(
     public function __construct(
         private EntityManagerInterface $entityManager,
         private EntityManagerInterface $entityManager,
@@ -79,6 +79,7 @@ class FiltersConfigurationService
         $filter = $filters->getFilter('date_time_filter');
         $filter = $filters->getFilter('date_time_filter');
 
 
         $this->previousTimeConstraintState = $filter->isDisabled() === false;
         $this->previousTimeConstraintState = $filter->isDisabled() === false;
+
         $filter->setDisabled(true);
         $filter->setDisabled(true);
     }
     }
 
 

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

@@ -4,9 +4,13 @@ declare(strict_types=1);
 
 
 namespace App\Service\Dolibarr;
 namespace App\Service\Dolibarr;
 
 
+use App\Entity\Organization\Organization;
+use App\Enum\Dolibarr\DolibarrDocTypeEnum;
 use App\Service\Rest\ApiRequestService;
 use App\Service\Rest\ApiRequestService;
 use JetBrains\PhpStorm\Pure;
 use JetBrains\PhpStorm\Pure;
+use Symfony\Component\HttpFoundation\Response;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 
 
 /**
 /**
@@ -178,4 +182,66 @@ class DolibarrApiService extends ApiRequestService
             throw $e;
             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');
+        }
+    }
+
+    /**
+     * @return array<string, string|number> The resulting document. Ex :
+     *                                      {
+     *                                      "filename": "CO2502-0380.pdf",
+     *                                      "content-type": "application/pdf",
+     *                                      "filesize": 10660,
+     *                                      "content": "JVBERi0xLjcKJeLjz9MKNyAwIG9iago8PCAvV...",
+     *                                      "encoding": "base64"
+     *                                      }
+     *
+     * @throws \JsonException
+     */
+    public function downloadBillingDocPdf(string $type, string $docRef): array
+    {
+        if (!DolibarrDocTypeEnum::tryFrom($type)) {
+            throw new \InvalidArgumentException(sprintf('Invalid type "%s" provided. Allowed values are: %s', $type, implode(', ', array_map(fn ($t) => $t->value, DolibarrDocTypeEnum::cases()))));
+        }
+
+        $route = 'documents/download?modulepart='.$type.'&original_file='.urlencode("$docRef/$docRef.pdf");
+
+        return $this->getJsonContent($route);
+    }
 }
 }

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

@@ -13,11 +13,13 @@ use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Entity\Person\Person;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Enum\Core\FileVisibilityEnum;
+use App\Repository\Core\FileRepository;
 use App\Service\File\Exception\FileNotFoundException;
 use App\Service\File\Exception\FileNotFoundException;
 use App\Service\File\Factory\ImageFactory;
 use App\Service\File\Factory\ImageFactory;
 use App\Service\File\Storage\FileStorageInterface;
 use App\Service\File\Storage\FileStorageInterface;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\ServiceIterator\StorageIterator;
 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
  * 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 StorageIterator $storageIterator,
         protected readonly ImageFactory $imageFactory,
         protected readonly ImageFactory $imageFactory,
         protected readonly LocalStorage $localStorage,
         protected readonly LocalStorage $localStorage,
+        protected readonly EntityManagerInterface $entityManager,
+        protected readonly FileRepository $fileRepository,
     ) {
     ) {
     }
     }
 
 
@@ -137,4 +141,30 @@ class FileManager
             ['fileId' => $file->getId()]
             ['fileId' => $file->getId()]
         );
         );
     }
     }
+
+    /**
+     * Permanently delete the organization's files from each storage, and remove any reference
+     * in the DB.
+     */
+    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.
+     */
+    public function deletePersonFiles(int $personId): void
+    {
+        foreach ($this->storageIterator->getStorages() as $storageService) {
+            $storageService->deletePersonFiles($personId);
+        }
+
+        $this->fileRepository->deleteByPerson($personId);
+    }
 }
 }

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

@@ -56,4 +56,22 @@ class ApiLegacyStorage implements FileStorageInterface
     {
     {
         return $file->getHost() === FileHostEnum::API1;
         return $file->getHost() === FileHostEnum::API1;
     }
     }
+
+    /**
+     * Permanently delete the entire file storage of the given Organization.
+     */
+    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.
+     */
+    public function deletePersonFiles(int $personId): void
+    {
+        $url = sprintf('/_internal/request/person-files/delete/%s', $personId);
+        $this->apiLegacyRequestService->get($url);
+    }
 }
 }

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

@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace App\Service\File\Storage;
 namespace App\Service\File\Storage;
 
 
 use App\Entity\Core\File;
 use App\Entity\Core\File;
+use App\Enum\Core\FileSizeEnum;
 
 
 interface FileStorageInterface
 interface FileStorageInterface
 {
 {
@@ -12,7 +13,12 @@ interface FileStorageInterface
 
 
     public function read(File $file): string;
     public function read(File $file): string;
 
 
+    // TODO: remplacer `string $size` par `FileSizeEnum $size`
     public function getImageUrl(File $file, string $size, bool $relativePath): string;
     public function getImageUrl(File $file, string $size, bool $relativePath): string;
 
 
     public function support(File $file): bool;
     public function support(File $file): bool;
+
+    public function deleteOrganizationFiles(int $organizationId): void;
+
+    public function deletePersonFiles(int $personId): void;
 }
 }

+ 37 - 4
src/Service/File/Storage/LocalStorage.php

@@ -55,6 +55,7 @@ class LocalStorage implements FileStorageInterface
         protected readonly ImageFactory $imageFactory,
         protected readonly ImageFactory $imageFactory,
         protected readonly FileUtils $fileUtils,
         protected readonly FileUtils $fileUtils,
         protected readonly UrlBuilder $urlBuilder,
         protected readonly UrlBuilder $urlBuilder,
+        protected readonly string $fileStorageDir,
     ) {
     ) {
         $this->filesystem = $filesystemMap->get(static::FS_KEY);
         $this->filesystem = $filesystemMap->get(static::FS_KEY);
     }
     }
@@ -114,17 +115,17 @@ class LocalStorage implements FileStorageInterface
     /**
     /**
      * Retourne le filtre Liip correspondant à la taille désirée.
      * Retourne le filtre Liip correspondant à la taille désirée.
      */
      */
-    private function getFilterFromSizeAndConfig(string $size, bool $configExist): string
+    protected function getFilterFromSizeAndConfig(string $size, bool $configExist): string
     {
     {
         switch ($size) {
         switch ($size) {
-            case FileSizeEnum::SM :
+            case FileSizeEnum::SM->value :
                 $filter = $configExist ? self::CROP_SM : self::SM_FOLDER;
                 $filter = $configExist ? self::CROP_SM : self::SM_FOLDER;
                 break;
                 break;
-            case FileSizeEnum::MD :
+            case FileSizeEnum::MD->value :
             default:
             default:
                 $filter = $configExist ? self::CROP_MD : self::MD_FOLDER;
                 $filter = $configExist ? self::CROP_MD : self::MD_FOLDER;
                 break;
                 break;
-            case FileSizeEnum::LG :
+            case FileSizeEnum::LG->value :
                 $filter = $configExist ? self::CROP_LG : self::LG_FOLDER;
                 $filter = $configExist ? self::CROP_LG : self::LG_FOLDER;
                 break;
                 break;
         }
         }
@@ -294,6 +295,38 @@ class LocalStorage implements FileStorageInterface
         }
         }
     }
     }
 
 
+    /**
+     * Permanently delete the entire file storage of the given Organization.
+     */
+    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.
+     */
+    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épertoires)
+     */
+    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 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})'
      * 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\Education\EducationNotationConfig;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Parameters;
 use App\Enum\Education\AdvancedEducationNotationTypeEnum;
 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\Repository\Booking\CourseRepository;
 use App\Service\Network\Utils as NetworkUtils;
 use App\Service\Network\Utils as NetworkUtils;
 use App\Service\OnChange\OnChangeContext;
 use App\Service\OnChange\OnChangeContext;
@@ -86,7 +86,7 @@ class OnParametersChange extends OnChangeDefault
             && $context->previousData()->getCustomDomain() !== $parameters->getCustomDomain()
             && $context->previousData()->getCustomDomain() !== $parameters->getCustomDomain()
         ) {
         ) {
             $this->messageBus->dispatch(
             $this->messageBus->dispatch(
-                new Typo3UpdateCommand($parameters->getOrganization()->getId())
+                new Typo3Update($parameters->getOrganization()->getId())
             );
             );
         }
         }
 
 
@@ -97,14 +97,14 @@ class OnParametersChange extends OnChangeDefault
         ) {
         ) {
             if ($parameters->getDesactivateOpentalentSiteWeb()) {
             if ($parameters->getDesactivateOpentalentSiteWeb()) {
                 $this->messageBus->dispatch(
                 $this->messageBus->dispatch(
-                    new Typo3DeleteCommand($parameters->getOrganization()->getId())
+                    new Typo3Delete($parameters->getOrganization()->getId())
                 );
                 );
             } else {
             } else {
                 $this->messageBus->dispatch(
                 $this->messageBus->dispatch(
-                    new Typo3UndeleteCommand($parameters->getOrganization()->getId())
+                    new Typo3Undelete($parameters->getOrganization()->getId())
                 );
                 );
                 $this->messageBus->dispatch(
                 $this->messageBus->dispatch(
-                    new Typo3UpdateCommand($parameters->getOrganization()->getId())
+                    new Typo3Update($parameters->getOrganization()->getId())
                 );
                 );
             }
             }
         }
         }

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

@@ -0,0 +1,924 @@
+<?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\SecurityUtils;
+use App\Service\Utils\UrlBuilder;
+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,
+        private readonly FileManager $fileManager,
+    ) {
+        $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);
+            $this->logger->info("The organization has been created (id=".$organization->getId().").");
+        }
+
+        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->getFutureOrphanPersons($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();
+            $this->logger->info("Organization deleted");
+        } catch (\Exception $e) {
+            $this->logger->critical("An error happened, operation cancelled\n".$e);
+            $this->entityManager->rollback();
+            throw $e;
+        }
+
+        try {
+            $this->deleteTypo3Website($organizationDeletionRequest->getOrganizationId());
+            $this->logger->info("Typo3 website deleted");
+        } 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($organizationDeletionRequest->getOrganizationId());
+            $this->logger->info("Dolibarr society switched to prospect");
+        } 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());
+            $this->logger->info("Organization files deleted");
+        } catch (\RuntimeException $e) {
+            // Nothing to delete
+        } 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 (\RuntimeException $e) {
+                // Nothing to delete
+            } catch (\Exception $e) {
+                $this->logger->critical("An error happened while deleting the person's files, please proceed manually (id=".$personId.').');
+                $this->logger->debug($e);
+                $withError = true;
+            }
+        }
+        $this->logger->info("Organization's persons files deleted");
+
+        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);
+        }
+
+        $this->logger->info(
+            "The organization has been deleted."
+        );
+
+        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).
+     *
+     * @return array<Person>
+     */
+    protected function getFutureOrphanPersons(Organization $organization): array
+    {
+        $orphans = [];
+
+        foreach ($organization->getAccesses() as $access) {
+            $person = $access->getPerson();
+            if ($person->getAccesses()->count() === 1) {
+                $orphans[] = $person;
+            }
+        }
+
+        return $orphans;
+    }
+
+    protected function deleteTypo3Website(int $organizationId): void
+    {
+        $this->typo3Service->hardDeleteSite($organizationId);
+    }
+
+    protected function switchDolibarrSocietyToProspect(int $organizationId): void
+    {
+        $this->dolibarrApiService->switchSocietyToProspect($organizationId);
+    }
+}

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

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

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

@@ -47,22 +47,24 @@ interface ApiRequestInterface
     /**
     /**
      * Sends a POST request and returns the response.
      * Sends a POST request and returns the response.
      *
      *
-     * @param array<mixed> $parameters
-     * @param array<mixed> $options
+     * @param array<mixed>|string $body
+     * @param array<mixed>        $parameters
+     * @param array<mixed>        $options
      *
      *
      * @throws HttpException
      * @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.
      * Sends a PUT request and returns the response.
      *
      *
-     * @param array<mixed> $parameters
-     * @param array<mixed> $options
+     * @param array<mixed>|string $body
+     * @param array<mixed>        $parameters
+     * @param array<mixed>        $options
      *
      *
      * @throws HttpException
      * @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.
      * Sends a DELETE request and returns the response.

+ 27 - 6
src/Service/Rest/ApiRequestService.php

@@ -71,29 +71,50 @@ class ApiRequestService implements ApiRequestInterface
         return $this->request('GET', $path, $parameters, $options);
         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.
      * Sends a POST request and returns the response.
      *
      *
-     * @param array<mixed> $parameters
-     * @param array<mixed> $options
+     * @param array<mixed>|string $body
+     * @param array<mixed>        $parameters
+     * @param array<mixed>        $options
      *
      *
      * @throws HttpException
      * @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);
         return $this->request('POST', $path, $parameters, $options);
     }
     }
 
 
     /**
     /**
      * Sends a PUT request and returns the response.
      * Sends a PUT request and returns the response.
      *
      *
-     * @param array<mixed> $parameters
-     * @param array<mixed> $options
+     * @param array<mixed>|string $body
+     * @param array<mixed>        $parameters
+     * @param array<mixed>        $options
      *
      *
      * @throws HttpException
      * @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);
         return $this->request('PUT', $path, $parameters, $options);
     }
     }
 
 

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

@@ -2,6 +2,10 @@
 
 
 namespace App\Service\Security;
 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.
  * Identify and allow internal requests between api v1 and v2.
  *
  *
@@ -22,6 +26,7 @@ class InternalRequestsService
 
 
     public function __construct(
     public function __construct(
         readonly private string $internalRequestsToken,
         readonly private string $internalRequestsToken,
+        private Security $security,
     ) {
     ) {
     }
     }
 
 
@@ -43,16 +48,27 @@ class InternalRequestsService
      * Compare the given token to the expected one, and return true if they are identical
      * Compare the given token to the expected one, and return true if they are identical
      * An empty token can not be valid.
      * An empty token can not be valid.
      */
      */
-    protected function tokenIsValid(string $token): bool
+    protected function tokenIsValid(?string $token): bool
     {
     {
         return $token && $token === $this->internalRequestsToken;
         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.
      * 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));
     }
     }
 }
 }

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

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

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

@@ -4,8 +4,8 @@ namespace App\Service\Typo3;
 
 
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Subdomain;
 use App\Entity\Organization\Subdomain;
-use App\Message\Command\MailerCommand;
-use App\Message\Command\Typo3\Typo3UpdateCommand;
+use App\Message\Message\Mailer;
+use App\Message\Message\Typo3\Typo3Update;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Access\AccessRepository;
 use App\Repository\Organization\SubdomainRepository;
 use App\Repository\Organization\SubdomainRepository;
 use App\Service\Mailer\Model\SubdomainChangeModel;
 use App\Service\Mailer\Model\SubdomainChangeModel;
@@ -117,20 +117,13 @@ class SubdomainService
             throw new \RuntimeException('This subdomain is already registered');
             throw new \RuntimeException('This subdomain is already registered');
         }
         }
 
 
-        $subdomain = new Subdomain();
-        $subdomain->setSubdomain($subdomainValue);
-        $subdomain->setOrganization($organization);
-        $subdomain->setActive(false);
-
         $this->em->beginTransaction();
         $this->em->beginTransaction();
 
 
         try {
         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->persist($subdomain);
             $this->em->flush();
             $this->em->flush();
@@ -191,6 +184,13 @@ class SubdomainService
         }
         }
         $subdomain->setActive(true);
         $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
         // TODO: comprendre pourquoi ce refresh est indispensable pour que l'organisation soit à jour
         $this->em->flush();
         $this->em->flush();
         $this->em->refresh($subdomain->getOrganization());
         $this->em->refresh($subdomain->getOrganization());
@@ -215,7 +215,7 @@ class SubdomainService
     protected function updateTypo3Website(Organization $organization): void
     protected function updateTypo3Website(Organization $organization): void
     {
     {
         $this->messageBus->dispatch(
         $this->messageBus->dispatch(
-            new Typo3UpdateCommand($organization->getId())
+            new Typo3Update($organization->getId())
         );
         );
     }
     }
 
 
@@ -244,7 +244,7 @@ class SubdomainService
 
 
         // Envoi d'un email
         // Envoi d'un email
         $this->messageBus->dispatch(
         $this->messageBus->dispatch(
-            new MailerCommand($model)
+            new Mailer($model)
         );
         );
     }
     }
 }
 }

+ 27 - 3
src/Service/Typo3/Typo3Service.php

@@ -2,6 +2,7 @@
 
 
 namespace App\Service\Typo3;
 namespace App\Service\Typo3;
 
 
+use App\Service\Utils\DatesUtils;
 use App\Service\Utils\UrlBuilder;
 use App\Service\Utils\UrlBuilder;
 use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
 use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
@@ -21,14 +22,19 @@ class Typo3Service
      * Send a command to the given route of the Typo3 API.
      * Send a command to the given route of the Typo3 API.
      *
      *
      * @param array<mixed> $parameters
      * @param array<mixed> $parameters
+     * @param array<mixed> $headers
      *
      *
      * @throws TransportExceptionInterface
      * @throws TransportExceptionInterface
      */
      */
-    protected function sendCommand(string $route, array $parameters): ResponseInterface
+    protected function sendCommand(string $route, array $parameters, array $headers = []): ResponseInterface
     {
     {
-        $url = UrlBuilder::concatParameters('/typo3/index.php?route='.$route, $parameters);
+        $url = UrlBuilder::concat(
+            '/typo3',
+            [$route],
+            $parameters
+        );
 
 
-        return $this->typo3_client->request('GET', $url);
+        return $this->typo3_client->request('GET', $url, ['headers' => $headers]);
     }
     }
 
 
     /**
     /**
@@ -71,6 +77,24 @@ class Typo3Service
         return $this->sendCommand('/otadmin/site/delete', ['organization-id' => $organizationId]);
         return $this->sendCommand('/otadmin/site/delete', ['organization-id' => $organizationId]);
     }
     }
 
 
+    /**
+     * Permanently delete the organization's website.
+     *
+     * @throws TransportExceptionInterface
+     */
+    public function hardDeleteSite(int $organizationId): ResponseInterface
+    {
+        $date = DatesUtils::new()->format('Ymd');
+
+        $headers = ['Confirmation-Token' => "DEL-$organizationId-".$date];
+
+        return $this->sendCommand(
+            '/otadmin/site/delete',
+            ['organization-id' => $organizationId, 'hard' => 1],
+            $headers
+        );
+    }
+
     /**
     /**
      * Restore a website that has been deleted with 'deleteSite'.
      * Restore a website that has been deleted with 'deleteSite'.
      *
      *

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

@@ -16,8 +16,12 @@ class EntityUtils
     /**
     /**
      * @throws \ReflectionException
      * @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->organizationDefaultValue($entity, $access);
         $this->billingSettingDefaultValueDefaultValue($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
      * @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)) {
         if (!file_exists($path)) {
             return true;
             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 - 1
src/Service/Utils/UrlBuilder.php

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

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

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

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

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Organization;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\Entity\Access\Access;
+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;
+    }
+}

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

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Organization;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\Organization\OrganizationDeletionRequest;
+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;
+    }
+}

+ 54 - 0
src/State/Provider/Dolibarr/DolibarrDocDownloadProvider.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\Dolibarr;
+
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\Service\Dolibarr\DolibarrApiService;
+use Symfony\Component\HttpFoundation\HeaderUtils;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Custom provider pour les DolibarrAccounts récupérés via l'api dolibarr.
+ */
+final readonly class DolibarrDocDownloadProvider implements ProviderInterface
+{
+    public function __construct(
+        private DolibarrApiService $dolibarrApiService,
+    ) {
+    }
+
+    /**
+     * @param mixed[] $uriVariables
+     * @param mixed[] $context
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
+    {
+        if ($operation instanceof GetCollection) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        $data = $this->dolibarrApiService->downloadBillingDocPdf(
+            $uriVariables['dolibarrDocType'],
+            $uriVariables['ref']
+        );
+
+        // Build the response and attach the file to it
+        // @see https://symfony.com/doc/current/components/http_foundation.html#serving-files
+        $response = new Response(base64_decode($data['content']));
+
+        $response->headers->set('Charset', 'UTF-8');
+        $response->headers->set('Access-Control-Expose-Headers', 'Content-Disposition');
+        $response->headers->set('Content-Type', $data['content-type']);
+
+        $response->headers->set(
+            'Content-Disposition',
+            HeaderUtils::makeDisposition(HeaderUtils::DISPOSITION_ATTACHMENT, $data['filename'])
+        );
+
+        return $response;
+    }
+}

+ 1 - 0
tests/Fixture/Factory/Organization/OrganizationFactory.php

@@ -3,6 +3,7 @@
 namespace App\Tests\Fixture\Factory\Organization;
 namespace App\Tests\Fixture\Factory\Organization;
 
 
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Organization;
+use App\Repository\Organization\OrganizationRepository;
 use Zenstruck\Foundry\ModelFactory;
 use Zenstruck\Foundry\ModelFactory;
 use Zenstruck\Foundry\Proxy;
 use Zenstruck\Foundry\Proxy;
 use Zenstruck\Foundry\RepositoryProxy;
 use Zenstruck\Foundry\RepositoryProxy;

+ 301 - 0
tests/Unit/Service/Cron/Job/CleanDbTest.php

@@ -0,0 +1,301 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Service\Cron\Job;
+
+use App\Service\Cron\Job\CleanDb;
+use App\Service\Cron\UI\CronUIInterface;
+use App\Service\Utils\DatesUtils;
+use Doctrine\DBAL\Connection;
+use Doctrine\DBAL\Statement;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+use Psr\Log\LoggerInterface;
+
+class TestableCleanDb extends CleanDb
+{
+    public function purgeDb(bool $commit = true): void
+    {
+        parent::purgeDb($commit);
+    }
+
+    public function purgeAuditTables(\DateTime $maxDate): int
+    {
+        return parent::purgeAuditTables($maxDate);
+    }
+
+    public function purgeMessages(\DateTime $maxDate): int
+    {
+        return parent::purgeMessages($maxDate);
+    }
+
+    public function purgeNotifications(\DateTime $maxDate): int
+    {
+        return parent::purgeNotifications($maxDate);
+    }
+}
+
+class CleanDbTest extends TestCase
+{
+    private CronUIInterface|MockObject $ui;
+    private MockObject|LoggerInterface $logger;
+    private Connection|MockObject $connection;
+
+    public function setUp(): void
+    {
+        $this->ui = $this->getMockBuilder(CronUIInterface::class)->disableOriginalConstructor()->getMock();
+        $this->logger = $this->getMockBuilder(LoggerInterface::class)->disableOriginalConstructor()->getMock();
+
+        $this->connection = $this->getMockBuilder(Connection::class)->disableOriginalConstructor()->getMock();
+    }
+
+    private function getMockFor(string $method): MockObject|TestableCleanDb
+    {
+        $cleanDb = $this->getMockBuilder(TestableCleanDb::class)
+            ->setConstructorArgs([$this->connection])
+            ->setMethodsExcept([$method, 'setUI', 'setLoggerInterface'])
+            ->getMock();
+        $cleanDb->setUI($this->ui);
+        $cleanDb->setLoggerInterface($this->logger);
+
+        return $cleanDb;
+    }
+
+    public function testPreview(): void
+    {
+        $cleanDb = $this->getMockFor('preview');
+
+        $cleanDb->expects(self::once())->method('purgeDb')->with(false);
+
+        $cleanDb->preview();
+    }
+
+    public function testExecute(): void
+    {
+        $cleanDb = $this->getMockFor('execute');
+
+        $cleanDb->expects(self::once())->method('purgeDb');
+
+        $cleanDb->execute();
+    }
+
+    public function testPurgeDb(): void
+    {
+        DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
+
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P60D'));
+
+        $maxDateAudit = DatesUtils::new();
+        $maxDateAudit->sub(new \DateInterval('P180D'));
+
+        $cleanDb = $this->getMockFor('purgeDb');
+
+        $this->connection->expects($this->once())->method('beginTransaction');
+        $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
+        $this->connection->expects($this->once())->method('commit');
+        $this->connection->expects($this->never())->method('rollback');
+
+        $cleanDb->expects(self::once())->method('purgeAuditTables')->with($maxDateAudit)->willReturn(100);
+        $cleanDb->expects(self::once())->method('purgeMessages')->with($maxDate)->willReturn(101);
+        $cleanDb->expects(self::once())->method('purgeNotifications')->with($maxDate)->willReturn(102);
+
+        $this->logger->expects(self::once())->method('info')->with('DB purged - 303 records permanently deleted');
+
+        $cleanDb->purgeDb();
+    }
+
+    public function testPurgeDbNoCommit(): void
+    {
+        DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
+
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P60D'));
+
+        $maxDateAudit = DatesUtils::new();
+        $maxDateAudit->sub(new \DateInterval('P180D'));
+
+        $cleanDb = $this->getMockFor('purgeDb');
+
+        $this->connection->expects($this->once())->method('beginTransaction');
+        $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
+        $this->connection->expects($this->never())->method('commit');
+        $this->connection->expects($this->once())->method('rollback');
+
+        $cleanDb->expects(self::once())->method('purgeAuditTables')->with($maxDateAudit)->willReturn(100);
+        $cleanDb->expects(self::once())->method('purgeMessages')->with($maxDate)->willReturn(101);
+        $cleanDb->expects(self::once())->method('purgeNotifications')->with($maxDate)->willReturn(102);
+
+        $this->ui->expects(self::once())->method('print')->with('DB purged - 303 records would be permanently deleted');
+
+        $cleanDb->purgeDb(false);
+    }
+
+    public function testPurgeDbWithError(): void
+    {
+        DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
+
+        $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P60D'));
+
+        $maxDateAudit = DatesUtils::new();
+        $maxDateAudit->sub(new \DateInterval('P180D'));
+
+        $cleanDb = $this->getMockFor('purgeDb');
+
+        $this->connection->expects($this->once())->method('beginTransaction');
+        $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
+        $this->connection->expects($this->never())->method('commit');
+        $this->connection->expects($this->once())->method('rollback');
+
+        $cleanDb->expects(self::once())->method('purgeAuditTables')->with($maxDateAudit)->willThrowException(new \Exception('Error'));
+
+        $this->expectException(\Exception::class);
+
+        $cleanDb->purgeDb(false);
+    }
+
+    public function testPurgeAuditTables(): void
+    {
+        $maxDateAudit = DatesUtils::new('2022-06-30 00:00:00');
+        $maxDateAudit->sub(new \DateInterval('P180D'));
+
+        $cleanDb = $this->getMockFor('purgeAuditTables');
+
+        $schemaManager = $this->getMockBuilder(\Doctrine\DBAL\Schema\AbstractSchemaManager::class)->disableOriginalConstructor()->getMock();
+        $this->connection->method('getSchemaManager')->willReturn($schemaManager);
+
+        $schemaManager->method('listTableNames')->willReturn([
+            'table1',
+            'Audit_table1',
+            'table2',
+            'Audit_table2',
+        ]);
+
+        $stmt1 = $this->getMockBuilder(Statement::class)->disableOriginalConstructor()->getMock();
+        $stmt2 = $this->getMockBuilder(Statement::class)->disableOriginalConstructor()->getMock();
+
+        $this->connection
+            ->expects(self::exactly(2))
+            ->method('prepare')
+            ->willReturnMap([
+                [
+                    'DELETE a, r 
+                     FROM opentalent.Audit_table1 a
+                     INNER JOIN opentalent.revisions r ON r.id = a.rev
+                     WHERE r.timestamp < :maxDate;',
+                    $stmt1,
+                ],
+                [
+                    'DELETE a, r 
+                     FROM opentalent.Audit_table2 a
+                     INNER JOIN opentalent.revisions r ON r.id = a.rev
+                     WHERE r.timestamp < :maxDate;',
+                    $stmt2,
+                ],
+            ]);
+
+        $stmt1
+            ->expects(self::once())
+            ->method('executeStatement')
+            ->with(['maxDate' => '2022-01-01'])
+            ->willReturn(100);
+
+        $stmt2
+            ->expects(self::once())
+            ->method('executeStatement')
+            ->with(['maxDate' => '2022-01-01'])
+            ->willReturn(100);
+
+        $this->assertEquals(200, $cleanDb->purgeAuditTables($maxDateAudit));
+    }
+
+    public function testPurgeMessages(): void
+    {
+        $maxDate = DatesUtils::new('2022-03-02');
+        $maxDate->sub(new \DateInterval('P60D'));
+
+        $cleanDb = $this->getMockFor('purgeMessages');
+
+        $stmt1 = $this->getMockBuilder(Statement::class)->disableOriginalConstructor()->getMock();
+        $stmt2 = $this->getMockBuilder(Statement::class)->disableOriginalConstructor()->getMock();
+
+        $this->connection
+            ->expects(self::exactly(2))
+            ->method('prepare')
+            ->willReturnMap([
+                [
+                    'DELETE r
+                FROM opentalent.Message m
+                inner join opentalent.ReportMessage r on r.message_id = m.id
+                where (m.dateSent < :maxDate or (m.dateSent is null and m.createDate < :maxDate)) and m.isSystem = true and m.id > 0;',
+                    $stmt1,
+                ],
+                [
+                    'DELETE
+                FROM opentalent.Message
+                where (dateSent < :maxDate or (dateSent is null and createDate < :maxDate)) and isSystem = true and id > 0;',
+                    $stmt2,
+                ],
+            ]);
+
+        $stmt1
+            ->expects(self::once())
+            ->method('executeStatement')
+            ->with(['maxDate' => '2022-01-01'])
+            ->willReturn(100);
+
+        $stmt2
+            ->expects(self::once())
+            ->method('executeStatement')
+            ->with(['maxDate' => '2022-01-01'])
+            ->willReturn(100);
+
+        $this->assertEquals(200, $cleanDb->purgeMessages($maxDate));
+    }
+
+    public function testPurgeNotifications(): void
+    {
+        $maxDate = DatesUtils::new('2022-03-02');
+        $maxDate->sub(new \DateInterval('P60D'));
+
+        $cleanDb = $this->getMockFor('purgeNotifications');
+
+        $stmt1 = $this->getMockBuilder(Statement::class)->disableOriginalConstructor()->getMock();
+        $stmt2 = $this->getMockBuilder(Statement::class)->disableOriginalConstructor()->getMock();
+
+        $this->connection
+            ->expects(self::exactly(2))
+            ->method('prepare')
+            ->willReturnMap([
+                [
+                    "DELETE u
+                FROM opentalent.Information i
+                inner join opentalent.NotificationUser u on u.notification_id = i.id
+                where i.createDate < :maxDate and i.discr = 'notification';",
+                    $stmt1,
+                ],
+                [
+                    "DELETE
+                FROM opentalent.Information
+                where createDate < :maxDate and discr = 'notification';",
+                    $stmt2,
+                ],
+            ]);
+
+        $stmt1
+            ->expects(self::once())
+            ->method('executeStatement')
+            ->with(['maxDate' => '2022-01-01'])
+            ->willReturn(100);
+
+        $stmt2
+            ->expects(self::once())
+            ->method('executeStatement')
+            ->with(['maxDate' => '2022-01-01'])
+            ->willReturn(100);
+
+        $this->assertEquals(200, $cleanDb->purgeNotifications($maxDate));
+    }
+}

+ 190 - 81
tests/Unit/Service/Cron/Job/CleanTempFilesTest.php

@@ -3,6 +3,8 @@
 namespace App\Tests\Unit\Service\Cron\Job;
 namespace App\Tests\Unit\Service\Cron\Job;
 
 
 use App\Entity\Core\File;
 use App\Entity\Core\File;
+use App\Enum\Core\FileHostEnum;
+use App\Enum\Core\FileStatusEnum;
 use App\Repository\Core\FileRepository;
 use App\Repository\Core\FileRepository;
 use App\Service\Cron\Job\CleanTempFiles;
 use App\Service\Cron\Job\CleanTempFiles;
 use App\Service\Cron\UI\CronUIInterface;
 use App\Service\Cron\UI\CronUIInterface;
@@ -10,6 +12,8 @@ use App\Service\File\Storage\LocalStorage;
 use App\Service\Utils\DatesUtils;
 use App\Service\Utils\DatesUtils;
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\Connection;
 use Doctrine\ORM\AbstractQuery;
 use Doctrine\ORM\AbstractQuery;
+use Doctrine\ORM\Query\Expr;
+use Doctrine\ORM\Query\Expr\Comparison;
 use Doctrine\ORM\QueryBuilder;
 use Doctrine\ORM\QueryBuilder;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use PHPUnit\Framework\TestCase;
@@ -27,16 +31,6 @@ class TestableCleanTempFile extends CleanTempFiles
         parent::deleteFiles($files);
         parent::deleteFiles($files);
     }
     }
 
 
-    public function purgeDb(\DateTime $maxDate, bool $commit = true): void
-    {
-        parent::purgeDb($maxDate, $commit);
-    }
-
-    public function purgeFiles(\DateTime $maxDate): int
-    {
-        return parent::purgeFiles($maxDate);
-    }
-
     public function getQueryConditions(QueryBuilder $queryBuilder, \DateTime $maxDate): void
     public function getQueryConditions(QueryBuilder $queryBuilder, \DateTime $maxDate): void
     {
     {
         parent::getQueryConditions($queryBuilder, $maxDate);
         parent::getQueryConditions($queryBuilder, $maxDate);
@@ -61,7 +55,7 @@ class CleanTempFilesTest extends TestCase
         $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
         $this->storage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
     }
     }
 
 
-    private function getMockFor(string $method)
+    private function getMockFor(string $method): MockObject|TestableCleanTempFile
     {
     {
         $cleanTempFiles = $this->getMockBuilder(TestableCleanTempFile::class)
         $cleanTempFiles = $this->getMockBuilder(TestableCleanTempFile::class)
             ->setConstructorArgs([$this->connection, $this->fileRepository, $this->storage])
             ->setConstructorArgs([$this->connection, $this->fileRepository, $this->storage])
@@ -100,8 +94,6 @@ class CleanTempFilesTest extends TestCase
             ['  * /foo/bar']
             ['  * /foo/bar']
         );
         );
 
 
-        $cleanTempFiles->expects(self::once())->method('purgeDb')->with($maxDate, false);
-
         $cleanTempFiles->preview();
         $cleanTempFiles->preview();
     }
     }
 
 
@@ -119,16 +111,14 @@ class CleanTempFilesTest extends TestCase
             $this->getMockBuilder(File::class)->getMock(),
             $this->getMockBuilder(File::class)->getMock(),
         ];
         ];
 
 
-        $cleanTempFiles->method('listFilesToDelete')->willReturn($files)->with($maxDate);
+        $cleanTempFiles->method('listFilesToDelete')->with($maxDate)->willReturn($files);
 
 
         $cleanTempFiles->expects(self::once())->method('deleteFiles')->with($files);
         $cleanTempFiles->expects(self::once())->method('deleteFiles')->with($files);
 
 
-        $cleanTempFiles->expects(self::once())->method('purgeDb')->with($maxDate);
-
         $cleanTempFiles->execute();
         $cleanTempFiles->execute();
     }
     }
 
 
-    public function testListFilesToDelete()
+    public function testListFilesToDelete(): void
     {
     {
         $cleanTempFiles = $this->getMockFor('listFilesToDelete');
         $cleanTempFiles = $this->getMockFor('listFilesToDelete');
 
 
@@ -171,101 +161,220 @@ class CleanTempFilesTest extends TestCase
         $this->assertEquals([], $result);
         $this->assertEquals([], $result);
     }
     }
 
 
-    public function testPurgeDbCommitsTransactionIfCommitIsTrue(): void
+    public function testDeleteFiles(): void
     {
     {
-        $cleanTempFiles = $this->getMockFor('purgeDb');
+        $cleanTempFiles = $this->getMockFor('deleteFiles');
 
 
-        DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
-        $maxDate = new \DateTime('now');
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getId')->willReturn(1);
 
 
-        $this->connection->expects($this->once())
-            ->method('beginTransaction');
-        $this->connection->expects($this->once())
-            ->method('setAutoCommit')
-            ->with(false);
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file2->method('getId')->willReturn(2);
+
+        $file3 = $this->getMockBuilder(File::class)->getMock();
+        $file3->method('getId')->willReturn(3);
+
+        $files = [$file1, $file2, $file3];
+
+        $this->connection->expects($this->exactly(3))->method('beginTransaction');
+        $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
 
 
-        $cleanTempFiles->method('purgeFiles')->willReturn(5)->with($maxDate);
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository->expects($this->once())->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
 
 
-        $this->connection->expects($this->once())
-            ->method('commit');
+        $queryBuilder
+            ->expects(self::exactly(3))
+            ->method('delete')
+            ->willReturnSelf();
+
+        $queryBuilder
+            ->expects(self::exactly(3))
+            ->method('where')
+            ->with('f.id = :id')
+            ->willReturnSelf();
 
 
-        $this->ui->expects($this->once())
-            ->method('print')
-            ->with('DB purged - 5 records permanently deleted');
+        $queryBuilder
+            ->expects(self::exactly(3))
+            ->method('setParameter')
+            ->withConsecutive(['id', 1], ['id', 2], ['id', 3]);
 
 
-        $cleanTempFiles->purgeDb($maxDate);
+        $this->storage
+            ->expects(self::exactly(3))
+            ->method('hardDelete')
+            ->withConsecutive([$file1], [$file1], [$file3]);
+
+        $this->connection->expects($this->exactly(3))->method('commit');
+        $this->connection->expects($this->never())->method('rollback');
+
+        $this->logger->expects(self::atLeastOnce())->method('info')->withConsecutive(
+            ['3 temporary files to be removed'],
+            ['Deleting files...'],
+            ['3 files deleted']
+        );
+
+        $cleanTempFiles->deleteFiles($files);
     }
     }
 
 
-    public function testPurgeDbRollsbackTransactionIfCommitIsFalse(): void
+    public function testDeleteFilesWithNonBlockingErrors(): void
     {
     {
-        $cleanTempFiles = $this->getMockFor('purgeDb');
+        $cleanTempFiles = $this->getMockFor('deleteFiles');
 
 
-        DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
-        $maxDate = DatesUtils::new();
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getId')->willReturn(1);
+
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file2->method('getId')->willReturn(2);
 
 
-        $this->connection->expects($this->once())
-            ->method('beginTransaction');
-        $this->connection->expects($this->once())
-            ->method('setAutoCommit')
-            ->with(false);
+        $files = [$file1, $file2];
 
 
-        $cleanTempFiles->method('purgeFiles')->willReturn(5)->with($maxDate);
+        $this->connection->expects($this->exactly(2))->method('beginTransaction');
+        $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
 
 
-        $this->connection->expects($this->once())
-            ->method('rollback');
-        $this->ui->expects($this->once())
-            ->method('print')
-            ->with('DB purged - 5 records would be permanently deleted');
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository->expects($this->once())->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
 
 
-        $cleanTempFiles->purgeDb($maxDate, false);
+        $queryBuilder
+            ->expects(self::never())
+            ->method('delete')
+            ->willReturnSelf();
+
+        $this->storage
+            ->expects(self::exactly(2))
+            ->method('hardDelete')
+            ->willReturnCallback(function ($file) {
+                switch ($file->getId()) {
+                    case 1:
+                        throw new \RuntimeException('Some error');
+                    case 2:
+                        throw new \InvalidArgumentException('Some other error');
+                }
+            });
+
+        $this->connection->expects($this->never())->method('commit');
+        $this->connection->expects($this->exactly(2))->method('rollback');
+
+        $this->logger->expects(self::atLeastOnce())->method('info')->withConsecutive(
+            ['2 temporary files to be removed'],
+            ['Deleting files...'],
+            ['0 files deleted']
+        );
+
+        $this->logger
+            ->expects(self::exactly(2))
+            ->method('error')
+            ->withConsecutive(
+                ['ERROR : Some error'],
+                ['ERROR : Some other error'],
+            );
+
+        $cleanTempFiles->deleteFiles($files);
     }
     }
 
 
-    public function testPurgeDbRollsbackTransactionOnException(): void
+    public function testDeleteFilesWithBlockingError(): void
     {
     {
-        $cleanTempFiles = $this->getMockFor('purgeDb');
+        $cleanTempFiles = $this->getMockFor('deleteFiles');
 
 
-        DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
-        $maxDate = DatesUtils::new();
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getId')->willReturn(1);
+
+        $files = [$file1];
+
+        $this->connection->expects($this->once())->method('beginTransaction');
+        $this->connection->expects($this->once())->method('setAutoCommit')->with(false);
 
 
-        $cleanTempFiles->method('purgeFiles')->willThrowException(new \Exception('error'))->with($maxDate);
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository->expects($this->once())->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
+
+        $queryBuilder
+            ->expects(self::never())
+            ->method('delete')
+            ->willReturnSelf();
 
 
-        $this->connection->expects($this->once())
-            ->method('beginTransaction');
-        $this->connection->expects($this->once())
-            ->method('rollback');
-        $this->ui->expects($this->never())
-            ->method('print');
+        $this->storage
+            ->expects(self::exactly(1))
+            ->method('hardDelete')
+            ->willReturnCallback(function ($file) {
+                switch ($file->getId()) {
+                    case 1:
+                        throw new \Exception('Some unknown error');
+                }
+            });
+
+        $this->connection->expects($this->never())->method('commit');
+        $this->connection->expects($this->once())->method('rollback');
+
+        $this->logger->expects(self::atLeastOnce())->method('info')->withConsecutive(
+            ['1 temporary files to be removed'],
+            ['Deleting files...'],
+        );
 
 
         $this->expectException(\Exception::class);
         $this->expectException(\Exception::class);
-        $cleanTempFiles->purgeDb($maxDate, true);
+
+        $cleanTempFiles->deleteFiles($files);
     }
     }
 
 
-    public function testPurgeFilesDeletes()
+    public function testGetQueryConditions(): void
     {
     {
-        $cleanTempFiles = $this->getMockFor('purgeFiles');
-
         DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
         DatesUtils::setFakeDatetime('2022-01-08 00:00:00');
+
+        $cleanTempFiles = $this->getMockFor('getQueryConditions');
+
         $maxDate = DatesUtils::new();
         $maxDate = DatesUtils::new();
+        $maxDate->sub(new \DateInterval('P60D'));
 
 
-        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-        $queryBuilder->expects($this->once())
-            ->method('delete')
-            ->willReturnSelf();
+        $queryBuilder = $this->getMockBuilder(QueryBuilder::class)->disableOriginalConstructor()->getMock();
 
 
-        $query = $this->getMockBuilder(AbstractQuery::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-        $queryBuilder->expects($this->once())
-            ->method('getQuery')
-            ->willReturn($query);
-        $query->expects($this->once())
-            ->method('execute')
-            ->willReturn(3);
+        $expr = $this->getMockBuilder(Expr::class)->disableOriginalConstructor()->getMock();
+        $queryBuilder->method('expr')->willReturn($expr);
 
 
-        $this->fileRepository->method('createQueryBuilder')->with('f')->willReturn($queryBuilder);
+        $cmp1 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
+        $cmp2 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
+        $cmp3 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
+        $cmp4 = $this->getMockBuilder(Comparison::class)->disableOriginalConstructor()->getMock();
+
+        $expr->expects(self::exactly(3))->method('eq')->willReturnMap(
+            [
+                ['f.isTemporaryFile', ':temporaryTrue', $cmp1],
+                ['f.status', ':status', $cmp2],
+                ['f.host', ':host', $cmp3],
+            ]
+        );
+
+        $expr->expects(self::once())->method('lt')->with('f.createDate', ':maxDate')->willReturn($cmp4);
+
+        $expr->expects(self::once())->method('isNull')->with('f.createDate')->willReturn('f.createDate is null');
+
+        $orX1 = $this->getMockBuilder(Expr\Orx::class)->disableOriginalConstructor()->getMock();
+        $orX2 = $this->getMockBuilder(Expr\Orx::class)->disableOriginalConstructor()->getMock();
+
+        $expr->expects(self::exactly(2))->method('orX')->willReturnMap(
+            [
+                [$cmp1, $cmp2, $orX1],
+                [$cmp4, 'f.createDate is null', $orX2],
+            ]
+        );
+
+        $queryBuilder
+            ->expects(self::exactly(3))
+            ->method('andWhere')
+            ->withConsecutive(
+                [$orX1],
+                [$cmp3],
+                [$orX2],
+            )
+            ->willReturnSelf();
+
+        $queryBuilder
+            ->expects(self::exactly(4))
+            ->method('setParameter')
+            ->withConsecutive(
+                ['temporaryTrue', true],
+                ['host', FileHostEnum::AP2I],
+                ['status', FileStatusEnum::DELETED],
+                ['maxDate', '2021-11-09'],
+            )
+            ->willReturnSelf();
 
 
-        $this->assertEquals(3, $cleanTempFiles->purgeFiles($maxDate));
+        $cleanTempFiles->getQueryConditions($queryBuilder, $maxDate);
     }
     }
 }
 }

+ 0 - 90
tests/Unit/Service/Doctrine/FiltersConfigurationService.php

@@ -1,90 +0,0 @@
-<?php
-
-namespace App\Tests\Unit\Service\Doctrine;
-
-use App\Filter\Doctrine\TimeConstraint\ActivityYearFilter;
-use App\Filter\Doctrine\TimeConstraint\DatetimeFilter;
-use App\Service\Constraint\ActivityYearConstraint;
-use App\Service\Constraint\DateTimeConstraint;
-use App\Service\Doctrine\FiltersConfigurationService;
-use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\Query\FilterCollection;
-use PHPUnit\Framework\MockObject\MockObject;
-use PHPUnit\Framework\TestCase;
-
-class TestableFiltersConfigurationService extends FiltersConfigurationService
-{
-    public function getFilters(): FilterCollection
-    {
-        return parent::getFilters();
-    }
-}
-
-class FiltersConfigurationServiceTest extends TestCase
-{
-    private \PHPUnit\Framework\MockObject\MockObject|EntityManagerInterface $em;
-    private DateTimeConstraint|MockObject $dateTimeConstraint;
-    private ActivityYearConstraint|MockObject $activityYearConstraint;
-
-    public function setUp(): void
-    {
-        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
-        $this->dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)->disableOriginalConstructor()->getMock();
-        $this->activityYearConstraint = $this->getMockBuilder(ActivityYearConstraint::class)->disableOriginalConstructor()->getMock();
-    }
-
-    public function getSUTMockForMethod(string $methodName): MockObject|TestableFiltersConfigurationService
-    {
-        return $this
-            ->getMockBuilder(TestableFiltersConfigurationService::class)
-            ->setConstructorArgs([$this->em, $this->dateTimeConstraint, $this->activityYearConstraint])
-            ->setMethodsExcept([$methodName])
-            ->getMock();
-    }
-
-    public function testGetFilters(): void
-    {
-        $filterConfigurationService = $this->getSUTMockForMethod('getFilters');
-
-        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
-
-        $this->em->expects(self::once())->method('getFilters')->willReturn($filterCollection);
-
-        $this->assertEquals(
-            $filterCollection,
-            $filterConfigurationService->getFilters()
-        );
-    }
-
-    public function testConfigureTimeConstraintFilters(): void
-    {
-        $filterConfigurationService = $this->getSUTMockForMethod('configureTimeConstraintFilters');
-
-        $accessId = 123;
-
-        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
-
-        $filterConfigurationService
-            ->method('getFilters')
-            ->willReturn($filterCollection);
-
-        $datetimeFilter = $this->getMockBuilder(DatetimeFilter::class)->disableOriginalConstructor()->getMock();
-        $activityYearFilter = $this->getMockBuilder(ActivityYearFilter::class)->disableOriginalConstructor()->getMock();
-
-        $filterCollection
-            ->expects(self::exactly(2))
-            ->method('enable')
-            ->willReturnMap([
-                ['date_time_filter', $datetimeFilter],
-                ['activity_year_filter', $activityYearFilter],
-            ]);
-
-        $datetimeFilter->expects(self::once())->method('setAccessId')->with($accessId);
-        $datetimeFilter->expects(self::once())->method('setTimeConstraint')->with($this->dateTimeConstraint);
-
-        $activityYearFilter->expects(self::once())->method('setAccessId')->with($accessId);
-        $activityYearFilter->expects(self::once())->method('setTimeConstraint')->with($this->activityYearConstraint);
-
-        $filterConfigurationService->configureTimeConstraintFilters($accessId);
-    }
-}

+ 213 - 0
tests/Unit/Service/Doctrine/FiltersConfigurationServiceTest.php

@@ -0,0 +1,213 @@
+<?php
+
+namespace App\Tests\Unit\Service\Doctrine;
+
+use App\Filter\Doctrine\TimeConstraint\ActivityYearFilter;
+use App\Filter\Doctrine\TimeConstraint\DatetimeFilter;
+use App\Service\Constraint\ActivityYearConstraint;
+use App\Service\Constraint\DateTimeConstraint;
+use App\Service\Doctrine\FiltersConfigurationService;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\Query\FilterCollection;
+use PHPUnit\Framework\MockObject\MockObject;
+use PHPUnit\Framework\TestCase;
+
+class TestableFiltersConfigurationService extends FiltersConfigurationService
+{
+    public function getFilters(): FilterCollection
+    {
+        return parent::getFilters();
+    }
+
+    public function setPreviousTimeConstraintState(?bool $value): void
+    {
+        $this->previousTimeConstraintState = $value;
+    }
+
+    public function getPreviousTimeConstraintState(): ?bool
+    {
+        return $this->previousTimeConstraintState;
+    }
+}
+
+class FiltersConfigurationServiceTest extends TestCase
+{
+    private \PHPUnit\Framework\MockObject\MockObject|EntityManagerInterface $em;
+    private DateTimeConstraint|MockObject $dateTimeConstraint;
+    private ActivityYearConstraint|MockObject $activityYearConstraint;
+
+    public function setUp(): void
+    {
+        $this->em = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->dateTimeConstraint = $this->getMockBuilder(DateTimeConstraint::class)->disableOriginalConstructor()->getMock();
+        $this->activityYearConstraint = $this->getMockBuilder(ActivityYearConstraint::class)->disableOriginalConstructor()->getMock();
+    }
+
+    public function getSUTMockForMethod(string $methodName): MockObject|TestableFiltersConfigurationService
+    {
+        return $this
+            ->getMockBuilder(TestableFiltersConfigurationService::class)
+            ->setConstructorArgs([$this->em, $this->dateTimeConstraint, $this->activityYearConstraint])
+            ->setMethodsExcept([$methodName, 'getPreviousTimeConstraintState', 'setPreviousTimeConstraintState'])
+            ->getMock();
+    }
+
+    public function testGetFilters(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('getFilters');
+
+        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
+
+        $this->em->expects(self::once())->method('getFilters')->willReturn($filterCollection);
+
+        $this->assertEquals(
+            $filterCollection,
+            $filterConfigurationService->getFilters()
+        );
+    }
+
+    public function testConfigureTimeConstraintFilters(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('configureTimeConstraintFilters');
+
+        $accessId = 123;
+
+        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
+
+        $filterConfigurationService
+            ->method('getFilters')
+            ->willReturn($filterCollection);
+
+        $datetimeFilter = $this->getMockBuilder(DatetimeFilter::class)->disableOriginalConstructor()->getMock();
+        $activityYearFilter = $this->getMockBuilder(ActivityYearFilter::class)->disableOriginalConstructor()->getMock();
+
+        $filterCollection
+            ->expects(self::exactly(2))
+            ->method('enable')
+            ->willReturnMap([
+                ['date_time_filter', $datetimeFilter],
+                ['activity_year_filter', $activityYearFilter],
+            ]);
+
+        $datetimeFilter->expects(self::once())->method('setAccessId')->with($accessId);
+        $datetimeFilter->expects(self::once())->method('setTimeConstraint')->with($this->dateTimeConstraint);
+
+        $activityYearFilter->expects(self::once())->method('setAccessId')->with($accessId);
+        $activityYearFilter->expects(self::once())->method('setTimeConstraint')->with($this->activityYearConstraint);
+
+        $filterConfigurationService->configureTimeConstraintFilters($accessId);
+    }
+
+    public function testSuspendTimeConstraintFilters(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('suspendTimeConstraintFilters');
+
+        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
+
+        $this->em->expects(self::once())->method('getFilters')->willReturn($filterCollection);
+
+        $filterCollection->method('isEnabled')->with('date_time_filter')->willReturn(true);
+
+        $datetimeFilter = $this->getMockBuilder(DatetimeFilter::class)->disableOriginalConstructor()->getMock();
+
+        $filterCollection->method('getFilter')->with('date_time_filter')->willReturn($datetimeFilter);
+
+        $datetimeFilter->method('isDisabled')->willReturn(false);
+        $datetimeFilter->expects(self::once())->method('setDisabled')->with(true);
+
+        $filterConfigurationService->suspendTimeConstraintFilters();
+
+        $this->assertEquals(
+            true,
+            $filterConfigurationService->getPreviousTimeConstraintState()
+        );
+    }
+
+    public function testSuspendTimeConstraintFiltersAlreadySuspended(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('suspendTimeConstraintFilters');
+
+        $filterConfigurationService->setPreviousTimeConstraintState(true);
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('date_time_filter is already suspended');
+
+        $filterConfigurationService->suspendTimeConstraintFilters();
+    }
+
+    public function testSuspendTimeConstraintFiltersAlreadyDisabled(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('suspendTimeConstraintFilters');
+
+        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
+
+        $this->em->expects(self::once())->method('getFilters')->willReturn($filterCollection);
+
+        $filterCollection->method('isEnabled')->with('date_time_filter')->willReturn(false);
+
+        $filterConfigurationService->suspendTimeConstraintFilters();
+
+        $this->assertEquals(
+            false,
+            $filterConfigurationService->getPreviousTimeConstraintState()
+        );
+    }
+
+    public function testRestoreTimeConstraintFilters(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('restoreTimeConstraintFilters');
+
+        $filterConfigurationService->setPreviousTimeConstraintState(true);
+
+        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
+
+        $this->em->expects(self::once())->method('getFilters')->willReturn($filterCollection);
+
+        $filterCollection->method('isEnabled')->with('date_time_filter')->willReturn(true);
+
+        $datetimeFilter = $this->getMockBuilder(DatetimeFilter::class)->disableOriginalConstructor()->getMock();
+
+        $filterCollection->method('getFilter')->with('date_time_filter')->willReturn($datetimeFilter);
+
+        $datetimeFilter->expects(self::once())->method('setDisabled')->with(true);
+
+        $filterConfigurationService->restoreTimeConstraintFilters();
+
+        $this->assertEquals(
+            $filterConfigurationService->getPreviousTimeConstraintState(),
+            null
+        );
+    }
+
+    public function testRestoreTimeConstraintFiltersNotSuspended(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('restoreTimeConstraintFilters');
+
+        $this->expectException(\RuntimeException::class);
+        $this->expectExceptionMessage('date_time_filter has not been suspended, can not be restored');
+
+        $filterConfigurationService->restoreTimeConstraintFilters();
+    }
+
+    public function testRestoreTimeConstraintFiltersNotEnabled(): void
+    {
+        $filterConfigurationService = $this->getSUTMockForMethod('restoreTimeConstraintFilters');
+
+        $filterConfigurationService->setPreviousTimeConstraintState(true);
+
+        $filterCollection = $this->getMockBuilder(FilterCollection::class)->disableOriginalConstructor()->getMock();
+
+        $this->em->expects(self::once())->method('getFilters')->willReturn($filterCollection);
+
+        $filterCollection->method('isEnabled')->with('date_time_filter')->willReturn(false);
+
+        $filterCollection->expects(self::never())->method('getFilter')->with('date_time_filter');
+
+        $filterConfigurationService->restoreTimeConstraintFilters();
+
+        $this->assertEquals(
+            $filterConfigurationService->getPreviousTimeConstraintState(),
+            null
+        );
+    }
+}

+ 1 - 1
tests/Unit/Service/Dolibarr/DolibarrAccountCreatorTest.php

@@ -118,7 +118,7 @@ class DolibarrAccountCreatorTest extends TestCase
         $accountData = [
         $accountData = [
             'id' => '2',
             'id' => '2',
             'code_client' => 'C2',
             'code_client' => 'C2',
-            'array_options' => ['2iopen_software_opentalent' => 'Opentalent Artist'],
+            'array_options' => ['options_2iopen_software_opentalent' => 'Opentalent Artist'],
         ];
         ];
 
 
         $dolibarrAccount = $dolibarrAccountCreator->createDolibarrAccount($organizationId, $accountData);
         $dolibarrAccount = $dolibarrAccountCreator->createDolibarrAccount($organizationId, $accountData);

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

@@ -2,11 +2,13 @@
 
 
 namespace App\Tests\Unit\Service\Dolibarr;
 namespace App\Tests\Unit\Service\Dolibarr;
 
 
+use App\Entity\Organization\Organization;
 use App\Service\Dolibarr\DolibarrApiService;
 use App\Service\Dolibarr\DolibarrApiService;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use PHPUnit\Framework\TestCase;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
 
 
 class DolibarrApiServiceTest extends TestCase
 class DolibarrApiServiceTest extends TestCase
 {
 {
@@ -439,4 +441,127 @@ class DolibarrApiServiceTest extends TestCase
 
 
         $dolibarrApiService->getSocietyTagsIds($socId);
         $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);
+    }
 }
 }

+ 80 - 34
tests/Unit/Service/File/FileManagerTest.php

@@ -8,16 +8,15 @@ use ApiPlatform\Metadata\Get;
 use App\Entity\Access\Access;
 use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Organization;
-use App\Enum\Core\FileHostEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Enum\Core\FileVisibilityEnum;
-use App\Service\File\Exception\FileNotFoundException;
+use App\Repository\Core\FileRepository;
 use App\Service\File\Factory\ImageFactory;
 use App\Service\File\Factory\ImageFactory;
 use App\Service\File\FileManager;
 use App\Service\File\FileManager;
-use App\Service\File\Storage\ApiLegacyStorage;
 use App\Service\File\Storage\FileStorageInterface;
 use App\Service\File\Storage\FileStorageInterface;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\File\Storage\LocalStorage;
 use App\Service\ServiceIterator\StorageIterator;
 use App\Service\ServiceIterator\StorageIterator;
+use Doctrine\ORM\EntityManagerInterface;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use PHPUnit\Framework\TestCase;
 
 
@@ -34,46 +33,39 @@ class FileManagerTest extends TestCase
         $this->storageIterator = $this->getMockBuilder(StorageIterator::class)->disableOriginalConstructor()->getMock();
         $this->storageIterator = $this->getMockBuilder(StorageIterator::class)->disableOriginalConstructor()->getMock();
         $this->imageFactory = $this->getMockBuilder(ImageFactory::class)->disableOriginalConstructor()->getMock();
         $this->imageFactory = $this->getMockBuilder(ImageFactory::class)->disableOriginalConstructor()->getMock();
         $this->localStorage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
         $this->localStorage = $this->getMockBuilder(LocalStorage::class)->disableOriginalConstructor()->getMock();
+        $this->entityManager = $this->getMockBuilder(EntityManagerInterface::class)->disableOriginalConstructor()->getMock();
+        $this->fileRepository = $this->getMockBuilder(FileRepository::class)->disableOriginalConstructor()->getMock();
     }
     }
 
 
     public function getFileManagerMockFor(string $methodName): FileManager|MockObject
     public function getFileManagerMockFor(string $methodName): FileManager|MockObject
     {
     {
         return $this->getMockBuilder(FileManager::class)
         return $this->getMockBuilder(FileManager::class)
-            ->setConstructorArgs([$this->iriConverter, $this->storageIterator, $this->imageFactory, $this->localStorage])
+            ->setConstructorArgs([
+                $this->iriConverter,
+                $this->storageIterator,
+                $this->imageFactory,
+                $this->localStorage,
+                $this->entityManager,
+                $this->fileRepository,
+            ])
             ->setMethodsExcept([$methodName])
             ->setMethodsExcept([$methodName])
             ->getMock();
             ->getMock();
     }
     }
 
 
-    //    public function testGetStorageFor(): void {
-    //        $fileManager = $this->getFileManagerMockFor('getStorageFor');
-    //
-    //        $file1 = $this->getMockBuilder(File::class)->getMock();
-    //        $file1->method('getHost')->willReturn(FileHostEnum::API1()->getValue());
-    //
-    //        $file2 = $this->getMockBuilder(File::class)->getMock();
-    //        $file2->method('getHost')->willReturn(FileHostEnum::AP2I()->getValue());
-    //
-    //        $this->assertInstanceOf(
-    //            ApiLegacyStorage::class,
-    //            $fileManager->getStorageFor($file1)
-    //        );
-    //
-    //        $this->assertInstanceOf(
-    //            LocalStorage::class,
-    //            $fileManager->getStorageFor($file2)
-    //        );
-    //    }
-    //
-    //    public function testGetStorageForUnknown(): void {
-    //        $fileManager = $this->getFileManagerMockFor('getStorageFor');
-    //
-    //        $file = $this->getMockBuilder(File::class)->getMock();
-    //        $file->method('getHost')->willReturn('unknown');
-    //
-    //        $this->expectException(FileNotFoundException::class);
-    //
-    //        $fileManager->getStorageFor($file);
-    //    }
+    public function testGetStorageFor(): void
+    {
+        $fileManager = $this->getFileManagerMockFor('getStorageFor');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $fileStorage = $this->getMockBuilder(FileStorageInterface::class)->getMock();
+
+        $this->storageIterator->expects(self::once())->method('getStorageFor')->with($file)->willReturn($fileStorage);
+
+        $this->assertEquals(
+            $fileStorage,
+            $fileManager->getStorageFor($file)
+        );
+    }
 
 
     public function testRead(): void
     public function testRead(): void
     {
     {
@@ -92,6 +84,26 @@ class FileManagerTest extends TestCase
         );
         );
     }
     }
 
 
+    public function testGetImageUrl(): void
+    {
+        $fileManager = $this->getFileManagerMockFor('getImageUrl');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $fileStorage = $this->getMockBuilder(FileStorageInterface::class)->getMock();
+
+        $fileManager->expects(self::once())->method('getStorageFor')->with($file)->willReturn($fileStorage);
+
+        $fileStorage
+            ->method('getImageUrl')
+            ->with($file, 'md', true)
+            ->willReturn('foo');
+
+        $this->assertEquals(
+            'foo',
+            $fileManager->getImageUrl($file, 'md', true)
+        );
+    }
+
     public function testPrepareFile(): void
     public function testPrepareFile(): void
     {
     {
         $fileManager = $this->getFileManagerMockFor('prepareFile');
         $fileManager = $this->getFileManagerMockFor('prepareFile');
@@ -171,4 +183,38 @@ class FileManagerTest extends TestCase
             $fileManager->getDownloadIri($file)
             $fileManager->getDownloadIri($file)
         );
         );
     }
     }
+
+    public function testDeleteOrganizationFiles(): void
+    {
+        $fileManager = $this->getFileManagerMockFor('deleteOrganizationFiles');
+
+        $fileStorage1 = $this->getMockBuilder(FileStorageInterface::class)->getMock();
+        $fileStorage2 = $this->getMockBuilder(FileStorageInterface::class)->getMock();
+
+        $this->storageIterator->method('getStorages')->willReturn([$fileStorage1, $fileStorage2]);
+
+        $fileStorage1->expects(self::once())->method('deleteOrganizationFiles')->with(123);
+        $fileStorage2->expects(self::once())->method('deleteOrganizationFiles')->with(123);
+
+        $this->fileRepository->expects(self::once())->method('deleteByOrganization')->with(123);
+
+        $fileManager->deleteOrganizationFiles(123);
+    }
+
+    public function testDeletePersonFiles(): void
+    {
+        $fileManager = $this->getFileManagerMockFor('deletePersonFiles');
+
+        $fileStorage1 = $this->getMockBuilder(FileStorageInterface::class)->getMock();
+        $fileStorage2 = $this->getMockBuilder(FileStorageInterface::class)->getMock();
+
+        $this->storageIterator->method('getStorages')->willReturn([$fileStorage1, $fileStorage2]);
+
+        $fileStorage1->expects(self::once())->method('deletePersonFiles')->with(123);
+        $fileStorage2->expects(self::once())->method('deletePersonFiles')->with(123);
+
+        $this->fileRepository->expects(self::once())->method('deleteByPerson')->with(123);
+
+        $fileManager->deletePersonFiles(123);
+    }
 }
 }

+ 99 - 18
tests/Unit/Service/File/Storage/ApiLegacyStorageTest.php

@@ -3,25 +3,41 @@
 namespace App\Tests\Unit\Service\File\Storage;
 namespace App\Tests\Unit\Service\File\Storage;
 
 
 use App\Entity\Core\File;
 use App\Entity\Core\File;
+use App\Enum\Core\FileHostEnum;
 use App\Service\ApiLegacy\ApiLegacyRequestService;
 use App\Service\ApiLegacy\ApiLegacyRequestService;
 use App\Service\File\Storage\ApiLegacyStorage;
 use App\Service\File\Storage\ApiLegacyStorage;
 use App\Service\Utils\UrlBuilder;
 use App\Service\Utils\UrlBuilder;
 use Liip\ImagineBundle\Imagine\Data\DataManager;
 use Liip\ImagineBundle\Imagine\Data\DataManager;
+use PHPUnit\Framework\MockObject\MockObject;
 use PHPUnit\Framework\TestCase;
 use PHPUnit\Framework\TestCase;
 
 
 class ApiLegacyStorageTest extends TestCase
 class ApiLegacyStorageTest extends TestCase
 {
 {
-    public function testExists(): void
+    private ApiLegacyRequestService|MockObject $apiLegacyRequestService;
+    private ApiLegacyRequestService|MockObject $dataManager;
+    private ApiLegacyRequestService|MockObject $urlBuilder;
+
+    public function setUp(): void
     {
     {
-        $apiLegacyRequestService = $this->getMockBuilder(ApiLegacyRequestService::class)
+        $this->apiLegacyRequestService = $this->getMockBuilder(ApiLegacyRequestService::class)
             ->disableOriginalConstructor()
             ->disableOriginalConstructor()
             ->getMock();
             ->getMock();
+        $this->dataManager = $this->getMockBuilder(DataManager::class)->disableOriginalConstructor()->getMock();
+        $this->urlBuilder = $this->getMockBuilder(UrlBuilder::class)->disableOriginalConstructor()->getMock();
+    }
 
 
-        $apiLegacyStorage = $this
+    public function getApiLegacyStorageMockFor(string $methodName): ApiLegacyStorage|MockObject
+    {
+        return $this
             ->getMockBuilder(ApiLegacyStorage::class)
             ->getMockBuilder(ApiLegacyStorage::class)
-            ->disableOriginalConstructor()
-            ->setMethodsExcept(['exists'])
+            ->setConstructorArgs([$this->apiLegacyRequestService, $this->dataManager, $this->urlBuilder, 'url', 'publicUrl'])
+            ->setMethodsExcept([$methodName])
             ->getMock();
             ->getMock();
+    }
+
+    public function testExists(): void
+    {
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('exists');
 
 
         $this->expectException(\RuntimeException::class);
         $this->expectException(\RuntimeException::class);
         $this->expectExceptionMessage('not implemented error');
         $this->expectExceptionMessage('not implemented error');
@@ -33,23 +49,12 @@ class ApiLegacyStorageTest extends TestCase
 
 
     public function testRead(): void
     public function testRead(): void
     {
     {
-        $apiLegacyRequestService = $this->getMockBuilder(ApiLegacyRequestService::class)
-            ->disableOriginalConstructor()
-            ->getMock();
-
-        $dataManager = $this->getMockBuilder(DataManager::class)->disableOriginalConstructor()->getMock();
-        $urlBuilder = $this->getMockBuilder(UrlBuilder::class)->disableOriginalConstructor()->getMock();
-
-        $apiLegacyStorage = $this
-            ->getMockBuilder(ApiLegacyStorage::class)
-            ->setConstructorArgs([$apiLegacyRequestService, $dataManager, $urlBuilder, 'url', 'publicUrl'])
-            ->setMethodsExcept(['read'])
-            ->getMock();
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('read');
 
 
         $file = $this->getMockBuilder(File::class)->getMock();
         $file = $this->getMockBuilder(File::class)->getMock();
         $file->method('getId')->willReturn(123);
         $file->method('getId')->willReturn(123);
 
 
-        $apiLegacyRequestService
+        $this->apiLegacyRequestService
             ->expects(self::once())
             ->expects(self::once())
             ->method('getContent')
             ->method('getContent')
             ->with('_internal/secure/files/123')
             ->with('_internal/secure/files/123')
@@ -59,4 +64,80 @@ class ApiLegacyStorageTest extends TestCase
 
 
         $this->assertEquals('xyz', $result);
         $this->assertEquals('xyz', $result);
     }
     }
+
+    public function testGetImageUrl(): void
+    {
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('getImageUrl');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getId')->willReturn(123);
+
+        $this->apiLegacyRequestService
+            ->expects(self::once())
+            ->method('getContent')
+            ->with('api/files/123/download/md?relativePath=1')
+            ->willReturn('xyz');
+
+        $this->assertEquals(
+            'publicUrl/xyz',
+            $apiLegacyStorage->getImageUrl($file, 'md', false)
+        );
+    }
+
+    public function testGetImageUrlRelativePath(): void
+    {
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('getImageUrl');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getId')->willReturn(123);
+
+        $this->apiLegacyRequestService
+            ->expects(self::once())
+            ->method('getContent')
+            ->with('api/files/123/download/lg?relativePath=1')
+            ->willReturn('xyz');
+
+        $this->assertEquals(
+            'url/xyz',
+            $apiLegacyStorage->getImageUrl($file, 'lg', true)
+        );
+    }
+
+    public function testSupport(): void
+    {
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('support');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getHost')->willReturn(FileHostEnum::API1);
+
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file2->method('getHost')->willReturn(FileHostEnum::AP2I);
+
+        $this->assertTrue($apiLegacyStorage->support($file1));
+        $this->assertFalse($apiLegacyStorage->support($file2));
+    }
+
+    public function testDeleteOrganizationFiles(): void
+    {
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('deleteOrganizationFiles');
+
+        $this->apiLegacyRequestService
+            ->expects(self::once())
+            ->method('get')
+            ->with('/_internal/request/organization-files/delete/123');
+
+        $apiLegacyStorage->deleteOrganizationFiles(123);
+    }
+
+    public function testDeletePersonFiles(): void
+    {
+        $apiLegacyStorage = $this->getApiLegacyStorageMockFor('deletePersonFiles');
+
+        $this->apiLegacyRequestService
+            ->expects(self::once())
+            ->method('get')
+            ->with('/_internal/request/person-files/delete/123');
+
+        $apiLegacyStorage->deletePersonFiles(123);
+    }
 }
 }

+ 262 - 0
tests/Unit/Service/File/Storage/LocalStorageTest.php

@@ -6,6 +6,8 @@ use App\Entity\Access\Access;
 use App\Entity\Core\File;
 use App\Entity\Core\File;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Organization;
 use App\Entity\Person\Person;
 use App\Entity\Person\Person;
+use App\Enum\Core\FileHostEnum;
+use App\Enum\Core\FileSizeEnum;
 use App\Enum\Core\FileStatusEnum;
 use App\Enum\Core\FileStatusEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileTypeEnum;
 use App\Enum\Core\FileVisibilityEnum;
 use App\Enum\Core\FileVisibilityEnum;
@@ -37,6 +39,16 @@ class TestableLocalStorage extends LocalStorage
     {
     {
         return parent::getOrganizationAndPersonFromOwner($owner);
         return parent::getOrganizationAndPersonFromOwner($owner);
     }
     }
+
+    public function getFilterFromSizeAndConfig(string $size, bool $configExist): string
+    {
+        return parent::getFilterFromSizeAndConfig($size, $configExist);
+    }
+
+    public function rrmDir(string $dirKey): void
+    {
+        parent::rrmDir($dirKey);
+    }
 }
 }
 
 
 class LocalStorageTest extends TestCase
 class LocalStorageTest extends TestCase
@@ -78,6 +90,7 @@ class LocalStorageTest extends TestCase
                 $this->imageFactory,
                 $this->imageFactory,
                 $this->fileUtils,
                 $this->fileUtils,
                 $this->urlBuilder,
                 $this->urlBuilder,
+                '/file/storage/dir/',
             ])
             ])
             ->setMethodsExcept([$methodName])
             ->setMethodsExcept([$methodName])
             ->getMock();
             ->getMock();
@@ -150,6 +163,211 @@ class LocalStorageTest extends TestCase
         );
         );
     }
     }
 
 
+    public function testGetImageUrl(): void
+    {
+        $localStorage = $this->getMockForMethod('getImageUrl');
+
+        $localStorage
+            ->method('getFilterFromSizeAndConfig')
+            ->with('sm', true)
+            ->willReturn('crop_sm');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getPath')->willReturn('abc');
+        $file->method('getConfig')->willReturn('xyz');
+
+        $this->cacheManager
+            ->method('isStored')
+            ->with('abc', 'crop_sm')
+            ->willReturn(false);
+
+        $this->cacheManager
+            ->method('resolve')
+            ->with('abc', 'crop_sm')
+            ->willReturn('publicUrl/xyz');
+
+        $this->assertEquals(
+            'publicUrl/xyz',
+            $localStorage->getImageUrl($file, 'sm', false)
+        );
+    }
+
+    public function testGetImageUrlRelativePath(): void
+    {
+        $localStorage = $this->getMockForMethod('getImageUrl');
+
+        $localStorage
+            ->method('getFilterFromSizeAndConfig')
+            ->with('sm', true)
+            ->willReturn('crop_sm');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getPath')->willReturn('abc');
+        $file->method('getConfig')->willReturn('xyz');
+
+        $this->cacheManager
+            ->method('isStored')
+            ->with('abc', 'crop_sm')
+            ->willReturn(false);
+
+        $this->cacheManager
+            ->method('resolve')
+            ->with('abc', 'crop_sm')
+            ->willReturn('publicUrl/xyz');
+
+        $this->urlBuilder
+            ->method('getRelativeUrl')
+            ->with('publicUrl/xyz')
+            ->willReturn('xyz');
+
+        $this->assertEquals(
+            'xyz',
+            $localStorage->getImageUrl($file, 'sm', true)
+        );
+    }
+
+    public function testGetImageUrlNotCached(): void
+    {
+        $localStorage = $this->getMockForMethod('getImageUrl');
+
+        $localStorage
+            ->method('getFilterFromSizeAndConfig')
+            ->with('sm', true)
+            ->willReturn('crop_sm');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getPath')->willReturn('abc');
+        $file->method('getConfig')->willReturn('xyz');
+
+        $this->cacheManager
+            ->method('isStored')
+            ->with('abc', 'crop_sm')
+            ->willReturn(false);
+
+        $this->imageFactory
+            ->expects(self::once())
+            ->method('createImageContent')
+            ->with($file, 'crop_sm');
+
+        $this->cacheManager
+            ->method('resolve')
+            ->with('abc', 'crop_sm')
+            ->willReturn('publicUrl/xyz');
+
+        $this->assertEquals(
+            'publicUrl/xyz',
+            $localStorage->getImageUrl($file, 'sm', false)
+        );
+    }
+
+    public function testGetImageUrlNotCachedMissingFile(): void
+    {
+        $localStorage = $this->getMockForMethod('getImageUrl');
+
+        $localStorage
+            ->method('getFilterFromSizeAndConfig')
+            ->with('sm', true)
+            ->willReturn('crop_sm');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getPath')->willReturn('abc');
+        $file->method('getConfig')->willReturn('xyz');
+
+        $this->cacheManager
+            ->method('isStored')
+            ->with('abc', 'crop_sm')
+            ->willReturn(false);
+
+        $this->imageFactory
+            ->expects(self::once())
+            ->method('createImageContent')
+            ->with($file, 'crop_sm')
+            ->willThrowException(new \Exception('File not found'));
+
+        $this->cacheManager
+            ->expects(self::never())
+            ->method('resolve');
+
+        $this->urlBuilder
+            ->method('getAbsoluteUrl')
+            ->with('images/missing-file.png')
+            ->willReturn('publicUrl/images/missing-file.png');
+
+        $this->assertEquals(
+            'publicUrl/images/missing-file.png',
+            $localStorage->getImageUrl($file, 'sm', false)
+        );
+    }
+
+    public function testGetImageUrlNotCachedMissingFileRelativePath(): void
+    {
+        $localStorage = $this->getMockForMethod('getImageUrl');
+
+        $localStorage
+            ->method('getFilterFromSizeAndConfig')
+            ->with('sm', true)
+            ->willReturn('crop_sm');
+
+        $file = $this->getMockBuilder(File::class)->getMock();
+        $file->method('getPath')->willReturn('abc');
+        $file->method('getConfig')->willReturn('xyz');
+
+        $this->cacheManager
+            ->method('isStored')
+            ->with('abc', 'crop_sm')
+            ->willReturn(false);
+
+        $this->imageFactory
+            ->expects(self::once())
+            ->method('createImageContent')
+            ->with($file, 'crop_sm')
+            ->willThrowException(new \Exception('File not found'));
+
+        $this->cacheManager
+            ->expects(self::never())
+            ->method('resolve');
+
+        $this->assertEquals(
+            'images/missing-file.png',
+            $localStorage->getImageUrl($file, 'sm', true)
+        );
+    }
+
+    public function testGetFilterFromSizeAndConfig(): void
+    {
+        $fileStorage = $this->getMockForMethod('getFilterFromSizeAndConfig');
+
+        $this->assertEquals(
+            'crop_sm',
+            $fileStorage->getFilterFromSizeAndConfig(FileSizeEnum::SM->value, true)
+        );
+
+        $this->assertEquals(
+            'sm',
+            $fileStorage->getFilterFromSizeAndConfig(FileSizeEnum::SM->value, false)
+        );
+
+        $this->assertEquals(
+            'crop_md',
+            $fileStorage->getFilterFromSizeAndConfig(FileSizeEnum::MD->value, true)
+        );
+
+        $this->assertEquals(
+            'md',
+            $fileStorage->getFilterFromSizeAndConfig(FileSizeEnum::MD->value, false)
+        );
+
+        $this->assertEquals(
+            'crop_lg',
+            $fileStorage->getFilterFromSizeAndConfig(FileSizeEnum::LG->value, true)
+        );
+
+        $this->assertEquals(
+            'lg',
+            $fileStorage->getFilterFromSizeAndConfig(FileSizeEnum::LG->value, false)
+        );
+    }
+
     /**
     /**
      * @see LocalStorage::prepareFile()
      * @see LocalStorage::prepareFile()
      */
      */
@@ -524,6 +742,50 @@ class LocalStorageTest extends TestCase
         $fileStorage->hardDelete($file);
         $fileStorage->hardDelete($file);
     }
     }
 
 
+    public function testDeleteOrganizationFiles(): void
+    {
+        $fileStorage = $this->getMockForMethod('deleteOrganizationFiles');
+
+        $fileStorage
+            ->expects(self::exactly(2))
+            ->method('rrmDir')
+            ->withConsecutive(
+                ['organization/123'],
+                ['temp/organization/123'],
+            );
+
+        $fileStorage->deleteOrganizationFiles(123);
+    }
+
+    public function testDeletePersonFiles(): void
+    {
+        $fileStorage = $this->getMockForMethod('deletePersonFiles');
+
+        $fileStorage
+            ->expects(self::exactly(2))
+            ->method('rrmDir')
+            ->withConsecutive(
+                ['person/123'],
+                ['temp/person/123'],
+            );
+
+        $fileStorage->deletePersonFiles(123);
+    }
+
+    public function testSupport(): void
+    {
+        $apiLegacyStorage = $this->getMockForMethod('support');
+
+        $file1 = $this->getMockBuilder(File::class)->getMock();
+        $file1->method('getHost')->willReturn(FileHostEnum::API1);
+
+        $file2 = $this->getMockBuilder(File::class)->getMock();
+        $file2->method('getHost')->willReturn(FileHostEnum::AP2I);
+
+        $this->assertFalse($apiLegacyStorage->support($file1));
+        $this->assertTrue($apiLegacyStorage->support($file2));
+    }
+
     /**
     /**
      * @see LocalStorage::getPrefix()
      * @see LocalStorage::getPrefix()
      */
      */

+ 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\Organization;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Parameters;
 use App\Enum\Education\AdvancedEducationNotationTypeEnum;
 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\Repository\Booking\CourseRepository;
 use App\Service\Network\Utils as NetworkUtils;
 use App\Service\Network\Utils as NetworkUtils;
 use App\Service\OnChange\OnChangeContext;
 use App\Service\OnChange\OnChangeContext;
@@ -206,8 +206,8 @@ class OnParametersChangeTest extends TestCase
         $this->messageBus
         $this->messageBus
             ->expects(self::once())
             ->expects(self::once())
             ->method('dispatch')
             ->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 = $this->getMockBuilder(Parameters::class)->getMock();
         $previousParameters->method('getId')->willReturn(1);
         $previousParameters->method('getId')->willReturn(1);
@@ -245,8 +245,8 @@ class OnParametersChangeTest extends TestCase
         $this->messageBus
         $this->messageBus
             ->expects(self::once())
             ->expects(self::once())
             ->method('dispatch')
             ->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 = $this->getMockBuilder(Parameters::class)->getMock();
         $previousParameters->method('getId')->willReturn(1);
         $previousParameters->method('getId')->willReturn(1);
@@ -285,11 +285,11 @@ class OnParametersChangeTest extends TestCase
             ->expects(self::exactly(2))
             ->expects(self::exactly(2))
             ->method('dispatch')
             ->method('dispatch')
             ->willReturnCallback(function ($message) {
             ->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);
                 throw new \AssertionError('unexpected message : '.$message::class);
             });
             });

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

@@ -0,0 +1,2193 @@
+<?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\File\FileManager;
+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 deleteTypo3Website(int $organizationId): void
+    {
+        parent::deleteTypo3Website($organizationId);
+    }
+
+    public function switchDolibarrSocietyToProspect(int $organizationId): void
+    {
+        parent::switchDolibarrSocietyToProspect($organizationId);
+    }
+
+    public function getFutureOrphanPersons(Organization $organization): array
+    {
+        return parent::getFutureOrphanPersons($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();
+        $this->fileManager = $this->getMockBuilder(FileManager::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,
+                    $this->fileManager,
+                ])
+            ->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(): void
+    {
+        $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 testMakePostalAddressNonExistingCountry(): 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(): void
+    {
+        $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('get')
+            ->with('/_internal/request/adminassos/create/organization/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('get')
+            ->with('/_internal/request/adminassos/create/organization/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');
+
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $person1->method('getId')->willReturn(1);
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->method('getId')->willReturn(2);
+
+        $person3 = $this->getMockBuilder(Person::class)->getMock();
+        $person3->method('getId')->willReturn(3);
+
+        $organizationFactory
+            ->method('getFutureOrphanPersons')
+            ->with($organization)
+            ->willReturn([$person1, $person2, $person3]);
+
+        $this->entityManager->expects(self::exactly(5))->method('remove')->withConsecutive(
+            [$parameters],
+            [$organization],
+            [$person1],
+            [$person2],
+            [$person3],
+        );
+
+        $organizationFactory->expects(self::once())->method('deleteTypo3Website')->with(123);
+        $organizationFactory->expects(self::once())->method('switchDolibarrSocietyToProspect')->with(123);
+
+        $this->fileManager->expects(self::once())->method('deleteOrganizationFiles')->with(123);
+
+        $this->fileManager
+            ->expects(self::exactly(3))
+            ->method('deletePersonFiles')
+            ->withConsecutive([1], [2], [3]);
+
+        $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);
+
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $person1->method('getId')->willReturn(1);
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->method('getId')->willReturn(2);
+
+        $person3 = $this->getMockBuilder(Person::class)->getMock();
+        $person3->method('getId')->willReturn(3);
+
+        $organizationFactory
+            ->method('getFutureOrphanPersons')
+            ->with($organization)
+            ->willReturn([$person1, $person2, $person3]);
+
+        $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');
+
+        $this->entityManager->method('remove')->willThrowException(new \Exception('some error'));
+
+        $organizationFactory->expects(self::never())->method('deleteTypo3Website');
+        $organizationFactory->expects(self::never())->method('switchDolibarrSocietyToProspect');
+
+        $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);
+
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $person1->method('getId')->willReturn(1);
+
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $person2->method('getId')->willReturn(2);
+
+        $person3 = $this->getMockBuilder(Person::class)->getMock();
+        $person3->method('getId')->willReturn(3);
+
+        $organizationFactory
+            ->method('getFutureOrphanPersons')
+            ->with($organization)
+            ->willReturn([$person1, $person2, $person3]);
+
+        $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');
+
+        $this->entityManager->expects(self::exactly(5))->method('remove')->withConsecutive(
+            [$parameters],
+            [$organization],
+            [$person1],
+            [$person2],
+            [$person3],
+        );
+
+        $organizationFactory->expects(self::once())->method('deleteTypo3Website')->willThrowException(new \Exception('some error'));
+        $organizationFactory->expects(self::once())->method('switchDolibarrSocietyToProspect')->willThrowException(new \Exception('some error'));
+
+        $organizationDeletionRequest
+            ->expects(self::once())
+            ->method('setStatus')
+            ->with(OrganizationDeletionRequest::STATUS_OK_WITH_ERRORS);
+
+        $this->fileManager
+            ->expects(self::once())
+            ->method('deleteOrganizationFiles')
+            ->with(123)
+            ->willThrowException(new \Exception('some error'));
+
+        $this->fileManager
+            ->expects(self::exactly(3))
+            ->method('deletePersonFiles')
+            ->withConsecutive([1], [2], [3])
+            ->willThrowException(new \Exception('some error'));
+
+        $this->logger
+            ->expects(self::exactly(6))
+            ->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 organization's files, please proceed manually."],
+            ["An error happened while deleting the person's files, please proceed manually (id=1)."],
+            ["An error happened while deleting the person's files, please proceed manually (id=2)."],
+            ["An error happened while deleting the person's files, please proceed manually (id=3)."],
+        );
+
+        $result = $organizationFactory->delete($organizationDeletionRequest);
+
+        $this->assertEquals(
+            $organizationDeletionRequest,
+            $result
+        );
+    }
+
+    public function testGetFutureOrphanPersons(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('getFutureOrphanPersons');
+
+        $organization = $this->getMockBuilder(Organization::class)->getMock();
+
+        $access1 = $this->getMockBuilder(Access::class)->getMock();
+        $person1 = $this->getMockBuilder(Person::class)->getMock();
+        $access1->method('getPerson')->willReturn($person1);
+
+        $access2 = $this->getMockBuilder(Access::class)->getMock();
+        $person2 = $this->getMockBuilder(Person::class)->getMock();
+        $access2->method('getPerson')->willReturn($person2);
+
+        $access3 = $this->getMockBuilder(Access::class)->getMock();
+        $person3 = $this->getMockBuilder(Person::class)->getMock();
+        $access3->method('getPerson')->willReturn($person3);
+
+        $otherAccess1 = $this->getMockBuilder(Access::class)->getMock();
+        $otherAccess2 = $this->getMockBuilder(Access::class)->getMock();
+
+        $person1->method('getAccesses')->willReturn(new ArrayCollection([$access1, $otherAccess1]));
+        $person2->method('getAccesses')->willReturn(new ArrayCollection([$access2, $otherAccess2]));
+        $person3->method('getAccesses')->willReturn(new ArrayCollection([$access3]));
+
+        $organization->method('getAccesses')->willReturn(new ArrayCollection([$access1, $access2, $access3]));
+
+        $this->assertEquals(
+            [$person3],
+            $organizationFactory->getFutureOrphanPersons($organization)
+        );
+    }
+
+    public function testDeleteTypo3Website(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('deleteTypo3Website');
+
+        $this->typo3Service->expects(self::once())->method('hardDeleteSite')->with(123);
+
+        $organizationFactory->deleteTypo3Website(123);
+    }
+
+    public function testSwitchDolibarrSocietyToProspect(): void
+    {
+        $organizationFactory = $this->getOrganizationFactoryMockFor('switchDolibarrSocietyToProspect');
+
+        $this->dolibarrApiService
+            ->expects(self::once())
+            ->method('switchSocietyToProspect')
+            ->with(123);
+
+        $organizationFactory->switchDolibarrSocietyToProspect(123);
+    }
+}

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

@@ -4,6 +4,7 @@
 
 
 namespace App\Tests\Unit\Service\Organization;
 namespace App\Tests\Unit\Service\Organization;
 
 
+use App\Entity\Network\NetworkOrganization;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Organization;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Parameters;
 use App\Entity\Organization\Settings;
 use App\Entity\Organization\Settings;
@@ -653,4 +654,64 @@ class UtilsTest extends TestCase
         $this->assertTrue($organizationUtils->hasModule($organization, 'foo'));
         $this->assertTrue($organizationUtils->hasModule($organization, 'foo'));
         $this->assertFalse($organizationUtils->hasModule($organization, 'other'));
         $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);
+    }
 }
 }

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

@@ -9,6 +9,14 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\HttpClientInterface;
 use Symfony\Contracts\HttpClient\ResponseInterface;
 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
 class ApiRequestServiceTest extends TestCase
 {
 {
     private HttpClientInterface $client;
     private HttpClientInterface $client;
@@ -19,11 +27,11 @@ class ApiRequestServiceTest extends TestCase
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::getJsonContent()
+     * @see TestableApiRequestService::getJsonContent()
      */
      */
     public function testGetJsonContent(): void
     public function testGetJsonContent(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['getJsonContent'])
             ->setMethodsExcept(['getJsonContent'])
             ->getMock();
             ->getMock();
@@ -39,11 +47,11 @@ class ApiRequestServiceTest extends TestCase
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::getJsonContent()
+     * @see TestableApiRequestService::getJsonContent()
      */
      */
     public function testGetContent(): void
     public function testGetContent(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['getContent'])
             ->setMethodsExcept(['getContent'])
             ->getMock();
             ->getMock();
@@ -62,11 +70,11 @@ class ApiRequestServiceTest extends TestCase
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::getContent()
+     * @see TestableApiRequestService::getContent()
      */
      */
     public function testGetContentWithError(): void
     public function testGetContentWithError(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['getContent'])
             ->setMethodsExcept(['getContent'])
             ->getMock();
             ->getMock();
@@ -80,11 +88,11 @@ class ApiRequestServiceTest extends TestCase
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::get()
+     * @see TestableApiRequestService::get()
      */
      */
     public function testGet(): void
     public function testGet(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['get'])
             ->setMethodsExcept(['get'])
             ->getMock();
             ->getMock();
@@ -101,34 +109,95 @@ class ApiRequestServiceTest extends TestCase
         $this->assertEquals($response, $actualResponse);
         $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
     public function testPost(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['post'])
             ->setMethodsExcept(['post'])
             ->getMock();
             ->getMock();
 
 
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
         $response = $this->getMockBuilder(ResponseInterface::class)->disableOriginalConstructor()->getMock();
 
 
-        $apiRequestService->expects(self::once())
+        $apiRequestService
+            ->expects(self::once())
             ->method('request')
             ->method('request')
-            ->with('POST', 'path/to/data', [], [])
+            ->with('POST', 'path/to/data', [], ['option' => 2, 'json' => ['foo' => 1]])
             ->willReturn($response);
             ->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);
         $this->assertEquals($response, $actualResponse);
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::put()
+     * @see TestableApiRequestService::put()
      */
      */
     public function testPut(): void
     public function testPut(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['put'])
             ->setMethodsExcept(['put'])
             ->getMock();
             ->getMock();
@@ -137,20 +206,26 @@ class ApiRequestServiceTest extends TestCase
 
 
         $apiRequestService->expects(self::once())
         $apiRequestService->expects(self::once())
             ->method('request')
             ->method('request')
-            ->with('PUT', 'path/to/data', [], [])
+            ->with('PUT', 'path/to/data', [], ['option' => 2, 'body' => 'foo'])
             ->willReturn($response);
             ->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);
         $this->assertEquals($response, $actualResponse);
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::delete()
+     * @see TestableApiRequestService::delete()
      */
      */
     public function testDelete(): void
     public function testDelete(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['delete'])
             ->setMethodsExcept(['delete'])
             ->getMock();
             ->getMock();
@@ -168,11 +243,11 @@ class ApiRequestServiceTest extends TestCase
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::request()
+     * @see TestableApiRequestService::request()
      */
      */
     public function testRequest(): void
     public function testRequest(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['request'])
             ->setMethodsExcept(['request'])
             ->getMock();
             ->getMock();
@@ -186,11 +261,11 @@ class ApiRequestServiceTest extends TestCase
     }
     }
 
 
     /**
     /**
-     * @see ApiRequestService::request()
+     * @see TestableApiRequestService::request()
      */
      */
     public function testRequestWithError(): void
     public function testRequestWithError(): void
     {
     {
-        $apiRequestService = $this->getMockBuilder(ApiRequestService::class)
+        $apiRequestService = $this->getMockBuilder(TestableApiRequestService::class)
             ->setConstructorArgs([$this->client])
             ->setConstructorArgs([$this->client])
             ->setMethodsExcept(['request'])
             ->setMethodsExcept(['request'])
             ->getMock();
             ->getMock();

部分文件因为文件数量过多而无法显示