Parcourir la source

add the ShopService and associated resources

Olivier Massot il y a 6 mois
Parent
commit
23d26f656b

+ 0 - 0
src/ApiResource/.gitignore


+ 298 - 0
src/ApiResources/Shop/NewStructureArtistPremiumTrialRequest.php

@@ -0,0 +1,298 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\ApiResources\Shop;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Post;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
+use App\State\Processor\Shop\NewStructureArtistPremiumTrialRequestProcessor;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * New structure trial request for the artist premium product.
+ */
+#[ApiResource(
+    operations: [
+        new Post(processor: NewStructureArtistPremiumTrialRequestProcessor::class),
+    ]
+)]
+class NewStructureArtistPremiumTrialRequest
+{
+    /**
+     * Id 'bidon' ajouté par défaut pour permettre la construction
+     * de l'IRI par api platform.
+     */
+    #[ApiProperty(identifier: true)]
+    private int $id = 0;
+
+    #[Assert\NotBlank]
+    private string $structureName;
+
+    #[Assert\NotBlank]
+    private string $address;
+
+    private ?string $addressComplement = null;
+
+    #[Assert\NotBlank]
+    private string $postalCode;
+
+    #[Assert\NotBlank]
+    private string $city;
+
+    #[Assert\NotBlank]
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private string $structureEmail;
+
+    #[Assert\NotBlank]
+    private PrincipalTypeEnum $structureType;
+
+    #[Assert\NotBlank]
+    private LegalEnum $legalStatus;
+
+    #[Assert\NotBlank]
+    #[Assert\Length(min: 9, max: 9)]
+    private string $siren;
+
+    #[Assert\NotBlank]
+    private string $representativeFirstName;
+
+    #[Assert\NotBlank]
+    private string $representativeLastName;
+
+    #[Assert\NotBlank]
+    private string $representativeFunction;
+
+    #[Assert\NotBlank]
+    #[Assert\Email(message: 'The email {{ value }} is not a valid email.')]
+    private string $representativeEmail;
+
+    #[Assert\NotBlank]
+    private string $representativePhone;
+
+    #[Assert\IsTrue(message: 'terms-must-be-accepted')]
+    private bool $termsAccepted = false;
+
+    private bool $legalRepresentative = false;
+
+    private bool $newsletterSubscription = false;
+
+    public function getId(): int
+    {
+        return $this->id;
+    }
+
+    public function setId(int $id): self
+    {
+        $this->id = $id;
+
+        return $this;
+    }
+
+    public function getStructureName(): string
+    {
+        return $this->structureName;
+    }
+
+    public function setStructureName(string $structureName): self
+    {
+        $this->structureName = $structureName;
+
+        return $this;
+    }
+
+    public function getAddress(): string
+    {
+        return $this->address;
+    }
+
+    public function setAddress(string $address): self
+    {
+        $this->address = $address;
+
+        return $this;
+    }
+
+    public function getAddressComplement(): ?string
+    {
+        return $this->addressComplement;
+    }
+
+    public function setAddressComplement(?string $addressComplement): self
+    {
+        $this->addressComplement = $addressComplement;
+
+        return $this;
+    }
+
+    public function getPostalCode(): string
+    {
+        return $this->postalCode;
+    }
+
+    public function setPostalCode(string $postalCode): self
+    {
+        $this->postalCode = $postalCode;
+
+        return $this;
+    }
+
+    public function getCity(): string
+    {
+        return $this->city;
+    }
+
+    public function setCity(string $city): self
+    {
+        $this->city = $city;
+
+        return $this;
+    }
+
+    public function getStructureEmail(): string
+    {
+        return $this->structureEmail;
+    }
+
+    public function setStructureEmail(string $structureEmail): self
+    {
+        $this->structureEmail = $structureEmail;
+
+        return $this;
+    }
+
+    public function getStructureType(): PrincipalTypeEnum
+    {
+        return $this->structureType;
+    }
+
+    public function setStructureType(PrincipalTypeEnum $structureType): self
+    {
+        $this->structureType = $structureType;
+
+        return $this;
+    }
+
+    public function getLegalStatus(): LegalEnum
+    {
+        return $this->legalStatus;
+    }
+
+    public function setLegalStatus(LegalEnum $legalStatus): self
+    {
+        $this->legalStatus = $legalStatus;
+
+        return $this;
+    }
+
+    public function getSiren(): string
+    {
+        return $this->siren;
+    }
+
+    public function setSiren(string $siren): self
+    {
+        $this->siren = $siren;
+
+        return $this;
+    }
+
+    public function getRepresentativeFirstName(): string
+    {
+        return $this->representativeFirstName;
+    }
+
+    public function setRepresentativeFirstName(string $representativeFirstName): self
+    {
+        $this->representativeFirstName = $representativeFirstName;
+
+        return $this;
+    }
+
+    public function getRepresentativeLastName(): string
+    {
+        return $this->representativeLastName;
+    }
+
+    public function setRepresentativeLastName(string $representativeLastName): self
+    {
+        $this->representativeLastName = $representativeLastName;
+
+        return $this;
+    }
+
+    public function getRepresentativeFunction(): string
+    {
+        return $this->representativeFunction;
+    }
+
+    public function setRepresentativeFunction(string $representativeFunction): self
+    {
+        $this->representativeFunction = $representativeFunction;
+
+        return $this;
+    }
+
+    public function getRepresentativeEmail(): string
+    {
+        return $this->representativeEmail;
+    }
+
+    public function setRepresentativeEmail(string $representativeEmail): self
+    {
+        $this->representativeEmail = $representativeEmail;
+
+        return $this;
+    }
+
+    public function getRepresentativePhone(): string
+    {
+        return $this->representativePhone;
+    }
+
+    public function setRepresentativePhone(string $representativePhone): self
+    {
+        $this->representativePhone = $representativePhone;
+
+        return $this;
+    }
+
+    public function getTermsAccepted(): bool
+    {
+        return $this->termsAccepted;
+    }
+
+    public function setTermsAccepted(bool $termsAccepted): self
+    {
+        $this->termsAccepted = $termsAccepted;
+
+        return $this;
+    }
+
+    public function getLegalRepresentative(): bool
+    {
+        return $this->legalRepresentative;
+    }
+
+    public function setLegalRepresentative(bool $legalRepresentative): self
+    {
+        $this->legalRepresentative = $legalRepresentative;
+
+        return $this;
+    }
+
+    public function getNewsletterSubscription(): bool
+    {
+        return $this->newsletterSubscription;
+    }
+
+    public function setNewsletterSubscription(bool $newsletterSubscription): self
+    {
+        $this->newsletterSubscription = $newsletterSubscription;
+
+        return $this;
+    }
+}

