Просмотр исходного кода

Merge branch 'feature/V8-7750-la-mire-de-connexion' into develop

# Conflicts:
#	.junie/guidelines.md
#	env/.env.prod
#	env/.env.test
#	env/.env.test1
#	env/.env.test2
#	env/.env.test3
#	env/.env.test4
#	env/.env.test5
#	env/.env.test6
#	env/.env.test7
#	env/.env.test8
#	env/.env.test9
#	src/Entity/Booking/Event.php
Vincent 2 месяцев назад
Родитель
Сommit
cbef3627d7
91 измененных файлов с 1856 добавлено и 9 удалено
  1. 9 0
      .env
  2. 6 0
      .junie/guidelines.md
  3. 11 0
      config/opentalent/products.yaml
  4. 1 1
      config/packages/monolog.yaml
  5. 2 0
      config/packages/security.yaml
  6. 3 0
      config/secrets/docker/docker.HELLOASSO_CLIENT_SECRET.bee9ed.php
  7. 1 0
      config/secrets/docker/docker.list.php
  8. 3 0
      config/secrets/prod/prod.HELLOASSO_CLIENT_SECRET.bee9ed.php
  9. 1 0
      config/secrets/prod/prod.list.php
  10. 3 0
      config/secrets/staging/staging.HELLOASSO_CLIENT_SECRET.bee9ed.php
  11. 1 0
      config/secrets/staging/staging.list.php
  12. 3 0
      config/secrets/test/test.HELLOASSO_CLIENT_SECRET.bee9ed.php
  13. 1 0
      config/secrets/test/test.list.php
  14. 3 0
      config/secrets/test1/test1.HELLOASSO_CLIENT_SECRET.bee9ed.php
  15. 4 0
      config/secrets/test1/test1.decrypt.private.php
  16. 3 0
      config/secrets/test1/test1.encrypt.public.php
  17. 5 0
      config/secrets/test1/test1.list.php
  18. 3 0
      config/secrets/test2/test2.HELLOASSO_CLIENT_SECRET.bee9ed.php
  19. 4 0
      config/secrets/test2/test2.decrypt.private.php
  20. 3 0
      config/secrets/test2/test2.encrypt.public.php
  21. 5 0
      config/secrets/test2/test2.list.php
  22. 3 0
      config/secrets/test3/test3.HELLOASSO_CLIENT_SECRET.bee9ed.php
  23. 4 0
      config/secrets/test3/test3.decrypt.private.php
  24. 3 0
      config/secrets/test3/test3.encrypt.public.php
  25. 5 0
      config/secrets/test3/test3.list.php
  26. 3 0
      config/secrets/test4/test4.HELLOASSO_CLIENT_SECRET.bee9ed.php
  27. 4 0
      config/secrets/test4/test4.decrypt.private.php
  28. 3 0
      config/secrets/test4/test4.encrypt.public.php
  29. 5 0
      config/secrets/test4/test4.list.php
  30. 3 0
      config/secrets/test5/test5.HELLOASSO_CLIENT_SECRET.bee9ed.php
  31. 4 0
      config/secrets/test5/test5.decrypt.private.php
  32. 3 0
      config/secrets/test5/test5.encrypt.public.php
  33. 5 0
      config/secrets/test5/test5.list.php
  34. 3 0
      config/secrets/test6/test6.HELLOASSO_CLIENT_SECRET.bee9ed.php
  35. 4 0
      config/secrets/test6/test6.decrypt.private.php
  36. 3 0
      config/secrets/test6/test6.encrypt.public.php
  37. 5 0
      config/secrets/test6/test6.list.php
  38. 3 0
      config/secrets/test7/test7.HELLOASSO_CLIENT_SECRET.bee9ed.php
  39. 4 0
      config/secrets/test7/test7.decrypt.private.php
  40. 3 0
      config/secrets/test7/test7.encrypt.public.php
  41. 5 0
      config/secrets/test7/test7.list.php
  42. 3 0
      config/secrets/test8/test8.HELLOASSO_CLIENT_SECRET.bee9ed.php
  43. 4 0
      config/secrets/test8/test8.decrypt.private.php
  44. 3 0
      config/secrets/test8/test8.encrypt.public.php
  45. 5 0
      config/secrets/test8/test8.list.php
  46. 3 0
      config/secrets/test9/test9.HELLOASSO_CLIENT_SECRET.bee9ed.php
  47. 4 0
      config/secrets/test9/test9.decrypt.private.php
  48. 3 0
      config/secrets/test9/test9.encrypt.public.php
  49. 5 0
      config/secrets/test9/test9.list.php
  50. 5 0
      config/services.yaml
  51. 109 0
      doc/helloasso.md
  52. 1 0
      env/.env.docker
  53. 7 0
      env/.env.prod
  54. 1 0
      env/.env.staging
  55. 1 0
      env/.env.test
  56. 1 0
      env/.env.test1
  57. 1 0
      env/.env.test2
  58. 1 0
      env/.env.test3
  59. 1 0
      env/.env.test4
  60. 1 0
      env/.env.test5
  61. 1 0
      env/.env.test6
  62. 1 0
      env/.env.test7
  63. 1 0
      env/.env.test8
  64. 1 0
      env/.env.test9
  65. 2 0
      sql/schema-extensions/001-view_public_events.sql
  66. 1 1
      src/ApiResources/Dolibarr/DolibarrDocDownload.php
  67. 1 1
      src/ApiResources/Export/ExportRequest.php
  68. 60 0
      src/ApiResources/HelloAsso/AuthUrl.php
  69. 78 0
      src/ApiResources/HelloAsso/ConnectionRequest.php
  70. 107 0
      src/ApiResources/HelloAsso/EventForm.php
  71. 93 0
      src/ApiResources/HelloAsso/HelloAssoProfile.php
  72. 51 0
      src/ApiResources/HelloAsso/UnlinkRequest.php
  73. 1 1
      src/ApiResources/Organization/OrganizationCreationRequest.php
  74. 1 1
      src/ApiResources/Organization/OrganizationDeletionRequest.php
  75. 1 1
      src/ApiResources/Organization/OrganizationMemberCreationRequest.php
  76. 1 1
      src/ApiResources/Organization/Subdomain/SubdomainAvailability.php
  77. 1 1
      src/ApiResources/Shop/NewStructureArtistPremiumTrialRequest.php
  78. 28 0
      src/Entity/Booking/Event.php
  79. 162 0
      src/Entity/HelloAsso/HelloAsso.php
  80. 18 1
      src/Entity/Organization/Organization.php
  81. 15 0
      src/Entity/Public/PublicEvent.php
  82. 1 0
      src/Enum/Cotisation/CategoryTypeOfPracticeEnum.php
  83. 31 0
      src/Repository/HelloAsso/HelloAssoRepository.php
  84. 119 0
      src/Service/Cron/Job/RefreshHelloassoTokens.php
  85. 467 0
      src/Service/HelloAsso/HelloAssoService.php
  86. 27 0
      src/Service/Security/OAuthPkceGenerator.php
  87. 71 0
      src/State/Processor/HelloAsso/ConnectionRequestProcessor.php
  88. 58 0
      src/State/Processor/HelloAsso/UnlinkRequestProcessor.php
  89. 46 0
      src/State/Provider/HelloAsso/AuthUrlProvider.php
  90. 55 0
      src/State/Provider/HelloAsso/EventFormProvider.php
  91. 47 0
      src/State/Provider/HelloAsso/HelloAssoProfileProvider.php

+ 9 - 0
.env

@@ -26,6 +26,7 @@ DATABASE_AUDIT_URL=xxx
 DATABASE_DOLIBARR_URL=xxx
 DOLIBARR_API_TOKEN=xxx
 MERCURE_JWT_SECRET=xxx
+HELLOASSO_CLIENT_SECRET=xxx
 ###< secret values ###
 
 ###> nelmio/cors-bundle ###
@@ -38,6 +39,7 @@ ADMIN_BASE_URL=https://local.admin.opentalent.fr
 
 ###> url v2 ###
 PUBLIC_API_BASE_URL=https://local.ap2i.opentalent.fr
+PUBLIC_APP_BASE_URL=https://local.app.opentalent.fr
 ###
 
 ###> lexik/jwt-authentication-bundle ###
@@ -128,3 +130,10 @@ FAQ_URL=https://ressources.opentalent.fr/space/FAQ/2495122/Artist+Standard+et+Pr
 ### Site logiciels
 SOFTWARE_WEBSITE_URL=https://local.logiciels.opentalent.fr
 ###
+
+### Hello asso
+HELLOASSO_API_BASE_URL=https://api.helloasso-sandbox.com
+HELLOASSO_AUTH_BASE_URL=https://auth.helloasso-sandbox.com
+HELLOASSO_CLIENT_ID=0c26ff8fc8434715a1520b1ff6debb5e
+HELLOASSO_AUTHORIZE_URL=https://auth.helloasso-sandbox.com/authorize
+###

+ 6 - 0
.junie/guidelines.md

@@ -52,6 +52,12 @@ migrations/          # Doctrine database migrations
 
 ## Development Guidelines
 
+### Documenting
+
+* Write docstrings and comments in french
+* Write everything else in english (errors, variable names, etc.)
+* When documenting a method, do not detail the implementation
+
 ### Testing
 - **Always run tests** before submitting changes to ensure correctness
 - The project uses PHPUnit for testing with comprehensive unit and application tests

+ 11 - 0
config/opentalent/products.yaml

@@ -299,6 +299,16 @@ parameters:
         roles:
           - ROLE_BASICOMPTA
 
+      HelloAsso:
+        roles:
+          - ROLE_HELLOASSO
+        resources:
+          - AuthUrl
+          - ConnectionRequest
+          - UnlinkRequest
+          - HelloAssoProfile
+          - EventForm
+
   opentalent.products:
       freemium:
         modules:
@@ -321,6 +331,7 @@ parameters:
           - Statistic
           - Dolibarr
           - Basicompta
+          - HelloAsso
 
       artist-premium:
         extend: artist

+ 1 - 1
config/packages/monolog.yaml

@@ -23,7 +23,7 @@ monolog:
             path: "%kernel.logs_dir%/%env(LOG_FILE_NAME)%.main.log"
             level: debug
             max_files: 3
-            channels: ['!security', '!doctrine', '!cron', '!event', '!deprecation', '!app']
+            channels: ['!security', '!doctrine', '!cron', '!event', '!deprecation']
 #        file_doctrine:
 #            type: rotating_file
 #            path: "%kernel.logs_dir%/%env(LOG_FILE_NAME)%.doctrine.log"

+ 2 - 0
config/packages/security.yaml

@@ -27,6 +27,7 @@ security:
             - ROLE_ADMIN_CORE
             - ROLE_REWARD
             - ROLE_BASICOMPTA
+            - ROLE_HELLOASSO
 
         ROLE_ADMIN_CORE: *BASE_ROLE_ADMINISTRATION_CORE
 
@@ -47,6 +48,7 @@ security:
             - ROLE_ONLINEREGISTRATION_ADMINISTRATION
             - ROLE_ADMINISTRATIF_MANAGER_CORE
             - ROLE_BASICOMPTA
+            - ROLE_HELLOASSO
 
         ROLE_ADMINISTRATIF_MANAGER_CORE: *BASE_ROLE_ADMINISTRATION_CORE
 

