subman-1.0.x-dev/src/SubmanSync.php

src/SubmanSync.php
<?php

namespace Drupal\subman;

use Drupal\Core\Logger\RfcLogLevel;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\subman\Event\SubmanIncomingWebhook;
use Drupal\subman\Event\SubmanSubscriberUserModified;
use Drupal\subman\Event\SubmanSynchronousResult;
use Drupal\subman\Exception\SubmanException;
use Drupal\subman\Exception\SubmanWebhookSubscriberMissOrConflictException;
use Drupal\user\Entity\User;
use Drupal\user\UserInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * Class SubmanSync.
 *
 * Provides the re-usable parts of handling and syncing data from a
 * subscription management SaaS to Drupal.
 *
 * This is the abstract base class for any specific service that
 * connects to a specific subscription management SaaS.
 * As such, it can and has to be extended by a specific SaaS integration module.
 *
 * As abstract basis, this class provides all parts of external data handling
 * that are independent of any specific SaaS, e.g.:
 * * Work with normalized versions of specific external data
 * * Find and update Drupal user corresponding to normalized external data
 * * Store sync information on to the corresponding Drupal user
 * * Declare abstract base methods (that can be overridden in specific
 *   implementations for specific SaaS).
 *
 * A specific implementation of this base class should be declared as service
 * "subman.sync", and as event subscriber via service tag "event_subscriber".
 * We assume that only one implementation of the abstract class SubmanSync
 * is used at a time. This is an easy way to let other code use a specific
 * service class for a specific SaaS without knowing about it specifically.
 */
abstract class SubmanSync implements SubmanSyncInterface, EventSubscriberInterface
{
  use StringTranslationTrait;

  const DRUPAL_FIELDNAME_SAAS_ID = 'field_subman_external_id';
  const DRUPAL_FIELDNAME_SYNC = 'field_subman_sync';
  const DRUPAL_FIELDNAME_ID_SECONDARY = 'mail';
  const SAAS_FIELDNAME_ID_SECONDARY = 'EmailAddress';
  const SAAS_FIELDNAME_DRUPAL_UID = 'ExternalCustomerId';

  // @todo rename to DATA_COMPONENT_...
  const DATA_KEY_SUBSCRIBER = 'subscriber';
  const DATA_KEY_SUBSCRIPTION = 'subscription';
  const DATA_KEY_SUBSCRIPTION_TYPE = 'subscription_type';
  const DATA_KEY_ADDONS = '_addons';
  const DATA_KEY_TIMESTAMP = 'timestamp';

  const MAP_NORMALIZED_KEY = '_normalized';

  const MAP_NORMALIZED_SUBSCRIBER_ID = '_id';
  const MAP_NORMALIZED_SUBSCRIBER_EMAIL = '_email';
  const MAP_NORMALIZED_SUBSCRIBER_FIRSTNAME = '_firstname';
  const MAP_NORMALIZED_SUBSCRIBER_LASTNAME = '_lastname';
  const MAP_NORMALIZED_SUBSCRIBER_DEBITOR_ID = '_foreign_id';
  const MAP_NORMALIZED_SUBSCRIBER_CREATED = '_created';
  const MAP_NORMALIZED_SUBSCRIBER_ACTIVE = '_active';
  const MAP_NORMALIZED_SUBSCRIBER_DELETED = '_deleted';
  const MAP_NORMALIZED_SUBSCRIBER_LOCALE = '_locale';
  const MAP_NORMALIZED_SUBSCRIBER_LANGUAGE = '_language';

  const MAP_NORMALIZED_SUBSCRIPTION_ID = '_id';
  const MAP_NORMALIZED_SUBSCRIPTION_SUBSCRIBER_ID = '_subscriber_id';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE = '_type';
  const MAP_NORMALIZED_SUBSCRIPTION_VARIANT = '_variant';
  const MAP_NORMALIZED_SUBSCRIPTION_REFERENCE = '_reference';
  const MAP_NORMALIZED_SUBSCRIPTION_VALID = '_valid';
  const MAP_NORMALIZED_SUBSCRIPTION_MESSAGE = '_message';
  const MAP_NORMALIZED_SUBSCRIPTION_ESCALATION = '_escalation';

  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_ID = '_id';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_NAME = '_name';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_NAME_TECHNICAL = '_name_technical';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_ROLES = '_roles';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_CLASS = '_class';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_CLASS_MAIN = 'main';
  const MAP_NORMALIZED_SUBSCRIPTION_TYPE_CLASS_VARIANT = 'variant';