+ 1 - 1
src/Entity/Message/AbstractMessage.php

@@ -90,7 +90,7 @@ abstract class AbstractMessage
 
     public function __construct()
     {
-        $this->uuid = Uuid::uuid4();
+        $this->uuid = Uuid::uuid4()->toRfc4122();
         $this->files = new ArrayCollection();
         $this->tags = new ArrayCollection();
         $this->reportMessage = new ArrayCollection();

+ 107 - 0
src/Entity/Shop/ShopRequest.php

@@ -0,0 +1,107 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Entity\Shop;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\Get;
+use ApiPlatform\Metadata\Post;
+use App\Enum\Shop\NewStructureTrialRequestStatusEnum;
+use App\Enum\Shop\ShopRequestType;
+use App\State\Processor\Shop\NewStructureArtistPremiumTrialRequestProcessor;
+use App\State\Provider\Shop\NewStructureTrialRequestProvider;
+use Doctrine\ORM\Mapping as ORM;
+use Doctrine\DBAL\Types\Types;
+use DateTimeImmutable;
+use Symfony\Component\Validator\Constraints as Assert;
+
+/**
+ * Une demande effectuée par un client via la boutique en ligne (ex: demande d'essai premium)
+ */
+#[ApiResource(operations: [])]
+#[ORM\Entity]
+class ShopRequest
+{
+    #[ORM\Id]
+    #[ORM\Column(type: 'guid')]
+    private string $token;
+
+    #[ORM\Column(type: 'datetime_immutable')]
+    private DateTimeImmutable $submissionDate;
+
+    #[ORM\Column(length: 50, enumType: NewStructureTrialRequestStatusEnum::class)]
+    private NewStructureTrialRequestStatusEnum $status;
+
+    #[ORM\Column(length: 50, enumType: ShopRequestType::class)]
+    private ShopRequestType $type;
+
+    #[ORM\Column(type: Types::JSON)]
+    private array $data = [];
+
+    public function __construct()
+    {
+        $this->submissionDate = new DateTimeImmutable();
+        $this->status = NewStructureTrialRequestStatusEnum::PENDING;
+    }
+
+    public function getToken(): string
+    {
+        return $this->token;
+    }
+
+    public function setToken(string $token): self
+    {
+        $this->token = $token;
+
+        return $this;
+    }
+
+    public function getSubmissionDate(): DateTimeImmutable
+    {
+        return $this->submissionDate;
+    }
+
+    public function setSubmissionDate(DateTimeImmutable $submissionDate): self
+    {
+        $this->submissionDate = $submissionDate;
+
+        return $this;
+    }
+
+    public function getStatus(): ?NewStructureTrialRequestStatusEnum
+    {
+        return $this->status;
+    }
+
+    public function setStatus(?NewStructureTrialRequestStatusEnum $status): self
+    {
+        $this->status = $status;
+
+        return $this;
+    }
+
+    public function getType(): ShopRequestType
+    {
+        return $this->type;
+    }
+
+    public function setType(ShopRequestType $type): self
+    {
+        $this->type = $type;
+
+        return $this;
+    }
+
+    public function getData(): array
+    {
+        return $this->data;
+    }
+
+    public function setData(array $data): self
+    {
+        $this->data = $data;
+
+        return $this;
+    }
+}

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

@@ -13,6 +13,7 @@ enum SettingsProductEnum: string
 {
     use EnumMethodsTrait;
 
+    case FREEMIUM = 'freemium';
     case ARTIST = 'artist';
     case ARTIST_PREMIUM = 'artist-premium';
     case SCHOOL = 'school';

+ 21 - 0
src/Enum/Shop/NewStructureTrialRequestStatusEnum.php

@@ -0,0 +1,21 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Shop;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Statuts des demandes d'essai de nouvelle structure.
+ */
+enum NewStructureTrialRequestStatusEnum: string
+{
+    use EnumMethodsTrait;
+
+    case PENDING = 'PENDING';
+    case ACTIVATION_LINK_SENT = 'ACTIVATION_LINK_SENT';
+    case VALIDATED = 'VALIDATED';
+    case COMPLETED = 'COMPLETED';
+    case ERROR = 'ERROR';
+}

+ 17 - 0
src/Enum/Shop/ShopRequestType.php

@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Enum\Shop;
+
+use App\Enum\EnumMethodsTrait;
+
+/**
+ * Type of shop request.
+ */
+enum ShopRequestType: string
+{
+    use EnumMethodsTrait;
+
+    case NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL = 'NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL';
+}

+ 109 - 0
src/Message/Handler/Shop/NewStructureArtistPremiumTrialHandler.php

@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Handler\Shop;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Entity\Shop\ShopRequest;
+use App\Enum\Organization\LegalEnum;
+use App\Enum\Organization\PrincipalTypeEnum;
+use App\Enum\Organization\SettingsProductEnum;
+use App\Message\Message\Shop\NewStructureArtistPremiumTrial;
+use App\Service\Organization\OrganizationFactory;
+use App\Service\Shop\ShopService;
+use Doctrine\ORM\EntityManagerInterface;
+use Psr\Log\LoggerInterface;
+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
+use Symfony\Component\Serializer\SerializerInterface;
+
+#[AsMessageHandler]
+class NewStructureArtistPremiumTrialHandler
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly OrganizationFactory $organizationFactory,
+        private readonly ShopService $shopService,
+        private readonly SerializerInterface $serializer,
+        private readonly LoggerInterface $logger
+    ) {
+    }
+
+    public function __invoke(NewStructureArtistPremiumTrial $message): void
+    {
+        $token = $message->getToken();
+
+        // Retrieve the ShopRequest entity using its token
+        $shopRequest = $this->entityManager->find(ShopRequest::class, $token);
+
+        if (!$shopRequest) {
+            $this->logger->error('Cannot find ShopRequest with token: ' . $token);
+            return;
+        }
+
+        try {
+            // Convert the stored JSON data to a NewStructureArtistPremiumTrialRequest object
+            $data = $shopRequest->getData();
+            $trialRequest = $this->serializer->deserialize(
+                json_encode($data),
+                NewStructureArtistPremiumTrialRequest::class,
+                'json'
+            );
+
+            // Generate an OrganizationCreationRequest object
+            $organizationCreationRequest = new OrganizationCreationRequest();
+            $organizationCreationRequest->setName($trialRequest->getStructureName());
+            $organizationCreationRequest->setStreetAddress1($trialRequest->getAddress());
+            $organizationCreationRequest->setStreetAddress2($trialRequest->getAddressComplement());
+            $organizationCreationRequest->setPostalCode($trialRequest->getPostalCode());
+            $organizationCreationRequest->setCity($trialRequest->getCity());
+            $organizationCreationRequest->setEmail($trialRequest->getStructureEmail());
+            $organizationCreationRequest->setPrincipalType($trialRequest->getStructureType());
+            $organizationCreationRequest->setLegalStatus($trialRequest->getLegalStatus());
+            $organizationCreationRequest->setSiretNumber($trialRequest->getSiren());
+
+            // Generate a subdomain from the structure name
+            $subdomain = $this->generateSubdomain($trialRequest->getStructureName());
+            $organizationCreationRequest->setSubdomain($subdomain);
+
+            // Set default values
+            $organizationCreationRequest->setProduct(SettingsProductEnum::ARTIST_PREMIUM);
+            $organizationCreationRequest->setCountryId(41); // Default country ID
+            $organizationCreationRequest->setParentId(1); // Default parent ID
+            $organizationCreationRequest->setCreateWebsite(true);
+            $organizationCreationRequest->setCreationDate(new \DateTime());
+
+            // Create the organization
+            $organization = $this->organizationFactory->create($organizationCreationRequest);
+
+            // Start the artist premium trial
+            $this->shopService->startArtistPremiumTrial($organization, $trialRequest);
+
+            $this->logger->info('Successfully processed NewStructureArtistPremiumTrial for token: ' . $token);
+        } catch (\Exception $e) {
+            $this->logger->error('Error processing NewStructureArtistPremiumTrial: ' . $e->getMessage());
+        }
+    }
+
+    /**
+     * Generate a subdomain from a structure name.
+     */
+    private function generateSubdomain(string $name): string
+    {
+        // Remove accents and special characters
+        $subdomain = iconv('UTF-8', 'ASCII//TRANSLIT', $name);
+        // Replace spaces and special characters with hyphens
+        $subdomain = preg_replace('/[^a-zA-Z0-9]/', '-', $subdomain);
+        // Convert to lowercase
+        $subdomain = strtolower($subdomain);
+        // Remove consecutive hyphens
+        $subdomain = preg_replace('/-+/', '-', $subdomain);
+        // Trim hyphens from beginning and end
+        $subdomain = trim($subdomain, '-');
+        // Limit length
+        $subdomain = substr($subdomain, 0, 30);
+
+        return $subdomain;
+    }
+}