+ 3 - 0
config/secrets/docker/docker.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // docker.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 16 Sep 2025 09:28:18 +0000
+
+return "\xCF\x94\xAA\xDE\xBC\xF0\x21\x60s-\x17\x2Bd\x3B\x7C\xAF\xCE\xB70-\x18\xCB\xD3\x83\x03\x1E\x29\xC0S\xB4\x19\x2A\xD5\xFE\xB8\x17v\x83\x9B\x5B\xD5\xD8\x02y\xB0\x1Dd\xF9\xF7\x1B\x00\x2A\xED\xED\x21\xDA\x0F\xB0\xA6\x13\x24\xC9\x87\x09d\x978\xBBo\x5Bkl\xE8\xF6\xAA\x9EZ\xD2\xBEp";

+ 1 - 0
config/secrets/docker/docker.list.php

@@ -6,5 +6,6 @@ return [
     'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
+    'HELLOASSO_CLIENT_SECRET' => null,
     'MERCURE_JWT_SECRET' => null,
 ];

+ 3 - 0
config/secrets/prod/prod.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // prod.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:40:00 +0000
+
+return "\x81\xF2\x88m\x3B\xA842\xADQ\xA5i\x1D\x92\x8A\xA9qOP\x25\x3D\xC5\xC4\x9F\x17\xD2\xC4\xC5\xFE\x3ENu\x82\xADw\xF9R\x80\x95k\x3A\x10\xFE\x60\x08\x98\x90\xC1X\xFE\xFB\x00\xCF\x7B\x20\x0E\x0A\xD6\x8B\xE5\xF2\xF3\xB0\x9Fo\xCA5\xF6\x26\x8A\xDB\x87X\xE1\xDD\xDF\xED-\x0F\x99";

+ 1 - 0
config/secrets/prod/prod.list.php

@@ -6,5 +6,6 @@ return [
     'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
+    'HELLOASSO_CLIENT_SECRET' => null,
     'MERCURE_JWT_SECRET' => null,
 ];

+ 3 - 0
config/secrets/staging/staging.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // staging.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:39:49 +0000
+
+return "1\xF1\x8C8\xD8N\xBB\x1E\xA7\xCE\x7CU\x1295_\xD9Y\x3F\xBF\x07j\xFD\xB1\x97\xD7-zx\xCD\x82SX\xB5\x2F\xE8q\xA5\xA6\xEA\xF0\x0A\x84\xF3\x0B\x0C\x19n\xDD\xD8yi\xE7\x3C\x13\xCA\x24\x93\xF1n\xFC\xF3\x01\x5E.\x3F\xBFc\x8B\x3B\x97J\xA4\xEFM\x2A\x12\x0BE\x1A";

+ 1 - 0
config/secrets/staging/staging.list.php

@@ -6,4 +6,5 @@ return [
     'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
+    'HELLOASSO_CLIENT_SECRET' => null,
 ];

+ 3 - 0
config/secrets/test/test.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:39:40 +0000
+
+return "Q\x1E\x1F\xE0\x9CbL\xB9\x8B\xC5\xC0\xE24hZ\x02\xB3\x0Ao\xDE\x5D\xC3\xA3\xD4dP\xA6S\xFB\xE9\xB6\x14\x115\x03\xD1\x88\x3E\xB0d\xBB\x98\x99\x9A\xD3\x074\xE1l\x1F\xEE\x09\xA6\xF0\xE5\x7BYE\xB3a\x8D\xD5\xB1\xA6\x87\xC0\xAB\xEF4\x83\x03\xED\xD5\xAF\xA13\xA3\x8D\x1E\x85";

+ 1 - 0
config/secrets/test/test.list.php

@@ -6,5 +6,6 @@ return [
     'DATABASE_DOLIBARR_URL' => null,
     'DATABASE_URL' => null,
     'DOLIBARR_API_TOKEN' => null,
+    'HELLOASSO_CLIENT_SECRET' => null,
     'MERCURE_JWT_SECRET' => null,
 ];

+ 3 - 0
config/secrets/test1/test1.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test1.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:38:08 +0000
+
+return "OA\xE8X\x15\x2C\x07E\x16\x9C\x07\x12\xD9\xC0\xF3\xE3P\xE0\xBA\x5B\x3B\x10\x1BVI\xE0\x9E\xB5\xE9j\xF5\x03.\x5C\xC6\x09\x88\xCC\xC3\xCC\xB9\xAE\x835\xC1C\x60\x22_\x5C\xD8\xE0\xC0V6\x16\x16\xB8\xE0\xD1\x18\xB7q\x93~\x9B\x20d\xC8\x86\xB1\xA5\xDCT\xDCB\x93\x2A\x3B0";

+ 4 - 0
config/secrets/test1/test1.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test1.decrypt.private on Tue, 07 Oct 2025 13:38:08 +0000
+
+// SYMFONY_DECRYPTION_SECRET=Toa7oydt074/WZv0tAg8MbF5DxIEOQr10R6ksY5ENvi4k/MLk+9RVtrzh4HKZ44V/EQYCCHGfp1SX6AYSHHBUA==
+return "N\x86\xBB\xA3\x27m\xD3\xBE\x3FY\x9B\xF4\xB4\x08\x3C1\xB1y\x0F\x12\x049\x0A\xF5\xD1\x1E\xA4\xB1\x8ED6\xF8\xB8\x93\xF3\x0B\x93\xEFQV\xDA\xF3\x87\x81\xCAg\x8E\x15\xFCD\x18\x08\x21\xC6~\x9DR_\xA0\x18Hq\xC1P";

+ 3 - 0
config/secrets/test1/test1.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test1.encrypt.public on Tue, 07 Oct 2025 13:38:08 +0000
+
+return "\xB8\x93\xF3\x0B\x93\xEFQV\xDA\xF3\x87\x81\xCAg\x8E\x15\xFCD\x18\x08\x21\xC6~\x9DR_\xA0\x18Hq\xC1P";

+ 5 - 0
config/secrets/test1/test1.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test2/test2.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test2.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:38:26 +0000
+
+return "\xD7f\x95\xDA\xFF\xB4ItY\x3C\xD3-\x9E\x82r\x92\xB8l~\xC6\xDF\xFBo\x94\x13\xA7\xA9\x89\x8B\xB5IE\xE2\x1F\x0B\x40~\x18\xEE\xF7\xF9\x1A\xE0\x85\xDE\x88d5\xC7q\xB9\x0Ex\x8APwR\x7Fi\xA3\xD3\xC3\x0A\x7Bd\x97\xFC\xB7y\xE6P\x3E\x0B\xEC\x3Ck\x90\xB6\xAE\x9D";

+ 4 - 0
config/secrets/test2/test2.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test2.decrypt.private on Tue, 07 Oct 2025 13:38:26 +0000
+
+// SYMFONY_DECRYPTION_SECRET=n612vx020Xd73o0mvZW5/E7PwEg7WRt8ZKSTJ4qK2vOarou0VAVmGD80ZHuUBpEimSV1ay1kounb5sH1QEzDMQ==
+return "\x9F\xADv\xBF\x1D6\xD1w\x7B\xDE\x8D\x26\xBD\x95\xB9\xFCN\xCF\xC0H\x3BY\x1B\x7Cd\xA4\x93\x27\x8A\x8A\xDA\xF3\x9A\xAE\x8B\xB4T\x05f\x18\x3F4d\x7B\x94\x06\x91\x22\x99\x25uk-d\xA2\xE9\xDB\xE6\xC1\xF5\x40L\xC31";

+ 3 - 0
config/secrets/test2/test2.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test2.encrypt.public on Tue, 07 Oct 2025 13:38:26 +0000
+
+return "\x9A\xAE\x8B\xB4T\x05f\x18\x3F4d\x7B\x94\x06\x91\x22\x99\x25uk-d\xA2\xE9\xDB\xE6\xC1\xF5\x40L\xC31";

+ 5 - 0
config/secrets/test2/test2.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test3/test3.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test3.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:38:35 +0000
+
+return "\x40\x9E\x85\xF2m\xB4\xFC\x18\xB3G\xF8\xFD\xDF\x03\x2A6\x0B\x94\x1A\xDC\xD7\x85\x7C\xD9\xF90\xB2\x9AA\xDF\xC9\x1A\x11\x95\x27\x5Ca\x12\xB69\x2B\x82\x2F\xCB\xC2_\x90\x25G\xAD\x0F\xF3\x89\xA0\xC8x~\x82\x98\x40\xAD\xB3\xE5\xB6\x7C\xA59\xFB\x3E\x18K\x0A\x80f9\x0C\xD5\xD9\x2Ft";

+ 4 - 0
config/secrets/test3/test3.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test3.decrypt.private on Tue, 07 Oct 2025 13:38:35 +0000
+
+// SYMFONY_DECRYPTION_SECRET=GglQw3ruu3sDsT8qDMd0HG5QpGdm4yEKXgMHUGy6W4LzuHDEWIlwARzxvvAJxshcKq3oJMxuEZ9JeoCd9iHLHA==
+return "\x1A\x09P\xC3z\xEE\xBB\x7B\x03\xB1\x3F\x2A\x0C\xC7t\x1CnP\xA4gf\xE3\x21\x0A\x5E\x03\x07Pl\xBA\x5B\x82\xF3\xB8p\xC4X\x89p\x01\x1C\xF1\xBE\xF0\x09\xC6\xC8\x5C\x2A\xAD\xE8\x24\xCCn\x11\x9FIz\x80\x9D\xF6\x21\xCB\x1C";

+ 3 - 0
config/secrets/test3/test3.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test3.encrypt.public on Tue, 07 Oct 2025 13:38:35 +0000
+
+return "\xF3\xB8p\xC4X\x89p\x01\x1C\xF1\xBE\xF0\x09\xC6\xC8\x5C\x2A\xAD\xE8\x24\xCCn\x11\x9FIz\x80\x9D\xF6\x21\xCB\x1C";

+ 5 - 0
config/secrets/test3/test3.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test4/test4.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test4.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:38:44 +0000
+
+return "\x7D\x5E\xBF\x23\xCC\xD4.\xDD\x12nz\xB6\xA3\x5B\x90\xCB\xC9\xD0\x3A\xA09\xCE\x07\x0E\xB7\x1D\xC1\xDF\xA52\xE8\x0D\x2F\x13\xE0\xF1\x5D_gp\x253\xD7_~\xF0\xDB\x88\xD0\x84\x7B\xCB\x1D\xB4\xAC\xF4\x29\x60\xCB\x9D\x19os\x84\xE3\xE1\xEB\xA4\xF3\xDE\x3D\xA8\x3BAhG\xC1\x3E\x9El";

+ 4 - 0
config/secrets/test4/test4.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test4.decrypt.private on Tue, 07 Oct 2025 13:38:44 +0000
+
+// SYMFONY_DECRYPTION_SECRET=0sZ0WFDExjU7GC0A19Yp28+b3NH/Hzu1+SXqF054XJKml77Sk57WDbLnWjngErwPw9j+gRC5V7nWXT9CioUWSg==
+return "\xD2\xC6tXP\xC4\xC65\x3B\x18-\x00\xD7\xD6\x29\xDB\xCF\x9B\xDC\xD1\xFF\x1F\x3B\xB5\xF9\x25\xEA\x17Nx\x5C\x92\xA6\x97\xBE\xD2\x93\x9E\xD6\x0D\xB2\xE7Z9\xE0\x12\xBC\x0F\xC3\xD8\xFE\x81\x10\xB9W\xB9\xD6\x5D\x3FB\x8A\x85\x16J";

