Background

Enrollment Flow Plugins implement functionality for Enrollment Flows.

Naming Conventions

(info) Naming conventions are not mandatory, but make it easier to understand what a Plugin does.

  1. Enrollment Flow Plugin Entry Point Models should be named after their function, eg WidgetCollector or WidgetVerifier.
  2. Additional Models used to store Petition state should be prefixed Petition, eg PetitionWidgets.

Enrollment Flow Plugin Expectations

Enrollment Flows are executed in two parts: Collection and Finalization. Enrollment Flow Plugins are configured to run in a given order as part of an Enrollment Flow, each instantiation of an Enrollment Flow Plugin is an Enrollment Flow Step. The same order is used for both Collection and Finalization.

Collection

During Collection, each Enrollment Flow Step is run in order by calling the configured Entry Point Model's dispatch() function. During Collection, Plugins should not manipulate operational records (and indeed there will not be a new Person created yet for new enrollments), but should instead maintain interim state in Plugin specific petition_ tables.

Finalization

When the last Enrollment Flow Step has run, Finalization takes place, and consists of several phases:

  1. Hydration. The Petition is updated to Finalizing status. If no Person is already associated with the Petition, a new Person is created (with no attributes). Then, each Plugin is checked for a hydrate() function in the same order as the Enrollment Flow Step configuration. If present, the function is called.
    1. During hydration, the Plugin should create any operational attributes that are not dependent on other attributes having been created (an example of the latter might be Identifiers, since Identifier Assignment has not yet run).
    2. The Plugin may terminate the Enrollment Flow by throwing an Exception. Throwing an \OverflowException will cause the Petition to be flagged as a duplicate. Throwing any other type of exception will cause an error to be recorded in the Petition History and will interrupt the Flow, but will not otherwise cause the Petition to terminate (allowing an administrator to restart it). In general, Plugins should not throw an Exception unless absolutely necessary.
  2. Assign Identifiers. After Hydration, Identifier Assignment runs.
    1. Enrollment Flow Plugins are not called during this phase.
  3. Derivation. Each Plugin is checked for a derive() function in the same order as the Enrollment Flow Step configuration. If present, the function is called.
    1. During Derivation, Plugins should perform any tasks that are dependent on attributes created in the previous phases.
    2. Plugins may not interrupt Derivation. Any Exception will be logged, but then the next Step will be called.
  4. Provisioning. Provisioning is called (with the Enrollment context, so Provisioners in Enrollment Only mode will be invoked). The Petition is then updated to Finalized status and the actor redirected to the appropriate redirect target.

Entry Point Model

Each Entry Point Model for Enrollment Flow Plugins will be made available when a new Enrollment Flow Step is defined. The corresponding controller should extend StandardEnrollerController, and implement an edit-only fields.inc.

(info) Plugins can be instantiated multiple times in the same Enrollment Flow (as multiple Steps).

The Entry Point Model's Controller is expected to implement two functions: dispatch() and display() (described below). The model should set permissions for both of these functions to true, as detailed permission calculation will be handled by StandardEnrollerController.

In addition, the Entry Point Model may optionally implement the additional calls as described below.

public function initialize(array $config): void {
  ...

  $this->setAllowLookupPrimaryLink(['dispatch', 'display']);
  
  $this->setPermissions([
    'entity' => [
      'delete'   => false, // delete the parent object instead
      'dispatch' => true,  // StandardEnrollerController will handle this
      'display'  => true,  // StandardEnrollerController will handle this
      'edit' =>     ['platformAdmin', 'coAdmin'],
      'view' =>     ['platformAdmin', 'coAdmin']
    ],
    'table' => [
      'add' =>      false, // This is added by the parent model
      'index' =>    ['platformAdmin', 'coAdmin']
    ],
    ...
  ]);
}

dispatch()

When an Enrollment Flow Step executes during Collection, StandardEnrollerController will perform authorization and validation checks before passing through to the Plugin's dispatch function. The function will be passed the instantiated plugin ID. A utility function is available to obtain the current Petition.