+ 26 - 0
src/Message/Message/Shop/NewStructureArtistPremiumTrial.php

@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Message\Message\Shop;
+
+/**
+ * Message for processing a new structure artist premium trial request.
+ */
+class NewStructureArtistPremiumTrial
+{
+    public function __construct(
+        private string $token,
+    ) {
+    }
+
+    public function getToken(): string
+    {
+        return $this->token;
+    }
+
+    public function setToken(string $token): void
+    {
+        $this->token = $token;
+    }
+}

+ 89 - 0
src/MessageHandler/StartArtistPremiumTrialHandler.php

@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\MessageHandler;
+
+use App\Message\StartArtistPremiumTrial;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use Symfony\Component\Messenger\Attribute\AsMessageHandler;
+use Symfony\Component\HttpFoundation\Response;
+use Psr\Log\LoggerInterface;
+
+#[AsMessageHandler]
+class StartArtistPremiumTrialHandler
+{
+    public function __construct(
+        private readonly ApiLegacyRequestService $apiLegacyRequestService,
+        private readonly LoggerInterface $logger,
+        private readonly string $legacyBaseUrl
+    ) {
+    }
+
+    public function __invoke(StartArtistPremiumTrial $message): void
+    {
+        $organization = $message->getOrganization();
+        $representative = $message->getRepresentative();
+        $person = $representative->getPerson();
+
+        // Get the organization's address
+        $organizationAddressPostal = null;
+        foreach ($organization->getOrganizationAddressPostals() as $addressPostal) {
+            $organizationAddressPostal = $addressPostal;
+            break; // Get the first one
+        }
+
+        if (!$organizationAddressPostal) {
+            $this->logger->error('Cannot start artist premium trial: organization has no address');
+            return;
+        }
+
+        $addressPostal = $organizationAddressPostal->getAddressPostal();
+
+        // Get the representative's contact point
+        $contactPoint = null;
+        foreach ($person->getContactPoints() as $cp) {
+            $contactPoint = $cp;
+            break; // Get the first one
+        }
+
+        if (!$contactPoint) {
+            $this->logger->error('Cannot start artist premium trial: representative has no contact point');
+            return;
+        }
+
+        // Prepare the request data
+        $data = [
+            "organization" => [
+                "streetAddress" => $addressPostal->getStreetAddress(),
+                "streetAddressSecond" => $addressPostal->getStreetAddressSecond(),
+                "streetAddressThird" => $addressPostal->getStreetAddressThird(),
+                "cp" => $addressPostal->getPostalCode(),
+                "city" => $addressPostal->getAddressCity(),
+                "organizationAddressPostalId" => "/api/organization_address_postals/" . $organizationAddressPostal->getId(),
+                "name" => $organization->getName()
+            ],
+            "access" => [
+                "isAdmin" => $representative->isAdminAccess(),
+                "email" => $contactPoint->getEmail(),
+                "contactPointId" => "/api/contactpoints/" . $contactPoint->getId(),
+                "name" => $person->getName(),
+                "givenName" => $person->getGivenName(),
+                "function" => "representative",
+                "telphone" => (string)$contactPoint->getTelphone()
+            ]
+        ];
+
+        try {
+            $response = $this->apiLegacyRequestService->get('/api/trial/artist_premium', $data);
+
+            if ($response->getStatusCode() !== Response::HTTP_OK) {
+                $this->logger->error('Failed to start artist premium trial: ' . $response->getContent());
+            } else {
+                $this->logger->info('Successfully started artist premium trial for organization ' . $organization->getId());
+            }
+        } catch (\Exception $e) {
+            $this->logger->error('Exception while starting artist premium trial: ' . $e->getMessage());
+        }
+    }
+}

+ 59 - 0
src/Service/Mailer/Builder/NewStructureTrialRequestValidationBuilder.php

@@ -0,0 +1,59 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Builder;
+
+use App\Entity\Access\Access;
+use App\Enum\Core\EmailSendingTypeEnum;
+use App\Service\Mailer\Email;
+use App\Service\Mailer\Model\MailerModelInterface;
+use App\Service\Mailer\Model\NewStructureTrialRequestValidationModel;
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\ORM\EntityManagerInterface;
+
+/**
+ * Classe NewStructureTrialRequestValidationBuilder qui est chargé de construire l'Email de validation d'une demande d'essai de nouvelle structure.
+ */
+class NewStructureTrialRequestValidationBuilder extends AbstractBuilder implements BuilderInterface
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly string $opentalentNoReplyEmailAddress,
+    ) {
+    }
+
+    public function support(MailerModelInterface $mailerModel): bool
+    {
+        return $mailerModel instanceof NewStructureTrialRequestValidationModel;
+    }
+
+    /**
+     * @param NewStructureTrialRequestValidationModel $mailerModel
+     */
+    public function build(MailerModelInterface $mailerModel): ArrayCollection
+    {
+        $author = $this->entityManager->getRepository(Access::class)->find($mailerModel->getSenderId());
+
+        $context = [
+            'token' => $mailerModel->getToken(),
+            'representativeFirstName' => $mailerModel->getRepresentativeFirstName(),
+            'representativeLastName' => $mailerModel->getRepresentativeLastName(),
+            'structureName' => $mailerModel->getStructureName(),
+            'validationUrl' => $mailerModel->getValidationUrl(),
+        ];
+        $content = $this->render('shop/new-structure-validation', $context);
+
+        $email = (new Email())
+            ->setEmailEntity($this->buildEmailEntity('Validation de votre demande d\'essai', $author, $content))
+            ->setContent($content)
+            ->setFrom($this->opentalentNoReplyEmailAddress)
+            ->setFromName('OpenTalent')
+        ;
+
+        // Add recipient as a string (direct email address)
+        $this->addRecipient($email, $mailerModel->getRepresentativeEmail(), EmailSendingTypeEnum::TO);
+
+        return new ArrayCollection([$email]);
+    }
+}