+ 3 - 0
config/secrets/test4/test4.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test4.encrypt.public on Tue, 07 Oct 2025 13:38:44 +0000
+
+return "\xA6\x97\xBE\xD2\x93\x9E\xD6\x0D\xB2\xE7Z9\xE0\x12\xBC\x0F\xC3\xD8\xFE\x81\x10\xB9W\xB9\xD6\x5D\x3FB\x8A\x85\x16J";

+ 5 - 0
config/secrets/test4/test4.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test5/test5.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test5.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:38:51 +0000
+
+return "o\x5D\xA0\x00\x16\xB2\xA0\x0C\xFB\x24\x3D\x23\x96\xA4B\xBE\xFF\x1D\x7C\x93\x8B\x40\x86\x1D\x7D\xAB\x8C-a\x27\xF9g\x1C\x1A\xD1\xC5\xC0\xB7\xEAw\x2A\xC7\x18\xC6\x8DT\xB8\xA3\x220\xA3\x12\xF9q\xD86\x7F7B6m\xF4\x9E\xAE\x5C\xEBN\x2CH\xF4\xA8fs\x9C\xD95\xB3e\x0E-";

+ 4 - 0
config/secrets/test5/test5.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test5.decrypt.private on Tue, 07 Oct 2025 13:38:51 +0000
+
+// SYMFONY_DECRYPTION_SECRET=iKhqrvtG+aXpq9j9HlFqaokLk70Dtz/JYc+muK5z7S/DogOPiab+5ZXshuLz/znzFuzM0LczybBKk0JYHSQ4CA==
+return "\x88\xA8j\xAE\xFBF\xF9\xA5\xE9\xAB\xD8\xFD\x1EQjj\x89\x0B\x93\xBD\x03\xB7\x3F\xC9a\xCF\xA6\xB8\xAEs\xED\x2F\xC3\xA2\x03\x8F\x89\xA6\xFE\xE5\x95\xEC\x86\xE2\xF3\xFF9\xF3\x16\xEC\xCC\xD0\xB73\xC9\xB0J\x93BX\x1D\x248\x08";

+ 3 - 0
config/secrets/test5/test5.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test5.encrypt.public on Tue, 07 Oct 2025 13:38:51 +0000
+
+return "\xC3\xA2\x03\x8F\x89\xA6\xFE\xE5\x95\xEC\x86\xE2\xF3\xFF9\xF3\x16\xEC\xCC\xD0\xB73\xC9\xB0J\x93BX\x1D\x248\x08";

+ 5 - 0
config/secrets/test5/test5.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test6/test6.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test6.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:39:00 +0000
+
+return "\x84\xEE\xFA\xFE\xF1K\x9E\x12\x0E\xD1\xE6\xC7\x13i\x3F\x84\xC4\xE3\xDA7\x13c\xE11\x9F\xB8\xEB\x87v\xD1\xEF\x11zf-\xB4H\x82\xFE\xF0V\x0F\x7C\x5Eq\x3E\xCBBb\xF4\x0B\xCC\x23\xBE\x9E\xD4\xB0r1\x0A\xBB-\xB5\xE1\xCAQ1\xC0\xEE\x82\xA1\xC3\x22\x60\x9Dj\x98C\xE1H";

+ 4 - 0
config/secrets/test6/test6.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test6.decrypt.private on Tue, 07 Oct 2025 13:39:00 +0000
+
+// SYMFONY_DECRYPTION_SECRET=fyQyuTwtmuNZDitr8LxFW31ATJZZoZMMztc25pDbOcC2EgyQVb4XBr+xGDtMd5a+3I1WD7FO56DeTtHl01eFPw==
+return "\x7F\x242\xB9\x3C-\x9A\xE3Y\x0E\x2Bk\xF0\xBCE\x5B\x7D\x40L\x96Y\xA1\x93\x0C\xCE\xD76\xE6\x90\xDB9\xC0\xB6\x12\x0C\x90U\xBE\x17\x06\xBF\xB1\x18\x3BLw\x96\xBE\xDC\x8DV\x0F\xB1N\xE7\xA0\xDEN\xD1\xE5\xD3W\x85\x3F";

+ 3 - 0
config/secrets/test6/test6.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test6.encrypt.public on Tue, 07 Oct 2025 13:39:00 +0000
+
+return "\xB6\x12\x0C\x90U\xBE\x17\x06\xBF\xB1\x18\x3BLw\x96\xBE\xDC\x8DV\x0F\xB1N\xE7\xA0\xDEN\xD1\xE5\xD3W\x85\x3F";

+ 5 - 0
config/secrets/test6/test6.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test7/test7.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test7.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:39:07 +0000
+
+return "~\xAE\xD4U\x3D.\x9E\xE2\xD7H\x91V\x3ErP\xF4_\xB5Y\x2FG\x7D\x9D\xBD\x88\xBB\x1FQTY\xA1\x28\x8D\xDC\x8Fr\x88\x3A\x9C\xED\x06\xA3G\xB3\x13\x3A\xF7\x5E\xEE\x86\x14A\xB0L\xA0L\x22\xBF\xE9\x8A\xDE\xCFvE\x1F\x83.\x06\xF6\xEA~\xFF\xBF\x81\xF2\x90\xFA\xAF\x7B\x26";

+ 4 - 0
config/secrets/test7/test7.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test7.decrypt.private on Tue, 07 Oct 2025 13:39:07 +0000
+
+// SYMFONY_DECRYPTION_SECRET=Sl46fjLJ2StBAy32yjM/PqSmISaZvobJ0S9h2/mnklSMRKO63FWH7NlwkgaGRZUN3z+6i9/v5BohrEEErq06Kg==
+return "J\x5E\x3A~2\xC9\xD9\x2BA\x03-\xF6\xCA3\x3F\x3E\xA4\xA6\x21\x26\x99\xBE\x86\xC9\xD1\x2Fa\xDB\xF9\xA7\x92T\x8CD\xA3\xBA\xDCU\x87\xEC\xD9p\x92\x06\x86E\x95\x0D\xDF\x3F\xBA\x8B\xDF\xEF\xE4\x1A\x21\xACA\x04\xAE\xAD\x3A\x2A";

+ 3 - 0
config/secrets/test7/test7.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test7.encrypt.public on Tue, 07 Oct 2025 13:39:07 +0000
+
+return "\x8CD\xA3\xBA\xDCU\x87\xEC\xD9p\x92\x06\x86E\x95\x0D\xDF\x3F\xBA\x8B\xDF\xEF\xE4\x1A\x21\xACA\x04\xAE\xAD\x3A\x2A";

+ 5 - 0
config/secrets/test7/test7.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test8/test8.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test8.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:39:14 +0000
+
+return "\x80\x5BL\xE1\xFD\x9F\xCA\xC8K\x3E\x93C\xBD\xAC\x85\x17\xCE\x2B\x84Z\x3E\x9F\xD9KL\x0C\x5E\x9C\x1B\x9A\x11\x1Afg\xB7\xC8\x8Cs\x21P\xA0\x84\xA1\xE7\x2C\x26\xB68\x91\x5B\xC7\x80\xB8\x85\x22N~v\x8C\x88\x7B\xE1\x1C\xA3\xF1H\x2A\xD3\xB8W\x06\x2B\x9392\xF2e\x1A\xE8U";

+ 4 - 0
config/secrets/test8/test8.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test8.decrypt.private on Tue, 07 Oct 2025 13:39:14 +0000
+
+// SYMFONY_DECRYPTION_SECRET=PEWswIDBAykxjFtCdg0qQs+kUPwZ1LRkf12qH7EbW8NGhvVwQYLeVRF/tICmlNvtrn+IhJ/No7mOzvRgSuCUZA==
+return "\x3CE\xAC\xC0\x80\xC1\x03\x291\x8C\x5BBv\x0D\x2AB\xCF\xA4P\xFC\x19\xD4\xB4d\x7F\x5D\xAA\x1F\xB1\x1B\x5B\xC3F\x86\xF5pA\x82\xDEU\x11\x7F\xB4\x80\xA6\x94\xDB\xED\xAE\x7F\x88\x84\x9F\xCD\xA3\xB9\x8E\xCE\xF4\x60J\xE0\x94d";

+ 3 - 0
config/secrets/test8/test8.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test8.encrypt.public on Tue, 07 Oct 2025 13:39:14 +0000
+
+return "F\x86\xF5pA\x82\xDEU\x11\x7F\xB4\x80\xA6\x94\xDB\xED\xAE\x7F\x88\x84\x9F\xCD\xA3\xB9\x8E\xCE\xF4\x60J\xE0\x94d";

+ 5 - 0
config/secrets/test8/test8.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 3 - 0
config/secrets/test9/test9.HELLOASSO_CLIENT_SECRET.bee9ed.php

@@ -0,0 +1,3 @@
+<?php // test9.HELLOASSO_CLIENT_SECRET.bee9ed on Tue, 07 Oct 2025 13:39:23 +0000
+
+return "\xF2\x8F\x8F\x02I\x0B\x1Cr\xCFi\x2A\x2F\x09E\xAE\xC0\xB7\xA2\xD2\x89\x1E\x87L\xF2\xA1\x14\xA2\x86\x17\xBB\x02\x29\x09\x7D\x91\x9C\x9C\x82e\x8283\x9De\xC7M\xE8\x8A\x8E\xB1\xA6A\xF0\xCB\x06\x3B6\xE5\x88~b\xFA\x3F\x9B2\x99\x8Cv\xCB\x84\x5E\xEA0\xFB\xCBYQ\xB4\x28\xFA";

+ 4 - 0
config/secrets/test9/test9.decrypt.private.php

@@ -0,0 +1,4 @@
+<?php // test9.decrypt.private on Tue, 07 Oct 2025 13:39:23 +0000
+
+// SYMFONY_DECRYPTION_SECRET=qHkEXb6bwUuVxWo768Vc4la4jpFY9KFmqP+YIrrALyM+jcqlRRTvUZezieYkdSLk+FtXaQQ5ONBAIB1daRtZHA==
+return "\xA8y\x04\x5D\xBE\x9B\xC1K\x95\xC5j\x3B\xEB\xC5\x5C\xE2V\xB8\x8E\x91X\xF4\xA1f\xA8\xFF\x98\x22\xBA\xC0\x2F\x23\x3E\x8D\xCA\xA5E\x14\xEFQ\x97\xB3\x89\xE6\x24u\x22\xE4\xF8\x5BWi\x0498\xD0\x40\x20\x1D\x5Di\x1BY\x1C";

+ 3 - 0
config/secrets/test9/test9.encrypt.public.php

@@ -0,0 +1,3 @@
+<?php // test9.encrypt.public on Tue, 07 Oct 2025 13:39:23 +0000
+
+return "\x3E\x8D\xCA\xA5E\x14\xEFQ\x97\xB3\x89\xE6\x24u\x22\xE4\xF8\x5BWi\x0498\xD0\x40\x20\x1D\x5Di\x1BY\x1C";

+ 5 - 0
config/secrets/test9/test9.list.php

@@ -0,0 +1,5 @@
+<?php
+
+return [
+    'HELLOASSO_CLIENT_SECRET' => null,
+];

+ 5 - 0
config/services.yaml

@@ -25,11 +25,16 @@ services:
             $publicLegacyBaseUrl: '%env(PUBLIC_API_LEG_BASE_URL)%'
             $baseUrl: '%env(API_BASE_URL)%'
             $publicBaseUrl: '%env(PUBLIC_API_BASE_URL)%'
