From b1e744d047c9407830f584a85cb7d6202800726f Mon Sep 17 00:00:00 2001 From: Xavier Leune Date: Tue, 28 Mar 2017 08:20:33 +0200 Subject: [PATCH] Ajout d'une synchronisation automatique des membres vers mailchimp --- app/config/parameters.yml.dist | 3 + app/config/services.yml | 9 ++ composer.json | 3 +- composer.lock | 153 +++++++++++++++++- .../Model/Repository/UserRepository.php | 88 ++++++++-- .../Command/SubscriptionReminderCommand.php | 2 +- .../Command/UpdateMailchimpMembersCommand.php | 67 ++++++++ sources/AppBundle/Mailchimp/Mailchimp.php | 57 +++++++ sources/AppBundle/Mailchimp/Runner.php | 99 ++++++++++++ 9 files changed, 466 insertions(+), 15 deletions(-) create mode 100644 sources/AppBundle/Command/UpdateMailchimpMembersCommand.php create mode 100644 sources/AppBundle/Mailchimp/Mailchimp.php create mode 100644 sources/AppBundle/Mailchimp/Runner.php diff --git a/app/config/parameters.yml.dist b/app/config/parameters.yml.dist index 492064c32..aae7747fb 100644 --- a/app/config/parameters.yml.dist +++ b/app/config/parameters.yml.dist @@ -25,3 +25,6 @@ parameters: twitter_oauth_access_token_secret: "" twitter_consumer_key: "" twitter_consumer_secret: "" + + mailchimp_api_key: "" + mailchimp_members_list: diff --git a/app/config/services.yml b/app/config/services.yml index b4ddb80fa..fbfdadc65 100644 --- a/app/config/services.yml +++ b/app/config/services.yml @@ -110,3 +110,12 @@ services: app.twitter_api: class: TwitterAPIExchange arguments: ["%twitter_api_settings%"] + + app.mailchimp_client: + class: Mailchimp\Mailchimp + arguments: ["%mailchimp_api_key%"] + public: false + + app.mailchimp_api: + class: AppBundle\Mailchimp\Mailchimp + arguments: ["@app.mailchimp_client"] \ No newline at end of file diff --git a/composer.json b/composer.json index 905319d90..e0bf8ff20 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "algolia/algoliasearch-client-php": "^1.12", "cocur/slugify": "^2.3", "j7mbo/twitter-api-php": "^1.0", - "nojimage/twitter-text-php": "^1.1" + "nojimage/twitter-text-php": "^1.1", + "pacely/mailchimp-apiv3": "^1.0" }, "scripts": { "post-install-cmd": [ diff --git a/composer.lock b/composer.lock index d365c62fb..17da6438c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "content-hash": "a02512eb5713245d3eaa90ea3e44fb02", + "content-hash": "de9072ed0929e007a610ea71dee05b76", "packages": [ { "name": "algolia/algoliasearch-client-php", @@ -977,6 +977,105 @@ ], "time": "2016-06-24T23:00:38+00:00" }, + { + "name": "illuminate/contracts", + "version": "v5.4.13", + "source": { + "type": "git", + "url": "https://github.com/illuminate/contracts.git", + "reference": "dd256891c80fd94a58ab83d7989d6da2f50e30ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/dd256891c80fd94a58ab83d7989d6da2f50e30ea", + "reference": "dd256891c80fd94a58ab83d7989d6da2f50e30ea", + "shasum": "" + }, + "require": { + "php": ">=5.6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.4-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Contracts\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Contracts package.", + "homepage": "https://laravel.com", + "time": "2017-02-21T14:21:59+00:00" + }, + { + "name": "illuminate/support", + "version": "v5.4.13", + "source": { + "type": "git", + "url": "https://github.com/illuminate/support.git", + "reference": "904f63003fd67ede2ec3be018b322d1c29415465" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/support/zipball/904f63003fd67ede2ec3be018b322d1c29415465", + "reference": "904f63003fd67ede2ec3be018b322d1c29415465", + "shasum": "" + }, + "require": { + "doctrine/inflector": "~1.0", + "ext-mbstring": "*", + "illuminate/contracts": "5.4.*", + "paragonie/random_compat": "~1.4|~2.0", + "php": ">=5.6.4" + }, + "replace": { + "tightenco/collect": "self.version" + }, + "suggest": { + "illuminate/filesystem": "Required to use the composer class (5.2.*).", + "symfony/process": "Required to use the composer class (~3.2).", + "symfony/var-dumper": "Required to use the dd function (~3.2)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.4-dev" + } + }, + "autoload": { + "psr-4": { + "Illuminate\\Support\\": "" + }, + "files": [ + "helpers.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", + "time": "2017-02-15T19:29:24+00:00" + }, { "name": "ircmaxell/random-lib", "version": "v1.2.0", @@ -1673,6 +1772,58 @@ ], "time": "2014-02-28T15:18:45+00:00" }, + { + "name": "pacely/mailchimp-apiv3", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/pacely/mailchimp-api-v3.git", + "reference": "859943e93fef53e93af1882faee5c1338a654c88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pacely/mailchimp-api-v3/zipball/859943e93fef53e93af1882faee5c1338a654c88", + "reference": "859943e93fef53e93af1882faee5c1338a654c88", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "~5.0|~6.0", + "illuminate/support": "~5.0", + "php": ">=5.5.0" + }, + "require-dev": { + "phpspec/phpspec": "~2.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Mailchimp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Magnussen", + "email": "chris@hirvi.no" + } + ], + "description": "Simple API wrapper for Mailchimp API V3", + "keywords": [ + "api", + "mailchimp", + "php", + "v3" + ], + "time": "2016-08-02T12:04:20+00:00" + }, { "name": "paragonie/random_compat", "version": "v2.0.2", diff --git a/sources/AppBundle/Association/Model/Repository/UserRepository.php b/sources/AppBundle/Association/Model/Repository/UserRepository.php index 258f02153..a4e319f91 100644 --- a/sources/AppBundle/Association/Model/Repository/UserRepository.php +++ b/sources/AppBundle/Association/Model/Repository/UserRepository.php @@ -4,6 +4,7 @@ namespace AppBundle\Association\Model\Repository; use AppBundle\Association\Model\User; +use Aura\SqlQuery\Common\SelectInterface; use CCMBenchmark\Ting\Repository\HydratorSingleObject; use CCMBenchmark\Ting\Repository\Metadata; use CCMBenchmark\Ting\Repository\MetadataInitializer; @@ -16,6 +17,10 @@ class UserRepository extends Repository implements MetadataInitializer, UserProviderInterface { + const USER_TYPE_PHYSICAL = 0; + const USER_TYPE_COMPANY = 1; + const USER_TYPE_ALL = 2; + public function loadUserByUsername($username) { $user = $this->getOneBy(['username' => $username]); @@ -26,30 +31,89 @@ public function loadUserByUsername($username) } /** - * Retrieve all "physical" users by the date of end of membership. + * @return SelectInterface + */ + private function getQueryBuilderWithSubscriptions() + { + /** + * @var $queryBuilder SelectInterface + */ + $queryBuilder = $this->getQueryBuilder(self::QUERY_SELECT); + $queryBuilder + ->cols(['app.`id`', 'app.`login`', 'app.`prenom`', 'app.`nom`', 'app.`email`']) + ->from('afup_personnes_physiques app') + ->join('LEFT', 'afup_personnes_morales apm', 'apm.id = app.id_personne_morale') + ->join('LEFT', 'afup_cotisations ac', 'ac.type_personne = IF(apm.id IS NULL, 0, 1) AND ac.id_personne = IFNULL(apm.id, app.id)') + ->groupBy(['app.`id`']) + ; + + return $queryBuilder; + } + + /** + * Retrieve all users by the date of end of membership. + * + * @param int $userType one of self::USER_TYPE_* + * @return \CCMBenchmark\Ting\Repository\CollectionInterface + */ + public function getActiveMembers($userType = self::USER_TYPE_PHYSICAL) + { + $today = new \DateTimeImmutable(); + $queryBuilder = $this->getQueryBuilderWithSubscriptions(); + $queryBuilder + ->where('app.`etat` = :status') + ->having('MAX(ac.`date_fin`) > :start ') + ; + + if ($userType === self::USER_TYPE_PHYSICAL) { + $queryBuilder->where('id_personne_morale = 0'); + } elseif ($userType === self::USER_TYPE_COMPANY) { + $queryBuilder->where('id_personne_morale <> 0'); + } elseif ($userType !== self::USER_TYPE_ALL) { + throw new \UnexpectedValueException(sprintf('Unknown user type "%s"', $userType)); + } + + return $this + ->getPreparedQuery($queryBuilder->getStatement()) + ->setParams([ + 'start' => $today->format('U'), + 'status' => User::STATUS_ACTIVE + ]) + ->query($this->getCollection(new HydratorSingleObject())); + } + + /** + * Retrieve all users by the date of end of membership. * * @param \DateTimeImmutable $endOfSubscription + * @param int $userType one of self::USER_TYPE_* * @return \CCMBenchmark\Ting\Repository\CollectionInterface */ - public function getUsersByEndOfMembership(\DateTimeImmutable $endOfSubscription) + public function getUsersByEndOfMembership(\DateTimeImmutable $endOfSubscription, $userType = self::USER_TYPE_PHYSICAL) { $startOfDay = $endOfSubscription->setTime(0, 0, 0); $endOfDay = $endOfSubscription->setTime(23, 59, 59); + $queryBuilder = $this->getQueryBuilderWithSubscriptions(); + $queryBuilder + ->where('app.`etat` = :status') + ->having('MAX(ac.`date_fin`) BETWEEN :start AND :end') + ; + + if ($userType === self::USER_TYPE_PHYSICAL) { + $queryBuilder->where('id_personne_morale = 0'); + } elseif ($userType === self::USER_TYPE_COMPANY) { + $queryBuilder->where('id_personne_morale <> 0'); + } elseif ($userType !== self::USER_TYPE_ALL) { + throw new \UnexpectedValueException(sprintf('Unknown user type "%s"', $userType)); + } + return $this - ->getQuery(<<getPreparedQuery($queryBuilder->getStatement()) ->setParams([ 'start' => $startOfDay->format('U'), 'end' => $endOfDay->format('U'), - 'type' => 0 + 'status' => User::STATUS_ACTIVE ]) ->query($this->getCollection(new HydratorSingleObject())); } diff --git a/sources/AppBundle/Command/SubscriptionReminderCommand.php b/sources/AppBundle/Command/SubscriptionReminderCommand.php index e960515e4..2bc611d8f 100644 --- a/sources/AppBundle/Command/SubscriptionReminderCommand.php +++ b/sources/AppBundle/Command/SubscriptionReminderCommand.php @@ -70,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output) foreach ($reminders as $name => $details) { $reminder = $factory->getReminder($details['class']); - $users = $repository->getUsersByEndOfMembership($details['date']); + $users = $repository->getUsersByEndOfMembership($details['date'], UserRepository::USER_TYPE_PHYSICAL); $output->writeln(sprintf('%s (%s)', $name, $details['date']->format('d/m/Y'))); $output->writeln(sprintf('%s membres', $users->count())); foreach ($users as $user) { diff --git a/sources/AppBundle/Command/UpdateMailchimpMembersCommand.php b/sources/AppBundle/Command/UpdateMailchimpMembersCommand.php new file mode 100644 index 000000000..94973cdce --- /dev/null +++ b/sources/AppBundle/Command/UpdateMailchimpMembersCommand.php @@ -0,0 +1,67 @@ +setName('mailchimp:update-members') + ->addOption('init', null, null, "Add all active members to the list") + ; + } + + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return void + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $container = $this->getContainer(); + $ting = $container->get('ting'); + + $membersListId = $this->getContainer()->getParameter('mailchimp_members_list'); + + /** + * @var $userRepository UserRepository + */ + $userRepository = $ting->get(UserRepository::class); + + $mailchimp = $this->getContainer()->get('app.mailchimp_api'); + + $runner = new Runner( + $mailchimp, + $userRepository, + $membersListId + ); + + if ($input->getOption('init') === true) { + $errors = $runner->initList(); + } else { + $errors = $runner->updateList(); + } + if ($errors !== []) { + $table = new Table($output); + $table + ->setHeaders(['email', 'erreur']) + ->setRows($errors) + ->render() + ; + } else { + $output->writeln("Pas d'erreur durant le traitement"); + } + } +} diff --git a/sources/AppBundle/Mailchimp/Mailchimp.php b/sources/AppBundle/Mailchimp/Mailchimp.php new file mode 100644 index 000000000..bacbab073 --- /dev/null +++ b/sources/AppBundle/Mailchimp/Mailchimp.php @@ -0,0 +1,57 @@ +client = $client; + } + + /** + * Subscribe an address to a list + * + * @param string $list + * @param string $email + * @return \Illuminate\Support\Collection + */ + public function subscribeAddress($list, $email) + { + return $this->client->put( + 'lists/' . $list . '/members/' . $this->getAddressId($email), + ['status' => 'subscribed', 'email_address' => $email] + ); + } + + /** + * Unsubscribe an address from a list + * + * @param string $list + * @param string $email + * @return \Illuminate\Support\Collection + */ + public function unSubscribeAddress($list, $email) + { + return $this->client->put( + 'lists/' . $list . '/members/' . $this->getAddressId($email), + ['status' => 'unsubscribed', 'email_address' => $email] + ); + } + + /** + * Mailchimp uses a predictable id to allow upsert operations on subscriptions. + * It's based on a hash of the email. + * + * @param string $email + * @return string + */ + private function getAddressId($email) + { + return md5($email); + } +} diff --git a/sources/AppBundle/Mailchimp/Runner.php b/sources/AppBundle/Mailchimp/Runner.php new file mode 100644 index 000000000..05562924d --- /dev/null +++ b/sources/AppBundle/Mailchimp/Runner.php @@ -0,0 +1,99 @@ +mailchimp = $mailchimp; + $this->userRepository = $userRepository; + $this->membersListId = $membersListId; + } + + /** + * Add all active members to the list + * @return array list of errors + */ + public function initList() + { + $errors = []; + /** + * @var $users User[] + */ + $users = $this->userRepository->getActiveMembers(UserRepository::USER_TYPE_ALL); + foreach ($users as $user) { + // Add to members list + try { + $this->mailchimp->subscribeAddress($this->membersListId, $user->getEmail()); + } catch (\Exception $e) { + $errors[] = [$user->getEmail(), $e->getMessage()]; + } + } + + return $errors; + } + + /** + * Add new users and remove old users + * @return array list of errors + */ + public function updateList() + { + $errors = []; + // First - delete expired members + $dateUnsubscribe = new \DateTimeImmutable('-15 day'); + /** + * @var $users User[] + */ + $users = $this->userRepository->getUsersByEndOfMembership($dateUnsubscribe, UserRepository::USER_TYPE_ALL); + foreach ($users as $user) { + // Delete from members list + try { + $this->mailchimp->unSubscribeAddress($this->membersListId, $user->getEmail()); + } catch (\Exception $e) { + $errors[] = [$user->getEmail(), $e->getMessage()]; + } + } + // Then - add new members + $dateNextYear = new \DateTimeImmutable('+1 year - 1 day'); + $users = $this->userRepository->getUsersByEndOfMembership($dateNextYear, UserRepository::USER_TYPE_ALL); + foreach ($users as $user) { + // Add to the members list + try { + $this->mailchimp->subscribeAddress($this->membersListId, $user->getEmail()); + } catch (\Exception $e) { + $errors[] = [$user->getEmail(), $e->getMessage()]; + } + } + + return $errors; + } +}