+ 90 - 0
src/Service/Mailer/Model/NewStructureTrialRequestValidationModel.php

@@ -0,0 +1,90 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Mailer\Model;
+
+/**
+ * Classe NewStructureTrialRequestValidationModel qui conserve les données pour construire le mail de validation d'une demande d'essai de nouvelle structure.
+ */
+class NewStructureTrialRequestValidationModel extends AbstractMailerModel implements MailerModelInterface
+{
+    private string $token;
+    private string $representativeEmail;
+    private string $representativeFirstName;
+    private string $representativeLastName;
+    private string $structureName;
+    private string $validationUrl;
+
+    public function getToken(): string
+    {
+        return $this->token;
+    }
+
+    public function setToken(string $token): self
+    {
+        $this->token = $token;
+
+        return $this;
+    }
+
+    public function getRepresentativeEmail(): string
+    {
+        return $this->representativeEmail;
+    }
+
+    public function setRepresentativeEmail(string $representativeEmail): self
+    {
+        $this->representativeEmail = $representativeEmail;
+
+        return $this;
+    }
+
+    public function getRepresentativeFirstName(): string
+    {
+        return $this->representativeFirstName;
+    }
+
+    public function setRepresentativeFirstName(string $representativeFirstName): self
+    {
+        $this->representativeFirstName = $representativeFirstName;
+
+        return $this;
+    }
+
+    public function getRepresentativeLastName(): string
+    {
+        return $this->representativeLastName;
+    }
+
+    public function setRepresentativeLastName(string $representativeLastName): self
+    {
+        $this->representativeLastName = $representativeLastName;
+
+        return $this;
+    }
+
+    public function getStructureName(): string
+    {
+        return $this->structureName;
+    }
+
+    public function setStructureName(string $structureName): self
+    {
+        $this->structureName = $structureName;
+
+        return $this;
+    }
+
+    public function getValidationUrl(): string
+    {
+        return $this->validationUrl;
+    }
+
+    public function setValidationUrl(string $validationUrl): self
+    {
+        $this->validationUrl = $validationUrl;
+
+        return $this;
+    }
+}