+            $publicAppBaseUrl: '%env(PUBLIC_APP_BASE_URL)%'
             $adminBaseUrl: '%env(ADMIN_BASE_URL)%'
             $softwareWebsiteUrl: '%env(SOFTWARE_WEBSITE_URL)%'
             $opentalentMailReport: 'mail.report@opentalent.fr'
             $fileStorageDir: '%kernel.project_dir%/var/files/storage'
             $faqUrl: '%env(FAQ_URL)%'
+            $helloAssoApiBaseUrl: '%env(HELLOASSO_API_BASE_URL)%'
+            $helloAssoAuthBaseUrl: '%env(HELLOASSO_AUTH_BASE_URL)%'
+            $helloAssoClientId: '%env(HELLOASSO_CLIENT_ID)%'
+            $helloAssoClientSecret: '%env(HELLOASSO_CLIENT_SECRET)%'
 
     # makes classes in src/ available to be used as services
     # this creates a service per class whose id is the fully-qualified class name

+ 109 - 0
doc/helloasso.md

@@ -0,0 +1,109 @@
+## Helloasso
+
+> Voir : 
+> https://ressources-opentalent.atlassian.net/wiki/spaces/SPEC/pages/421822467/User+Stories#La-mire-de-connexion
+> https://dev.helloasso.com/docs/mire-authorisation#2-enregistrement-de-votre-domaine-de-redirection
+
+### Principe général
+
+Une Organization doit pouvoir associer son compte Opentalent à son compte Helloasso. Ce lien permettra 
+ensuite des interactions avec HelloAsso comme par exemple le paiement de factures (en une ou plusieurs fois) 
+ou l’affichage de billetteries HelloAsso sur les pages de détails des évènements (agenda, sites Typo3).
+
+
+### Configuration initiale
+
+Les identifiants et clés d'API sont fournis par Helloasso, et enregistrés comme variables d'environnement (voir variables 
+`HELLOASSO_****`).
+
+On enregistre comme domaine de redirection le domaine opentalent.fr : https://*.opentalent.fr/ 
+(voir: `ConnectionService::setupOpentalentDomain()`)
+
+### Sandbox
+
+Toutes les URLs mentionnées ici ont une version équivalente de test : 
+
+* xxx.helloasso.com : Version de production
+* xxx.helloasso-sandbox.com : Version de test
+
+### Mise en oeuvre
+
+Pour lier une Organization à son compte Helloasso : 
+
+1. Se connecter à HelloAsso : un bouton "se connecter avec HelloAsso" est présent sur une page du logiciel. Il permet d'afficher une popup contenant le formulaire de connexion HelloAsso.
+2. Une fois identifié, Helloasso redirige le visiteur vers une page de callback dont on a fourni l'url au préalable. Ce retour s'accompagne d'un jeton d'autorisation HelloAsso que l'on récupère.
+3. Grâce à ce jeton d'autorisation, on récupère auprès de l'API HelloAsso un jeton d'accès (durée de vie: 30min) et un jeton de renouvellement (durée de vie: 30jours).
+4. Ce jeton d'accès est stocké en base, et sera ensuite attaché aux requêtes envoyées à l'API HelloAsso.
+
+A noter qu'on renouvelle ce jeton stocké en base de manière régulière afin que celui ci reste valide.
+
+Par ailleurs, l'utilisateur peut : 
+
+* dissocier son compte Helloasso de son compte Opentalent
+
+#### Se connecter avec HelloAsso
+
+La [page HelloAsso](https://local.app.opentalent.fr/helloasso) du front (V2) donne accès à un bouton "Connecter à HelloAsso".
+
+Ce bouton permet d'afficher une popup contenant le forumaire de connexion HelloAsso : `https://auth.helloasso.com/authorize?<params>`
+
+On ajoute à l'URL de la page HelloAsso une query composée des éléments suivants :
+
+* `client_id` : l'identifiant client Opentalent enregistré comme variable d'environnement.
+* `redirect_uri` : l'url vers laquelle l'utilisateur sera redirigé après s'être authentifié.
+* `code_challenge`: code challenge PKCE à générer en amont (voir : `OAuthPkceGenerator::generatePkce()`).
+* `code_challenge_method` : méthode du test, doit être égal à "S256".
+* `state` : *optionnel, pas utilisé*.
+
+Le formulaire ainsi affiché permet à l'utilisateur de s'authentifier sur HelloAsso, puis en cas de réussite, de 
+confirmer vouloir lier son compte HelloAsso à son compte Opentalent. 
+
+
+#### Le callback après authentification
+
+Une fois l'authentification effectuée via le formulaire HelloAsso, l'utilisateur est redirigé vers l'URL de 
+callback fournie précédemment : https://app.opentalent.fr/helloasso/callback.
+
+Lorsqu'il redirige vers l'url de callback, HelloAsso y ajoute une query.
+Cette query de retour contient :
+
+* Un authorization_code en cas de succès
+* Un message d'erreur en cas de problème de configuration.
+
+A ce niveau là, si tout s'est bien déroulé, on dispose donc d'un jeton d'autorisation HelloAsso `authorization_code`.
+
+#### Récupérer et stocker les jetons d'accès
+
+On adresse ensuite une requête POST à l'adresse https://api.helloasso.com/oauth2/token, 
+contenant le body suivant (`application/x-www-form-urlencoded`) : 
+
+* `client_id` : l'identifiant client Opentalent enregistré comme variable d'environnement.
+* `client_secret` : la clé secrète Opentalent enregistré comme variable d'environnement.
+* `grant_type`: doit valoir 'authorization_code'.
+* `code`: l'authorization_code fourni précédemment.
+* `code_verifier`: la valeur initiale utilisée pour le challenge PCKE, stockée en base.
+* `redirect_uri` : l'url de callback passée précédemment au formulaire HelloAsso..
+
+On récupère ensuite une réponse JSON de la forme : 
+
+* `access_token` : le jeton qui servira pour les requêtes vers l'API, valable 30 min.
+* `refresh_token` : Le jeton permettant de demander le renouvellement de l'access_token, valable 30 jours.
+* `token_type` : doit valoir "bearer".
+* `expires_in` : la durée de validité du token d'accès, en secondes.
+* `organization_slug` : le slug de l'organization que l'on vient de connecter.
+
+On stocke enfin dans la table HelloAsso les valeurs de `access_token`, `refresh_token` et `organization_slug`, associées
+à l'id de l'Organization.
+
+#### Envoyer une requête à l'API HelloAsso
+
+TODO: à compléter
+
+#### Le renouvellement du jeton d'accès
+
+TODO: à compléter
+
+#### Dissocier le compte Helloasso de son compte Opentalent
+
+Pour dissocier le compte HelloAsso du compte Opentalent, il suffira de supprimer la ligne correspondant à 
+l'Organization dans la table `HelloAsso`.

+ 1 - 0
env/.env.docker

@@ -15,6 +15,7 @@ ADMIN_BASE_URL=https://local.admin.opentalent.fr
 ###> api v1 ###
 API_LEG_BASE_URL=http://nginx/
 PUBLIC_API_LEG_BASE_URL=https://local.api.opentalent.fr
+PUBLIC_APP_BASE_URL=https://local.app.opentalent.fr
 ###< api v1 ###
 
 ###> api v2 ###

+ 7 - 0
env/.env.prod

@@ -13,6 +13,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.opentalent.fr
 ###> url v2 ###
 API_BASE_URL=https://ap2i.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.opentalent.fr
 ###
 
 ###> typo3 client ###
@@ -42,3 +43,9 @@ BIND_FILE_BUFFER_FILE=/env/subdomain.txt
 ###> filename log ###
 LOG_FILE_NAME=prod
 ###< filename log ###
+
+### Hello asso
+HELLOASSO_API_BASE_URL=https://api.helloasso.com
+HELLOASSO_AUTH_BASE_URL=https://auth.helloasso.com
+HELLOASSO_AUTHORIZE_URL=https://auth.helloasso.com/authorize
+###

+ 1 - 0
env/.env.staging

@@ -20,6 +20,7 @@ PUBLIC_API_LEG_BASE_URL=https://none
 ###> api v2 ###
 API_BASE_URL=https://ap2i.ci.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.ci.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.ci.opentalent.fr
 ###< api v2 ###
 
 ###> elasticsearch ###

+ 1 - 0
env/.env.test

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test1

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test1.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test1.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test1.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test1.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test2

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test2.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test2.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test2.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test2.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test3

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test3.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test3.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test3.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test3.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test4

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test4.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test4.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test4.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test4.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test5

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test5.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test5.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test5.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test5.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test6

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test6.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test6.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test6.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test6.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test7

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test7.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test7.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test7.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test7.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test8

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test8.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test8.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test8.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test8.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 1 - 0
env/.env.test9

@@ -15,6 +15,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.test9.opentalent.fr
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test9.opentalent.fr
 PUBLIC_API_BASE_URL=https://ap2i.test9.opentalent.fr
+PUBLIC_APP_BASE_URL=https://my.test9.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 2 - 0
sql/schema-extensions/001-view_public_events.sql

@@ -6,6 +6,7 @@ CREATE OR REPLACE VIEW view_public_events AS
         b.name,
         b.description,
         b.url,
+        IF(b.helloAssoSlug is not null, 1, 0) AS hasHelloAssoForm,
         b.datetimeStart,
         b.datetimeEnd,
         b.gender_id as gender,
@@ -60,6 +61,7 @@ CREATE OR REPLACE VIEW view_public_events AS
         aw.name,
         aw.description,
         aw.deepLink AS url,
+        0 AS hasHelloAssoForm,
         aw.datetimeStart,
         aw.datetimeEnd,
         NULL as gender,

+ 1 - 1
src/ApiResources/Dolibarr/DolibarrDocDownload.php

@@ -36,7 +36,7 @@ class DolibarrDocDownload
      * de l'IRI par api platform.
      */
     #[ApiProperty(identifier: true)]
-    protected int $id = 0;
+    protected int $id = 1;
 
     /**
      * Type de fichier à télécharger.

+ 1 - 1
src/ApiResources/Export/ExportRequest.php

@@ -19,7 +19,7 @@ abstract class ExportRequest
      * de l'IRI par api platform.
      */
     #[ApiProperty(identifier: true)]
-    protected int $id = 0;
+    protected int $id = 1;
 
     /**
      * Format de sortie attendu (pdf, txt...).

+ 60 - 0
src/ApiResources/HelloAsso/AuthUrl.php

@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use App\State\Provider\HelloAsso\AuthUrlProvider;
+
+/**
+ * Ressource contenant l'URL d'authentification HelloAsso et le vérificateur de défi PKCE.
+ */
+#[ApiResource(
+    operations: [
+        new Get(
+            uriTemplate: '/helloasso/auth-url',
+        ),
+    ],
+    provider: AuthUrlProvider::class,
+    security: 'is_granted("ROLE_ORGANIZATION")'
+)]
+class AuthUrl
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 1;
+
+    /**
+     * URL d'authentification HelloAsso pour l'autorisation OAuth2.
+     */
+    private string $authUrl;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    public function getAuthUrl(): string
+    {
+        return $this->authUrl;
+    }
+
+    public function setAuthUrl(string $authUrl): self
+    {
+        $this->authUrl = $authUrl;
+
+        return $this;
+    }
+}

+ 78 - 0
src/ApiResources/HelloAsso/ConnectionRequest.php