Plugins should process all actions through dispatch to avoid complications with calculating permissions. While ordinarily the request type should be sufficient to distinguish actions (eg: GET to render a form, POST to process it), plugins can also insert flags into the request URL or form data to track state (eg: /dispatch/2?petition_id=18&action=verify).

Views required by dispatch() should be generated by creating a view template dispatch.inc. Doing so will leverage standard infrastructure to create a form and insert Petition and (if applicable) Token information so that the Plugin does not need to worry about carrying this metadata across the form submission. The view variable $vv_petition will have information about the current Petition.

(info) Plugins should not update operational records as part of dispatch(). Instead, state should be saved in Plugin-specific tables.

(warning) An Enrollment Flow Step can be rerun if the Petition is not yet considered complete. Plugins should present already submitted data, or otherwise behave in a manner that permits the Actor to change their previous decisions.

// In the Plugin Entry Point Model's Controller

public function dispatch(string $id) {
  $petition = $this->getPetition();

  if($this->request->is(['post', 'put']) {
    try {
      // Back from the form, do something with the data
      $data = $this->request->getData();

      // On success, indicate the step is completed and generate a redirect to the next step

      $link = $this->getPrimaryLink(true);

      return $this->finishStep(
        enrollmentFlowStepId: $link->value,
        petitionId:           $petition->id,
        // This comment will be stored in the PetitionStepResult artifact
        comment:              __d('widget_enroller', 'result.widget.saved')
      );
    }
    catch(\Exception $e) {
       $this->Flash->error($e->getMessage());
    }
  }

  // Let the form render (for GETs and failed POSTs)
  $this->render('/Standard/dispatch');
}

Under certain circumstances, it may be necessary for the Plugin to construct its own URLs, and in doing so may need to insert the Petition token for actions performed by unregistered Enrollees. A utility call is provided to facilitate this:

// In the Plugin Entry Point Model's Controller

public function dispatch(string $id) {
  $petition = $this->getPetition();

  // ... something happened ...


  $url = [
    'plugin'      => 'MyEnroller',
    'controller'  => 'widget_processors',
    'action'      => 'dispatch',
    $myConfig->id,
    '?' => [
      'op'          => 'reprocess',
      'petition_id' => $petition->id
    ]
  ];

  $token = $this->injectToken($petition->id);

  if($token) {
    $url['?']['token'] = $token;
  }

Plugins should not store the token, or try to verify it directly. Plugins should only inject the token when staying within the same step and Actor type. Plugins should not try to switch Actor types within the same Step, or to manually redirect to another Step using the token. Improper handling of the token may introduce security risks.

If a Plugin needs to be executed by two different Actor types (eg: the Enrollee and then an Approver), it must accomplish this by instantiating as two different Enrollment Flow Steps, one for each Actor type, eg: CollectWidget and ApproveWidget.

display()

When a Petition is rendered, each configured Step will be given an opportunity to render Step-specific information. This is facilitated using CakePHP Cells, which consist of two parts:

  1. The Cell itself, which acts as a pseudo-controller. The cell's display() function will be passed the Petition ID as a function parameter, and will also have access to the Petition object and Step configuration.
  2. The Cell template, which acts as a normal view file loaded after display() is called.
// $PLUGIN/src/View/Cell/WidgetCollectorsCell.php (name matches Entry Point Model)

namespace WidgetEnroller\View\Cell;

use Cake\View\Cell;

class WidgetCollectorsCell extends cell {
  public function display(int $petitionId): void {
    protected $_validCellOptions = [
      'vv_obj',
      'vv_step',
      'viewVars',
    ];
	
    $vv_pw = $this->fetchTable('CoreEnroller.PetitionWidgets')
      ->find()
      ->where([
        'widget_collector_id' => $this->vv_step->widget_collector->id,
        'petition_id' => $petitionId
      ])
      ->all();

    $this->set('vv_pw', $vv_pw);
  }
}

// $PLUGIN/templates/cell/WidgetCollectors/display.php (directory name matches Entry Point Model)

if(!empty($vv_pw)) {
  // Render Widget information
}

derive()

As described above, Plugins are expected not to touch operational data during Petition execution. During dispatch(), Plugins should only write to their own specific tables.

Derivation is the last opportunity for each Plugin to update the operational record based on the state it maintained during the Petition, and should be used to create operational attributes that are dependent on other operational attributes first being created. derive() will be called for each Plugin in the same order as the original Enrollment Flow Step execution. (info) If the Plugin is instantiated in multiple Steps, it will be called once for each Step, and should behave appropriately.

Any work performed by derive() in the Plugin should be done quickly, as all derivation calls to all Plugins defined in the Enrollment Flow must be completed during a single web browser request, and the longer this request takes the more likely the user is to prematurely terminate the page or for the browser to time out. For example, calls to external systems via REST APIs should not be performed in derive(), but should instead be handled by dispatch() or a separate provisioning Plugin.

If the Plugin does not implement derive(), then the Plugin is expected not to perform any derivation actions.

// In the Entry Point Model

use Cake\ORM\TableRegistry;

public function derive(int $id, \App\Model\Entity\Petition $petition): bool {
  // Pull our configuration. Note the Enrollment Flow configuration is available in the $petition
  // object as a related model.
  $widgetEnroller = $this->get($id);

  // And find the subject Person from the Petition
  $People = TableRegistry::getTableLocator()->get('People');

  $enrollee = $People->get($petition->enrollee_person_id);

  // Create a new attribute based on an existing attribute established during hydration
  // or Identifier Assignment

  return true;
}

enrolleeName()

Plugins that collect Names should implement enrolleeName(), which is used by the Petition management code to construct a display name for the Enrollee before the Petition is Finalized. (Since a Person record is not created for Enrollees until the Petition is Finalized, there is no existing Person Name to render during the Enrollment Flow.)

If multiple Plugins in a given Enrollment Flow implement this interface, the first non-null result returned (in Enrollment Flow Step order) will be used.

// In the Entry Point Model

public function enrolleeName(
  EntityInterface $config, 
  int $petitionId
): ?string {
  // Return a display string, not a Name entity or array
  return "Enrollee Name";
}

hydrate()

As described above, Plugins are expected not to touch operational data during Petition execution. During dispatch(), Plugins should only write to their own specific tables.

Hydration is the first opportunity for each Plugin to update the operational record based on the state it maintained during the Petition, and should be used to create new operational attributes that do not have any dependencies on other attributes. hydrate() will be called for each Plugin in the same order as the original Enrollment Flow Step execution. (info) If the Plugin is instantiated in multiple Steps, it will be called once for each Step, and should behave appropriately.

Any work performed by hydrate() in the Plugin should be done quickly, as all hydration calls to all Plugins defined in the Enrollment Flow must be completed during a single web browser request, and the longer this request takes the more likely the user is to prematurely terminate the page or for the browser to time out. For example, calls to external systems via REST APIs should not be performed in hydrate(), but should instead be handled by dispatch() or a separate provisioning Plugin.

If the Plugin does not implement hydrate(), then the Plugin is expected not to perform any hydration actions.

// In the Entry Point Model

use Cake\ORM\TableRegistry;

public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool {
  // Pull our configuration. Note the Enrollment Flow configuration is available in the $petition
  // object as a related model.
  $widgetEnroller = $this->get($id);

  // And find the subject Person from the Petition
  $People = TableRegistry::getTableLocator()->get('People');

  $enrollee = $People->get($petition->enrollee_person_id);

  // Add some new Person attributes here...

  return true;
}

prepare()

Plugins are given an opportunity to perform work when a handoff to the Enrollment Flow Step using the Plugin is about to happen. This is intended, eg, to allow the Plugin to put the Petition into a Pending state to indicate that an action is required to continue. Any actions taken here must be background only, and not require user input.

(warning) Plugins should not try to interrupt the Enrollment Flow using this mechanism. Doing so may irrecoverably break the Enrollment Flow.

// In the Entry Point Model

use Cake\ORM\TableRegistry;

public function prepare(
  \App\Model\Entity\EnrollmentFlowStep $step,
  \App\Model\Entity\Petition $petition
): bool {
  $Petitions = TableRegistry::getTableLocator()->get('Petitions');

  $petition->status = PetitionStatusEnum::PendingAcceptance;

  $Petitions->saveOrFail($petition);

  return true;
}

verifiableEmailAddresses()

Plugins that collect Email Addresses should implement verifiableEmailAddresses(), which is used by the Email Verifier Enroller Plugin to perform address verification. The return value is an array, where the keys are email addresses and values are true to indicate that the address was already verified, or false if the address is not verified.

Returning addresses that are already verified is optional, doing so may provide a better user experience for the Enrollee depending on the functionality of the Plugin. Regardless, Email Addresses that are already considered verified should be flagged as such by the Plugin during hydration. The Plugin should not try to determine if the Email Verifier successfully verified any unverified addresses returned by this function – the Email Verifier Enroller will update the verification status of relevant Email Addresses during its finalization step.

// In the Entry Point Model

public function verifiableEmailAddresses(
  EntityInterface $config, 
  int $petitionId
): array {
  return [
    'abc@university.nil' => true,
    'def@gmail.com' => false
  ];
}

Additional Considerations

COUs

Enroller Plugins that collect a COU for purposes of attaching to the Enrollee's Person Role should set the Petition's cou_id when the Plugin is run during dispatch() and not wait until finalization. This will allow subsequent Enrollment Flow Steps (such as approvals) to use the selected COU when they run to set appropriate context (for example, to use the members of that COU's Approvers Group for an approval Step).

Primary Names

Enroller Plugins that collect Name attributes must consider how to handle Primary Name, typically during Hydration, but potentially during Derivation. An Enrollment Flow that results in a Person without a Primary Name will result in an unusable Person record.

In general, if an Enroller Plugin collects Name attributes, it should ensure that there is a Primary Name set before completing its work. A Primary Name could have been set by a different Plugin, or if the Enrollment Flow is operating on a previously existing Person. If there is a prior Primary Name, the Plugin may determine whether to set a new one based on its context.

// In the Entry Point Model

public function hydrate(int $id, \App\Model\Entity\Petition $petition) {
  ...

  // Determine if there is already a Primary Name
  $Names = \Cake\ORM\TableRegistry::getTableLocator()->get('Names');

  try {
    $Names->primaryName($petition->enrollee_person_id);
  }
  catch(Cake\Datasource\Exception\RecordNotFoundException $e) {
    // There is no Primary Name yet, do something to set one
  }
}

Resolving Approval Notifications

For Plugins that operate with an Actor Type of Approval, the Notification to the Approvers Group will be handled by the core code. However, the core does not know when the action taken by the Plugin is sufficient to resolve the Notification, so the Plugin must handle this.

// In the Plugin Entry Point Model's Controller

public function dispatch(string $id): {
  $petition = $this->getPetition();

  // ... do some work ...

  $Notifications = TableRegistry::getTableLocator()->get('Notifications');

  // The URL was originally created by $EnrollmentFlows->calculateNextStep, but we
  // can easily reconstruct what it should be

  $url = [
    'plugin' => 'WidgetEnroller',
    'controller' => 'widget_collectors',
    'action' => 'dispatch',
    $id,
    '?' => ['petition_id' => $petition->id]
  ];

  $Notifications->resolveFromSource(
    source: $url,
    resolution: \App\Lib\Enum\NotificationStatusEnum::Resolved,
    resolverPersonId: $this->RegistryAuth->getPersonID($this->getCOID()),
  );
}

Schema Best Practices

Enroller Plugins that store state as part of the Petition artifact should prefix these table names with petition_ (and the associated model should therefore be called FooEnroller.PetitionFoo). Configuration and other models should not start with petition_. These tables should belongTo Petition (via the petition_id foreign key), and may also reference the Enrollment Flow Step or plugin configuration as appropriate.

See Also

Changes From Earlier Versions

As of Registry v5.2.0

  • The Plugin Type was corrected from enroller to enrollment_flow_step.
  • No labels