  const MAP_NORMALIZED_ADDON_ID = '_id';
  const MAP_NORMALIZED_ADDON_SUBSCRIBER_ID = '_subscriber_id';
  const MAP_NORMALIZED_ADDON_SUBSCRIPTION_ID = '_subscription_id';
  const MAP_NORMALIZED_ADDON_QUANTITY = '_quantity';
  const MAP_NORMALIZED_ADDON_ROLES = '_roles';
  const MAP_NORMALIZED_ADDON_STATUS = '_status';

  const NOTIFY_TRIAL_ENDING = 'trial_ending';

  /**
   * Constructs a new SubmanSubscriberSync object.
   *
   * @param SubmanUtilities $utils
   *   The subman utilities service.
   */
  public function __construct(protected SubmanUtilities $utils)
  {
  }

  /**
   * Get the title of the subscription management service.
   *
   * @return string
   *   The title of the subscription management service.
   */
  abstract public function getSubscriptionManagementServiceTitle(): string;

  /**
   * {@inheritDoc}
   */
  public static function getSubscribedEvents(): array
  {
    return [
      SubmanIncomingWebhook::EVENT_NAME => 'onIncomingWebhook',
      SubmanSynchronousResult::EVENT_NAME => 'onSynchronousResult',
      SubmanSubscriberUserModified::EVENT_NAME => 'onSubscriberModified',
    ];
  }

  /**
   * {@inheritDoc}
   */
  abstract public function onIncomingWebhook(SubmanIncomingWebhook $event): void;

  /**
   * {@inheritDoc}
   */
  abstract public function onSynchronousResult(SubmanSynchronousResult $event): void;

  /**
   * {@inheritDoc}
   */
  public function onSubscriberModified(SubmanSubscriberUserModified $event): void
  {
    // By default, send welcome email with password reset link for newly created
    // subscribers.
    if ($event->operation == SubmanSubscriberUserModified::OP_POST_CREATE) {
      $this->notifyNewSubscriber($event->subscriber);
    }
  }

  /**
   * Welcome a newly create subscriber.
   *
   * By default this is done by triggering the register_admin_created mail.
   *
   * @param \Drupal\user\UserInterface $subscriber
   */
  protected function notifyNewSubscriber(UserInterface $user): void
  {
    if ($user instanceof UserInterface) {
      // Register with no admin approval required.
      _user_mail_notify('register_no_approval_required', $user);
    }
  }

  /**
   * Updates the subscriber by an external id.
   *
   * Determines the customer with given id and triggers subman to update the
   * subscriber with it.
   *
   * @param string $saasSubscriberId
   *   The id of the customer to get updated data on.
   */
  protected function updateUserByExternalId($saasSubscriberId): void
  {
    // Get complete data for customer (incl. subscriptions).
    $data = $this->getSubscriberDataComplete($saasSubscriberId);

    // Do the actual updating.
    $this->createOrUpdateUserByData($data);
  }

  /**
   * Updates basic subscriber fields with provided data.
   *
   * To extend this updating, override this method in derived/overridden class,
   * with parent::updateSubscriber(...) called last for saving.
   *
   * @param array $data
   *   Array with the subscriber data as it comes from the specific SaaS,
   *   including '_normalized' => ['id' => ..., 'email' => ..., 'active' => bool].
   * @param \Drupal\user\UserInterface $subscriber
   *   The content entity to us as subscriber and update with $data. Will be looked up, if empty.
   */
  protected function createOrUpdateUserByData(array $data, UserInterface $subscriber = NULL): void
  {
    $norm = $data[static::DATA_KEY_SUBSCRIBER][static::MAP_NORMALIZED_KEY];

    // Lookup subscriber if none is specified. Create user account, if not yet existing:
    $subscriber = $subscriber ?? $this->lookupSubscriber($data[static::DATA_KEY_SUBSCRIBER], TRUE);

    // Abort with exception if no subscriber could be determined by now.
    if (empty($subscriber) || empty($subscriber->id())) {
      throw new SubmanWebhookSubscriberMissOrConflictException('Could neither find subscriber by primary ID: "' . $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID] . '" nor by secondary (' . static::DRUPAL_FIELDNAME_ID_SECONDARY . '): ' . ($data[static::SAAS_FIELDNAME_ID_SECONDARY] ?? ''));
    }

    // Save sync data into field.
    $this->storeSyncData($subscriber, $data);

    // Do any additional steps.
    $this->updateSubscriberFields($subscriber, $data);

    // Save updated entity, if still desirable.
    if (empty($subscriber->_subman_abort_save)) {
      $isNew = $subscriber->isNew();
      // Trigger event:
      $event = new SubmanSubscriberUserModified($subscriber, $isNew ? SubmanSubscriberUserModified::OP_PRE_CREATE : SubmanSubscriberUserModified::OP_PRE_UPDATE);
      $this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);