@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\ApiResources\ApiResourcesInterface;
+use App\State\Processor\HelloAsso\ConnectionRequestProcessor;
+
+/**
+ * Demande de connexion d'une organisation à HelloAsso.
+ */
+#[ApiResource(
+    operations: [
+        new Post(
+            uriTemplate: '/helloasso/connect',
+        ),
+    ],
+    processor: ConnectionRequestProcessor::class,
+)]
+class ConnectionRequest implements ApiResourcesInterface
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 1;
+
+    private int $organizationId;
+
+    private string $authorizationCode;
+
+    private string $challengeVerifier;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    public function setOrganizationId(int $organizationId): self
+    {
+        $this->organizationId = $organizationId;
+
+        return $this;
+    }
+
+    public function getAuthorizationCode(): string
+    {
+        return $this->authorizationCode;
+    }
+
+    public function setAuthorizationCode(string $authorizationCode): self
+    {
+        $this->authorizationCode = $authorizationCode;
+
+        return $this;
+    }
+
+    public function getChallengeVerifier(): string
+    {
+        return $this->challengeVerifier;
+    }
+
+    public function setChallengeVerifier(string $challengeVerifier): self
+    {
+        $this->challengeVerifier = $challengeVerifier;
+        return $this;
+    }
+}

+ 107 - 0
src/ApiResources/HelloAsso/EventForm.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Link;
+use App\State\Provider\HelloAsso\AuthUrlProvider;
+use App\State\Provider\HelloAsso\HelloAssoProfileProvider;
+use App\State\Provider\HelloAsso\EventFormProvider;
+
+/**
+ * Retourne les formulaires d'une organisation.
+ */
+#[ApiResource(
+    operations: [
+        new GetCollection(
+            uriTemplate: '/helloasso/forms',
+        ),
+        new Get(
+            uriTemplate: '/helloasso/form/{slug}',
+        ),
+        new Get(
+            uriTemplate: '/public/helloasso/form/by-event/{eventId}',
+            uriVariables: [
+                'eventId' => new Link(
+                    fromProperty: 'eventId',
+                    identifiers: ['eventId']
+                )
+            ]
+        )
+    ],
+    provider: EventFormProvider::class,
+)]
+class EventForm
+{
+    /**
+     * Slug du formulaire
+     */
+    #[ApiProperty(identifier: true)]
+    private ?string $slug = null;
+
+    /**
+     * Id de l'Event auquel le formulaire est associé
+     * @var int | null
+     */
+    private ?int $eventId = null;
+
+    /**
+     * Titre du formulaire
+     * @var bool
+     */
+    private ?string $title = null;
+
+    /**
+     * Url du formulaire
+     */
+    private ?string $widgetUrl = null;
+
+    public function getSlug(): ?string
+    {
+        return $this->slug;
+    }
+
+    public function setSlug(?string $slug): self
+    {
+        $this->slug = $slug;
+        return $this;
+    }
+
+    public function getEventId(): ?int
+    {
+        return $this->eventId;
+    }
+
+    public function setEventId(?int $eventId): self
+    {
+        $this->eventId = $eventId;
+        return $this;
+    }
+
+    public function getTitle(): ?string
+    {
+        return $this->title;
+    }
+
+    public function setTitle(?string $title): self
+    {
+        $this->title = $title;
+        return $this;
+    }
+
+    public function getWidgetUrl(): ?string
+    {
+        return $this->widgetUrl;
+    }
+
+    public function setWidgetUrl(?string $widgetUrl): self
+    {
+        $this->widgetUrl = $widgetUrl;
+        return $this;
+    }
+}

+ 93 - 0
src/ApiResources/HelloAsso/HelloAssoProfile.php

@@ -0,0 +1,93 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use App\State\Provider\HelloAsso\AuthUrlProvider;
+use App\State\Provider\HelloAsso\HelloAssoProfileProvider;
+
+/**
+ * Profil HelloAsso d'une organisation.
+ */
+#[ApiResource(
+    operations: [
+        new Get(
+            uriTemplate: '/helloasso/profile',
+        ),
+    ],
+    provider: HelloAssoProfileProvider::class,
+    security: 'is_granted("ROLE_ORGANIZATION")'
+)]
+class HelloAssoProfile
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 1;
+
+    /**
+     * Is there a HelloAsso profile linked to this organization ?
+     * @var bool
+     */
+    private bool $existing = false;
+
+    /**
+     * Token HelloAsso pour l'autorisation OAuth2.
+     */
+    private string | null $token = null;
+
+    /**
+     * Slug de l'organization HelloAsso
+     */
+    private string | null $organizationSlug = null;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+        return $this;
+    }
+
+    public function isExisting(): bool
+    {
+        return $this->existing;
+    }
+
+    public function setExisting(bool $existing): self
+    {
+        $this->existing = $existing;
+        return $this;
+    }
+
+    public function getToken(): ?string
+    {
+        return $this->token;
+    }
+
+    public function setToken(?string $token): self
+    {
+        $this->token = $token;
+        return $this;
+    }
+
+    public function getOrganizationSlug(): ?string
+    {
+        return $this->organizationSlug;
+    }
+
+    public function setOrganizationSlug(?string $organizationSlug): self
+    {
+        $this->organizationSlug = $organizationSlug;
+        return $this;
+    }
+}

+ 51 - 0
src/ApiResources/HelloAsso/UnlinkRequest.php

@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\HelloAsso;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\ApiResources\ApiResourcesInterface;
+use App\State\Processor\HelloAsso\UnlinkRequestProcessor;
+
+/**
+ * Demande d'une organisation de dissocier son compte Opentalent de son compte HelloAsso.
+ */
+#[ApiResource(
+    operations: [
+        new Post(
+            uriTemplate: '/helloasso/unlink',
+        ),
+    ],
+    processor: UnlinkRequestProcessor::class,
+)]
+class UnlinkRequest implements ApiResourcesInterface
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 1;
+
+    private int $organizationId;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function getOrganizationId(): int
+    {
+        return $this->organizationId;
+    }
+
+    public function setOrganizationId(int $organizationId): self
+    {
+        $this->organizationId = $organizationId;
+
+        return $this;
+    }
+}

+ 1 - 1
src/ApiResources/Organization/OrganizationCreationRequest.php

@@ -39,7 +39,7 @@ class OrganizationCreationRequest
      * de l'IRI par api platform.
      */
     #[ApiProperty(identifier: true)]
-    private int $id = 0;
+    private int $id = 1;
 
     /**
      * A quelle adresse email notifier la création de l'organisation, ou d'éventuelles erreurs ?

+ 1 - 1
src/ApiResources/Organization/OrganizationDeletionRequest.php

@@ -32,7 +32,7 @@ class OrganizationDeletionRequest
      * de l'IRI par api platform.
      */
     #[ApiProperty(identifier: true)]
-    private int $id = 0;
+    private int $id = 1;
 
     private int $organizationId;
 

+ 1 - 1
src/ApiResources/Organization/OrganizationMemberCreationRequest.php

@@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints as Assert;
  */
 class OrganizationMemberCreationRequest
 {
-    #[Assert\Type(type: FileTypeEnum::class)]
+    #[Assert\Type(type: GenderEnum::class)]
     private GenderEnum $gender = GenderEnum::MISTER;
 
     #[Assert\Regex(pattern: '/^[a-z0-9\-]{3,}$/')]

+ 1 - 1
src/ApiResources/Organization/Subdomain/SubdomainAvailability.php

@@ -30,7 +30,7 @@ class SubdomainAvailability
      * de l'IRI par api platform.
      */
     #[ApiProperty(identifier: true)]
-    protected int $id = 0;
+    protected int $id = 1;
 
     /**
      * The subdomain.

+ 1 - 1
src/ApiResources/Shop/NewStructureArtistPremiumTrialRequest.php

@@ -39,7 +39,7 @@ class NewStructureArtistPremiumTrialRequest implements ShopRequestData
      * de l'IRI par api platform.
      */
     #[ApiProperty(identifier: true)]
-    private int $id = 0;
+    private int $id = 1;
 
     #[Assert\Length(
         min: 2,

+ 28 - 0
src/Entity/Booking/Event.php

@@ -121,6 +121,12 @@ class Event extends AbstractBooking
     #[ORM\Column(length: 255, nullable: false, enumType: VisibilityEnum::class)]
     protected VisibilityEnum $visibility;
 
+    #[ORM\Column(length: 255, nullable: true)]
+    private ?string $helloAssoSlug = null;
+
+    #[ORM\Column(length: 255, nullable: true)]
+    private ?string $helloAssoPublicUrl = null;
+
     public function __construct()
     {
         $this->eventRecur = new ArrayCollection();
@@ -508,4 +514,26 @@ class Event extends AbstractBooking
 
         return $this;
     }
+
+    public function getHelloAssoSlug(): ?string
+    {
+        return $this->helloAssoSlug;
+    }
+
+    public function setHelloAssoSlug(?string $helloAssoSlug): self
+    {
+        $this->helloAssoSlug = $helloAssoSlug;
+        return $this;
+    }
+
+    public function getHelloAssoPublicUrl(): ?string
+    {
+        return $this->helloAssoPublicUrl;
+    }
+
+    public function setHelloAssoPublicUrl(?string $helloAssoPublicUrl): self
+    {
+        $this->helloAssoPublicUrl = $helloAssoPublicUrl;
+        return $this;
+    }
 }

+ 162 - 0
src/Entity/HelloAsso/HelloAsso.php

