浏览代码

add HelloAsso entity and env variables

Olivier Massot 3 月之前
父节点
当前提交
e2f0dee631

+ 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.com
+HELLOASSO_CLIENT_ID=0c26ff8fc8434715a1520b1ff6debb5e
+HELLOASSO_AUTHORIZE_URL=https://auth.helloasso-sandbox.com/authorize
+###

+ 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,
 ];

+ 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

+ 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 ###

+ 6 - 0
env/.env.prod

@@ -13,6 +13,7 @@ PUBLIC_API_LEG_BASE_URL=https://api.opentalent.fr/api
 ###> url v2 ###
 API_BASE_URL=https://ap2i.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.opentalent.fr/api
+PUBLIC_APP_BASE_URL=https://my.opentalent.fr
 ###
 
 ###> typo3 client ###
@@ -42,3 +43,8 @@ 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_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/api
 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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test1.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test1.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test2.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test2.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test3.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test3.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test4.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test4.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test5.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test5.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test6.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test6.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test7.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test7.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test8.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test8.opentalent.fr/api
+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
 ###> api v2 ###
 API_BASE_URL=https://ap2i.test9.opentalent.fr/api
 PUBLIC_API_BASE_URL=https://ap2i.test9.opentalent.fr/api
+PUBLIC_APP_BASE_URL=https://my.test9.opentalent.fr
 ###< api v2 ###
 
 ###> typo3 client ###

+ 92 - 0
src/Entity/Organization/HelloAsso.php

@@ -0,0 +1,92 @@
+<?php
+
+namespace App\Entity\Organization;
+
+use App\Entity\Traits\CreatedOnAndByTrait;
+use Doctrine\ORM\Mapping as ORM;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * HelloAsso entity for storing HelloAsso connection information
+ *
+ * @see https://dev.helloasso.com/docs/mire-authorisation
+ */
+#[ORM\Entity]
+#[ORM\Table]
+class HelloAsso
+{
+    use CreatedOnAndByTrait;
+
+    #[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;
+
+    #[ORM\Column(type: "text", nullable: true)]
+    private ?string $token = null;
+
+    #[ORM\Column(type: "text", nullable: true)]
+    private ?string $refreshToken = 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 getToken(): ?string
+    {
+        return $this->token;
+    }
+
+    public function setToken(?string $token): self
+    {
+        $this->token = $token;
+        return $this;
+    }
+
+    public function getRefreshToken(): ?string
+    {
+        return $this->refreshToken;
+    }
+
+    public function setRefreshToken(?string $refreshToken): self
+    {
+        $this->refreshToken = $refreshToken;
+        return $this;
+    }
+
+    public function getOrganizationSlug(): ?string
+    {
+        return $this->organizationSlug;
+    }
+
+    public function setOrganizationSlug(?string $organizationSlug): self
+    {
+        $this->organizationSlug = $organizationSlug;
+        return $this;
+    }
+}

+ 16 - 0
src/Entity/Organization/Organization.php

@@ -38,6 +38,7 @@ use App\Entity\Message\Mail;
 use App\Entity\Message\Sms;
 use App\Entity\Network\NetworkOrganization;
 use App\Entity\Organization\Traits\OrganizationComputedTraits;
+use App\Entity\Organization\HelloAsso;
 use App\Entity\Person\Commission;
 use App\Entity\Place\Place;
 use App\Entity\Product\Equipment;
@@ -409,6 +410,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 +2094,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;

+ 295 - 0
src/Service/HelloAsso/ConnectionService.php

