diff --git a/app/config/routes.php b/app/config/routes.php index 89664c075..2bf695e7b 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -168,6 +168,20 @@ function (RouteBuilder $builder) { * ...and connect the rest of 'Pages' controller's URLs. */ $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + + /** + * Registry allows URLs of the form /coid/name to render as a Mostly Static Page. + * + * Note this will effectively route any URL of the form /registry/x, where x consists of + * digits, to the Pages controller. We need to filter on digits, or we'll end up taking + * over all controllers as well. (The implication is we can't have a controller whose + * name consists entirely of digits, but we probably shouldn't...) + */ + $builder->connect( + '/{coid}/{name}', + ['controller' => 'Pages', 'action' => 'show' ], + ['coid' => '\d+', 'pass' => ['coid', 'name']] + ); /* * Connect catchall routes for all controllers. diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index de84a8e43..21e03464a 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -25,6 +25,7 @@ "id": { "type": "integer", "autoincrement": true, "primarykey": true }, "identifier_assignment_id": { "type": "integer", "foreignkey": { "table": "identifier_assignments", "column": "id" }, "notnull": true }, "language": { "type": "string", "size": 16 }, + "message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } }, "name": { "type": "string", "size": 128, "notnull": true }, "ordr": { "type": "integer" }, "person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, @@ -598,6 +599,22 @@ } }, + "mostly_static_pages": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "title": { "type": "string", "size": 256 }, + "description": {}, + "status": {}, + "context": {}, + "body": { "type": "text" } + }, + "indexes": { + "mostly_static_pages_i1": { "columns": [ "co_id" ] } + } + }, + "enrollment_flows": { "columns": { "id": {}, @@ -626,10 +643,13 @@ "status": {}, "plugin": {}, "ordr": {}, - "actor_type": { "type": "string", "size": 2 } + "actor_type": { "type": "string", "size": 2 }, + "message_template_id": {}, + "redirect_on_handoff": { "type": "string", "size": 256 } }, "indexes": { - "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]} + "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]}, + "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]} } }, diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po index 7341b0a35..0976cc229 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -35,6 +35,15 @@ msgstr "{0,plural,=1{Enrollment Attribute} other{Enrollment Attributes}}" msgid "enumeration.DefaultValueValidityType.after" msgstr "Days After Finalization" +msgid "error.PetitionAcceptances.exists" +msgstr "An Invitation for this Petition already exists" + +msgid "error.PetitionAcceptances.expired" +msgstr "This Invitation has expired" + +msgid "error.PetitionAcceptances.processed" +msgstr "This Invitation has already been processed" + msgid "field.BasicAttributeCollectors.affiliation_type_id" msgstr "Affiliation Type" @@ -131,8 +140,32 @@ msgstr "Telephone Number Type" msgid "field.EnrollmentAttributes.url_type_id" msgstr "URL Type" +msgid "field.InvitationAccepters.invitation_validity" +msgstr "Invitation Validity" + +msgid "field.InvitationAccepters.invitation_validity.desc" +msgstr "Time in minutes before the invitation expires, default is 1440 (1 day)" + +msgid "field.InvitationAccepters.welcome_message" +msgstr "Welcome Message" + +msgid "result.accept.accepted" +msgstr "Invitation Accepted" + +msgid "result.accept.declined" +msgstr "Invitation Declined" + msgid "result.attr.saved" msgstr "Petition Attributes recorded" msgid "result.basicattr.finalized" msgstr "Name, Email Address, and Person Role created during finalization" + +msgid "result.InvitationAccepters.accepted" +msgstr "Invitation Accepted at {0}" + +msgid "result.InvitationAccepters.declined" +msgstr "Invitation Declined at {0}" + +msgid "result.InvitationAccepters.none" +msgstr "No response to invitation yet" \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php index 56c9af772..bf3f701f8 100644 --- a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php +++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php @@ -98,10 +98,10 @@ public function dispatch(string $id) { public function display(string $id) { $petition = $this->getPetition(); - $this->set('vv_basic_petition_attribute_set', $this->BasicAttributeCollectors - ->BasicPetitionAttributeSets + $this->set('vv_petition_basic_attribute_set', $this->BasicAttributeCollectors + ->PetitionBasicAttributeSets ->find() - ->where(['BasicPetitionAttributeSets.petition_id' => $petition->id]) + ->where(['PetitionBasicAttributeSets.petition_id' => $petition->id]) ->firstOrFail()); } } diff --git a/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php new file mode 100644 index 000000000..3b377d355 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php @@ -0,0 +1,130 @@ + [ + 'InvitationAccepters.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepter ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $cfg = $this->InvitationAccepters->get($id); + + $this->set('vv_config', $cfg); + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $pa = $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first(); + + $this->set('vv_pa', $pa); + + if($this->request->is(['post', 'put'])) { + $data = $this->request->getData(); + + $accepted = (bool)$data['accepted']; + + try { + $PetitionAcceptances->processReply( + $petition->id, + $cfg->enrollment_flow_step_id, + $accepted + ); + + if($accepted) { + // On acceptance, 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, + comment: __d('core_enroller', ($accepted ? 'result.accept.accepted' : 'result.accept.declined')) + ); + } else { + // On decline, set a flash message and redirect to the petition complete landing + $this->Flash->success(__d('core_enroller', 'result.accept.declined')); + + $coId = $this->getCOID(); + + return $this->redirect("/$coId/petition-complete"); + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } else { + // Record that the invitation was viewed + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::InvitationViewed, + comment: __d('result', 'Petitions.viewed.inv') + // actorPersonId + ); + } + + // Fall through and let the form render*/ + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepters ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $this->set('vv_pa', $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first()); + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/BasicPetitionAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php similarity index 93% rename from app/plugins/CoreEnroller/src/Model/Entity/BasicPetitionAttributeSet.php rename to app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php index b810bd47d..8017d29b1 100644 --- a/app/plugins/CoreEnroller/src/Model/Entity/BasicPetitionAttributeSet.php +++ b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php @@ -1,6 +1,6 @@ + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php new file mode 100644 index 000000000..d506679eb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php index bc550daba..5ed3d6aa0 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php @@ -78,7 +78,7 @@ public function initialize(array $config): void { ->setProperty('affiliation_type'); $this->belongsTo('Cous'); - $this->hasMany('CoreEnroller.BasicPetitionAttributeSets') + $this->hasMany('CoreEnroller.PetitionBasicAttributeSets') ->setDependent(true) ->setCascadeCallbacks(true); @@ -151,10 +151,10 @@ public function finalize(int $id, \App\Model\Entity\Petition $petition) { } $People = TableRegistry::getTableLocator()->get('People'); - + $person = $People->get($petition->enrollee_person_id); - $attributes = $this->BasicPetitionAttributeSets + $attributes = $this->PetitionBasicAttributeSets ->find() ->where([ 'petition_id' => $petition->id, @@ -237,7 +237,7 @@ public function upsert(int $id, int $petitionId, array $attributes) { // _all_ attributes for the Petition, not just those associated with this // particular Attribute Collector; however we'll only look at the attributes // we need below. - $entity = $this->BasicPetitionAttributeSets + $entity = $this->PetitionBasicAttributeSets ->find() ->where([ 'petition_id' => $petitionId, @@ -250,7 +250,7 @@ public function upsert(int $id, int $petitionId, array $attributes) { if(!$entity) { // insert, not update - $entity = $this->BasicPetitionAttributeSets->newEntity([ + $entity = $this->PetitionBasicAttributeSets->newEntity([ 'basic_attribute_collector_id' => $id, 'petition_id' => $petitionId ]); @@ -261,7 +261,7 @@ public function upsert(int $id, int $petitionId, array $attributes) { $entity->$f = $attributes[$f] ?? null; } - $this->BasicPetitionAttributeSets->saveOrFail($entity); + $this->PetitionBasicAttributeSets->saveOrFail($entity); // Record Petition History diff --git a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php new file mode 100644 index 000000000..dfc844519 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php @@ -0,0 +1,208 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + + // We intentionally don't hasMany PetitionAcceptances since there should only be one + // acceptance per Petition, and the net result of not having the direct foreign key + // is that if an admin instantiates the plugin multiple times the second instance + // will refuse to do anything. + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Invitation Accepter ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + // $cfg = $this->get($id); + + // We don't have anything to do for finalization + + return true; + } + + /** + * Perform tasks prior to transitioning to this step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition + * @return bool true on success + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ): bool { + // Allocate an Invitation. We create an empty petition_acceptance to use to measure + // invitation validity (based on the created timestamp). + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $acceptance = $PetitionAcceptances->find() + ->where(['petition_id' => $petition->id]) + ->first(); + + if(!empty($acceptance)) { + // We'll allow a null (pending) invitation, but if there is already a response + // we throw an error. This probably isn't the best behavior, once we have some + // more requirements we should probably change this to do something else (eg: + // fast forward to the next step). + + if(!is_null($acceptance)) { + throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.exists')); + } + + // We don't reset the Petition status, at least pending further requirements. + } else { + // Register a new, pending invitation + + $acceptance = [ + 'petition_id' => $petition->id, + 'accepted' => null + ]; + + // If for some reason there is already an acceptance record + // for this petition we'll basically reset it + $PetitionAcceptances->saveOrFail($acceptance); + + // Set this petition to Pending Invitation + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::PendingAcceptance; + + $Petitions->saveOrFail($petition); + + // Record history + $Petitions->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $step->id, + action: PetitionActionEnum::InvitationViewed, + comment: __d('result', 'Petitions.viewed.inv') + // actorPersonId + ); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('invitation_validity', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('invitation_validity'); + + $validator->add('welcome_message', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('welcome_message'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php new file mode 100644 index 000000000..76a716cfd --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php @@ -0,0 +1,177 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + + $this->setDisplayField('accepted'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false //['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Process an Invitation reply. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param bool $accepted true if the reply was accepted, false if declined + * @throws Exceptions + */ + + public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $accepted) { + // There should already be an empty Acceptance indicating when the step was handed off + // for Invitation, and for which we need to refer to to determine the invitation validity. + // If it doesn't exist, that's an error. + + $acceptance = $this->find()->where(['petition_id' => $petitionId])->firstOrFail(); + + // If accepted is not null, we've already processed this invitation, so that's also an error. + + if(!is_null($acceptance->accepted)) { + throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.processed')); + } + + // Next check the create time of the original record. For this, we need the configuration. + + $InvitationAccepters = TableRegistry::getTableLocator()->get('CoreEnroller.InvitationAccepters'); + + $ia = $InvitationAccepters->find() + ->where(['enrollment_flow_step_id' => $enrollmentFlowStepId]) + ->firstOrFail(); + + // A validity of 0 disables expiration + + if($ia->invitation_validity > 0) { + $expires = $acceptance->created->addSeconds($ia->invitation_validity); + + if($expires->isPast()) { + throw new \RuntimeException('error.PetitionAcceptances.expired'); + } + } + + // We're finally ready to process the reply + + $acceptance->accepted = $accepted; + + $this->saveOrFail($acceptance); + + // Set the Petition status appropriately + + $petition = $this->Petitions->get($petitionId); + + $petition->status = $accepted ? PetitionStatusEnum::Accepted : PetitionStatusEnum::Declined; + + $this->Petitions->saveOrFail($petition); + + // Record Petition History + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::Finalized, + comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('accepted', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('accepted'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicPetitionAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php similarity index 97% rename from app/plugins/CoreEnroller/src/Model/Table/BasicPetitionAttributeSetsTable.php rename to app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php index d0f7bb5bb..a51e31976 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/BasicPetitionAttributeSetsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php @@ -1,6 +1,6 @@ -
  • Honorific:
  • -
  • Given:
  • -
  • Middle:
  • -
  • Family:
  • -
  • Suffix:
  • -
  • Email:
  • +
  • Honorific:
  • +
  • Given:
  • +
  • Middle:
  • +
  • Family:
  • +
  • Suffix:
  • +
  • Email:
  • diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc index b1dd114b8..23c5a986c 100644 --- a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc @@ -1,6 +1,6 @@ welcome_message . "\n"; + + // Make the Form fields editable + $this->Field->enableFormEditMode(); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'accepted', + 'fieldLabel' => "Accept this invitation?", + 'fieldOptions' => [ + 'type' => 'radio', + 'options' => [ + ['value' => '1', 'text' => 'Accept'], + ['value' => '0', 'text' => 'Decline'], + ], + 'empty' => false, + 'required' => true + ] + ]]); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php new file mode 100644 index 000000000..d1d911100 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php @@ -0,0 +1,10 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'invitation_validity', + 'fieldOptions' => [ + 'default' => 1440 + ] + ] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'welcome_message'] + ]); +} diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index fcdddd7d2..b4fab7b4a 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -102,6 +102,9 @@ msgstr "{0,plural,=1{Job} other{Jobs}}" msgid "MessageTemplates" msgstr "{0,plural,=1{Message Template} other{Message Templates}}" +msgid "MostlyStaticPages" +msgstr "{0,plural,=1{Mostly Static Page} other{Mostly Static Pages}}" + msgid "Names" msgstr "{0,plural,=1{Name} other{Names}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 080f6554b..9cd7394fe 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -381,6 +381,15 @@ msgstr "Resolved" msgid "NotificationStatusEnum.X" msgstr "Canceled" +msgid "PageContextEnum.EH" +msgstr "Enrollment Handoff" + +msgid "PageContextEnum.ER" +msgstr "Error Landing" + +msgid "PageContextEnum.G" +msgstr "General" + msgid "PermittedNameFieldsEnum.given,family" msgstr "Given, Family" @@ -432,14 +441,24 @@ msgstr "Country Code, Area Code, Number, Extension" msgid "PetitionActionEnum.AU" msgstr "Attributes Updated" +msgid "PetitionActionEnum.F" +msgstr "Finalized" + +msgid "PetitionActionEnum.IV" +msgstr "Invitation Viewed" + +msgid "PetitionActionEnum.SU" +msgstr "Status Updated" + msgid "PetitionStatusEnum.A" msgstr "Active" msgid "PetitionStatusEnum.Y" msgstr "Approved" +# This was "Confirmed" in v4 msgid "PetitionStatusEnum.C" -msgstr "Confirmed" +msgstr "Accepted" msgid "PetitionStatusEnum.CR" msgstr "Created" @@ -457,7 +476,7 @@ msgid "PetitionStatusEnum.PA" msgstr "Pending Approval" msgid "PetitionStatusEnum.PC" -msgstr "Pending Confirmation" +msgstr "Pending Acceptance" msgid "PetitionStatusEnum.PV" msgstr "Pending Vetting" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 339832b89..a0c71eb18 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -124,6 +124,9 @@ msgstr "Email Address is already verified" msgid "EmailAddresses.mail.verify.force.person" msgstr "Email Addresses not associated with People cannot be force verified" +msgid "EnrollmentFlowSteps.message_template" +msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have a Mesasge Template configured" + msgid "EnrollmentFlowSteps.none" msgstr "This Enrollment Flow has no Active steps and so cannot be run" @@ -241,6 +244,15 @@ msgstr "Job {0} is not in {1} status and cannot be set to {2} (Job is {3})" msgid "Jobs.status.invalid.cancel" msgstr "Job {0} is not in a cancelable status (Job is {1})" +msgid "MostlyStaticPages.default.delete" +msgstr "This page cannot be deleted" + +msgid "MostlyStaticPages.default.modify" +msgstr "This page cannot be renamed, suspended, or given a different context" + +msgid "MostlyStaticPages.slug.invalid" +msgstr "Slug contains invalid characters" + msgid "Names.minimum" msgstr "At least one name is required" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index c9260bfe6..60f7343fb 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -113,6 +113,9 @@ msgstr "Ends at:" msgid "ends_at.tz" msgstr "Ends at ({0})" +msgid "enrollee_email" +msgstr "Enrollee Email" + msgid "extension" msgstr "Extension" @@ -405,6 +408,18 @@ msgstr "Limit Global Search Scope" msgid "CoSettings.search_global_limited_models.desc" msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." +msgid "EnrollmentFlowSteps.actor_type" +msgstr "Actor Type" + +msgid "EnrollmentFlowSteps.message_template_id.desc" +msgstr "If a handoff is required to start this step, this Message Template will be used to notify the next Actor" + +msgid "EnrollmentFlowSteps.redirect_on_handoff" +msgstr "Redirect on Handoff" + +msgid "EnrollmentFlowSteps.redirect_on_handoff.desc" +msgstr "If a handoff is required to start this step, the original Actor will be redirected here instead of the default landing page" + msgid "EnrollmentFlows.authz_cou_id" msgstr "Authorized COU" @@ -417,15 +432,9 @@ msgstr "Petitioner Authorization" msgid "EnrollmentFlows.collect_enrollee_email" msgstr "Collect Enrollee Email" -msgid "EnrollmentFlows.enrollee_email" -msgstr "Enrollee Email" - msgid "EnrollmentFlows.redirect_on_finalize" msgstr "Redirect on Finalize" -msgid "EnrollmentFlowSteps.actor_type" -msgstr "Actor Type" - msgid "ExternalIdentitySources.hash_source_record" msgstr "Hash Source Records" @@ -603,6 +612,50 @@ msgstr "Message Subject" msgid "MessageTemplates.subject.desc" msgstr "Subject line for message to be sent. See XXX LINK supported substitutions." +msgid "MostlyStaticPages.body" +msgstr "Body" + +msgid "MostlyStaticPages.name" +msgstr "Slug" + +msgid "MostlyStaticPages.name.desc" +msgstr "The URL fragment for this Page, which must be unique and use only lowercase alphanumeric characters and dashes (-)" + +msgid "MostlyStaticPages.pageUrl" +msgstr "Page Display URL" + +msgid "MostlyStaticPages.pageUrl.desc" +msgstr "The full (public) URL to access the rendered page (this is a read only field)" + +# These are strings for the default Pages that are created for each CO. +# It's not clear they belong here, but we don't have a better place for them right now. +msgid "MostlyStaticPages.default.dh.title" +msgstr "Enrollment Flow Handoff" + +msgid "MostlyStaticPages.default.dh.description" +msgstr "Default Enrollment Flow Handoff Landing Page" + +msgid "MostlyStaticPages.default.dh.body" +msgstr "Thank you for completing this Enrollment Flow Step. No further action is required from you at this time. The next person to act on this Petition has been notified." + +msgid "MostlyStaticPages.default.el.title" +msgstr "An Error Occurred" + +msgid "MostlyStaticPages.default.el.description" +msgstr "Default Error Landing Page" + +msgid "MostlyStaticPages.default.el.body" +msgstr "An unexpected error occurred. Please contact your administrator for further assistance." + +msgid "MostlyStaticPages.default.pc.title" +msgstr "Enrollment Complete" + +msgid "MostlyStaticPages.default.pc.description" +msgstr "Default Petition Finalization Landing Page" + +msgid "MostlyStaticPages.default.pc.body" +msgstr "This Petition is complete and has been finalized. Please contact your administrator for further instructions." + msgid "Notifications.actor_person_id" msgstr "Actor" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 655ae1706..373e11a11 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -111,6 +111,9 @@ msgstr "Additional History Records may be available via Petitions and Provisioni msgid "pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "Petitions.pending" +msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time." + msgid "plugin.active" msgstr "Active" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index e626a98ad..d478605cb 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -24,6 +24,9 @@ # Operations (Commands) +msgid "accept" +msgstr "Accept" + msgid "acknowledge" msgstr "Acknowledge" @@ -123,6 +126,9 @@ msgstr "{0} Configuration" msgid "deactivate" msgstr "Deactivate" +msgid "decline" +msgstr "Decline" + msgid "delete" msgstr "Delete" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 619a44488..70eb5119f 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -166,6 +166,9 @@ msgstr "Person Role status recalculated from {0} to {1}" msgid "Petitions.finalized" msgstr "Petition Finalized" +msgid "Petitions.viewed.inv" +msgstr "Invitation Viewed" + msgid "Pipelines.complete" msgstr "Pipeline {0} complete for EIS {1} source key {2}" diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 778680f61..08561e985 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -112,6 +112,11 @@ public function configuration() { 'controller' => 'message_templates', 'action' => 'index' ], + __d('controller', 'MostlyStaticPages', [99]) => [ + 'icon' => 'article', + 'controller' => 'mostly_static_pages', + 'action' => 'index' + ], __d('controller', 'Pipelines', [99]) => [ 'icon' => 'cable', 'controller' => 'pipelines', diff --git a/app/src/Controller/MostlyStaticPagesController.php b/app/src/Controller/MostlyStaticPagesController.php new file mode 100644 index 000000000..ad1ee8171 --- /dev/null +++ b/app/src/Controller/MostlyStaticPagesController.php @@ -0,0 +1,79 @@ + [ + 'MostlyStaticPages.title' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $this->set('vv_base_url', \Cake\Routing\Router::url( + url: "/" . $this->getCOID(), + full: true + )); + + return parent::beforeRender($event); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only take over authz for display + + if(in_array($action, ['display'])) { + return 'open'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 709348c60..669a4100d 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -20,7 +20,9 @@ use Cake\Http\Exception\ForbiddenException; use Cake\Http\Exception\NotFoundException; use Cake\Http\Response; +use Cake\ORM\TableRegistry; use Cake\View\Exception\MissingTemplateException; +use \App\Lib\Enum\SuspendableStatusEnum; /** * Static content controller @@ -88,6 +90,51 @@ public function display(...$path): ?Response return $this->render(); } + /** + * Render a Mostly Static Page. + * + * @since COmanage Registry v5.1.0 + * @param string $coid CO ID + * @param string $name MSP Name (slug) + */ + + public function show(string $coid, string $name) { + // We use PagesController rather than MostlyStaticPagesController to avoid complexities + // with PrimaryLink lookups. We use show() rather than render() because the latter is + // used by Controller, and rather than display() so we don't confuse things by + // redefining it. We render here rather than redirecting into the MSPController to + // reduce URL bar thrashing. + + $MSPTable = TableRegistry::getTableLocator()->get("MostlyStaticPages"); + + $msp = $MSPTable->find() + ->where([ + 'co_id' => (int)$coid, + 'name' => $name, + 'status' => SuspendableStatusEnum::Active + ]) + ->first(); + + if(empty($msp)) { + if($name == 'error-landing') { + // error-landing should always exist, if not throw an error + + throw new NotFoundException(); + } else { + $this->Flash->error(__d('error', 'notfound', $name)); + + return $this->redirect("/$coid/error-landing"); + } + } + + $this->set('vv_bc_skip', true); // this doesn't do anything? + + $this->set('vv_title', $msp->title); + $this->set('vv_body', $msp->body); + + return $this->render('/MostlyStaticPages/display'); + } + /** * Indicate whether this Controller will handle some or all authnz. * @@ -97,6 +144,17 @@ public function display(...$path): ?Response */ public function willHandleAuth(\Cake\Event\EventInterface $event): string { - return "open"; + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only take over authz for display and show + // (These are the only two actions we currently support, but better to require + // an explicit action to add to this list) + + if(in_array($action, ['display', 'show'])) { + return 'open'; + } + + return 'no'; } } diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php index 53e746196..974d63bcf 100644 --- a/app/src/Controller/PetitionsController.php +++ b/app/src/Controller/PetitionsController.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -57,7 +57,7 @@ class PetitionsController extends StandardController { * @param EventInterface $event Cake Event * * @return Response|void - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 */ public function beforeRender(EventInterface $event) { @@ -75,7 +75,7 @@ public function beforeRender(EventInterface $event) { /** * Calculate authorization for the current request. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @return bool True if the current request is permitted, false otherwise */ @@ -106,18 +106,18 @@ public function calculatePermission(): bool { /** * Continue a Petition (re-enter an Enrollment Flow). * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param string $id Petition ID */ public function continue(string $id) { - return $this->transitionToStep(int($id)); + return $this->transitionToStep((int)$id); } /** * Finalize a Petition. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param string $id Petition ID */ @@ -140,23 +140,17 @@ public function finalize(string $id) { $this->Flash->error($e->getMessage()); } - // For now redrect to the main dashboard on error or if there is no finalize - // redirect specified. XXX When we implement error landing pages, update this. - - return $this->redirect([ - 'plugin' => null, - 'controller' => 'Dashboards', - 'action' => 'dashboard', - '?' => [ - 'co_id' => $this->getCOID() - ] - ]); + // Redirect to the default Petition Complete landing page. + + $coId = $this->getCOID(); + + return $this->redirect("/$coId/petition-complete"); } /** * Redirect into a plugin to render the result of an Enrollment Flow Step. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param string $id Petition ID */ @@ -205,9 +199,9 @@ public function result(string $id) { } /** - * Resume an Enrollment Flow. More specifically. + * Resume an Enrollment Flow. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param string $id Petition ID */ @@ -286,7 +280,7 @@ public function resume(string $id) { /** * Indicate whether this Controller will handle some or all authnz. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param EventInterface $event Cake event, ie: from beforeFilter * @return string "no", "notauth", "open", "authz", or "yes" */ @@ -295,17 +289,37 @@ public function willHandleAuth(\Cake\Event\EventInterface $event): string { $request = $this->getRequest(); $action = $request->getParam('action'); - // We only take over authz for finalize, and only if the request will be - // authenticated via Petition Token. + // We take over authz for continue (which is really just a glorified redirect, + // but which will send handoff emails under certain circumstances); and for + // finalize but only if the request will be authenticated via Petition Token. - if($action == 'finalize') { - $petitionId = (int)$this->request->getParam('pass.0'); + $petitionId = (int)$this->request->getParam('pass.0'); - if(empty($petitionId)) { - $this->llog('error', "No Petition ID specified for finalize"); - return 'notauth'; + if(!in_array($action, ['continue', 'finalize'])) { + return 'no'; + } + + if(empty($petitionId)) { + $this->llog('error', "No Petition ID specified for finalize"); + return 'notauth'; + } + + if($action == 'continue') { + // For continue, we mostly just check that if the user type is anonymous + // that a token was provided and validates. + $actorInfo = $this->getCurrentActor($petitionId); + + if($actorInfo['type'] == 'anonymous') { + if(!$actorInfo['token_ok']) { + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } } + // We'll allow any authenticated user through since continue is basically + // a redirect + return 'yes'; + } elseif($action == 'finalize') { // For finalize, the relevant Step is the last one. We'll use calculateNextStep() // to get the last Step, which will also check if the petition is already completed. $this->nextStep = $this->Petitions->EnrollmentFlows->calculateNextStep($petitionId); diff --git a/app/src/Lib/Enum/PageContextEnum.php b/app/src/Lib/Enum/PageContextEnum.php new file mode 100644 index 000000000..a7e5b5587 --- /dev/null +++ b/app/src/Lib/Enum/PageContextEnum.php @@ -0,0 +1,36 @@ +enrollee_identifier) { $ret['roles'][] = EnrollmentActorEnum::Enrollee; } + + if(empty($petition->petitioner_identifier) + && empty($petition->enrollee_identifier)) { + // We have an identifier at run time but none in the petition. + // If we can validate a token we can store the identifier and + // use it instead. (eg: An Enrollee receives an initial handoff + // email/invitation.) + + // Note in general we should only accept an Enrollee identifier + // this way. Petitioner identifiers should be collected at Petition + // start, and Approvers shouldn't use tokens. + + $tokenRoles = $this->validateToken($petition); + + if(!empty($tokenRoles) && in_array(EnrollmentActorEnum::Enrollee, $tokenRoles)) { + $this->llog('trace', "Transitioning Enrollee to authenticated identifier " + . $ret['identifier'] . " for Petition " . $petition->id); + + // Update the Petition to store the identifier and remove the token + $petition->enrollee_identifier = $ret['identifier']; + $petition->token = null; + +// XXX Also add petition history? + $Petitions->saveOrFail($petition); + + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + } } elseif($ret['type'] == 'anonymous') { $ret['roles'] = $this->validateToken($petition); @@ -143,9 +172,11 @@ protected function getCurrentActor(?int $petitionId=null): array { * Transition to an Enrollment Flow Step. Typically this will be the next step, * but this also permits re-entering a flow. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param int $petitionId Petition ID * @param bool $start True if transitioning from start + * @throws Cake\Network\Exception\SocketException On SMTP error + * @throws RuntimeException If no SMTP server configured */ protected function transitionToStep(int $petitionId, bool $start=false) { @@ -153,6 +184,9 @@ protected function transitionToStep(int $petitionId, bool $start=false) { $stepInfo = $EnrollmentFlows->calculateNextStep($petitionId); $petition = $stepInfo['petition']; + + $coId = $EnrollmentFlows->findCoForRecord($petition->enrollment_flow_id); + /* no need to to this, we don't cache on start() if($start) { // $actorInfo was cached before the Petition was created, so force it to reload @@ -161,6 +195,17 @@ protected function transitionToStep(int $petitionId, bool $start=false) { $actorInfo = $this->getCurrentActor($petitionId); + // Before we process the handoff, give the plugin an opportunity to run any + // preparatory steps. We don't specifically support errors here, ie: if a plugin + // throws an Exception we let it bubble up because it's not really clear what we + // should do if a plugin fails. + + // (If this is the last step, 'step' will be null, and there's no prepare() to call.) + + if(!empty($stepInfo['step'])) { + $EnrollmentFlows->EnrollmentFlowSteps->prepare($stepInfo['step'], $petition); + } + // We need to compare the current actor type with the actor type configured for the // next step. If they are the same, we can simply redirect. If they are different, // we need to hand off via a notification, and then redirect the current actor to @@ -205,26 +250,65 @@ protected function transitionToStep(int $petitionId, bool $start=false) { $token = $EnrollmentFlows->Petitions->getToken($petitionId); - // Just inject the token into the URL we already have - $stepInfo['url']['?']['token'] = $token; + // For simplicity, we just inject the continue URL into the message. + $entryUrl = [ + 'controller' => 'petitions', + 'action' => 'continue', + $petition->id, + '?' => [ + 'token' => $token //$this->requestParam('token') + ] + ]; + + // Message Templates handle substitutions, so if none is configured it's an error + if(empty($stepInfo['step']->message_template_id)) { + throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ])); + } + + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Perform substitutions - // XXX send an email + $msg = $MessageTemplates->generateMessage( + id: $stepInfo['step']->message_template_id, + entryUrl: $entryUrl, + ); + + // Send the message. sendEmailToAddress will throw an Exception if SMTP failed, + // but if there is no SMTP server configured we'll just get false back. + + if(!DeliveryUtilities::sendEmailToAddress( + coId: $coId, + recipient: $petition->enrollee_email, + subject: $msg['subject'], + body_text: $msg['body_text'], + body_html: $msg['body_html'] + )) { + throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead? + } } else { // XXX Register a notification or send an email or whatever // (once notification infrastructure is available) - } debug("Handing off to actor type " . $nextActorType . " would send a notitication to visit " . \Cake\Routing\Router::url(url: $stepInfo['url'], full: true)); - } + } - return $this->redirect($stepInfo['url']); + // Redirect to a landing page indicating that no further action is required at this time + if(!empty($stepInfo['step']->redirect_on_handoff)) { + // Use the step specific handoff URL + return $this->redirect($stepInfo['step']->redirect_on_handoff); + } else { + // Redirect to the default Enrollment Handoff URL for this CO + return $this->redirect("/$coId/default-handoff"); + } + } } /** * Validate a token associated with the requested petition. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param Petition $petition Petition entity * @return array|bool Roles associated with the token, or false on token error */ diff --git a/app/src/Lib/Traits/UpsertTrait.php b/app/src/Lib/Traits/UpsertTrait.php new file mode 100644 index 000000000..449c4c772 --- /dev/null +++ b/app/src/Lib/Traits/UpsertTrait.php @@ -0,0 +1,87 @@ +find() + ->where($whereClause) + ->epilog('FOR UPDATE') + ->first(); + + if($entity) { + // This is an update + + $entity = $this->patchEntity($entity, $data); + } else { + // This is an insert + + $entity = $this->newEntity($data); + } + + return $this->save($entity); + } + + /** + * Perform an upsert, or throw an exception on failure. + * + * @since COmanage Registry v5.1.0 + * @param array $data Data to persist + * @param array $whereClause Conditions to search for current entity + * @return Cake\Datasource\EntityInterface|false Persisted entity, or false on failure + * @throws Cake\ORM\Exception\PersistenceFailedException + */ + + public function upsertOrFail( + array $data, + array $whereClause + ): \Cake\Datasource\EntityInterface { + $entity = $this->upsert($data, $whereClause); + + if($entity === false) { + throw new Cake\ORM\Exception\PersistenceFailedException($entity, ['upsert']); + } + + return $entity; + } +} diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php index eb6068806..f5a15a830 100644 --- a/app/src/Lib/Util/DeliveryUtilities.php +++ b/app/src/Lib/Util/DeliveryUtilities.php @@ -51,7 +51,8 @@ class DeliveryUtilities { * @param string $cc Addresses to cc * @param string $bcc Addresses to bcc * @param string $replyTo Reply-To address to use, instead of the default - * @return bool Returns true if mail was sent, false otherwise (including if no SMTP server was sent) + * @return bool Returns true if mail was sent, false if no SMTP server was set + * @throws Cake\Network\Exception\SocketException */ public static function sendEmailToAddress( @@ -127,9 +128,18 @@ public static function sendEmailToAddress( 'tls' => $smtp->use_tls ]); - $result = $transport->send($message); + try { + $result = $transport->send($message); + } + catch(Cake\Network\Exception\SocketException $e) { + self::slog('error', $e->getMessage()); + + throw $e; + } self::slog('debug', "Mail for $to sent successfully"); + + return true; } /** diff --git a/app/src/Model/Entity/MostlyStaticPage.php b/app/src/Model/Entity/MostlyStaticPage.php new file mode 100644 index 000000000..ee604e7a9 --- /dev/null +++ b/app/src/Model/Entity/MostlyStaticPage.php @@ -0,0 +1,56 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is a default Page (shipped out of the box and relied on by other + * parts of the Application). + * + * @since COmanage Registry v5.1.0 + * @return bool true if this entity is a defalut Page, false otherwise + */ + + public function isDefaultPage(): bool { + // We use the original value because if we're in the middle of a save we'll have + // the proposed new value even though we haven't persisted it yet + return in_array($this->getOriginal('name'), ['default-handoff', 'error-landing', 'petition-complete']); + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index 061e964b8..8aec0857b 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -86,6 +86,9 @@ public function initialize(array $config): void { $this->hasMany('Jobs') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('MostlyStaticPages') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('People') ->setDependent(true) ->setCascadeCallbacks(true); @@ -440,6 +443,9 @@ public function setup(int $id): bool { // AR-CO-6 Create the default groups $this->Groups->addDefaults($id); + // AR-MostlyStaticPages-3 Set up the default landing pages + $this->MostlyStaticPages->addDefaults($id); + // Set up the default settings $this->CoSettings->addDefaults($id); diff --git a/app/src/Model/Table/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php index 3cf26d16d..99f359388 100644 --- a/app/src/Model/Table/EnrollmentFlowStepsTable.php +++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php @@ -21,7 +21,7 @@ * * @link https://www.internet2.edu/comanage COmanage Project * @package registry - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ @@ -52,7 +52,7 @@ class EnrollmentFlowStepsTable extends Table { /** * Perform Cake Model initialization. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param array $config Configuration options passed to constructor */ @@ -67,6 +67,7 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('EnrollmentFlows'); + $this->belongsTo('MessageTemplates'); $this->hasMany('PetitionStepResults'); $this->setPluginRelations(); @@ -82,6 +83,11 @@ public function initialize(array $config): void { 'type' => 'enum', 'class' => 'EnrollmentActorEnum' ], + 'messageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentHandoff] + ], 'plugins' => [ 'type' => 'plugin', 'pluginType' => 'enroller' @@ -154,7 +160,7 @@ public function initialize(array $config): void { /** * Define business rules. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param RulesChecker $rules RulesChecker object * @return RulesChecker */ @@ -169,11 +175,38 @@ public function buildRules(RulesChecker $rules): RulesChecker { return $rules; } + + /** + * Prepare for handoff to an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition to prepare for + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ) { + // We give the plugin for this Step an opportunity to do something before + // the handoff to the Step takes place. This is intended, for example, to + // allow the Petition to be set to a Pending status prior to the next Actor + // taking an action. + + // (We accept a Step entity rather than an ID because in the context in which + // we're called we already have the Step, so no need to make another DB call.) + + $Plugin = TableRegistry::getTableLocator()->get($step->plugin); + + if(method_exists($Plugin, "prepare")) { + $Plugin->prepare($step, $petition); + } + } /** * Set validation rules. * - * @since COmanage Registry v5.0.0 + * @since COmanage Registry v5.1.0 * @param Validator $validator Validator * @return Validator Validator */ @@ -205,6 +238,16 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('actor_type'); + $validator->add('message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('message_template_id'); + + $validator->add('redirect_on_handoff', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_handoff'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php index d6aa26421..94ad0d413 100644 --- a/app/src/Model/Table/MessageTemplatesTable.php +++ b/app/src/Model/Table/MessageTemplatesTable.php @@ -43,7 +43,6 @@ class MessageTemplatesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; - use \App\Lib\Traits\PluggableModelTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -65,6 +64,7 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + $this->hasMany('EnrollmentFlowSteps'); $this->hasMany('Notifications'); $this->setDisplayField('description'); @@ -108,8 +108,9 @@ public function initialize(array $config): void { * * @since COmanage Registry v5.0.0 * @param int $id Message Template ID - * @param Person $subjectPerson Subject Person, including Primary Name + * @param array $entryUrl Entry URL for responding to a handoff or notification * @param Notification $notification Notification + * @param Person $subjectPerson Subject Person, including Primary Name * @return array 'subject': Message subject * 'body_text': Plaintext message * 'body_html': HTML message @@ -117,13 +118,15 @@ public function initialize(array $config): void { public function generateMessage( int $id, - \App\Model\Entity\Person $subjectPerson=null, - \App\Model\Entity\Notification $notification=null + array $entryUrl=[], + \App\Model\Entity\Notification $notification=null, + \App\Model\Entity\Person $subjectPerson=null ): array { + // We return "" instead of null by default for compatibility with DeliveryUtilities $ret = [ - 'subject' => null, - 'body_text' => null, - 'body_html' => null + 'subject' => "", + 'body_text' => "", + 'body_html' => "" ]; // First retrieve the requested template @@ -133,18 +136,29 @@ public function generateMessage( // entities were provided. $substitutions = []; - - if($subjectPerson && !empty($subjectPerson->primary_name)) { - $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + + // Lookup the CO Name + $co = $this->Cos->get($template->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if(!empty($entryUrl)) { + $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url( + array_merge($entryUrl, ['_full' => true]) + ); } if($notification) { $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; $substitutions['NOTIFICATION_SOURCE'] = $notification->source; } + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } // Finally run the substitutions through each of the supported parts - + foreach(array_keys($ret) as $part) { if(!empty($template->$part)) { // Process the (@SUBSTITUTIONS) for this part diff --git a/app/src/Model/Table/MostlyStaticPagesTable.php b/app/src/Model/Table/MostlyStaticPagesTable.php new file mode 100644 index 000000000..36704cabe --- /dev/null +++ b/app/src/Model/Table/MostlyStaticPagesTable.php @@ -0,0 +1,337 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'contexts' => [ + 'type' => 'enum', + 'class' => 'PageContextEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add the default Mostly Static Pages. + * + * @since COmanage Registry v5.1.0 + * @param int $coId CO ID + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function addDefaults(int $coId) { + // Any pages added here should also be added to MostlyStaticPage.php::isDefaultPage + $records = [ + [ + 'co_id' => $coId, + 'name' => 'default-handoff', + 'title' => __d('field', 'MostlyStaticPages.default.dh.title'), + 'description' => __d('field', 'MostlyStaticPages.default.dh.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::EnrollmentHandoff, + 'body' => __d('field', 'MostlyStaticPages.default.dh.body') + ], + [ + 'co_id' => $coId, + 'name' => 'error-landing', + 'title' => __d('field', 'MostlyStaticPages.default.el.title'), + 'description' => __d('field', 'MostlyStaticPages.default.el.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::ErrorLanding, + 'body' => __d('field', 'MostlyStaticPages.default.el.body') + ], + [ + 'co_id' => $coId, + 'name' => 'petition-complete', + 'title' => __d('field', 'MostlyStaticPages.default.pc.title'), + 'description' => __d('field', 'MostlyStaticPages.default.pc.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::EnrollmentHandoff, + 'body' => __d('field', 'MostlyStaticPages.default.pc.body') + ] + ]; + + // Convert the arrays to entities + $entities = $this->newEntities($records); + + // throws PersistenceFailedException on failure + $this->saveManyOrFail($entities); + + return true; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.1.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { +// XXX document these in the wiki + // AR-MostlyStaticPage-1 Two Mostly Static Pages within the same CO cannot share the same name + $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('field', 'MostlyStaticPages.name')]))); + + // AR-MostlyStaticPage-3 Default Pages can not be deleted, or have their names, status, or + // context changed + $rules->addUpdate([$this, 'ruleModifiedDefaultPage'], + 'modifiedDefaultPage', + ['errorField' => 'name']); + + $rules->addDelete([$this, 'ruleIsDefaultPage'], + 'isDefaultPage', + ['errorField' => 'name']); + + return $rules; + } + + /** + * Generate a message based on a Message Template, using the provided entities to + * perform variable substitution. + * + * @since COmanage Registry v5.0.0 + * @param int $id Message Template ID + * @param array $entryUrl Entry URL for responding to a handoff or notification + * @param Notification $notification Notification + * @param Person $subjectPerson Subject Person, including Primary Name + * @return array 'subject': Message subject + * 'body_text': Plaintext message + * 'body_html': HTML message + * + + public function generateMessage( + int $id, + array $entryUrl=[], + \App\Model\Entity\Notification $notification=null, + \App\Model\Entity\Person $subjectPerson=null + ): array { + // We return "" instead of null by default for compatibility with DeliveryUtilities + $ret = [ + 'subject' => "", + 'body_text' => "", + 'body_html' => "" + ]; + + // First retrieve the requested template + $template = $this->get($id); + + // Next build an array of supported substitutions for which appropriate + // entities were provided. + + $substitutions = []; + + // Lookup the CO Name + $co = $this->Cos->get($template->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if(!empty($entryUrl)) { +// debug($entryUrl); + $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url( + array_merge($entryUrl, ['_full' => true]) + ); + } + + if($notification) { + $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; + $substitutions['NOTIFICATION_SOURCE'] = $notification->source; + } + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } + + // Finally run the substitutions through each of the supported parts + +// debug($substitutions); + foreach(array_keys($ret) as $part) { + if(!empty($template->$part)) { + // Process the (@SUBSTITUTIONS) for this part + $searchKeys = []; + $replaceVals = []; + + foreach(array_keys($substitutions) as $k) { + $searchKeys[] = "(@" . $k . ")"; + $replaceVals[] = $substitutions[$k] ?? "(?)"; + } + + $ret[$part] = str_replace($searchKeys, $replaceVals, $template->$part); + } + } + + return $ret; + }*/ + + /** + * Application Rule to determine if the current entity is a default Page. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.1.0 + */ + + public function ruleIsDefaultPage($entity, array $options): string|bool { + if($entity->isDefaultPage()) { + return __d('error', 'MostlyStaticPages.default.delete'); + } + + return true; + } + + /** + * Application Rule to determine if a default Page has been modified. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.1.0 + */ + + public function ruleModifiedDefaultPage($entity, array $options): string|bool { + if($entity->isDefaultPage() + && ($entity->isDirty('name') || $entity->isDirty('status') || $entity->isDirty('context'))) { + return __d('error', 'MostlyStaticPages.default.modify'); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'name', true); + + // AR-MostlyStaticPage-2 A Mostly Static Page name may consist only of lowercase alphanumeric + // characters and dashes + $validator->add('name', [ + 'filter' => [ + 'rule' => ['custom', '/^[a-z0-9-]+$/'], + 'message' => __d('error', 'MostlyStaticPages.slug.invalid') + ] + ]); + + $this->registerStringValidation($validator, $schema, 'title', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('context', [ + 'content' => ['rule' => ['inList', PageContextEnum::getConstValues()]] + ]); + $validator->notEmptyString('context'); + + $validator->add('body', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('body'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php index 4b76121ef..8aa38d24c 100644 --- a/app/src/Model/Table/PetitionsTable.php +++ b/app/src/Model/Table/PetitionsTable.php @@ -94,7 +94,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('enrollment_flow_id'); $this->setRequiresCO(true); - $this->setAllowLookupPrimaryLink(['finalize', 'result', 'resume']); + $this->setAllowLookupPrimaryLink(['continue', 'finalize', 'pending', 'result', 'resume']); // These are required for the link to work from the Artifacts page $this->setAllowUnkeyedPrimaryCO(['index']); @@ -146,6 +146,8 @@ public function initialize(array $config): void { $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ + // We handle continue authorization in the Controller + 'continue' => true, 'delete' => false, 'edit' => false, // We handle finalize authorization in the Controller diff --git a/app/templates/EnrollmentFlowSteps/fields.inc b/app/templates/EnrollmentFlowSteps/fields.inc index d24a674fd..6664a3c00 100644 --- a/app/templates/EnrollmentFlowSteps/fields.inc +++ b/app/templates/EnrollmentFlowSteps/fields.inc @@ -33,6 +33,8 @@ if($vv_action == 'add' || $vv_action == 'edit') { 'plugin', 'ordr', 'actor_type', + 'message_template_id', + 'redirect_on_handoff' ] as $field) { print $this->element('form/listItem', [ 'arguments' => [ diff --git a/app/templates/MostlyStaticPages/columns.inc b/app/templates/MostlyStaticPages/columns.inc new file mode 100644 index 000000000..86f962bac --- /dev/null +++ b/app/templates/MostlyStaticPages/columns.inc @@ -0,0 +1,47 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'name' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum', + 'sortable' => true + ], + 'context' => [ + 'type' => 'enum', + 'class' => 'PageContextEnum', + 'sortable' => true + ] +]; diff --git a/app/templates/MostlyStaticPages/display.php b/app/templates/MostlyStaticPages/display.php new file mode 100644 index 000000000..b4bf1cf44 --- /dev/null +++ b/app/templates/MostlyStaticPages/display.php @@ -0,0 +1,54 @@ + + +
    +
    +

    +
    +
    + + +
    + Flash->render() ?> + + + + Alert->alert($b, 'warning') ?> + + + + + + Alert->alert($b, 'warning') ?> + + +
    + + \ No newline at end of file diff --git a/app/templates/MostlyStaticPages/fields.inc b/app/templates/MostlyStaticPages/fields.inc new file mode 100644 index 000000000..9ec30246d --- /dev/null +++ b/app/templates/MostlyStaticPages/fields.inc @@ -0,0 +1,83 @@ + + + +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'title' + ]]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'name', + 'fieldOptions' => [ + 'onChange' => 'jsLocalOnLoad()' + ] + ]]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'pageUrl', + 'fieldOptions' => [ + 'readOnly' => true + ] + ]]); + + foreach ([ + 'description', + 'status', + 'context', + 'body' + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); + } +} \ No newline at end of file diff --git a/app/templates/Petitions/fields.inc b/app/templates/Petitions/fields.inc index dba8a6aa7..ca8e86b35 100644 --- a/app/templates/Petitions/fields.inc +++ b/app/templates/Petitions/fields.inc @@ -41,6 +41,13 @@ print $this->element('form/listItem', [ ] ]); +// Enrollee Email, if provided +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'enrollee_email', + ] +]); + // Enrollee if (!empty($vv_obj?->enrollee_person?->id)) { $enrolleeStatus = $vv_obj->enrollee_person->primary_name->full_name ?? __d('field', 'Petitions.enrollee.new'); @@ -58,7 +65,6 @@ if (!empty($vv_obj?->enrollee_person?->id)) { 'link' => !empty($vv_obj->enrollee_person->id) ? $enrolleeLink : [], ] ]); - } // Petitioner