@@ -0,0 +1,162 @@
+<?php
+
+namespace App\Entity\HelloAsso;
+
+use App\Entity\Organization\Organization;
+use App\Entity\Traits\CreatedOnAndByTrait;
+use App\Repository\HelloAsso\HelloAssoRepository;
+use Doctrine\ORM\Mapping as ORM;
+
+/**
+ * HelloAsso entity for storing HelloAsso connection information.
+ *
+ * @see https://dev.helloasso.com/docs/mire-authorisation
+ */
+#[ORM\Entity(repositoryClass: HelloAssoRepository::class)]
+#[ORM\Table]
+class HelloAsso
+{
+    #[ORM\Id]
+    #[ORM\Column]
+    #[ORM\GeneratedValue]
+    private ?int $id = null;
+
+    #[ORM\OneToOne(targetEntity: Organization::class, inversedBy: 'helloAsso')]
+    #[ORM\JoinColumn(name: 'organization_id', referencedColumnName: 'id', nullable: false)]
+    private Organization $organization;
+
+    /**
+     * La valeur utilisée pour générer le challenge PCKE; à conserver en base le temps de l'authentification HelloAsso.
+     * @var string|null
+     */
+    #[ORM\Column(type: 'text', nullable: true)]
+    private ?string $challengeVerifier = null;
+
+    /**
+     * Le token d'authentification HelloAsso, valable 30min.'
+     * @var string|null
+     */
+    #[ORM\Column(type: 'text', nullable: true)]
+    private ?string $token = null;
+
+    /**
+     * Date à laquelle le token a été généré
+     * @var \DateTimeInterface|null
+     */
+    #[ORM\Column(type: 'datetime', nullable: true)]
+    private $tokenCreatedAt;
+
+    /**
+     * Un autre jeton, valable 30jours, permettant de regénérer le token d'authentification HelloAsso.'
+     * @var string|null
+     */
+    #[ORM\Column(type: 'text', nullable: true)]
+    private ?string $refreshToken = null;
+
+    /**
+     * Date à laquelle le refreshToken a été généré
+     * @var \DateTimeInterface|null
+     */
+    #[ORM\Column(type: 'datetime', nullable: true)]
+    private $refreshTokenCreatedAt;
+
+    /**
+     * Le slug de l'organisation sur HelloAsso.
+     * @var string|null
+     */
+    #[ORM\Column(type: 'string', length: 255, nullable: true)]
+    private ?string $organizationSlug = null;
+
+    public function getId(): ?int
+    {
+        return $this->id;
+    }
+
+    public function setId(?int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getOrganization(): Organization
+    {
+        return $this->organization;
+    }
+
+    public function setOrganization(Organization $organization): self
+    {
+        $this->organization = $organization;
+
+        return $this;
+    }
+
+    public function getChallengeVerifier(): ?string
+    {
+        return $this->challengeVerifier;
+    }
+
+    public function setChallengeVerifier(?string $challengeVerifier): self
+    {
+        $this->challengeVerifier = $challengeVerifier;
+        return $this;
+    }
+
+    public function getToken(): ?string
+    {
+        return $this->token;
+    }
+
+    public function setToken(?string $token): self
+    {
+        $this->token = $token;
+
+        return $this;
+    }
+
+    public function getTokenCreatedAt(): ?\DateTimeInterface
+    {
+        return $this->tokenCreatedAt;
+    }
+
+    public function setTokenCreatedAt(?\DateTimeInterface $tokenCreatedAt): self
+    {
+        $this->tokenCreatedAt = $tokenCreatedAt;
+        return $this;
+    }
+
+    public function getRefreshToken(): ?string
+    {
+        return $this->refreshToken;
+    }
+
+    public function setRefreshToken(?string $refreshToken): self
+    {
+        $this->refreshToken = $refreshToken;
+
+        return $this;
+    }
+
+    public function getRefreshTokenCreatedAt(): ?\DateTimeInterface
+    {
+        return $this->refreshTokenCreatedAt;
+    }
+
+    public function setRefreshTokenCreatedAt(?\DateTimeInterface $refreshTokenCreatedAt): self
+    {
+        $this->refreshTokenCreatedAt = $refreshTokenCreatedAt;
+        return $this;
+    }
+
+    public function getOrganizationSlug(): ?string
+    {
+        return $this->organizationSlug;
+    }
+
+    public function setOrganizationSlug(?string $organizationSlug): self
+    {
+        $this->organizationSlug = $organizationSlug;
+
+        return $this;
+    }
+}

+ 18 - 1
src/Entity/Organization/Organization.php

@@ -31,6 +31,7 @@ use App\Entity\Education\EducationCategory;
 use App\Entity\Education\EducationNotationConfig;
 use App\Entity\Education\EducationTiming;
 use App\Entity\Education\PeriodNotation;
+use App\Entity\HelloAsso\HelloAsso;
 use App\Entity\Message\AbstractMessage;
 use App\Entity\Message\AbstractReport;
 use App\Entity\Message\Email;
@@ -53,13 +54,14 @@ use App\Enum\Organization\SchoolCategoryEnum;
 use App\Enum\Organization\TypeEstablishmentDetailEnum;
 use App\Enum\Organization\TypeEstablishmentEnum;
 use App\Repository\Organization\OrganizationRepository;
-// use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
 use App\State\Processor\Organization\OrganizationProcessor;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
 use Doctrine\ORM\Mapping as ORM;
 use JetBrains\PhpStorm\Pure;
 
+// use DH\Auditor\Provider\Doctrine\Auditing\Annotation\Auditable;
+
 /**
  * Structure, organisation.
  *
@@ -409,6 +411,9 @@ class Organization
     #[ORM\OneToOne(targetEntity: OnlineRegistrationSettings::class, mappedBy: 'organization', cascade: ['persist', 'remove'])]
     protected ?OnlineRegistrationSettings $onlineRegistrationSettings;
 
+    #[ORM\OneToOne(targetEntity: HelloAsso::class, mappedBy: 'organization', cascade: ['persist', 'remove'])]
+    protected ?HelloAsso $helloAsso = null;
+
     /** @var Collection<int, CotisationByYear> */
     #[ORM\OneToMany(targetEntity: CotisationByYear::class, mappedBy: 'organization', cascade: ['persist', 'remove'])]
     protected Collection $cotisationByYears;
@@ -2090,6 +2095,18 @@ class Organization
         return $this;
     }
 
+    public function getHelloAsso(): ?HelloAsso
+    {
+        return $this->helloAsso;
+    }
+
+    public function setHelloAsso(?HelloAsso $helloAsso): self
+    {
+        $this->helloAsso = $helloAsso;
+
+        return $this;
+    }
+
     public function getCotisationByYears(): Collection
     {
         return $this->cotisationByYears;

+ 15 - 0
src/Entity/Public/PublicEvent.php

@@ -64,6 +64,9 @@ class PublicEvent
     #[ORM\Column(nullable: true)]
     private ?string $url;
 
+    #[ORM\Column(nullable: true)]
+    private ?bool $hasHelloAssoForm;
+
     #[ORM\Column(type: 'datetime')]
     private \DateTime $datetimeStart;
 
@@ -199,6 +202,18 @@ class PublicEvent
         return $this;
     }
 