      // Save the changes to the user.
      $subscriber->save();

      // Trigger event:
      $event = new SubmanSubscriberUserModified($subscriber, $isNew ? SubmanSubscriberUserModified::OP_POST_CREATE : SubmanSubscriberUserModified::OP_POST_UPDATE);
      $this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);

      // Log what we did.
      $this->utils->log('createOrUpdateSubscriberByData(): Subscriber @entity_id @message_op for external id:@id/email@:email', [], [
        '@entity_id' => $subscriber->id(),
        '@id' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID],
        '@email' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL] ?? $subscriber->get('mail')->value ?? '',
        '@message_op' => $isNew ? 'created' : 'updated',
      ], 'debug');
    }
  }

  /**
   * Update the subscriber fields. May be extended or overwritten by custom implementations.
   *
   * By default updates the external ID, email address and the status.
   *
   * @param \Drupal\user\UserInterface $subscriber
   *   The user / content entity of the subscriber, incl. current sync data in
   *   field self::DRUPAL_FIELDNAME_SYNC.
   * @param array $data
   *   The sync data as given by caller.
   */
  protected function updateSubscriberFields(UserInterface $subscriber, array $data): void
  {
    // Update external ID, mail and status:
    $norm = $data[static::DATA_KEY_SUBSCRIBER][static::MAP_NORMALIZED_KEY];
    if (isset($norm[static::MAP_NORMALIZED_SUBSCRIBER_ID]) && $subscriber->hasField(static::DRUPAL_FIELDNAME_SAAS_ID)) {
      $subscriber->set(static::DRUPAL_FIELDNAME_SAAS_ID, $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID]);
    }
    // Update email address from remote. Typically useful with setting: "disable_drupal_mail_edit"
    if (isset($norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL]) && $subscriber->hasField('mail')) {
      $currentMail = $subscriber->get('mail');
      if (empty($currentMail) || \Drupal::config('subman.settings')->get('fetch_overwrite_mail')) {
        $subscriber->set('mail', $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL]);
      }
    }
    if (isset($norm[static::MAP_NORMALIZED_SUBSCRIBER_ACTIVE]) && $subscriber->hasField('status')) {
      // @todo: Do we also need to consider MAP_NORMALIZED_SUBSCRIBER_DELETED?
      if (\Drupal::config('subman.settings')->get('fetch_overwrite_status')) {
        $subscriber->set('status', $norm[static::MAP_NORMALIZED_SUBSCRIBER_ACTIVE]);
      }
    }
  }

  /**
   * {@inheritDoc}
   */
  public function getUserBySaasId($saasSubscriberId): ?UserInterface
  {
    $subscriber = $this->utils->retrieveEntityByField('user', [static::DRUPAL_FIELDNAME_SAAS_ID => $saasSubscriberId]);
    return $subscriber;
  }

  /**
   * Looks up a subscriber entity (here: user) via the provided data.
   *
   * This method assumes the user entity type is used as subscriber.
   * To change the entity type to be used as subscriber, or how to look it up,
   * override this method in derived/overridden service class.
   *
   * @todo Consider and decide whether to make this entity type independent.
   *
   * @param array $data
   *   Array of data, see updateSubscriber().
   * @param bool $createIfNotFound
   *   A flag whether or not to create an empty entity if none found.
   *
   * @return ?\Drupal\user\UserInterface
   *   The subscriber entity found (if any).
   */
  protected function lookupSubscriber(array $data, bool $createIfNotFound = FALSE): ?UserInterface
  {
    $norm = $this->normalize('subscriber', $data);

    $id_primary = $norm[static::MAP_NORMALIZED_SUBSCRIBER_ID];
    $id_secondary_drupal = $this->utils->getSetting('mapping.subscriber.secondary_id.drupal', static::DRUPAL_FIELDNAME_ID_SECONDARY);
    $id_secondary_saas = $this->utils->getSetting('mapping.subscriber.secondary_id.saas', static::SAAS_FIELDNAME_ID_SECONDARY);

    // Don't create new user if subscriber is deleted:
    $createIfNotFound = $createIfNotFound & !($norm[static::MAP_NORMALIZED_SUBSCRIBER_DELETED] ?? FALSE);

    // First approach: try to find a subscriber by id:
    $approach = 1;
    $subscriber = $this->getUserBySaasId($id_primary);

    // Second approach: try to find a subscriber by secondary identifier
    // (e.g. email) but only use that user if it has not already a different
    // external id:
    if (!$subscriber) {
      $subscriber = $this->utils->retrieveEntityByField('user', [$id_secondary_drupal => $data[$id_secondary_saas]]);
      // If user found already has a different external id assigned, make sure
      // we return no result. This will lead to an "Subscriber entity could not
      // be found or created" exception being thrown and logged in the caller
      // (e.g. updateSubscriberByData), wherefrom it can be monitored and thus
      // used to trigger an alert. But this will more importantly prevent
      // creation of duplicate users.
      if ($subscriber && !$subscriber->get(static::DRUPAL_FIELDNAME_SAAS_ID)->isEmpty() && $subscriber->get(static::DRUPAL_FIELDNAME_SAAS_ID)->value != $id_primary) {
        $approach = 2;
        $subscriber = NULL;
      }
    }

    // If no approach succeeded, create empty new user, if desired.
    if ((!$subscriber || empty($subscriber->id())) && $createIfNotFound && $approach == 1) {
      if (empty($norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL])) {
        $external_id = $this->extractSubscriberIdFromData($data);
        $this->utils->log('User creation was skipped because no email address was given for subscriper id: @subscriber_id',
          NULL,
          [
            '@subscriber_id' => $external_id,
          ],
          'info'
        );
      }

      $userdata = [
        'name' => $this->generateUsername($data),
        'mail' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL],
        'init' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_EMAIL],
        'status' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_ACTIVE] && !$norm[static::MAP_NORMALIZED_SUBSCRIBER_DELETED],
        'langcode' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_LANGUAGE] ?: NULL,
        'preferred_langcode' => $norm[static::MAP_NORMALIZED_SUBSCRIBER_LANGUAGE],
      ];
      $subscriber = User::create($userdata);
      $subscriber->enforceIsNew();

      // User will be saved later with its data.

      $this->utils->log('Created a new user: %name',
        $userdata,
        [
          '%name' => $userdata['name']
        ]
      );
    }

    return $subscriber;
  }

  /**
   * Determine a new user's name from given data.
   *
   * @param array $data
   *   The structured subscriber data array.
   *
   * @return string
   *   The determined username.
   *
   * @throws SubmanException If the generated username is empty
   */
  protected function generateUsername(array $data): string
  {
    // Assign username as "firstname lastname".
    $name_parts = [
      $data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_FIRSTNAME],
      $data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_LASTNAME],
    ];
    $name = trim(implode(' ', $name_parts));

    // Fall back to the email address if empty:
    if (empty($name)) {
      $name = $data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_EMAIL];
    }

    // If user with same username exists, append part of the external user ID to make username unique.
    if (!empty($this->utils->retrieveEntityByField('user', ['name' => $name]))) {
      $name .= ' ' . substr($data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_ID], -5);
    }

    if (empty($name)) {
      throw new SubmanException('Generated Username may not be empty!');
    }

    return trim($name);
  }

  /**
   * {@inheritDoc}
   */
  public function deleteUser($saasSubscriberId, $cancelationMethod = 'user_cancel_delete'): void
  {
    // Lookup subscriber.
    $subscriber = $this->lookupSubscriber([static::MAP_NORMALIZED_KEY => [static::MAP_NORMALIZED_SUBSCRIBER_ID => $saasSubscriberId]], FALSE);

    // Delete the subscriber.
    if ($subscriber) {
      // Notify others of subscriber deletion. We do this short before the execution:
      $event = new SubmanSubscriberUserModified($subscriber, SubmanSubscriberUserModified::OP_PRE_DELETE);
      $this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);

      if ($subscriber->getEntityTypeId() == 'user') {
        // @improve: https://www.drupal.org/project/drupal/issues/3408997
        user_cancel([], $subscriber->id(), $cancelationMethod);
      } else {
        $subscriber->delete();
      }

      $event = new SubmanSubscriberUserModified($subscriber, SubmanSubscriberUserModified::OP_POST_DELETE);
      $this->utils->dispatchEvent(SubmanSubscriberUserModified::EVENT_NAME, $event);

      $this->utils->log('deleteSubscriber(): Subscriber @id deleted.', NULL, ['@id' => $saasSubscriberId], 'info');
    } else {
      throw new SubmanWebhookSubscriberMissOrConflictException('Subscriber not found', 0, NULL, $saasSubscriberId);
    }
  }

  /**
   * Save sync data into field on subscriber.
   *
   * @param \Drupal\user\UserInterface $subscriberUser
   *   The subscriber.
   * @param array $sync
   *   The sync data.
   */
  protected function storeSyncData(UserInterface $subscriberUser, array $sync = []): void
  {
    if ($subscriberUser->hasField(static::DRUPAL_FIELDNAME_SYNC)) {
      $sync[static::DATA_KEY_TIMESTAMP] = $this->utils->getRequestTime();
      $sync = json_encode($sync, JSON_PRETTY_PRINT);
      $subscriberUser->set(static::DRUPAL_FIELDNAME_SYNC, $sync);
    }
  }

  /**
   * {@inheritDoc}
   */
  public function syncUser(UserInterface $subscriberUser, bool $forceFullLookup = FALSE, $catchExceptions = FALSE): bool
  {
    $external_id = NULL;

    try {
      // First check whether external_id is part of stored sync info.
      if (!$subscriberUser->get(static::DRUPAL_FIELDNAME_SYNC)->isEmpty()) {
        $data = $this->getUserSaasData($subscriberUser, static::DATA_KEY_SUBSCRIBER);
        $external_id = $this->extractSubscriberIdFromData($data);
      }

      // Alternatively perform a full lookup, if necessary or explicitely called
      // for.
      if (empty($external_id) || $forceFullLookup) {
        $data = $this->fetchUserSaasData($subscriberUser);
        $external_id = $this->extractSubscriberIdFromData($data);
      }

      // If external subscriber data found, update local subscriber with data ...
      if (!empty($external_id)) {
        $data = $this->getSubscriberDataComplete($external_id);
        $this->createOrUpdateUserByData($data, $subscriberUser);
      }
      // ... or with empty sync data/timestamp to avoid future futile sync.
      else {
        $this->storeSyncData($subscriberUser);
        $subscriberUser->save();
      }

      return TRUE;
    } catch (\Exception $e) {
      if ($catchExceptions) {
        $this->utils->log(
          $this->t('syncSubscriberSafe(): An error occurred while syncing the @subscriber. Exception @ex_message occured at @ex_position during operation.', [
            '@ex_message' => $e->getMessage(),
            '@ex_position' => $e->getFile() . ':' . $e->getLine(),
            '@subscriber' => $subscriberUser->toLink()->toString(),
          ]),
          [],
          ['exception' => $e],
          'error'
        );
        return FALSE;
      } else {
        throw $e;
      }
    }
  }

  /**
   * Extracts the subscriber ID from the given sync data structure.
   *
   * @param array $data
   *   Sync data strucure to get the subscriber ID from.
   *
   * @return string|null
   *   The extracted subscriber ID, or NULL if none found.
   */
  protected function extractSubscriberIdFromData(array $data): ?string
  {
    if (is_array($data) && isset($data[static::MAP_NORMALIZED_KEY]) && isset($data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_ID])) {
      return $data[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIBER_ID];
    } else {
      return NULL;
    }
  }

  /**
   * {@inheritDoc}
   */
  public function handleUserLogin(AccountInterface $account): void
  {
    $user = User::load($account->id());
    if ($user->hasField(static::DRUPAL_FIELDNAME_SYNC) && $user->get(static::DRUPAL_FIELDNAME_SYNC)->isEmpty()) {
      $this->syncUser($user);
    }
  }

  /**
   * {@inheritDoc}
   */
  public function getUserSubscriptions(UserInterface $subscriber = NULL, bool $limitToValid = TRUE): array
  {
    $user = $this->utils->getCurrentUser($subscriber);
    $result = [];
    $subscriptions = $this->getUserSaasData($user, static::DATA_KEY_SUBSCRIPTION) ?? [];
    foreach ($subscriptions as $subscription) {
      if (!$limitToValid || $subscription[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_VALID]) {
        $result[$subscription[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_ID]] = $subscription;
      }
    }
    return $result;
  }

  /**
   * {@inheritDoc}
   */
  public function getUserSaasId(UserInterface $user): ?string
  {
    return !$user->get(static::DRUPAL_FIELDNAME_SAAS_ID)->isEmpty() ? $user->get(static::DRUPAL_FIELDNAME_SAAS_ID)->value : NULL;
  }

  /**
   * {@inheritDoc}
   */
  public function isUserSubscriber(UserInterface $user = NULL): bool
  {
    $user = $this->utils->getCurrentUser($user);
    return $user !== NULL && ($this->getUserSaasId($user) !== NULL);
  }

  /**
   * {@inheritDoc}
   */
  public function getUserSaasData(UserInterface $user, string $key = NULL, bool $decodeJson = TRUE): mixed
  {
    $data = $user->get(static::DRUPAL_FIELDNAME_SYNC);
    $data = $data ? $data->value : '';
    if (($key !== NULL || $decodeJson) && !empty($data)) {
      $data = json_decode($data, TRUE);
    }
    return empty($key) ? $data : $data[$key] ?? NULL;
  }

  /**
   * Retrieves the normalized subscriber data from the SaaS that matches the given user.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user to match.
   *
   * @return ?array
   *   The raw external data as array or NULL if none found.
   */
  protected function fetchUserSaasData(UserInterface $user): ?array
  {
    $data = NULL;

    // First approach: look up saas customer by external id field.
    $id = $user->get(static::DRUPAL_FIELDNAME_SAAS_ID)->value;
    if (!empty(($id))) {
      $data = $this->lookupUserSaasDataByKey(trim($id));
    }

    // Second approach: look up saas customer by secondarty criteria
    // (such as email).
    if (empty($data)) {
      $id_secondary = trim($this->getSecondaryId($user));
      if (!empty($id_secondary)) {
        $data = $this->lookupUserSaasDataByKey($id_secondary, FALSE);
      }
    }

    $this->normalize(static::DATA_KEY_SUBSCRIBER, $data);

    return $data;
  }

  /**
   * {@inheritDoc}
   */
  abstract protected function lookupUserSaasDataByKey(string $key, bool $keyIsSaasPrimaryId = TRUE): ?array;

  /**
   * {@inheritDoc}
   */
  public function getSecondaryId(UserInterface $user): ?string
  {
    $id_secondary_drupal = $this->utils->getSetting('mapping.subscriber.secondary_id.drupal', static::DRUPAL_FIELDNAME_ID_SECONDARY);
    $secondary_id = trim($user->get($id_secondary_drupal)->value);
    return $secondary_id;
  }

  /**
   * Retrieves external subscriber by the given external id.
   *
   * @param string $saasSubscriberId
   *   The external id for a subscriber to retrieve the subscriber data by.
   *
   * @return array
   *   The array with the raw external data.
   */
  abstract protected function fetchSubscriberData(string $saasId): array;

  /**
   * {@inheritDoc}
   */
  public function getSubscriberData(string $saasSubscriberId): array
  {
    $data = $this->fetchSubscriberData($saasSubscriberId);
    $this->normalize(static::DATA_KEY_SUBSCRIBER, $data);
    return $data;
  }

  /**
   * {@inheritDoc}
   */
  public function getSubscriberDataComplete(string $saasSubscriberId): array
  {
    $data = [
      static::DATA_KEY_SUBSCRIBER => $this->getSubscriberData($saasSubscriberId),
      static::DATA_KEY_SUBSCRIPTION => $this->getSubscriberSubscriptionsData($saasSubscriberId),
    ];
    return $data;
  }

  /**
   * Retrieves external subscription by the given external id.
   *
   * @param string $saasSubscriptionId
   *   The external id for a subscription to retrieve the subscription data by.
   *
   * @return array
   *   The array with the raw external data.
   */
  abstract protected function fetchSubscriptionData(string $saasId): array;

  /**
   * {@inheritDoc}
   */
  public function getSubscriptionData(string $saasSubscriptionId): array
  {
    $data = $this->fetchSubscriptionData($saasSubscriptionId);
    $this->normalize(static::DATA_KEY_SUBSCRIPTION, $data);
    return $data;
  }

  /**
   * Retrieves external subscriptions by the given external id.
   *
   * @param string $saasSubscriberId
   *   The external id for a subscriber to retrieve the subscriptions data for.
   *
   * @return array
   *   The array with the raw external data.
   */
  abstract protected function fetchSubscriberSubscriptionsData(string $saasSubscriberId): array;

  /**
   * {@inheritDoc}
   */
  public function getSubscriberSubscriptionsData(string $saasSubscriberId): array
  {
    $data = $this->fetchSubscriberSubscriptionsData($saasSubscriberId);
    $data = $this->normalizeMultiple(static::DATA_KEY_SUBSCRIPTION, $data);

    if (!empty($data)) {
      foreach ($data as $key => $subscription) {
        $saasSubscriptionId = $subscription[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_ADDON_ID];
        $data[$key][static::DATA_KEY_ADDONS] = $this->getSubscriptionAddonsData($saasSubscriptionId);
      }
    }

    return $data;
  }

  /**
   * Retrieves external subscription addons by the given external susbcription id.
   *
   * @param string $saasSubscriptionId
   *   The external id for a subscription to retrieve the subscriptions data for.
   *
   * @return array
   *   The array with the raw external data.
   */
  abstract protected function fetchSubscriptionAddonsData(string $saasSubscriptionId): array;

  /**
   * {@inheritDoc}
   */
  public function getSubscriptionAddonsData(string $saasSubscriptionId): array
  {
    $data = $this->fetchSubscriptionAddonsData($saasSubscriptionId);
    $data = $this->normalizeMultiple(static::DATA_KEY_ADDONS, $data);
    return $data;
  }

  /**
   * Retrieve data of subscription types from saas service.
   *
   * @return array
   *   The array with the raw external data.
   */
  abstract protected function fetchSubscriptionTypesData();

  /**
   * {@inheritDoc}
   */
  public function getSubscriptionType(string $typeId): ?array
  {
    if (empty($typeId)) {
      return NULL;
    }

    // Get subscription types (probably from cache).
    $types = $this->getSubscriptionTypes();

    // If not subscription type foud for specified type_id, try again without
    // cache.
    if (!isset($types[$typeId])) {
      $types = $this->getSubscriptionTypes(TRUE);
    }

    return $types[$typeId] ?? NULL;
  }

  /**
   * {@inheritDoc}
   */
  public function getSubscriptionTypes(bool $forceSaasRetrieval = FALSE): array
  {
    // First try if data is cached.
    $types = $this->utils->loadCachedData('subscription_types');

    // If no cache hit, retrieve data and store it in cache as associative
    // array.
    if (empty($types) || $forceSaasRetrieval) {
      $types = $this->fetchSubscriptionTypes();
    }

    return $types;
  }

  /**
   * Freshly retrieves subscription types.
   *
   * @return array
   *   The array of subscription type data, keyed by subscription type id.
   */
  protected function fetchSubscriptionTypes(): array
  {
    // Retrieve from SaaS service.
    $data = $this->fetchSubscriptionTypesData();
    // Normalize.
    $types = $this->normalizeMultiple(static::DATA_KEY_SUBSCRIPTION_TYPE, $data);
    // Convert to associative arras keyed by subscription type id.
    $types_assoc = [];
    foreach ($types as $type) {
      $type_id = $type[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_TYPE_ID];
      $types_assoc[$type_id] = $type;
    }
    $types = $types_assoc;
    // Store associative array.
    $this->utils->saveData('subscription_types', $types);

    return $types;
  }

  /**
   * Normalizes the given data for the given type as defined by config.
   *
   * You can define config based normalization for TYPE via:
   *   $settings['subman.settings']['mapping.TYPE'] = [
   *     'field_city.value' => 'Address.City',
   *   ];
   *
   * @param string $type
   *   The type to normalize (e.g. subscriber or subscription).
   *
   * @param ?array $data
   *   Customer data array as received by the subscription management service.
   *
   * @return array
   *   Array with normalized only data structured as following:
   *     'id'      => ... // String: The id of the subscriber
   *     'email'   => ... // String: the email address of the subscriber
   *     'created' => ... // Int: unix timestamp of the creation date of the
   *     subscriber
   *     'active'  => ... // Boolean: Whether the subscriber is active
   *     'deleted' => ... // Boolean: Whether the subscriber is deleted
   */
  protected function getNormalizedData(string $type, array $data = NULL): array
  {
    $normalized = [];

    // Get mapping.
    $mapping = $this->utils->getSetting("mapping.$type") ?? [];

    // For each mapping definition...
    foreach ($mapping as $norm_key => $orig_key) {
      // ... traverse original data structure to retrieve original value ...
      $orig_value = $data;
      foreach (explode('.', $orig_key) as $orig_key_part) {
        if (isset($orig_value[$orig_key_part])) {
          $orig_value = $orig_value[$orig_key_part];
        } else {
          $orig_value = NULL;
          break;
        }
      }
      // ... and assign original value to the normalized key.
      if ($orig_value != NULL) {
        $normalized[$norm_key] = $orig_value;
      }
    }

    return $normalized;
  }

  /**
   * Adds normalized form for and to given data.
   *
   * For more details see ::getNormalizeData().
   *
   * @param string $type
   *   The type to normalize (e.g. subscriber or subscription).
   * @param array $data
   *   Customer data array as received by the subscription management service.
   */
  protected function normalize(string $type, array &$data = NULL): array
  {
    if (!isset($data[static::MAP_NORMALIZED_KEY])) {
      $data[static::MAP_NORMALIZED_KEY] = $this->getNormalizedData($type, $data);
    }
    return $data[static::MAP_NORMALIZED_KEY];
  }

  /**
   * Normalize an array of multiple data items of given type.
   *
   * @param string $type
   *   The data type key to normalize for.
   * @param array $data
   *   The array of data items (each being nested array itself).
   *
   * @return array
   *   The array of data items now augmented with normalized data.
   */
  protected function normalizeMultiple(string $type, array $data): array
  {
    $normalized = [];
    foreach ((array) $data as $data_item) {
      $this->normalize($type, $data_item);
      $normalized[] = $data_item;
    }
    return $normalized;
  }

  /**
   * {@inheritDoc}
   */
  public function buildSubscriptionTitle(array $subscriptionData)
  {
    $subscription_id = $subscriptionData[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_ID];
    $subscription_reference = $subscriptionData[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_REFERENCE];
    $subscription_variant_id = $subscriptionData[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_VARIANT];
    $subscription_variant = $this->getSubscriptionType($subscription_variant_id);
    $subscription_type_name = $subscription_variant[static::MAP_NORMALIZED_KEY][static::MAP_NORMALIZED_SUBSCRIPTION_TYPE_NAME];
    return ($subscription_reference ?? $subscription_id) . ' – ' . $subscription_type_name;
  }

  /**
   * {@inheritDoc}
   */
  public function buildEmbed(string $url): array
  {
    $build = [
      '#type' => 'inline_template',
      '#template' => '<iframe class="subman-embed" src="{{ url }}"></iframe>',
      '#cache' => ['max-age' => 0],
      '#context' => [
        'url' => $url,
      ],
      '#attached' => [
        'library' => ['subman/iframe_embed'],
      ],
    ];

    return $build;
  }

  /**
   * {@inheritDoc}
   */
  public function buildEmbedSignup(string $embedParameter): array
  {
    return [];
  }

  /**
   * {@inheritDoc}
   */
  public function buildEmbedSelfservice(string $embedParameter): array
  {
    return [];
  }

  /**
   * Send the notification of given key to given user.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user to send the notification to.
   * @param string $notificationKey
   *   The notification to send. Can be configured via:
   *     $settings['subman.settings']['notifications']['trial_ending'] = [
   *       'subject' => 'A notification from [site:name]',
   *       'body' => "Your subscription on [site:name] is there for you.",
   *     ];.
   */
  protected function notifyUser(UserInterface $user, string $notificationKey)
  {
    $notifications = [];

    // Add Trial ending notification, if enabled:
    if (\Drupal::config('subman.settings')->get('notifications.trial_ending.enabled')) {
      $notifications[static::NOTIFY_TRIAL_ENDING] = [
        'subject' => \Drupal::config('subman.settings')->get('notifications.trial_ending.subject'),
        'body' => \Drupal::config('subman.settings')->get('notifications.trial_ending.body'),
      ];
    }

    $notification = $notifications[$notificationKey] ?? NULL;

    if (!empty($notification) && isset($notification['body'])) {
      // Translate template, subject and text.
      $subject = $this->t($notification['subject'], [], ['langcode' => $user->getPreferredLangcode()]);
      $text = $this->t($notification['body'], [], ['langcode' => $user->getPreferredLangcode()]);

      // Replace tokens using $user for [user].
      $token_service = \Drupal::token();
      $data = ['user' => $user];
      $subject = $token_service->replace($subject, $data, ['clear' => TRUE]);
      $text = $token_service->replace($text, $data, ['clear' => TRUE]);

      // Send notification.
      $result = $this->notifyUserSend($user, $subject, $text, $notificationKey);

      // Log any fail.
      if (!$result) {
        $this->utils->log('notifyUser(): Failed sending notification @notification_key to @user', [], [
          '@notification_key' => $notificationKey,
          '@user' => $user->id(),
        ], 'error');
      }
    }
  }

  /**
   * Actually send a notification.
   *
   * By default, mail is used. To use a different notification channel,
   * override this method in a derived and swapped out service class.
   *
   * @param \Drupal\user\UserInterface $user
   *   The user to notify.
   * @param string $subject
   *   The subject text of the notification.
   * @param string $text
   *   The body text of the notification.
   * @param string $notificationKey
   *   The key specifying of the notification.
   *
   * @return bool
   *   Whether the operation was successful (true) or not (false).
   */
  protected function notifyUserSend(UserInterface $user, string $subject, string $text, string $notificationKey): bool
  {
    $mailManager = \Drupal::service('plugin.manager.mail');
    $module = 'subman';
    $key = 'notification';
    $to = $user->getEmail();
    $langcode = $user->getPreferredLangcode();
    $params['notification_subject'] = $subject;
    $params['notification_text'] = $text;
    $params['notification_key'] = $notificationKey;
    $send = TRUE;
    $result = $mailManager->mail($module, $key, $to, $langcode, $params, NULL, $send);
    return (($result['result'] ?? FALSE) === TRUE);
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc