-
Notifications
You must be signed in to change notification settings - Fork 0
Add the product-review-approved earning trigger + channel resolver #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Setono\SyliusLoyaltyPlugin\Earning; | ||
|
|
||
| use Setono\SyliusLoyaltyPlugin\Repository\LoyaltyAccountRepositoryInterface; | ||
| use Sylius\Component\Core\Model\ChannelInterface; | ||
| use Sylius\Component\Core\Model\CustomerInterface; | ||
|
|
||
| /** | ||
| * Resolves the channels where the customer already holds a loyalty account. For a single-channel store | ||
| * this is the one channel; a customer with no account yet earns nothing from context-less triggers | ||
| * (they earn once they have an account, e.g. after their first order). | ||
| */ | ||
| final class DefaultTriggerChannelResolver implements TriggerChannelResolverInterface | ||
| { | ||
| public function __construct( | ||
| private readonly LoyaltyAccountRepositoryInterface $accountRepository, | ||
| ) { | ||
| } | ||
|
|
||
| public function resolve(CustomerInterface $customer): iterable | ||
| { | ||
| foreach ($this->accountRepository->findByCustomer($customer) as $account) { | ||
| $channel = $account->getChannel(); | ||
| if ($channel instanceof ChannelInterface) { | ||
| yield $channel; | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Setono\SyliusLoyaltyPlugin\Earning\Trigger; | ||
|
|
||
| use Setono\SyliusLoyaltyPlugin\Earning\ActionPointsAwarderInterface; | ||
| use Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface; | ||
| use Sylius\Component\Core\Model\CustomerInterface; | ||
| use Sylius\Component\Core\Model\ProductReviewInterface; | ||
| use Symfony\Component\Workflow\Event\CompletedEvent; | ||
|
|
||
| /** | ||
| * Awards the `product_review_approved` rules when a product review is accepted. Approval happens in | ||
| * admin (no shop channel), so the channels are resolved from the author's loyalty accounts. Bridges | ||
| * both state-machine adapters, like the order-award listener; the ledger's idempotency covers overlap. | ||
| */ | ||
| final class ReviewApprovedStateMachineListener | ||
| { | ||
| public const TRIGGER = 'product_review_approved'; | ||
|
|
||
| public function __construct( | ||
| private readonly ActionPointsAwarderInterface $awarder, | ||
| private readonly TriggerChannelResolverInterface $channelResolver, | ||
| ) { | ||
| } | ||
|
|
||
| public function onWorkflowReviewAccepted(CompletedEvent $event): void | ||
| { | ||
| $subject = $event->getSubject(); | ||
| if ($subject instanceof ProductReviewInterface) { | ||
| $this->award($subject); | ||
| } | ||
| } | ||
|
|
||
| public function onWinzouReviewAccepted(ProductReviewInterface $review): void | ||
| { | ||
| $this->award($review); | ||
| } | ||
|
|
||
| private function award(ProductReviewInterface $review): void | ||
| { | ||
| $author = $review->getAuthor(); | ||
| if (!$author instanceof CustomerInterface) { | ||
| return; | ||
| } | ||
|
|
||
| $reviewId = (int) $review->getId(); | ||
| foreach ($this->channelResolver->resolve($author) as $channel) { | ||
| $this->awarder->award( | ||
| $author, | ||
| $channel, | ||
| self::TRIGGER, | ||
| sprintf('%s:%d:%d', self::TRIGGER, $reviewId, (int) $channel->getId()), | ||
| ); | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Setono\SyliusLoyaltyPlugin\Earning; | ||
|
|
||
| use Sylius\Component\Core\Model\ChannelInterface; | ||
| use Sylius\Component\Core\Model\CustomerInterface; | ||
|
|
||
| /** | ||
| * Resolves which channel(s) an action trigger without a shop-channel context (review approval, | ||
| * birthday) should award on. An extension point: tag a replacement to change the strategy. | ||
| */ | ||
| interface TriggerChannelResolverInterface | ||
| { | ||
| /** | ||
| * @return iterable<ChannelInterface> | ||
| */ | ||
| public function resolve(CustomerInterface $customer): iterable; | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess we miss more implementations than the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done — |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -117,6 +117,21 @@ | |
| <tag name="kernel.event_subscriber" /> | ||
| </service> | ||
|
|
||
| <!-- Resolves which channels a context-less action trigger awards on (the customer's account channels) --> | ||
| <service id="Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver"> | ||
| <argument type="service" id="setono_sylius_loyalty.repository.account" /> | ||
| </service> | ||
| <service id="Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface" | ||
| alias="Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver" /> | ||
|
|
||
| <!-- Action trigger: awards the product_review_approved rules when a review is accepted (both | ||
| adapters; workflow tag here, winzou callback via the extension's prepend) --> | ||
| <service id="Setono\SyliusLoyaltyPlugin\Earning\Trigger\ReviewApprovedStateMachineListener" public="true"> | ||
| <argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\ActionPointsAwarderInterface" /> | ||
| <argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface" /> | ||
| <tag name="kernel.event_listener" event="workflow.sylius_product_review.completed.accept" method="onWorkflowReviewAccepted" /> | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this event be added as an event subscriber instead?
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It intentionally matches the order-award and redemption state-machine listeners: an |
||
| </service> | ||
|
|
||
| <service id="Setono\SyliusLoyaltyPlugin\Message\Handler\AwardOrderPointsHandler"> | ||
| <argument type="service" id="sylius.repository.order" /> | ||
| <argument type="service" id="Setono\SyliusLoyaltyPlugin\Earning\OrderPointsAwarderInterface" /> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,143 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace Setono\SyliusLoyaltyPlugin\Tests\Functional; | ||
|
|
||
| use Doctrine\ORM\EntityManagerInterface; | ||
| use Doctrine\Persistence\ObjectRepository; | ||
| use Setono\SyliusLoyaltyPlugin\Earning\TriggerChannelResolverInterface; | ||
| use Setono\SyliusLoyaltyPlugin\Model\LoyaltyAccount; | ||
| use Sylius\Component\Core\Model\ChannelInterface; | ||
| use Sylius\Component\Core\Model\CustomerInterface; | ||
| use Sylius\Component\Currency\Model\CurrencyInterface; | ||
| use Sylius\Component\Locale\Model\LocaleInterface; | ||
| use Sylius\Component\Resource\Factory\FactoryInterface; | ||
| use Sylius\Component\Resource\Model\ResourceInterface; | ||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||
|
|
||
| final class DefaultTriggerChannelResolverTest extends KernelTestCase | ||
| { | ||
| private EntityManagerInterface $manager; | ||
|
|
||
| private TriggerChannelResolverInterface $resolver; | ||
|
|
||
| protected function setUp(): void | ||
| { | ||
| self::bootKernel(); | ||
|
|
||
| $manager = self::getContainer()->get('doctrine.orm.entity_manager'); | ||
| \assert($manager instanceof EntityManagerInterface); | ||
| $this->manager = $manager; | ||
|
|
||
| $resolver = self::getContainer()->get(\Setono\SyliusLoyaltyPlugin\Earning\DefaultTriggerChannelResolver::class); | ||
| \assert($resolver instanceof TriggerChannelResolverInterface); | ||
| $this->resolver = $resolver; | ||
| } | ||
|
|
||
| /** | ||
| * @test | ||
| */ | ||
| public function it_resolves_the_channels_where_the_customer_has_an_account(): void | ||
| { | ||
| $customer = $this->createCustomer('resolver@example.com'); | ||
| $this->createAccount($customer, $this->createChannel('resolver-a')); | ||
| $this->createAccount($customer, $this->createChannel('resolver-b')); | ||
|
|
||
| $codes = []; | ||
| foreach ($this->resolver->resolve($customer) as $channel) { | ||
| $codes[] = $channel->getCode(); | ||
| } | ||
|
|
||
| self::assertCount(2, $codes); | ||
| self::assertContains('resolver-a', $codes); | ||
| self::assertContains('resolver-b', $codes); | ||
| } | ||
|
|
||
| /** | ||
| * @test | ||
| */ | ||
| public function it_resolves_nothing_for_a_customer_without_an_account(): void | ||
| { | ||
| $customer = $this->createCustomer('resolver-empty@example.com'); | ||
|
|
||
| $channels = []; | ||
| foreach ($this->resolver->resolve($customer) as $channel) { | ||
| $channels[] = $channel; | ||
| } | ||
|
|
||
| self::assertCount(0, $channels); | ||
| } | ||
|
|
||
| private function createAccount(CustomerInterface $customer, ChannelInterface $channel): void | ||
| { | ||
| $account = new LoyaltyAccount(); | ||
| $account->setCustomer($customer); | ||
| $account->setChannel($channel); | ||
| $this->manager->persist($account); | ||
| $this->manager->flush(); | ||
| } | ||
|
|
||
| private function createChannel(string $code): ChannelInterface | ||
| { | ||
| $currency = $this->getOrCreateByCode('sylius.repository.currency', 'sylius.factory.currency', 'USD', CurrencyInterface::class); | ||
| \assert($currency instanceof CurrencyInterface); | ||
| $locale = $this->getOrCreateByCode('sylius.repository.locale', 'sylius.factory.locale', 'en_US', LocaleInterface::class); | ||
| \assert($locale instanceof LocaleInterface); | ||
|
|
||
| $channel = $this->factory('sylius.factory.channel')->createNew(); | ||
| \assert($channel instanceof ChannelInterface); | ||
| $channel->setCode($code); | ||
| $channel->setName('Web'); | ||
| $channel->setBaseCurrency($currency); | ||
| $channel->setDefaultLocale($locale); | ||
| $channel->addCurrency($currency); | ||
| $channel->addLocale($locale); | ||
| $this->manager->persist($channel); | ||
| $this->manager->flush(); | ||
|
|
||
| return $channel; | ||
| } | ||
|
|
||
| private function createCustomer(string $email): CustomerInterface | ||
| { | ||
| $customer = $this->factory('sylius.factory.customer')->createNew(); | ||
| \assert($customer instanceof CustomerInterface); | ||
| $customer->setEmail($email); | ||
| $this->manager->persist($customer); | ||
| $this->manager->flush(); | ||
|
|
||
| return $customer; | ||
| } | ||
|
|
||
| private function getOrCreateByCode(string $repositoryId, string $factoryId, string $code, string $type): ResourceInterface | ||
| { | ||
| $repository = self::getContainer()->get($repositoryId); | ||
| \assert($repository instanceof ObjectRepository); | ||
|
|
||
| $resource = $repository->findOneBy(['code' => $code]); | ||
| if ($resource instanceof $type) { | ||
| \assert($resource instanceof ResourceInterface); | ||
|
|
||
| return $resource; | ||
| } | ||
|
|
||
| $resource = $this->factory($factoryId)->createNew(); | ||
| \assert(method_exists($resource, 'setCode')); | ||
| $resource->setCode($code); | ||
| $this->manager->persist($resource); | ||
|
|
||
| return $resource; | ||
| } | ||
|
|
||
| /** | ||
| * @return FactoryInterface<ResourceInterface> | ||
| */ | ||
| private function factory(string $id): FactoryInterface | ||
| { | ||
| $factory = self::getContainer()->get($id); | ||
| \assert($factory instanceof FactoryInterface); | ||
|
|
||
| return $factory; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add listener on Symfony workflow also