+    public function getHasHelloAssoForm(): ?bool
+    {
+        return $this->hasHelloAssoForm;
+    }
+
+    public function setHasHelloAssoForm(?bool $hasHelloAssoForm): PublicEvent
+    {
+        $this->hasHelloAssoForm = $hasHelloAssoForm;
+
+        return $this;
+    }
+
     public function getDatetimeStart(): \DateTime
     {
         return $this->datetimeStart;

+ 1 - 0
src/Enum/Cotisation/CategoryTypeOfPracticeEnum.php

@@ -17,6 +17,7 @@ enum CategoryTypeOfPracticeEnum: string
     case CATEGORY_AMBULATORY = 'CATEGORY_AMBULATORY';
     case CATEGORY_CHORUS = 'CATEGORY_CHORUS';
     case CATEGORY_BAND = 'CATEGORY_BAND';
+    case CATEGORY_TEACHING = 'CATEGORY_TEACHING';
     case CATEGORY_OTHER = 'CATEGORY_OTHER';
     case CATEGORY_DANCES = 'CATEGORY_DANCES';
     case CATEGORY_TEACHING = 'CATEGORY_TEACHING';

+ 31 - 0
src/Repository/HelloAsso/HelloAssoRepository.php

@@ -0,0 +1,31 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Repository\HelloAsso;
+
+use App\Entity\Education\Cycle;
+use App\Entity\HelloAsso\HelloAsso;
+use App\Service\Utils\DatesUtils;
+use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
+use Doctrine\Persistence\ManagerRegistry;
+
+class HelloAssoRepository extends ServiceEntityRepository
+{
+    public function __construct(ManagerRegistry $registry)
+    {
+        parent::__construct($registry, HelloAsso::class);
+    }
+
+    public function findOldRefreshTokens(int $age): array
+    {
+        $dateLimite = DatesUtils::new('-'.$age.' days');
+
+        return $this->createQueryBuilder('h')
+            ->where('h.refreshTokenCreatedAt <= :date')
+            ->setParameter('date', $dateLimite)
+            ->orderBy('h.refreshTokenCreatedAt', 'ASC')
+            ->getQuery()
+            ->getResult();
+    }
+}

+ 119 - 0
src/Service/Cron/Job/RefreshHelloassoTokens.php

@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Cron\Job;
+
+use App\Entity\Core\File;
+use App\Entity\HelloAsso\HelloAsso;
+use App\Enum\Core\FileHostEnum;
+use App\Enum\Core\FileStatusEnum;
+use App\Repository\Core\FileRepository;
+use App\Service\Cron\BaseCronJob;
+use App\Service\File\Storage\LocalStorage;
+use App\Service\HelloAsso\HelloAssoService;
+use App\Service\Utils\DatesUtils;
+use Doctrine\DBAL\Connection;
+use Doctrine\ORM\EntityManagerInterface;
+use Doctrine\ORM\QueryBuilder;
+use JetBrains\PhpStorm\Pure;
+
+/**
+ * Cronjob that refresh the tokens of the HelloAsso accounts.
+ *
+ * >>> ot:cron run refresh-helloasso-tokens --preview
+ * >>> ot:cron run refresh-helloasso-tokens
+ *
+ * /!\ Attention aux limites d'appels à l'API HelloAsso : https://dev.helloasso.com/docs/limitation-api
+ */
+class RefreshHelloassoTokens extends BaseCronJob
+{
+    /**
+     * Age (en jours) à partir duquel on rafraichit les tokens.
+     */
+    private const REFRESH_OLDER_THAN = 24;
+
+    /**
+     * Limite à poser au nombre d'appels API par exécution du cronjob.
+     */
+    private const CALLS_LIMIT = 10;
+
+    #[Pure]
+    public function __construct(
+        private EntityManagerInterface $entityManager,
+        private HelloAssoService $helloAssoService,
+    ) {
+        parent::__construct();
+    }
+
+    /**
+     * Preview the result of the execution, without actually deleting anything.
+     *
+     * @throws \Exception
+     */
+    public function preview(): void
+    {
+        $helloAssoEntities = $this->getHelloassoAccounts();
+
+        if (count($helloAssoEntities) === 0) {
+            $this->ui->print('No tokens to refresh');
+            return;
+        }
+
+        $this->ui->print("Tokens to refresh :");
+
+        foreach ($helloAssoEntities as $helloAssoEntity) {
+            $this->ui->print(
+                ' * Organization '.$helloAssoEntity->getOrganization()->getId().' : '.$helloAssoEntity->getRefreshTokenCreatedAt()->format('Y-m-d H:i:s')
+            );
+        }
+    }
+
+    /**
+     * Proceed to the deletion of the files and the purge of the DB.
+     *
+     * @throws \Exception
+     */
+    public function execute(): void
+    {
+        $helloAssoEntities = $this->getHelloassoAccounts();
+        $amount = count($helloAssoEntities);
+
+        if ($amount === 0) {
+            $this->logger->info('No tokens to refresh');
+            return;
+        }
+
+        $this->logger->info($amount . ' tokens to refresh');
+
+        $i = 0;
+
+        foreach ($helloAssoEntities as $helloAssoEntity) {
+            $this->logger->info(
+                ' * Refresh token for organization '.$helloAssoEntity->getOrganization()->getId().' : '.$helloAssoEntity->getRefreshTokenCreatedAt()->format('Y-m-d H:i:s')
+            );
+
+            $this->helloAssoService->refreshTokens($helloAssoEntity);
+
+            $i++;
+
+            if ($i >= self::CALLS_LIMIT) {
+                if ($amount > self::CALLS_LIMIT) {
+                    $this->logger->warning('API calls limit reached, aborting');
+                }
+                return;
+            }
+
+            sleep(1);
+        }
+
+        $this->logger->info('Tokens refreshed');
+    }
+
+    protected function getHelloassoAccounts(): array
+    {
+        $helloassoRepository = $this->entityManager->getRepository(HelloAsso::class);
+
+        return $helloassoRepository->findOldRefreshTokens(self::REFRESH_OLDER_THAN);
+    }
+}

+ 467 - 0
src/Service/HelloAsso/HelloAssoService.php

@@ -0,0 +1,467 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\HelloAsso;
+
+use App\ApiResources\HelloAsso\AuthUrl;
+use App\ApiResources\HelloAsso\HelloAssoProfile;
+use App\ApiResources\HelloAsso\EventForm;
+use App\Entity\Booking\Event;
+use App\Entity\HelloAsso\HelloAsso;
+use App\Entity\Organization\Organization;
+use App\Repository\Booking\EventRepository;
+use App\Repository\Organization\OrganizationRepository;
+use App\Service\Rest\ApiRequestService;
+use App\Service\Security\OAuthPkceGenerator;
+use App\Service\Utils\DatesUtils;
+use App\Service\Utils\UrlBuilder;
+use Doctrine\Common\Collections\Collection;
+use Doctrine\ORM\EntityManagerInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+
+/**
+ * Service de connexion à HelloAsso.
+ *
+ * @see doc/helloasso.md
+ * @see https://dev.helloasso.com/docs/mire-authorisation
+ */
+class HelloAssoService extends ApiRequestService
+{
+    public function __construct(
+        HttpClientInterface $client,
+        private readonly OrganizationRepository $organizationRepository,
+        private readonly EventRepository $eventRepository,
+        private readonly string $baseUrl,
+        private readonly string $publicAppBaseUrl,
+        private readonly string $helloAssoApiBaseUrl,
+        private readonly string $helloAssoAuthBaseUrl,
+        private readonly string $helloAssoClientId,
+        private readonly string $helloAssoClientSecret,
+        private readonly EntityManagerInterface $entityManager,
+        private readonly LoggerInterface $logger
+    ) {
+        parent::__construct($client);
+    }
+
+    /**
+     * Se connecte à Helloasso en tant qu'organisation Opentalent, et met à jour son domaine.
+     * Le domaine doit correspondre à celui utilisé pour les callbacks, sans quoi ceux ci seront bloqués
+     * pour des raisons de sécurité.
+     *
+     * En principe, cette opération n'est réalisée qu'une seule fois.
+     *
+     * @see doc/helloasso.md#2-enregistrement-de-votre-domaine-de-redirection
+     * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
+     */
+    public function setupOpentalentDomain(): void
+    {
+        $accessToken = $this->fetchAccessToken(null);
+        $this->updateDomain($accessToken, 'https://*.opentalent.fr');
+    }
+
+    /**
+     * Créé l'URL du formulaire d'authentification HelloAsso.
+     *
+     * @see doc/helloasso.md#se-connecter-avec-helloasso
+     *
+     * @param int    $organizationId    the ID of the organization to connect
+     */
+    public function getAuthUrl(int $organizationId): AuthUrl
+    {
+        $organization = $this->organizationRepository->find($organizationId);
+
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+
+        $challenge = OAuthPkceGenerator::generatePkce();
+
+        $params = [
+            'client_id' => $this->helloAssoClientId,
+            'redirect_uri' => $this->getCallbackUrl(),
+            'code_challenge' => $challenge['challenge'],
+            'code_challenge_method' => 'S256',
+        ];
+
+        $authUrl = UrlBuilder::concat($this->helloAssoAuthBaseUrl, ['authorize'], $params);
+        $challengeVerifier = $challenge['verifier'];
+
+        $helloAssoEntity = $organization->getHelloAsso();
+        if (!$helloAssoEntity) {
+            $helloAssoEntity = new HelloAsso();
+            $helloAssoEntity->setOrganization($organization);
+        }
+
+        $helloAssoEntity->setChallengeVerifier($challengeVerifier);
+        $this->entityManager->persist($helloAssoEntity);
+        $this->entityManager->flush();
+
+        $authUrlResource = new AuthUrl();
+        $authUrlResource->setAuthUrl($authUrl);
+
+        return $authUrlResource;
+    }
+
+    /**
+     * Establishes a connection for a specific organization with HelloAsso.
+     *
+     * @see doc/helloasso.md#r%C3%A9cup%C3%A9rer-et-stocker-les-jetons-dacc%C3%A8s
+     *
+     * @param int    $organizationId    the ID of the organization to connect
+     * @param string $authorizationCode Le code d'autorisation Helloasso fourni après l'authentification de l'utilisateur.'
+     *
+     * @return HelloAsso the HelloAsso entity for the organization
+     *
+     * @throws \RuntimeException if the organization is not found or if any connection step fails
+     */
+    public function connect(int $organizationId, string $authorizationCode): HelloAsso
+    {
+        $organization = $this->organizationRepository->find($organizationId);
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+
+        $helloAssoEntity = $organization->getHelloAsso();
+        if (!$helloAssoEntity) {
+            throw new \RuntimeException('HelloAsso entity not found');
+        }
+
+        $tokens = $this->fetchAccessToken(
+            $authorizationCode,
+            $helloAssoEntity->getChallengeVerifier()
+        );
+
+        if ($tokens['token_type'] !== 'bearer') {
+            throw new \RuntimeException('Invalid token type received');
+        }
+
+        $helloAssoEntity->setToken($tokens['access_token']);
+        $helloAssoEntity->setTokenCreatedAt(DatesUtils::new());
+        $helloAssoEntity->setRefreshToken($tokens['refresh_token']);
+        $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new());
+        $helloAssoEntity->setOrganizationSlug($tokens['organization_slug'] ?? null);
+        $helloAssoEntity->setChallengeVerifier(null);
+
+        $this->entityManager->persist($helloAssoEntity);
+        $this->entityManager->flush();
+
+        return $helloAssoEntity;
+    }
+
+    /**
+     * Génère le profil HelloAsso pour une organisation.
+     *
+     * @param int $organizationId
+     * @return HelloAssoProfile
+     */
+    public function makeHelloAssoProfile(int $organizationId): HelloAssoProfile
+    {
+        $organization = $this->organizationRepository->find($organizationId);
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+
+        $profile = new HelloAssoProfile();
+
+        $helloAssoEntity = $organization->getHelloAsso();
+        if (!$helloAssoEntity) {
+            return $profile;
+        }
+
+        $profile->setExisting(true);
+        $profile->setToken($helloAssoEntity->getToken());
+        $profile->setOrganizationSlug($helloAssoEntity->getOrganizationSlug());
+
+        return $profile;
+    }
+
+    public function unlinkHelloAssoAccount(int $organizationId): void
+    {
+        $organization = $this->organizationRepository->find($organizationId);
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+
+        $helloAssoEntity = $organization->getHelloAsso();
+        if (!$helloAssoEntity) {
+            return;
+        }
+
+        $helloAssoEntity->setToken(null);
+        $helloAssoEntity->setTokenCreatedAt(null);
+        $helloAssoEntity->setRefreshToken(null);
+        $helloAssoEntity->setRefreshTokenCreatedAt(null);
+        $helloAssoEntity->setOrganizationSlug(null);
+
+        $this->entityManager->persist($helloAssoEntity);
+        $this->entityManager->flush();
+    }
+
+    public function getResource(HelloAsso $helloAssoEntity, array $routeParts): array {
+        if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
+            throw new \RuntimeException('HelloAsso entity incomplete');
+        }
+
+        $helloAssoEntity = $this->refreshTokenIfNeeded($helloAssoEntity);
+
+        $url = UrlBuilder::concat(
+            $this->helloAssoApiBaseUrl,
+            array_merge(['/v5'], $routeParts),
+        );
+
+        $response = $this->get(
+            $url,
+            [],
+            ['headers' =>
+                [
+                    'accept' => 'application/json',
+                    'authorization' => 'Bearer '.$helloAssoEntity->getToken(),
+                ]
+            ]
+        );
+
+        if ($response->getStatusCode() !== 200) {
+            throw new HttpException(
+                500,
+                'Failed to fetch resource: ['.$response->getStatusCode().'] '.$response->getContent(false)
+            );
+        }
+
+        return json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+    }
+
+    public function getHelloAssoEventForms(int $organizationId): array
+    {
+        $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
+
+        $data = $this->getResource(
+            $helloAssoEntity,
+            ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms']
+        );
+
+        $forms = [];
+
+        foreach ($data['data'] as $formData) {
+            $forms[] = $this->makeHelloAssoEventForm($formData);
+        }
+
+        return $forms;
+    }
+
+    public function getHelloAssoEventForm(int $organizationId, string $formSlug): EventForm
+    {
+        $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
+
+        if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
+            throw new \RuntimeException('HelloAsso entity incomplete');
+        }
+
+        $formType = 'Event';
+
+        $data = $this->getResource(
+            $helloAssoEntity,
+            ['organizations', $helloAssoEntity->getOrganizationSlug(), 'forms', $formType, $formSlug, 'public']
+        );
+
+        return $this->makeHelloAssoEventForm($data);
+    }
+
+    public function getHelloAssoEventFormByEventId(int $eventId): EventForm
+    {
+        $event = $this->eventRepository->find($eventId);
+        if (!$event) {
+            throw new \RuntimeException('Event not found');
+        }
+
+        $organizationId = $event->getOrganization()->getId();
+
+        $helloAssoEntity = $this->getHelloAssoEntityFor($organizationId);
+
+        if (!$helloAssoEntity->getOrganizationSlug() || !$helloAssoEntity->getToken()) {
+            throw new \RuntimeException('HelloAsso entity incomplete');
+        }
+
+        $helloAssoFormSlug = $event->getHelloAssoSlug();
+        if (!$helloAssoFormSlug) {
+            throw new \RuntimeException('HelloAsso form slug not found');
+        }
+
+        return $this->getHelloAssoEventForm($organizationId, $helloAssoFormSlug);
+    }
+
+    protected function getHelloAssoEntityFor(int $organizationId): HelloAsso
+    {
+        $organization = $this->organizationRepository->find($organizationId);
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+        $helloAssoEntity = $organization->getHelloAsso();
+        if (!$helloAssoEntity) {
+            throw new \RuntimeException('HelloAsso entity not found');
+        }
+        return $helloAssoEntity;
+    }
+
+    /**
+     * Construit un objet EventForm à partir des données retournées par l'api HelloAsso.
+     * @param array $formData
+     * @return EventForm
+     */
+    protected function makeHelloAssoEventForm(array $formData): EventForm {
+        $form = new EventForm();
+        $form->setSlug($formData['formSlug']);
+        $form->setTitle($formData['title']);
+        $form->setWidgetUrl($formData['widgetFullUrl']);
+
+        return $form;
+    }
+
+    /**
+     * Génère l'URL de rappel pour les callbacks suite à l'authentification HelloAsso
+     *
+     * @return string
+     */
+    protected function getCallbackUrl(): string
+    {
+        return UrlBuilder::concat($this->publicAppBaseUrl, ['helloasso/callback']);
+    }
+
+    /**
+     * Récupère les jetons d'accès auprès de l'API HelloAsso.
+     *
+     * @param string|null $authorizationCode Le code d'autorisation HelloAsso. Si ce code n'est pas fourni, les jetons
+     *                                       retournés seront pour le compte principal Opentalent et non pour une
+     *                                       organisation (par exemple pour la mise à jour du domaine).
+     *
+     * @return array<string, string> an array containing access token details: access_token, refresh_token, token_type, and expires_in
+     *
+     * @throws \InvalidArgumentException if an authorization code is required but not provided for organization tokens
+     * @throws \JsonException            if the authentication response cannot be parsed
+     * @throws HttpException             if there is an error in parsing the authentication response or the request fails
+     */
+    protected function fetchAccessToken(?string $authorizationCode, ?string $challengeVerifier): array
+    {
+        $grantType = $authorizationCode !== null ? 'authorization_code' : 'client_credentials';
+
+        $body = [
+            'grant_type' => $grantType,
+            'client_id' => $this->helloAssoClientId,
+            'client_secret' => $this->helloAssoClientSecret,
+        ];
+
+        if ($authorizationCode !== null) {
+            $body['code'] = $authorizationCode;
+            $body['redirect_uri'] = $this->getCallbackUrl();
+        }
+
+        if ($challengeVerifier !== null) {
+            $body['code_verifier'] = $challengeVerifier;
+        }
+
+        $response = $this->client->request('POST',
+            UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
+            [
+                'headers' => [
+                    'Content-Type' => 'application/x-www-form-urlencoded',
+                ],
+                'body' => $body,
+            ]
+        );
+
+        if ($response->getStatusCode() !== 200) {
+            throw new HttpException(500, 'Failed to fetch access token: '.$response->getContent(false));
+        }
+
+        try {
+            $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+
+            return [
+                'access_token' => $data['access_token'] ?? null,
+                'refresh_token' => $data['refresh_token'] ?? null,
+                'token_type' => $data['token_type'] ?? 'Bearer',
+                'expires_in' => $data['expires_in'] ?? null,
+                'organization_slug' => $data['organization_slug'] ?? null,
+            ];
+        } catch (\JsonException $e) {
+            throw new HttpException(500, 'Failed to parse authentication response: '.$e->getMessage(), $e);
+        }
+    }
+
+    /**
+     * Updates the domain configuration.
+     *
+     * @throws HttpException
+     */
+    protected function updateDomain(string $accessToken, string $domain): void
+    {
+        $response = $this->put(
+            UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/v5/partners/me/api-clients']),
+            ['domain' => $domain],
+            [],
+            ['headers' => [
+                'Authorization' => 'Bearer '.$accessToken,
+                'Content-Type' => 'application/json'],
+            ],
+        );
+
+        if ($response->getStatusCode() !== 200) {
+            throw new HttpException(500, 'Failed to update domain: '.$response->getContent());
+        }
+    }
+
+    public function refreshTokenIfNeeded(HelloAsso $helloAssoEntity): HelloAsso {
+        if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) {
+            throw new \RuntimeException('HelloAsso entity incomplete');
+        }
+
+        // Les tokens ont une durée de validité de 30min, on les rafraichit passé 25min.
+        $needsRefreshing = $helloAssoEntity->getRefreshTokenCreatedAt()->add(new \DateInterval('PT25M')) < DatesUtils::new();
+        if (!$needsRefreshing) {
+            return $helloAssoEntity;
+        }
+
+        return $this->refreshTokens($helloAssoEntity);
+    }
+
+    public function refreshTokens(HelloAsso $helloAssoEntity): HelloAsso {
+        if (!$helloAssoEntity->getRefreshToken() || !$helloAssoEntity->getRefreshTokenCreatedAt()) {
+            throw new \RuntimeException('HelloAsso entity incomplete');
+        }
+
+        $body = [
+            'grant_type' => 'refresh_token',
+            'refresh_token' => $helloAssoEntity->getRefreshToken(),
+        ];
+
+        $response = $this->client->request('POST',
+            UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']),
+            [
+                'headers' => [
+                    'Content-Type' => 'application/x-www-form-urlencoded',
+                ],
+                'body' => $body,
+            ]
+        );
+
+        if ($response->getStatusCode() !== 200) {
+            throw new HttpException(500, 'Failed to refresh access token: '.$response->getContent(false));
+        }
+
+        $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+
+        $helloAssoEntity->setToken($data['access_token']);
+        $helloAssoEntity->setTokenCreatedAt(DatesUtils::new());
+        $helloAssoEntity->setRefreshToken($data['refresh_token']);
+        $helloAssoEntity->setRefreshTokenCreatedAt(DatesUtils::new());
+
+        $this->entityManager->persist($helloAssoEntity);
+        $this->entityManager->flush();
+
+        return $helloAssoEntity;
+    }
+}