+ 1 - 3
src/Service/Organization/OrganizationFactory.php

@@ -41,9 +41,7 @@ use App\Service\Typo3\SubdomainService;
 use App\Service\Typo3\Typo3Service;
 use App\Service\Utils\DatesUtils;
 use App\Service\Utils\SecurityUtils;
-use App\Service\Utils\UrlBuilder;
 use Doctrine\ORM\EntityManagerInterface;
-use Elastica\Param;
 use libphonenumber\NumberParseException;
 use libphonenumber\PhoneNumberUtil;
 use Psr\Log\LoggerInterface;
@@ -190,7 +188,7 @@ class OrganizationFactory
     /**
      * Lève une exception si cette organisation existe déjà.
      */
-    protected function interruptIfOrganizationExists(OrganizationCreationRequest $organizationCreationRequest): void
+    public function interruptIfOrganizationExists(OrganizationCreationRequest $organizationCreationRequest): void
     {
         if (
             $organizationCreationRequest->getSiretNumber()

+ 202 - 0
src/Service/Shop/ShopService.php

@@ -0,0 +1,202 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Service\Shop;
+
+use App\ApiResources\Organization\OrganizationCreationRequest;
+use App\ApiResources\Shop\NewStructureArtistPremiumTrialRequest;
+use App\Entity\Organization\Organization;
+use App\Entity\Shop\ShopRequest;
+use App\Enum\Shop\NewStructureTrialRequestStatusEnum;
+use App\Enum\Shop\ShopRequestType;
+use App\Message\Message\Shop\NewStructureArtistPremiumTrial;
+use App\Service\ApiLegacy\ApiLegacyRequestService;
+use App\Service\Mailer\Mailer;
+use App\Service\Mailer\Model\NewStructureTrialRequestValidationModel;
+use App\Service\Organization\OrganizationFactory;
+use App\Service\Utils\UrlBuilder;
+use Doctrine\ORM\EntityManagerInterface;
+use RuntimeException;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
+use Symfony\Component\Messenger\Exception\ExceptionInterface;
+use Symfony\Component\Messenger\MessageBusInterface;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * Service for managing shop requests.
+ */
+readonly class ShopService
+{
+    public function __construct(
+        private EntityManagerInterface  $entityManager,
+        private Mailer                  $mailer,
+        private string                  $baseUrl,
+        private MessageBusInterface     $messageBus,
+        private ApiLegacyRequestService $apiLegacyRequestService,
+        private OrganizationFactory     $organizationFactory,
+    ) {
+    }
+
+    /**
+     * A new shop request has been submitted.
+     * Register the request, and send the validation link by email.
+     *
+     * @param ShopRequestType $type
+     * @param array<string, mixed> $data
+     * @return ShopRequest
+     * @throws TransportExceptionInterface
+     */
+    public function registerNewShopRequest(ShopRequestType $type, array $data): ShopRequest
+    {
+        $this->validateShopRequest($type, $data);
+        $request = $this->createRequest($type, $data);
+        $this->sendRequestValidationLink($request);
+
+        return $request;
+    }
+
+    /**
+     * Validate the shop request based on its type.
+     * For NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL, check if the organization already exists.
+     * For other types, throw an error.
+     *
+     * @param ShopRequestType $type
+     * @param array<string, mixed> $data
+     */
+    protected function validateShopRequest(ShopRequestType $type, array $data): void
+    {
+        if ($type === ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL) {
+            // Create a minimal OrganizationCreationRequest with required info
+            $organizationCreationRequest = new OrganizationCreationRequest();
+            $organizationCreationRequest->setName($data['structureName'] ?? '');
+            $organizationCreationRequest->setCity($data['city'] ?? '');
+            $organizationCreationRequest->setPostalCode($data['postalCode'] ?? '');
+            $organizationCreationRequest->setStreetAddress1($data['address'] ?? '');
+            $organizationCreationRequest->setStreetAddress2($data['addressComplement'] ?? '');
+            $organizationCreationRequest->setStreetAddress3('');
+
+            // Check if organization already exists
+            $this->organizationFactory->interruptIfOrganizationExists($organizationCreationRequest);
+        } else {
+            throw new RuntimeException('request type not supported');
+        }
+    }
+
+    /**
+     * Validate the request and dispatch the appropriate job based on the request type.
+     *
+     * @throws RuntimeException|ExceptionInterface
+     */
+    public function processShopRequest(ShopRequest $shopRequest): void
+    {
+        $shopRequest->setStatus(NewStructureTrialRequestStatusEnum::VALIDATED);
+        $this->entityManager->persist($shopRequest);
+        $this->entityManager->flush();
+
+        // Dispatch appropriate job based on request type
+        if ($shopRequest->getType() === ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL) {
+            $this->messageBus->dispatch(
+                new NewStructureArtistPremiumTrial($shopRequest->getToken())
+            );
+        } else {
+            throw new RuntimeException('Unknown request type');
+        }
+    }
+
+    /**
+     * Create and persist a new ShopRequest entity.
+     * @param ShopRequestType $type
+     * @param array<string, mixed> $data
+     * @return ShopRequest
+     */
+    protected function createRequest(ShopRequestType $type, array $data): ShopRequest
+    {
+        $shopRequest = new ShopRequest();
+        $shopRequest->setToken(Uuid::v4()->toRfc4122());
+        $shopRequest->setType($type);
+        $shopRequest->setData($data);
+
+        $this->entityManager->persist($shopRequest);
+        $this->entityManager->flush();
+
+        return $shopRequest;
+    }
+
+    /**
+     * Send validation email with link.
+     *
+     * @throws TransportExceptionInterface
+     */
+    protected function sendRequestValidationLink(ShopRequest $shopRequest): void
+    {
+        $validationUrl = UrlBuilder::concat(
+            $this->baseUrl,
+            ['new-structure-trial-request', $shopRequest->getToken()]
+        );
+
+        $data = $shopRequest->getData();
+
+        $model = new NewStructureTrialRequestValidationModel();
+        $model->setToken($shopRequest->getToken())
+            ->setRepresentativeEmail($data['representativeEmail'] ?? '')
+            ->setRepresentativeFirstName($data['representativeFirstName'] ?? '')
+            ->setRepresentativeLastName($data['representativeLastName'] ?? '')
+            ->setStructureName($data['structureName'] ?? '')
+            ->setValidationUrl($validationUrl);
+
+        $this->mailer->main($model);
+
+        $shopRequest->setStatus(NewStructureTrialRequestStatusEnum::ACTIVATION_LINK_SENT);
+        $this->entityManager->persist($shopRequest);
+        $this->entityManager->flush();
+    }
+
+    /**
+     * Start an artist premium trial for an organization.
+     *
+     * @param Organization $organization The organization to start the trial for
+     * @param NewStructureArtistPremiumTrialRequest $request The trial request data
+     *
+     * @return bool True if the trial was started successfully, false otherwise
+     *
+     * @throws \Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface
+     */
+    public function startArtistPremiumTrial(Organization $organization, NewStructureArtistPremiumTrialRequest $request): bool
+    {
+        // Prepare the request data
+        $data = [
+            "organization" => [
+                "streetAddress" => $request->getAddress(),
+                "streetAddressSecond" => $request->getAddressComplement(),
+                "streetAddressThird" => "",
+                "cp" => $request->getPostalCode(),
+                "city" => $request->getCity(),
+                "name" => $organization->getName(),
+                "organizationAddressPostalId" => null
+            ],
+            "access" => [
+                "isAdmin" => true,
+                "email" => $request->getRepresentativeEmail(),
+                "contactPointId" => null,
+                "name" => $request->getRepresentativeLastName(),
+                "givenName" => $request->getRepresentativeFirstName(),
+                "function" => $request->getRepresentativeFunction(),
+                "telphone" => $request->getRepresentativePhone()
+            ]
+        ];
+
+        try {
+            $response = $this->apiLegacyRequestService->request(
+                'POST',
+                '/api/trial/artist_premium',
+                $data
+            );
+
+            return $response->getStatusCode() === Response::HTTP_OK;
+        } catch (\Exception $e) {
+            return false;
+        }
+    }
+}

+ 51 - 0
src/State/Processor/Shop/NewStructureArtistPremiumTrialRequestProcessor.php

@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Processor\Shop;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\Metadata\Post;
+use ApiPlatform\State\ProcessorInterface;
+use App\Enum\Shop\ShopRequestType;
+use App\Service\Shop\ShopService;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\SerializerInterface;
+
+/**
+ * Processor for handling new structure trial requests for the artist premium product.
+ */
+class NewStructureArtistPremiumTrialRequestProcessor implements ProcessorInterface
+{
+    public function __construct(
+        private readonly ShopService $shopService,
+        private readonly SerializerInterface $serializer,
+    ) {
+    }
+
+    /**
+     * @param mixed $data
+     * @param Operation $operation
+     * @param array $uriVariables
+     * @param array $context
+     * @return mixed
+     */
+    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
+    {
+        if (!$operation instanceof Post) {
+            throw new \RuntimeException('not supported', Response::HTTP_METHOD_NOT_ALLOWED);
+        }
+
+        // Serialize the entity to JSON and decode to array
+        $jsonData = $this->serializer->serialize($data, 'json');
+        $requestData = json_decode($jsonData, true);
+
+        // Create the shop request
+        $this->shopService->registerNewShopRequest(
+            ShopRequestType::NEW_STRUCTURE_ARTIST_PREMIUM_TRIAL,
+            $requestData
+        );
+
+        return $data;
+    }
+}

+ 67 - 0
src/State/Provider/Shop/NewStructureTrialRequestProvider.php

@@ -0,0 +1,67 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\State\Provider\Shop;
+
+use ApiPlatform\Metadata\Operation;
+use ApiPlatform\State\ProviderInterface;
+use App\Entity\Shop\ShopRequest;
+use App\Enum\Shop\NewStructureTrialRequestStatusEnum;
+use App\Service\Shop\ShopService;
+use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
+
+/**
+ * Provider for NewStructureTrialRequest validation.
+ */
+final class NewStructureTrialRequestProvider implements ProviderInterface
+{
+    public function __construct(
+        private readonly EntityManagerInterface $entityManager,
+        private readonly ShopService $shopService,
+    ) {
+    }
+
+    /**
+     * @param array<mixed> $uriVariables
+     * @param array<mixed> $context
+     */
+    public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
+    {
+        // Find the request by token
+        $token = $uriVariables['token'] ?? null;
+
+        if (!$token) {
+            throw new NotFoundHttpException('Token not provided');
+        }
+
+        $repository = $this->entityManager->getRepository(ShopRequest::class);
+        $shopRequest = $repository->findOneBy(['token' => $token]);
+
+        if (!$shopRequest) {
+            throw new NotFoundHttpException('Request not found or already validated');
+        }
+
+        if ($shopRequest->getStatus() !== NewStructureTrialRequestStatusEnum::ACTIVATION_LINK_SENT) {
+            throw new AccessDeniedHttpException('Invalid request status');
+        }
+
+        // Check if the submission date is more than 15 minutes old
+        $now = new \DateTimeImmutable();
+        $requestAge = $now->getTimestamp() - $shopRequest->getSubmissionDate()->getTimestamp();
+
+        if ($requestAge >= 15 * 60) {
+            throw new AccessDeniedHttpException('Request expired: submission date is more than 15 minutes old');
+        }
+
+        // Validate the request using the ShopService
+        $this->shopService->processShopRequest($shopRequest);
+
+        // Return a success response
+        return new Response('Request validated successfully', Response::HTTP_OK);
+    }
+
+}

+ 24 - 0
templates/emails/shop/new-structure-validation.html.twig

@@ -0,0 +1,24 @@
+{% extends '@templates/emails/base.html.twig' %}
+
+{% block title %}Validation de votre demande d'essai{% endblock %}
+
+{% block content %}
+    <h1>Bonjour {{ representativeFirstName }} {{ representativeLastName }},</h1>
+
+    <p>Nous avons bien reçu votre demande d'essai pour la structure "{{ structureName }}".</p>
+
+    <p>Pour valider votre demande et commencer votre période d'essai, veuillez cliquer sur le lien ci-dessous :</p>
+
+    <p style="text-align: center; margin: 30px 0;">
+        <a href="{{ validationUrl }}" style="background-color: #4CAF50; color: white; padding: 15px 32px; text-align: center; text-decoration: none; display: inline-block; font-size: 16px; margin: 4px 2px; cursor: pointer; border-radius: 10px;">
+            Valider ma demande
+        </a>
+    </p>
+
+    <p>Si le bouton ne fonctionne pas, vous pouvez copier et coller le lien suivant dans votre navigateur :</p>
+    <p>{{ validationUrl }}</p>
+
+    <p>Nous vous remercions pour votre confiance et restons à votre disposition pour toute question.</p>
+
+    <p>Cordialement,<br>L'équipe Opentalent</p>
+{% endblock %}