@@ -0,0 +1,295 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Service\HelloAsso;
+
+use App\Entity\Organization\HelloAsso;
+use App\Entity\Organization\Organization;
+use App\Service\Rest\ApiRequestService;
+use App\Service\Utils\UrlBuilder;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Contracts\HttpClient\HttpClientInterface;
+use Symfony\Contracts\HttpClient\ResponseInterface;
+use Symfony\Component\HttpKernel\Exception\HttpException;
+
+
+/**
+ * Service de connexion à HelloAsso.
+ *
+ * @see https://dev.helloasso.com/docs/mire-authorisation
+ */
+class ConnectionService extends ApiRequestService
+{
+    public function __construct(
+        HttpClientInterface                     $client,
+        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,
+    ) {
+        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 https://dev.helloasso.com/docs/mire-authorisation#2-enregistrement-de-votre-domaine-de-redirection
+     * @see https://dev.helloasso.com/reference/put_partners-me-api-clients
+     *
+     * @return void
+     */
+    public function setupOpentalentDomain(): void {
+        $accessToken = $this->fetchAccessToken();
+        $this->updateDomain($accessToken, $this->publicAppBaseUrl);
+    }
+
+    /**
+     * Establishes a connection for a specific organization with HelloAsso.
+     *
+     * @param int $organizationId The ID of the organization to connect.
+     *
+     * @return string The authorization URL for user authentication.
+     *
+     * @throws \RuntimeException If the organization is not found or if any connection step fails.
+     */
+    public function connect(int $organizationId): string
+    {
+        $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
+
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+
+        // Étape 1 : Obtenir le Bearer token
+        $accessToken = $this->fetchAccessToken();
+
+        // Étape 2 : Déclarer le nom de domaine (utile après la première fois?)
+        $this->updateDomain($accessToken);
+
+        // Étape 3 : Préparer la mire d'autorisation
+        $authData = $this->setupAuthorizationScreen();
+
+        return $authData['authorization_url'];
+    }
+
+    /**
+     * Handles the callback process for an organization by exchanging an authorization code for tokens,
+     * updating or creating associated HelloAsso entity, and persisting the changes to the database.
+     *
+     * @param int $organizationId The ID of the organization to process.
+     * @param string $authorizationCode The authorization code used to retrieve tokens.
+     *
+     * @throws \RuntimeException If the organization is not found.
+     */
+    public function callback(int $organizationId, string $authorizationCode): void
+    {
+        $organization = $this->entityManager->getRepository(Organization::class)->find($organizationId);
+
+        if (!$organization) {
+            throw new \RuntimeException('Organization not found');
+        }
+
+        try {
+            // Échanger le code d'autorisation contre des tokens
+            $tokenData = $this->handleClientAuthentication($authorizationCode);
+
+            // Créer ou mettre à jour l'entité HelloAsso
+            $helloAssoEntity = $organization->getHelloAsso();
+            if (!$helloAssoEntity) {
+                $helloAssoEntity = new HelloAsso();
+                $helloAssoEntity->setOrganization($organization);
+            }
+
+            $helloAssoEntity->setToken($tokenData['access_token']);
+            $helloAssoEntity->setRefreshToken($tokenData['refresh_token']);
+
+            // Récupérer l'organizationSlug depuis l'API HelloAsso
+            $organizationSlug = $this->getOrganizationSlugFromApi($tokenData['access_token']);
+            $helloAssoEntity->setOrganizationSlug($organizationSlug);
+
+            $this->entityManager->persist($helloAssoEntity);
+            $this->entityManager->flush();
+
+        } catch (\Exception $e) {
+            $this->addFlash('error', 'Erreur lors de la liaison : ' . $e->getMessage());
+        }
+
+    }
+
+    /**
+     * Connects to HelloAsso OAuth2 token endpoint to get access token
+     *
+     * On attend une réponse de l'API qui soit de la forme :
+     *
+     *     {
+     *         "access_token": "****",
+     *         "expires_in": 1800,
+     *         "refresh_token": "****",
+     *         "token_type": "bearer"
+     *     }
+     *
+     * @throws HttpException
+     */
+    protected function fetchAccessToken(): string
+    {
+        $body = [
+            'grant_type' => 'client_credentials',
+            'client_id' => $this->helloAssoClientId,
+            'client_secret' => $this->helloAssoClientSecret,
+        ];
+
+        $options = [
+            'headers' => [
+                'Content-Type' => 'application/x-www-form-urlencoded',
+            ],
+            'body' => http_build_query($body),
+        ];
+
+        $response = $this->post($this->getApiConnectionUrl(), null, [], $options);
+
+        try {
+            $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR);
+
+            if (!isset($data['access_token'])) {
+                throw new HttpException(400, 'Access token not found in response');
+            }
+
+            return $data['access_token'];
+        } catch (\JsonException $e) {
+            throw new HttpException(500, 'Failed to parse token 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());
+        }
+    }
+
+    /**
+     * Sets up the connection screen for HelloAsso OAuth2 flow
+     */
+    public function setupConnectionScreen(int $clientId): array
+    {
+        $authUrl = $this->buildAuthorizationUrl();
+
+        return [
+            'authorization_url' => $authUrl,
+            'domain' => $this->baseUrl,
+            'client_id' => $this->helloAssoClientId,
+        ];
+    }
+
+    /**
+     * Sets up the authorization screen for user authentication
+     */
+    public function setupAuthorizationScreen(): array
+    {
+        $authUrl = $this->buildAuthorizationUrl($this->helloAssoClientId);
+
+        return [
+            'authorization_url' => $authUrl,
+            'scopes' => 'api',
+            'redirect_uri' => $this->buildRedirectUri(),
+        ];
+    }
+
+    /**
+     * Handles client authentication with HelloAsso login/password
+     */
+    public function handleClientAuthentication(string $authorizationCode): array
+    {
+        if (!$this->bearerToken) {
+            throw new HttpException(401, 'Bearer token required. Please authenticate first.');
+        }
+
+        $body = [
+            'grant_type' => 'authorization_code',
+            'client_id' => $this->helloAssoClientId,
+            'client_secret' => $this->helloAssoClientSecret,
+            'code' => $authorizationCode,
+            'redirect_uri' => $this->buildRedirectUri(),
+        ];
+
+        $options = [
+            'headers' => [
+                'Content-Type' => 'application/x-www-form-urlencoded',
+                'Authorization' => 'Bearer ' . $this->bearerToken,
+            ],
+            'body' => http_build_query($body),
+        ];
+
+        $response = $this->client->request('POST', self::HELLOASSO_API_BASE_URL . self::TOKEN_ENDPOINT, $options);
+
+        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,
+            ];
+        } catch (\JsonException $e) {
+            throw new HttpException(500, 'Failed to parse authentication response: ' . $e->getMessage(), $e);
+        }
+    }
+
+
+    protected function getApiConnectionUrl(): string
+    {
+        return UrlBuilder::concat($this->helloAssoApiBaseUrl, ['/oauth2/token']);
+    }
+
+    /**
+     * Builds the redirect URI based on the domain
+     */
+    private function buildRedirectUri(string $domainName): string
+    {
+        return UrlBuilder::concat($this->baseUrl, ['helloasso/callback']);
+    }
+
+    /**
+     * Builds the authorization URL for HelloAsso OAuth2 flow
+     */
+    private function buildAuthorizationUrl(string $clientId): string
+    {
+        $params = [
+            'response_type' => 'code',
+            'client_id' => $clientId,
+            'redirect_uri' => $this->buildRedirectUri(),
+            'scope' => implode(' ', ['api']),
+            'state' => $this->generateState(),
+        ];
+
+        return UrlBuilder::concat(['authorize'], $params);
+    }
+
+    /**
+     * Generates a random state parameter for OAuth2 security
+     */
+    private function generateState(): string
+    {
+        return bin2hex(random_bytes(16));
+    }
+}