+ 27 - 0
src/Service/Security/OAuthPkceGenerator.php

@@ -0,0 +1,27 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Security;
+
+class OAuthPkceGenerator
+{
+    public static function generatePkce(): array
+    {
+        // 1. Générer un code_verifier (entre 43 et 128 caractères)
+        $codeVerifier = rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
+
+        // 2. Générer le code_challenge
+        $codeChallenge = rtrim(strtr(
+            base64_encode(hash('sha256', $codeVerifier, true)),
+            '+/',
+            '-_'
+        ), '=');
+
+        return [
+            'verifier' => $codeVerifier,
+            'challenge' => $codeChallenge,
+            'method' => 'S256',
+        ];
+    }
+}

+ 71 - 0
src/State/Processor/HelloAsso/ConnectionRequestProcessor.php

@@ -0,0 +1,71 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\HelloAsso;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\HelloAsso\ConnectionRequest;
+use App\Entity\Access\Access;
+use App\Service\HelloAsso\HelloAssoService;
+use App\Service\MercureHub;
+use http\Client\Response;
+use Psr\Log\LoggerInterface;
+use Symfony\Bundle\SecurityBundle\Security;
+
+/**
+ * Processor pour la ressource ConnectionRequest.
+ */
+class ConnectionRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly HelloAssoService $helloAssoService,
+        private Security                  $security,
+        private MercureHub                $mercureHub,
+        private LoggerInterface           $logger,
+    ) {
+    }
+
+    /**
+     * @param ConnectionRequest $data
+     * @param mixed[]           $uriVariables
+     * @param mixed[]           $context
+     *
+     * @throws \Exception
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ConnectionRequest
+    {
+        /**
+         * @var ConnectionRequest $connectionRequest
+         */
+        $connectionRequest = $data;
+
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        /** @var Access $access */
+        $access = $this->security->getUser();
+
+        if ($connectionRequest->getOrganizationId() !== $access->getOrganization()->getId()) {
+            throw new \RuntimeException('Forbidden');
+        }
+
+        $helloAssoEntity = $this->helloAssoService->connect(
+            $connectionRequest->getOrganizationId(),
+            $connectionRequest->getAuthorizationCode()
+        );
+
+        $helloAssoProfile = $this->helloAssoService->makeHelloAssoProfile($connectionRequest->getOrganizationId());
+
+        try {
+            $this->mercureHub->publishUpdate($access->getId(), $helloAssoProfile);
+        } catch (\Exception $e) {
+            $this->logger->error('Error while sending mercure update : ' . $e->getMessage());
+        }
+
+        return $connectionRequest;
+    }
+}

+ 58 - 0
src/State/Processor/HelloAsso/UnlinkRequestProcessor.php

@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\HelloAsso;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\ApiResources\HelloAsso\UnlinkRequest;
+use App\Entity\Access\Access;
+use App\Service\HelloAsso\HelloAssoService;
+use App\Service\MercureHub;
+use http\Client\Response;
+use Symfony\Bundle\SecurityBundle\Security;
+
+/**
+ * Processor pour la ressource ConnectionRequest.
+ */
+class UnlinkRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly HelloAssoService $helloAssoService,
+        private Security                  $security,
+        private MercureHub                $mercureHub,
+    ) {
+    }
+
+    /**
+     * @param UnlinkRequest $data
+     * @param mixed[]           $uriVariables
+     * @param mixed[]           $context
+     *
+     * @throws \Exception
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): UnlinkRequest
+    {
+        /**
+         * @var UnlinkRequest $unlinkRequest
+         */
+        $unlinkRequest = $data;
+
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        /** @var Access $access */
+        $access = $this->security->getUser();
+
+        if ($unlinkRequest->getOrganizationId() !== $access->getOrganization()->getId()) {
+            throw new \RuntimeException('Forbidden: ' . $unlinkRequest->getOrganizationId() . ' !== ' . $access->getOrganization()->getId());
+        }
+
+        $this->helloAssoService->unlinkHelloAssoAccount($unlinkRequest->getOrganizationId());
+
+        return $unlinkRequest;
+    }
+}

+ 46 - 0
src/State/Provider/HelloAsso/AuthUrlProvider.php

@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\HelloAsso;
+
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\ApiResources\HelloAsso\AuthUrl;
+use App\Entity\Access\Access;
+use App\Service\HelloAsso\HelloAssoService;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Provider pour la ressource AuthUrl HelloAsso.
+ */
+final class AuthUrlProvider implements ProviderInterface
+{
+    public function __construct(
+        private HelloAssoService $helloAssoService,
+        private Security         $security,
+    ) {
+    }
+
+    /**
+     * @param mixed[] $uriVariables
+     * @param mixed[] $context
+     *
+     * @throws \Exception
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?AuthUrl
+    {
+        if ($operation instanceof GetCollection) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        /** @var Access $access */
+        $access = $this->security->getUser();
+
+        $organizationId = $access->getOrganization()->getId();
+
+        return $this->helloAssoService->getAuthUrl($organizationId);
+    }
+}

+ 55 - 0
src/State/Provider/HelloAsso/EventFormProvider.php

@@ -0,0 +1,55 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\HelloAsso;
+
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\ApiResources\HelloAsso\AuthUrl;
+use App\ApiResources\HelloAsso\HelloAssoProfile;
+use App\ApiResources\HelloAsso\EventForm;
+use App\Entity\Access\Access;
+use App\Service\HelloAsso\HelloAssoService;
+use Doctrine\Common\Collections\ArrayCollection;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Provider pour la ressource HelloAssoProfile.
+ */
+final class EventFormProvider implements ProviderInterface
+{
+    public function __construct(
+        private HelloAssoService $helloAssoService,
+        private Security         $security,
+    ) {
+    }
+
+    /**
+     * @param mixed[] $uriVariables
+     * @param mixed[] $context
+     *
+     * @throws \Exception
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): ArrayCollection | EventForm
+    {
+        if ($operation instanceof Get && isset($uriVariables['eventId'])) {
+            // This is a public endpoint
+            return $this->helloAssoService->getHelloAssoEventFormByEventId($uriVariables['eventId']);
+        }
+
+        /** @var Access $access */
+        $access = $this->security->getUser();
+        $organizationId = $access->getOrganization()->getId();
+
+        if ($operation instanceof GetCollection) {
+            $forms = $this->helloAssoService->getHelloAssoEventForms($organizationId);
+            return new ArrayCollection($forms);
+        }
+
+        return $this->helloAssoService->getHelloAssoEventForm($organizationId, $uriVariables['slug']);
+    }
+}

+ 47 - 0
src/State/Provider/HelloAsso/HelloAssoProfileProvider.php

@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\HelloAsso;
+
+use ApiPlatform\Metadata\GetCollection;
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\ApiResources\HelloAsso\AuthUrl;
+use App\ApiResources\HelloAsso\HelloAssoProfile;
+use App\Entity\Access\Access;
+use App\Service\HelloAsso\HelloAssoService;
+use Symfony\Bundle\SecurityBundle\Security;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Provider pour la ressource HelloAssoProfile.
+ */
+final class HelloAssoProfileProvider implements ProviderInterface
+{
+    public function __construct(
+        private HelloAssoService $helloAssoService,
+        private Security         $security,
+    ) {
+    }
+
+    /**
+     * @param mixed[] $uriVariables
+     * @param mixed[] $context
+     *
+     * @throws \Exception
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): HelloAssoProfile
+    {
+        if ($operation instanceof GetCollection) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        /** @var Access $access */
+        $access = $this->security->getUser();
+
+        $organizationId = $access->getOrganization()->getId();
+
+        return $this->helloAssoService->makeHelloAssoProfile($organizationId);
+    }
+}