From 3f1dd1906d43568dd25f663d36cff0f428773014 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Tue, 15 Jul 2025 09:00:34 -0400 Subject: [PATCH] Initial implementation of Approval Enroller Plugin (CFM-337) --- app/config/schema/schema.json | 24 +- .../resources/locales/en_US/core_enroller.po | 43 ++- .../ApprovalCollectorsController.php | 151 +++++++++ .../BasicAttributeCollectorsController.php | 7 +- .../Controller/EmailVerifiersController.php | 8 +- .../src/Model/Entity/ApprovalCollector.php | 51 +++ .../src/Model/Entity/PetitionApproval.php | 49 +++ .../Model/Table/ApprovalCollectorsTable.php | 297 +++++++++++++++++ .../Table/BasicAttributeCollectorsTable.php | 92 ++++-- .../src/Model/Table/EmailVerifiersTable.php | 6 +- .../Model/Table/PetitionAcceptancesTable.php | 4 +- .../Model/Table/PetitionApprovalsTable.php | 127 ++++++++ .../Table/PetitionBasicAttributeSetsTable.php | 1 + .../src/View/Cell/ApprovalCollectorsCell.php | 81 +++++ .../CoreEnroller/src/config/plugin.json | 29 ++ .../templates/ApprovalCollectors/dispatch.inc | 72 ++++ .../templates/ApprovalCollectors/fields.inc | 40 +++ .../BasicAttributeCollectors/dispatch.inc | 15 +- .../cell/ApprovalCollectors/display.php | 64 ++++ .../templates/element/emailVerifiers/list.php | 10 +- app/resources/locales/en_US/command.po | 6 + app/resources/locales/en_US/enumeration.po | 32 +- app/resources/locales/en_US/error.po | 3 + app/resources/locales/en_US/field.po | 55 +++- app/resources/locales/en_US/information.po | 17 +- app/resources/locales/en_US/operation.po | 6 + app/resources/locales/en_US/result.po | 6 + app/src/Command/NotificationCommand.php | 44 ++- app/src/Command/UpgradeCommand.php | 85 ++++- .../Component/BreadcrumbComponent.php | 5 + .../Component/RegistryAuthComponent.php | 62 +++- app/src/Controller/DashboardsController.php | 6 + .../Controller/EmailAddressesController.php | 2 +- .../Controller/NotificationsController.php | 2 +- app/src/Controller/PetitionsController.php | 85 ++++- app/src/Controller/StandardController.php | 11 + .../Controller/StandardEnrollerController.php | 37 ++- app/src/Lib/Enum/ActionEnum.php | 3 + .../Lib/Enum/AllTernaryEnum.php} | 10 +- app/src/Lib/Enum/GroupTypeEnum.php | 2 + .../Lib/Enum/MessageTemplateContextEnum.php | 14 +- app/src/Lib/Enum/PetitionActionEnum.php | 4 + app/src/Lib/Enum/PetitionStatusEnum.php | 1 + .../Lib/Traits/EnrollmentControllerTrait.php | 214 ++++++++++-- app/src/Lib/Util/DeliveryUtilities.php | 69 ++-- app/src/Lib/Util/PaginatedSqlIterator.php | 59 +++- app/src/Lib/Util/StringUtilities.php | 25 ++ app/src/Model/Entity/Group.php | 1 + app/src/Model/Entity/MessageTemplate.php | 220 +++++++++++++ app/src/Model/Entity/Petition.php | 3 +- app/src/Model/Table/EmailAddressesTable.php | 6 +- .../Model/Table/EnrollmentFlowStepsTable.php | 49 +++ app/src/Model/Table/EnrollmentFlowsTable.php | 55 +++- .../Table/ExtIdentitySourceRecordsTable.php | 13 + app/src/Model/Table/GroupsTable.php | 120 ++++++- app/src/Model/Table/IdentifiersTable.php | 26 ++ app/src/Model/Table/MessageTemplatesTable.php | 87 +---- app/src/Model/Table/NotificationsTable.php | 307 +++++++++++++----- app/src/Model/Table/PeopleTable.php | 4 +- app/src/Model/Table/PetitionsTable.php | 275 +++++++++++++++- app/src/Model/Table/VerificationsTable.php | 38 +-- app/templates/EnrollmentFlowSteps/fields.inc | 75 ++++- app/templates/EnrollmentFlows/fields.inc | 28 +- app/templates/GroupMembers/columns.inc | 6 + app/templates/Notifications/columns.inc | 13 + app/templates/Notifications/fields.inc | 28 ++ app/templates/Petitions/fields-nav.inc | 16 + app/templates/Petitions/fields.inc | 55 +++- app/templates/Standard/index.php | 9 +- 69 files changed, 3008 insertions(+), 462 deletions(-) create mode 100644 app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php create mode 100644 app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php create mode 100644 app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php create mode 100644 app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php create mode 100644 app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc create mode 100644 app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc create mode 100644 app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php rename app/{plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php => src/Lib/Enum/AllTernaryEnum.php} (79%) diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 7cc67284f..483044454 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -575,10 +575,10 @@ "columns": { "id": {}, "subject_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, - "subject_group_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "subject_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "recipient_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, - "recipient_group_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "recipient_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "resolver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "action": { "comment": "revert this to use the library definition after feature-cfm31 merge", @@ -636,12 +636,18 @@ "authz_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, "collect_enrollee_email": { "type": "boolean" }, "redirect_on_duplicate": { "type": "string", "size": 256 }, - "redirect_on_finalize": { "type": "string", "size": 256 } + "redirect_on_finalize": { "type": "string", "size": 256 }, + "finalization_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }}, + "notification_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, + "notification_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }} }, "indexes": { "enrollment_flows_i1": { "columns": [ "co_id" ]}, "enrollment_flows_i2": { "needed": false, "columns": [ "authz_cou_id" ]}, - "enrollment_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]} + "enrollment_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]}, + "enrollment_flows_i4": { "needed": false, "columns": [ "finalization_message_template_id" ]}, + "enrollment_flows_i5": { "needed": false, "columns": [ "notification_group_id" ]}, + "enrollment_flows_i6": { "needed": false, "columns": [ "notification_message_template_id" ]} } }, @@ -654,12 +660,18 @@ "plugin": {}, "ordr": {}, "actor_type": { "type": "string", "size": 2 }, + "approver_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, "message_template_id": {}, - "redirect_on_handoff": { "type": "string", "size": 256 } + "redirect_on_handoff": { "type": "string", "size": 256 }, + "notification_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, + "notification_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }} }, "indexes": { "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]}, - "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]} + "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]}, + "enrollment_flow_steps_i3": { "needed": false, "columns": [ "notification_group_id" ]}, + "enrollment_flow_steps_i4": { "needed": false, "columns": [ "notification_message_template_id" ]}, + "enrollment_flow_steps_i5": { "needed": false, "columns": [ "approver_group_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 d53ad5dfb..b34f6ad04 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -64,14 +64,8 @@ msgstr "DefaultCodeLength" msgid "enumeration.VerificationDefaultsEnum.60" msgstr "DefaultVerificationValidity" -msgid "enumeration.VerificationModeEnum.0" -msgstr "None" - -msgid "enumeration.VerificationModeEnum.1" -msgstr "One" - -msgid "enumeration.VerificationModeEnum.A" -msgstr "All" +msgid "error.ApprovalCollectors.comment" +msgstr "Comment is required" msgid "error.EmailVerifiers.candidate" msgstr "Requested address is not a valid candidate" @@ -109,6 +103,9 @@ msgstr "This Invitation has expired" msgid "error.PetitionAcceptances.processed" msgstr "This Invitation has already been processed" +msgid "information.ApprovalCollectors.review" +msgstr "Please approve or deny Petition {0}." + msgid "information.EmailVerifiers.done" msgstr "All email addresses in this Petition have been verified. You may continue on to the next Enrollment Step." @@ -142,6 +139,27 @@ msgstr "New code sent" msgid "information.EmailVerifiers.abort" msgstr "Abort" +msgid "field.ApprovalCollectors.denial_message_template_id" +msgstr "Denial Message Template" + +msgid "field.ApprovalCollectors.denial_message_template_id.desc" +msgstr "Message Template to use when notifying the Enrollee of a denial (no denial message is sent if not set)" + +msgid "field.ApprovalCollectors.mode.desc" +msgstr "How many members of the Approver Group must approve Petitions for this Enrollment Flow Step" + +msgid "field.ApprovalCollectors.redirect_on_denial" +msgstr "Redirect on Denial" + +msgid "field.ApprovalCollectors.redirect_on_denial.desc" +msgstr "If the Petition is denied, the Approver will be redirected here instead of the default handoff page" + +msgid "field.ApprovalCollectors.require_comment" +msgstr "Require Comment" + +msgid "field.ApprovalCollectors.require_comment.desc" +msgstr "If set, the Approver must add a comment when approving or denying Petitions for this Enrollment Flow Step" + msgid "field.AttributeCollectors.valid_through.default.after.desc" msgstr "Days After Finalization" @@ -302,6 +320,15 @@ msgstr "Petition Attributes recorded" msgid "result.basicattr.finalized" msgstr "Name, Email Address, and Person Role created during finalization" +msgid "result.ApprovalCollectors.approved" +msgstr "Petition Approved" + +msgid "result.ApprovalCollectors.denied" +msgstr "Petition Denied" + +msgid "result.ApprovalCollectors.status" +msgstr "Petition {0} by {1} at {2} ({3})" + msgid "result.EmailVerifiers.verified" msgstr "Verified {0} of {1} available {2}" diff --git a/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php new file mode 100644 index 000000000..f2df9c905 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php @@ -0,0 +1,151 @@ + [ + 'ApprovalCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.2.0 + * @param string $id Approval Collector ID + */ + + public function dispatch(string $id) { + $request = $this->getRequest(); + $session = $request->getSession(); + // $username = $session->read('Auth.external.user'); + + $petition = $this->getPetition(); + $coId = $this->getCOID(); + + if($request->is('post')) { + $cfg = $this->ApprovalCollectors->get($id); + + try { + // Record approval or denial + + $approved = $this->requestParam('approved'); + $comment = $this->requestParam('comment'); + + // record() will handle updatind the Petition status and performing other + // recordkeeping transactions, including enforcing comment if required + + $this->ApprovalCollectors->record( + petitionId: $petition->id, + approvalCollectorId: (int)$id, + approverPersonId: $this->RegistryAuth->getPersonID($coId), + approved: $approved == StatusEnum::Approved, + comment: $comment + ); + + if($approved == StatusEnum::Denied) { + // If we have a denial Message Template, send the notification to the enrollee + // email address. We don't currently support using a Notification, since in most + // cases the Enrollee will not have a Person record yet. (There are some edge + // cases around processes like Additional Role Enrollment where we might want + // to be able to Notify the Person using their existing preferred Email Address, + // but for now we don't support that.) + + if(!empty($cfg->denial_message_template_id) + && !empty($petition->enrollee_email)) { + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Generate the message and send + + $template = $MessageTemplates->get($cfg->denial_message_template_id); + + $template->setContextPetition($petition); + + $template->generateMessage(); + + // 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: $this->getCOID(), + recipient: $petition->enrollee_email, + subject: $template->getMessagePart('subject'), + body_text: $template->getMessagePart('body_text'), + body_html: $template->getMessagePart('body_html') + )) { + throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead? + } + } + + // If we have a redirect on denial configured, send the Approver there + if(!empty($cfg->redirect_on_denial)) { + return $this->redirect($cfg->redirect_on_denial); + } else { + // Redirect to the default Enrollment Handoff URL for this CO + return $this->redirect("/$coId/default-handoff"); + } + } + + // Where do we redirect? On approval, it's possible that the next step has the + // same Approver's group on handoff, in which case we just let the flow continue. + // However on denial, we need to stop the flow. So basically we need a separate + // "redirect on denial" target (or we use the default Enrollment Flow handoff if + // not configured). + + // Redirect to the next step + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.ApprovalCollectors.' . ($approved == StatusEnum::Approved ? 'approved' : 'denied')) + ); + } + catch(\Exception $e) { + $this->llog('error', $e->getMessage()); + + $this->Flash->error($e->getMessage()); + } + } + + // Check for existing values in case we're re-running the step + $this->set('petition_approvals', + $this->ApprovalCollectors->PetitionApprovals->find() + ->where(['petition_id' => $petition->id, 'approval_collector_id' => $id]) + ->first()); + + $this->render('/Standard/dispatch'); + } +} \ 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 285a7fb9b..2e6a942d3 100644 --- a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php +++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php @@ -78,7 +78,6 @@ public function dispatch(string $id) { $this->set('vv_required_name_fields', $settings->name_required_fields_array()); if($this->request->is(['post', 'put'])) { - try { $this->BasicAttributeCollectors->upsert( id: (int)$id, @@ -102,6 +101,12 @@ public function dispatch(string $id) { $this->Flash->error($e->getMessage()); } } + + // Check for existing values in case we're re-running the step + $this->set('petition_basic_attribute_sets', + $this->BasicAttributeCollectors->PetitionBasicAttributeSets->find() + ->where(['petition_id' => $petition->id, 'basic_attribute_collector_id' => $id]) + ->first()); // Fall through and let the form render diff --git a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php index e05a16748..ed54b76d0 100644 --- a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php +++ b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php @@ -36,7 +36,7 @@ use App\Lib\Util\StringUtilities; use Cake\Http\Exception\BadRequestException; use Cake\ORM\TableRegistry; -use CoreEnroller\Lib\Enum\VerificationModeEnum; +use \App\Lib\Enum\AllTernaryEnum; use \App\Lib\Enum\HttpStatusCodesEnum; class EmailVerifiersController extends StandardEnrollerController { @@ -168,10 +168,10 @@ public function dispatch(string $id) { $doneCount = count($verifiedAddresses); $totalCount = count($candidateAddresses); $allDone = $doneCount == $totalCount; - $minimumMet = $cfg->mode == VerificationModeEnum::None - || ($cfg->mode == VerificationModeEnum::One + $minimumMet = $cfg->mode == AllTernaryEnum::None + || ($cfg->mode == AllTernaryEnum::One && $doneCount > 0) - || ($cfg->mode == VerificationModeEnum::All + || ($cfg->mode == AllTernaryEnum::All && $allDone); $this->set('vv_all_done', $allDone); diff --git a/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php new file mode 100644 index 000000000..5e70d595c --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php @@ -0,0 +1,51 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php new file mode 100644 index 000000000..d59aad765 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php new file mode 100644 index 000000000..29c0bd856 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php @@ -0,0 +1,297 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('Groups'); + $this->belongsTo('MessageTemplates') + ->setForeignKey('denial_message_template_id') + ->setProperty('denial_message_template'); + + $this->hasMany('CoreEnroller.PetitionApprovals') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.ApprovalCollectors'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.ApprovalCollectors' => ['edit'] + ] + ] + ); + + $this->setAutoViewVars([ + 'modes' => [ + 'type' => 'enum', + 'class' => 'AllTernaryEnum' + ], + 'denialMessageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentApproval] + ] + ]); + + $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'], + // 'resend' => true, + '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 hydrate the Person record as part of Petition finalization. + * + * @since COmanage Registry v5.2.0 + * @param int $id Approval Collector ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function hydrate(int $id, \App\Model\Entity\Petition $petition) { + // $cfg = $this->get($id); + // Approval Collector just affects the flow as it happens, and so nothing is + // currently required at finalization. + + return true; + } + + /** + * Perform tasks prior to transitioning to this step. + * + * @since COmanage Registry v5.2.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 { + // Set this petition to Pending Verification + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::PendingApproval; + + $Petitions->saveOrFail($petition); + + return true; + } + + /** + * Record an Approval (or denial). + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId Petition ID + * @param int $approvalCollectorId Approval Collector ID + * @param int $approverPersonId Approver Person ID + * @param bool $approved true if the Approval is granted, false otherwise + * @param string $comment Approval (or denial) Comment + * @throws \InvalidArgumentException + */ + + public function record( + int $petitionId, + int $approvalCollectorId, + int $approverPersonId, + bool $approved=true, + ?string $comment=null + ) { + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $cfg = $this->get($approvalCollectorId); + + $petition = $Petitions->get($petitionId); + + // First check if require_comment but no comment was provided + + if($cfg->require_comment && (!$comment || strlen(trim($comment)) == 0)) { + throw new \InvalidArgumentException(__d('core_enroller', 'error.ApprovalCollectors.comment')); + } + + // Record a PetitionApproval (which is also used for denials). + + $pa = [ + 'petition_id' => $petitionId, + 'approval_collector_id' => $approvalCollectorId, + 'approver_person_id' => $approverPersonId, + 'approved' => $approved, + 'comment' => $comment + ]; + + $this->PetitionApprovals->upsertOrFail( + $pa, + ['petition_id' => $petitionId, 'approval_collector_id' => $approvalCollectorId] + ); + + // Next, update the Petition status. + + // If there'a another Approval step after this one we'll bounce back to Pending + // Approval as soon as we switch to it, which creates a bit of noise but in some + // ways is sort of correct. We could try to calculate if there is another Approval + // step, but that's a bit complicated and not really worth the effort. We don't + // calculate Approval during finalization because the status wouldn't stick around + // long enough to be useful for an administrator. + $petition->status = $approved ? PetitionStatusEnum::Approved : PetitionStatusEnum::Denied; + + $Petitions->saveOrFail($petition); + + // Record PetitionHistory. + + $this->llog('debug', "Petition " . $petition->id . ($approved ? " approved" : " denied") . " by Person " . $approverPersonId); + + $Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: $approved ? PetitionActionEnum::Approved : PetitionActionEnum::Denied, + comment: __d('core_enroller', 'result.ApprovalCollectors.' . ($approved ? 'approved' : 'denied')) + ); + + // Finally, resolved the Notification created by the handoff. + + $Notifications = TableRegistry::getTableLocator()->get('Notifications'); + + // The URL was originally created by $EnrollmentFlows->calculateNextStep, but we + // can easily reconstruct what it should be + + $url = [ + 'plugin' => 'CoreEnroller', + 'controller' => 'approval_collectors', + 'action' => 'dispatch', + $approvalCollectorId, + '?' => ['petition_id' => $petitionId] + ]; + + $Notifications->resolveFromSource( + source: $url, + resolution: NotificationStatusEnum::Resolved, + resolverPersonId: $approverPersonId + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.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('require_comment', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('require_comment'); + + $validator->add('denial_message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('denial_message_template_id'); + + $validator->add('redirect_on_denial', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_denial'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php index 0a99fa869..3a1fcd516 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php @@ -149,6 +149,46 @@ public function initialize(array $config): void { ]); } + /** + * Obtain a name (as a string) for the Enrollee associated with the specified Petition. + * + * @since COmanage Registry v5.2.0 + * @param EntityInterface $config Configuration entity for this plugin + * @param int $petitionId Petition ID + * @return string Name, or null if thre is no name data + */ + + public function enrolleeName( + EntityInterface $config, + int $petitionId + ): ?string { + $set = $this->PetitionBasicAttributeSets->find() + ->where([ + 'basic_attribute_collector_id' => $config->id, + 'petition_id' => $petitionId + ]) + ->first(); + + if(!empty($set)) { + // We construct a throw-away Name entity so we can use it to generate the string + // representation of the name data we have. + + $Names = TableRegistry::getTableLocator()->get('Names'); + + $name = $Names->newEntity([ + 'honorific' => $set->honorific, + 'given' => $set->given, + 'middle' => $set->middle, + 'family' => $set->family, + 'suffix' => $set->suffix + ]); + + return $name->full_name; + } + + return null; + } + /** * Perform steps necessary to hydrate the Person record as part of Petition finalization. * @@ -232,8 +272,6 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { enrollmentFlowStepId: $cfg->enrollment_flow_step_id, action: PetitionActionEnum::Finalized, comment: __d('core_enroller', 'result.basicattr.finalized') -// We don't have $actorPersonId yet... -// ?int $actorPersonId=null ); return true; @@ -253,35 +291,35 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) { public function upsert(int $id, int $petitionId, array $attributes) { $basicAttributeCollector = $this->get($id); - // Do we have existing attributes for this petition? Note this will pull - // _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->PetitionBasicAttributeSets - ->find() - ->where([ - 'petition_id' => $petitionId, - // Strictly speaking we only support one instance per Flow, - // but we'll filter on the $id anyway since we have it - 'basic_attribute_collector_id' => $id - ]) - ->first(); - - if(!$entity) { - // insert, not update - - $entity = $this->PetitionBasicAttributeSets->newEntity([ - 'basic_attribute_collector_id' => $id, - 'petition_id' => $petitionId - ]); - } + $data = [ + 'basic_attribute_collector_id' => $id, + 'petition_id' => $petitionId + ]; foreach(['honorific', 'given', 'middle', 'family', 'suffix', 'mail'] as $f) { // XXX we should probably check CoSettings for name settings - $entity->$f = $attributes[$f] ?? null; + $data[$f] = $attributes[$f] ?? null; } - $this->PetitionBasicAttributeSets->saveOrFail($entity); + $pei = $this->PetitionBasicAttributeSets->upsertOrFail( + data: $data, + whereClause: [ + 'petition_id' => $petitionId, + 'basic_attribute_collector_id' => $id, + ] + ); + + if(!empty($basicAttributeCollector->cou_id)) { + // Insert the COU ID into the primary Petition artifact + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition = $Petitions->get($petitionId); + + $petition->cou_id = $basicAttributeCollector->cou_id; + + $Petitions->save($petition); + } // Record Petition History @@ -292,8 +330,6 @@ public function upsert(int $id, int $petitionId, array $attributes) { enrollmentFlowStepId: $basicAttributeCollector->enrollment_flow_step_id, action: PetitionActionEnum::AttributesUpdated, comment: __d('core_enroller', 'result.attr.saved') -// We don't have $actorPersonId yet... -// ?int $actorPersonId=null ); return true; diff --git a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php index 03b5e2313..3771e6205 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php @@ -29,6 +29,7 @@ namespace CoreEnroller\Model\Table; +use App\Lib\Enum\AllTernaryEnum; use App\Lib\Enum\EnrollmentActorEnum; use App\Lib\Enum\PermittedCharactersEnum; use App\Lib\Enum\PetitionStatusEnum; @@ -39,7 +40,6 @@ use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Validation\Validator; -use CoreEnroller\Lib\Enum\VerificationModeEnum; use CoreEnroller\Lib\Enum\VerificationDefaultsEnum; use CoreEnroller\Model\Entity\EmailVerifier; @@ -105,7 +105,7 @@ public function initialize(array $config): void { $this->setAutoViewVars([ 'modes' => [ 'type' => 'enum', - 'class' => 'CoreEnroller.VerificationModeEnum' + 'class' => 'AllTernaryEnum' ], 'defaults' => [ 'type' => 'enum', @@ -540,7 +540,7 @@ public function validationDefault(Validator $validator): Validator { $validator->notEmptyString('enrollment_flow_step_id'); $validator->add('mode', [ - 'content' => ['rule' => ['inList', VerificationModeEnum::getConstValues()]] + 'content' => ['rule' => ['inList', AllTernaryEnum::getConstValues()]] ]); $validator->notEmptyString('mode'); diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php index 1c5e47831..9a0afedbd 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php @@ -144,10 +144,8 @@ public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $a $this->Petitions->PetitionHistoryRecords->record( petitionId: $petitionId, enrollmentFlowStepId: $enrollmentFlowStepId, - action: PetitionActionEnum::StatusUpdated, + action: $accepted ? PetitionActionEnum::Accepted : PetitionActionEnum::Declined, comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined') -// We don't have $actorPersonId yet... -// ?int $actorPersonId=null ); } diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php new file mode 100644 index 000000000..f348589a3 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php @@ -0,0 +1,127 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('CoreEnroller.ApprovalCollectors'); + $this->belongsTo('Petitions'); + $this->belongsTo('ApproverPeople') + ->setClassName('People') + ->setForeignKey('approver_person_id') + ->setProperty('approver_person'); + + $this->setDisplayField('comment'); + + $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 + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.2.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('approval_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('approval_collector_id'); + + $validator->add('approver_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('approver_person_id'); + + $validator->add('approved', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('approved'); + + $this->registerStringValidation($validator, $schema, 'comment', false); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php index a51e31976..7cc0662a7 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php @@ -39,6 +39,7 @@ class PetitionBasicAttributeSetsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\UpsertTrait; use \App\Lib\Traits\ValidationTrait; /** diff --git a/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php b/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php new file mode 100644 index 000000000..04839c430 --- /dev/null +++ b/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php @@ -0,0 +1,81 @@ + + */ + protected $_validCellOptions = [ + 'vv_obj', + 'vv_step', + 'viewVars', + ]; + + /** + * Initialization logic run at the end of object construction. + * + * @return void + */ + public function initialize(): void + { + } + + /** + * Default display method. + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId + * @return void + */ + + public function display(int $petitionId): void { + $vv_pa = $this->fetchTable('CoreEnroller.PetitionApprovals') + ->find() + ->where([ + 'approval_collector_id' => $this->vv_step->approval_collector->id, + 'petition_id' => $this->vv_obj->id + ]) + ->contain(['ApproverPeople' => 'PrimaryName']) + ->first(); + + $this->set('vv_pa', $vv_pa); + } +} diff --git a/app/plugins/CoreEnroller/src/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json index 35efbcfe6..8870e9358 100644 --- a/app/plugins/CoreEnroller/src/config/plugin.json +++ b/app/plugins/CoreEnroller/src/config/plugin.json @@ -1,6 +1,7 @@ { "types": { "enroller": [ + "ApprovalCollectors", "AttributeCollectors", "BasicAttributeCollectors", "EmailVerifiers", @@ -10,6 +11,19 @@ }, "schema": { "tables": { + "approval_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "require_comment": { "type": "boolean" }, + "denial_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }}, + "redirect_on_denial": { "type": "string", "size": 256 } + }, + "indexes": { + "approval_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "approval_collectors_i2": { "needed": false, "columns": [ "denial_message_template_id" ] } + } + }, "attribute_collectors": { "columns": { "id": {}, @@ -110,6 +124,21 @@ "petition_acceptances_i1": { "columns": [ "petition_id" ] } } }, + "petition_approvals": { + "columns": { + "id": {}, + "petition_id": {}, + "approval_collector_id": { "type": "integer", "foreignkey": { "table": "approval_collectors", "column": "id" } }, + "approver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "approved": { "type": "boolean" }, + "comment": {} + }, + "indexes": { + "petition_approvals_i1": { "columns": [ "petition_id" ] }, + "petition_approvals_i2": { "needed": false, "columns": [ "approval_collector_id" ] }, + "petition_approvals_i3": { "needed": false, "columns": [ "approver_person_id" ] } + } + }, "petition_attributes": { "columns": { "id": {}, diff --git a/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc new file mode 100644 index 000000000..62375a0fb --- /dev/null +++ b/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc @@ -0,0 +1,72 @@ +element('flash', []); + +// Make the Form fields editable +$this->Field->enableFormEditMode(); +?> + +

id]); ?>

+ +

Html->link(__d('operation', 'view'), ['plugin' => null, 'controller' => 'petitions', 'action' => 'view', $vv_petition->id]); ?>

+ +Form->create(null, [ + 'id' => 'approval-form', + 'type' => 'post' + ]); + + print $this->Form->radio( + 'approved', + [ + StatusEnum::Approved => __d('enumeration', 'StatusEnum.'.StatusEnum::Approved), + StatusEnum::Denied => __d('enumeration', 'StatusEnum.'.StatusEnum::Denied) + ], + [ + 'value' => !empty($petition_approvals) + ? ($petition_approvals['approved'] === true + ? StatusEnum::Approved + : ($petition_approvals['approved'] === false + ? StatusEnum::Denied + : "")) + : "" + ] + ); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'comment', + 'fieldOptions' => [ + 'default' => $petition_approvals['comment'] ?? "" + ] + ]]); diff --git a/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc b/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc new file mode 100644 index 000000000..5b70e4daa --- /dev/null +++ b/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc @@ -0,0 +1,40 @@ +element('form/listItem', [ + 'arguments' => ['fieldName' => $field] + ]); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc index 564c21b3a..8333415ae 100644 --- a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc @@ -27,10 +27,8 @@ declare(strict_types = 1); -// This view is intended to work with dispatch -if($vv_action == 'dispatch') { - // Make the Form fields editable - $this->Field->enableFormEditMode(); +// Make the Form fields editable +$this->Field->enableFormEditMode(); ?>
  • @@ -46,9 +44,9 @@ if($vv_action == 'dispatch') { element('form/infoDiv/default', [ 'vv_field_arguments' => [ 'fieldName' => $n, - 'fieldLabel' => __d('field', $n), 'fieldOptions' => [ - 'required' => in_array($n, $vv_required_name_fields) + 'required' => in_array($n, $vv_required_name_fields), + 'default' => $petition_basic_attribute_sets[$n] ?? "" ] ]]); ?> @@ -64,6 +62,7 @@ if($vv_action == 'dispatch') { print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'mail', - 'fieldLabel' => __d('field', 'mail') + 'fieldOptions' => [ + 'default' => $petition_basic_attribute_sets['mail'] ?? "" + ] ]]); -} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php b/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php new file mode 100644 index 000000000..118393be5 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php @@ -0,0 +1,64 @@ +approver_person_id)) { + $enum = ($vv_pa->approved ? StatusEnum::Approved : StatusEnum::Denied); + + $approver = $this->Html->link( + $vv_pa->approver_person->primary_name->full_name, + [ + 'plugin' => null, + 'controller' => 'people', + 'action' => 'edit', + $vv_pa->approver_person_id + ] + ); + + $status = __d('core_enroller', 'result.ApprovalCollectors.status', [ + __d('enumeration', 'StatusEnum.'.$enum), + $approver, + $vv_pa->modified, + $vv_pa->comment + ]); +} +?> + + + \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php b/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php index 2c068d4e3..f4a44b24b 100644 --- a/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php +++ b/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php @@ -27,7 +27,7 @@ declare(strict_types = 1); -use CoreEnroller\Lib\Enum\VerificationModeEnum; +use App\Lib\Enum\AllTernaryEnum; use App\Lib\Util\StringUtilities; // Render the list of known email addresses and their verification statuses. @@ -38,12 +38,12 @@ $title = __d('core_enroller', 'information.EmailVerifiers.done'); } else { $title = match ($vv_config->mode) { - VerificationModeEnum::All => __d('core_enroller', 'information.EmailVerifiers.A'), - VerificationModeEnum::None => __d('core_enroller', 'information.EmailVerifiers.0'), - VerificationModeEnum::One => $vv_minimum_met + AllTernaryEnum::All => __d('core_enroller', 'information.EmailVerifiers.A'), + AllTernaryEnum::None => __d('core_enroller', 'information.EmailVerifiers.0'), + AllTernaryEnum::One => $vv_minimum_met ? __d('core_enroller', 'information.EmailVerifiers.1.met') : __d('core_enroller', 'information.EmailVerifiers.1.none'), - default => 'Unknown Verification Mode' // Optional fallback for unexpected cases + default => __d('error', 'Verifications.mode.unknown', [$vv_config->mode]) // Optional fallback for unexpected cases }; } diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index 8fe923810..25976181c 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -120,6 +120,9 @@ msgstr "This Notification must be resolved, not acknowledged" msgid "opt.notify.recipientid" msgstr "Recipient Identifier" +msgid "opt.notify.recipientgroupid" +msgstr "Recipient Group Identifier" + msgid "opt.notify.resolve" msgstr "Resolve the Notification associated with this source" @@ -129,6 +132,9 @@ msgstr "Notification source" msgid "opt.notify.subjectid" msgstr "Subject Identifier" +msgid "opt.notify.subjectgroupid" +msgstr "Subject Group Identifier" + msgid "opt.notify.templateid" msgstr "Message Template ID" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index efffd05dc..5aa881643 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -24,6 +24,15 @@ # Enumerations +msgid "AllTernaryEnum.0" +msgstr "None" + +msgid "AllTernaryEnum.1" +msgstr "One" + +msgid "AllTernaryEnum.A" +msgstr "All" + msgid "AuthenticationEventEnum.AI" msgstr "API Login" @@ -174,6 +183,9 @@ msgstr "Active Members" msgid "GroupTypeEnum.A" msgstr "Admins" +msgid "GroupTypeEnum.AP" +msgstr "Approvers" + msgid "GroupTypeEnum.M" msgstr "All Members" @@ -351,23 +363,22 @@ msgstr "HTML" msgid "MessageFormatEnum.text" msgstr "Plain Text" -# msgid "MessageTemplateContextEnum.AP" -# msgstr "Enrollment Approver" - # msgid "MessageTemplateContextEnum.AU" # msgstr "Authenticator" -# msgid "MessageTemplateContextEnum.EA" -# msgstr "Enrollment Approval" +msgid "MessageTemplateContextEnum.EA" +# We no longer have notifications on Approval (use Finalization or Handoff instead) +# but we maintain the use of EA from v4 and just change the text string +msgstr "Enrollment Denial" -# msgid "MessageTemplateContextEnum.EF" -# msgstr "Enrollment Finalization" +msgid "MessageTemplateContextEnum.EF" +msgstr "Enrollment Finalization" msgid "MessageTemplateContextEnum.EH" msgstr "Enrollment Handoff" -# msgid "MessageTemplateContextEnum.EI" -# msgstr "Enrollment Invitation" +msgid "MessageTemplateContextEnum.ES" +msgstr "Enrollment Step Completed" msgid "MessageTemplateContextEnum.PL" msgstr "Plugin" @@ -496,6 +507,9 @@ msgstr "Accepted" msgid "PetitionStatusEnum.CR" msgstr "Created" +msgid "PetitionStatusEnum.CX" +msgstr "Terminated" + msgid "PetitionStatusEnum.D2" msgstr "Duplicate" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index b9040adb6..6753ebf83 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -427,6 +427,9 @@ msgstr "Invalid code" msgid "Verifications.expired" msgstr "Verification request has expired" +msgid "Verifications.mode.unknown" +msgstr "Unknown Verification Mode \"{0}\"" + msgid "Verifications.petition" msgstr "Verification does not match requested Petition" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 95a8849a7..5ec0e79b6 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -441,9 +441,27 @@ msgstr "If true, Global Search will only search Names, Email Addresses, and Iden msgid "EnrollmentFlowSteps.actor_type" msgstr "Actor Type" +msgid "EnrollmentFlowSteps.approver_group_id" +msgstr "Approver Group" + +msgid "EnrollmentFlowSteps.approver_group_id.desc" +msgstr "The members of this Group may approve Petitions for this Enrollment Flow Step, as per the mode configuration" + +msgid "EnrollmentFlowSteps.message_template_id" +msgstr "Handoff Message Template" + 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.notification_group_id" +msgstr "Notification Group" + +msgid "EnrollmentFlowSteps.notification_group_id.desc" +msgstr "If set, this Group will be notified when this Step is completed" + +msgid "EnrollmentFlowSteps.notification_message_template_id" +msgstr "Group Notification Message Template" + msgid "EnrollmentFlowSteps.redirect_on_handoff" msgstr "Redirect on Handoff" @@ -462,6 +480,24 @@ msgstr "Petitioner Authorization" msgid "EnrollmentFlows.collect_enrollee_email" msgstr "Collect Enrollee Email" +msgid "EnrollmentFlows.finalization_message_template_id" +msgstr "Finalization Message Template" + +msgid "EnrollmentFlows.finalization_message_template_id.desc" +msgstr "If set, this Message Template will be used to notify the Enrollee" + +msgid "EnrollmentFlows.notification_group_id" +msgstr "Notification Group" + +msgid "EnrollmentFlows.notification_group_id.desc" +msgstr "If set, this Group will be notified whenever any Step associated with this Enrollment Flow is completed" + +msgid "EnrollmentFlows.notification_message_template_id" +msgstr "Group Notification Message Template" + +msgid "EnrollmentFlows.notification_message_template_id.desc" +msgstr "This Message Template will be used to notify the Notification Group" + msgid "EnrollmentFlows.redirect_on_duplicate" msgstr "Redirect on Duplicate" @@ -513,6 +549,9 @@ msgstr "Where the current group will be nested" msgid "Groups.desc.admins" msgstr "{0} Administrators" +msgid "Groups.desc.approvers" +msgstr "{0} Approvers" + msgid "Groups.desc.members" msgstr "{0} Members" @@ -713,8 +752,11 @@ msgstr "Notification Email Subject" msgid "Notifications.notification_time" msgstr "Notification Time" +msgid "Notifications.recipient_group_id" +msgstr "Recipient (Group)" + msgid "Notifications.recipient_person_id" -msgstr "Recipient" +msgstr "Recipient (Person)" msgid "Notifications.resolver_person_id" msgstr "Resolved By" @@ -728,8 +770,11 @@ msgstr "Resolution Email Subject" msgid "Notifications.resolution_time" msgstr "Resolution Time" +msgid "Notifications.subject_group_id" +msgstr "Subject (Group)" + msgid "Notifications.subject_person_id" -msgstr "Subject" +msgstr "Subject (Person)" msgid "Petitions.enrollee_email" msgstr "Enrollee Email Address" @@ -737,6 +782,12 @@ msgstr "Enrollee Email Address" msgid "Petitions.enrollee_email.desc" msgstr "The Email Address provided at the creation of the Petition" +msgid "Petitions.enrollee_identifier" +msgstr "Enrollee Identifier" + +msgid "Petitions.enrollee_name" +msgstr "Enrollee Name" + msgid "Petitions.enrollee.new" msgstr "New Enrollee" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 96ae32f41..f91d17a3e 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -126,15 +126,18 @@ msgstr "Petition History" msgid "petition.information" msgstr "Petition Information" -msgid "Petitions.pending" -msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time." - msgid "petition.step.toggle.details" msgstr "toggle petition step details" msgid "petition.step.toggle.details.all" msgstr "toggle all petition step details" +msgid "Petitions.pending" +msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time." + +msgid "Petitions.pending.approval" +msgstr "Petition {0} is now Pending Approval for Enrollment Flow Step {1}" + msgid "plugin.active" msgstr "Active" @@ -174,7 +177,13 @@ msgstr "Current version: {0}" msgid "ug.version.target" msgstr "Target version: {0}" -msgid "ug.installMostlyStaticPages" +msgid "ug.tasks.createApproverGroups.co" +msgstr "Creating Approver Groups for CO {0}" + +msgid "ug.tasks.createApproverGroups.cou" +msgstr "Creating Approver Groups for COU {0}" + +msgid "ug.tasks.installMostlyStaticPages.co" msgstr "Installing default Mostly Static Pages for CO {0}" msgid "value.copied" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 65f4d6b07..01aafaf1c 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -252,6 +252,9 @@ msgstr "Go to page" msgid "Petitions.rerun" msgstr "Rerun" +msgid "Petitions.terminate.confirm" +msgstr "Are you sure you want to terminate Petition {0}? This action cannot be undone." + msgid "pick" msgstr "Pick" @@ -318,6 +321,9 @@ msgstr "Start" msgid "Cos.switch" msgstr "Switch To This CO" +msgid "terminate" +msgstr "Terminate" + msgid "toggle.all" msgstr "Toggle All" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 848744e1e..f60b5b1e3 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -196,6 +196,12 @@ msgstr "Petition Finalized" msgid "Petitions.flaggedduplicate" msgstr "Petition flagged as Duplicate" +msgid "Petitions.step.completed" +msgstr "Petition {0} Step {1} completed" + +msgid "Petitions.terminated" +msgstr "Petition Terminated" + msgid "Petitions.viewed.inv" msgstr "Invitation Viewed" diff --git a/app/src/Command/NotificationCommand.php b/app/src/Command/NotificationCommand.php index 9e34860e7..b97bf4970 100644 --- a/app/src/Command/NotificationCommand.php +++ b/app/src/Command/NotificationCommand.php @@ -64,10 +64,17 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar )->addOption( 'subjectIdentifier', [ - 'required' => true, + 'required' => false, 'short' => 's', 'help' => __d('command', 'opt.notify.subjectid') ] + )->addOption( + 'subjectGroupIdentifier', + [ + 'required' => false, + 'short' => 'g', + 'help' => __d('command', 'opt.notify.subjectgroupid') + ] )->addOption( 'actorIdentifier', [ @@ -78,10 +85,17 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar )->addOption( 'recipientIdentifier', [ - 'required' => true, + 'required' => false, 'short' => 'r', 'help' => __d('command', 'opt.notify.recipientid') ] + )->addOption( + 'recipientGroupIdentifier', + [ + 'required' => false, + 'short' => 'G', + 'help' => __d('command', 'opt.notify.recipientgroupid') + ] )->addOption( 'action', [ @@ -154,28 +168,46 @@ public function execute(Arguments $args, ConsoleIo $io) $typeId = $Types->getTypeId($coId, 'Identifiers.type', $args->getOption('typeLabel')); // Subject + $subjectGroupId = null; + $subjectPersonId = null; - $subjectPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('subjectIdentifier')); + if($args->getOption('subjectGroupIdentifier')) { + $subjectGroupId = $Identifiers->lookupGroup($typeId, $args->getOption('subjectGroupIdentifier')); + } + + if($args->getOption('subjectIdentifier')) { + $subjectPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('subjectIdentifier')); + } // Actor $actorPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('actorIdentifier')); // Recipient + $recipientGroupId = null; + $recipientPersonId = null; + + if($args->getOption('recipientGroupIdentifier')) { + $recipientGroupId = $Identifiers->lookupGroup($typeId, $args->getOption('recipientGroupIdentifier')); + } - $recipientPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('recipientIdentifier')); + if($args->getOption('recipientIdentifier')) { + $recipientPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('recipientIdentifier')); + } $io->out("Registering notification:"); $io->out("- Subject Person ID: " . $subjectPersonId); + $io->out("- Subject Group ID: " . $subjectGroupId); $io->out("- Actor Person ID: " . $actorPersonId); $io->out("- Recipient Person ID: " . $recipientPersonId); + $io->out("- Recipient Group ID: " . $recipientGroupId); $notificationIds = $Notifications->register( subjectPersonId: $subjectPersonId, - subjectGroupId: null, + subjectGroupId: $subjectGroupId, actorPersonId: $actorPersonId, recipientPersonId: $recipientPersonId, - recipientGroupId: null, + recipientGroupId: $recipientGroupId, action: $args->getOption('action'), comment: $args->getOption('comment'), messageTemplateId: (int)$args->getOption('messageTemplateId'), diff --git a/app/src/Command/UpgradeCommand.php b/app/src/Command/UpgradeCommand.php index e3edf9e84..6dbc461a2 100644 --- a/app/src/Command/UpgradeCommand.php +++ b/app/src/Command/UpgradeCommand.php @@ -58,7 +58,7 @@ class UpgradeCommand extends Command // compare version strings. You must specify the 'block' parameter. If you flag // a version as blocking, be sure to document why. - // As of v5, pre and post are now a list of tasks instead of a single function + // As of v5, pre and post are now a list of tasks instead of a single function. protected $versions = [ "5.0.0" => [ @@ -67,9 +67,21 @@ class UpgradeCommand extends Command "5.1.0" => [ 'block' => false, 'post' => ['installMostlyStaticPages'] + ], + "5.2.0" => [ + 'block' => false, + 'post' => ['createApproverGroups'] ] ]; + // For descriptions of task parameters, see dispatch(). We store these separately + // to make them easier to use regardless of context (pre/post/manual). + + protected $taskParams = [ + 'createApproverGroups' => ['perCO' => true, 'perCOU' => true], + 'installMostlyStaticPages' => ['perCO' => true] + ]; + /** * Register command specific options. * @@ -252,17 +264,58 @@ public function execute(Arguments $args, ConsoleIo $io) */ protected function dispatch(string $task) { + // We support tasks being flagged to behave in certain ways. + + // global: The task should be run once + // perCO: The task should be run once per CO + // perCOU: The task should be run once per COU + // - perCOU does NOT imply perCO, though tasks will also be passed the CO ID + + $global = isset($this->taskParams[$task]['global']) + && $this->taskParams[$task]['global']; + $perCO = isset($this->taskParams[$task]['perCO']) + && $this->taskParams[$task]['perCO']; + $perCOU = isset($this->taskParams[$task]['perCOU']) + && $this->taskParams[$task]['perCOU']; + if(method_exists($this, $task)) { - // Pull the set of COs. We'll generally apply changes to _all_ COs, even if - // they're Suspended or Templates. + if($global) { + $this->io->out(__d('information', 'ug.tasks.'.$task)); + $this->$task(); + } + + $cos = []; + + if($perCO || $perCOU) { + // Pull the list of COs once. We'll generally apply changes to _all_ COs, + // even if they're Suspended or Templates. + + $CosTable = $this->getTableLocator()->get('Cos'); + + $cos = $CosTable->find()->all(); + } - $CosTable = $this->getTableLocator()->get('Cos'); + if($perCO) { + foreach($cos as $co) { + $this->io->out(__d('information', 'ug.tasks.'.$task.'.co', [$co->id])); + $this->$task(coId: $co->id); + } + } + + if($perCOU) { + $CousTable = $this->getTableLocator()->get('Cous'); + + // We iterate per CO rather than pull all COUs at once in case the task + // wants to know the CO for each COU. - $cos = $CosTable->find()->all(); + foreach($cos as $co) { + $cous = $CousTable->find()->where(['co_id' => $co->id])->all(); - foreach($cos as $co) { - $this->io->out(__d('information', 'ug.'.$task, [$co->id])); - $this->$task($co->id); + foreach($cous as $cou) { + $this->io->out(__d('information', 'ug.tasks.'.$task.'.cou', [$cou->id])); + $this->$task(coId: $co->id, couId: $cou->id); + } + } } $this->io->out(__d('result', 'ug.task.done', [$task])); @@ -271,6 +324,22 @@ protected function dispatch(string $task) { } } + /** + * Create Approver Groups. + * + * @since COmanage Registry v5.2.0 + * @param int $coId CO ID + * @param int $couId COU ID + */ + + protected function createApproverGroups(int $coId, int $couId=null) { + $GroupsTable = $this->getTableLocator()->get('Groups'); + + // Technically this will try to add all the default Groups, which is fine since + // it will skip the ones that already exist. + $GroupsTable->addDefaults($coId, $couId); + } + /** * Update the default set of Mostly Static Pages. * diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index f6e54df4f..17e42d509 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -178,6 +178,11 @@ public function injectPrimaryLink(object $link, bool $index=true, string $linkLa { // eg: "People" $modelsName = StringUtilities::foreignKeyToClassName($link->attr); + if(!empty($this->getController()->viewBuilder()->getVar('vv_primary_link_model'))) { + // $link doesn't seem to handle table aliases (eg "Groups" instead of "RecipientGroups" for + // Notifications)). + $modelsName = $this->getController()->viewBuilder()->getVar('vv_primary_link_model'); + } $modelPath = $modelsName; if(!empty($link->plugin)) { diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 6cd37df5c..f68e218eb 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -357,8 +357,23 @@ protected function calculatePermissions(?int $id=null): array { // Is this me? $selfMember = $this->isSelf($controller->getCOID(), $id); - // Get the action - $reqAction = $controller->getRequest()->getParam('action'); + // Is the user an approver for the current Petition? + // In order to calculate this we need to have a Petition ID. This will primarily + // come from the URL in the form /petitions/view/{id}, but we'll also accept a + // petition_id in the query parameter. (It's not ideal to hardcode a Controller + // like this, but Approvers are something of a special case and we don't have a + // better approach at the moment.) + $petitionId = null; + + if($modelsName == 'Petitions' + && in_array($reqAction, ['continue', 'view']) + && !empty($controller->getRequest()->getParam('pass.0'))) { + $petitionId = $controller->getRequest()->getParam('pass.0'); + } elseif(!empty($controller->getRequest()->getQuery('petition_id'))) { + $petitionId = $controller->getRequest()->getQuery('petition_id'); + } + + $approver = !empty($petitionId) ? $this->isApprover((int)$petitionId) : false; // Is this record read only? $readOnly = false; @@ -780,6 +795,49 @@ public function isAdminForIdentifier(string $identifier): bool { public function isApiUser(): bool { return $this->authenticatedApiUser; } + + /** + * Determine if the current user is an approver for the specified Petition. + * + * @since COmanage Registry v5.2.0 + * @param int $petitionId Petition ID + * @return bool true if the current user is an Approver, false otherwise + */ + + public function isApprover(int $petitionId): bool { + if(!isset($this->cache['isApprover'][$petitionId])) { + $this->cache['isApprover'][$petitionId] = false; + + if(!empty($this->authenticatedUser)) { + // We need to map the authenticated user to a Person in the same CO as the Petition + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + // Pull the Petition to find its CO + $petition = $Petitions->get($petitionId, ['contain' => 'EnrollmentFlows']); + + try { + // Map the authenticated user to a Person ID + $personId = $Identifiers->lookupPersonByLogin( + coId: $petition->enrollment_flow->co_id, + identifier: $this->authenticatedUser + ); + + // Finally determine if the Person ID is an approver for the flow + $this->cache['isApprover'][$petitionId] = $Petitions->isApproverForFlow( + id: $petition->id, + personId: $personId + ); + } + catch(RecordNotFoundException $e) { + // This is an unregistered user running a Petition, just ignore this exception + } + } + } + + return $this->cache['isApprover'][$petitionId]; + } /** * Determine if the current user is authenticated. diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index f3021cadc..5c26a3d89 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -232,6 +232,12 @@ public function configuration() { 'controller' => 'jobs', 'action' => 'index' ], + __d('controller', 'Notifications', [99]) => [ + 'icon' => 'notifications_active', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'notifications', + 'action' => 'index' + ], __d('controller', 'Petitions', [99]) => [ 'icon' => 'pending_actions', 'iconClass' => 'material-symbols-outlined', diff --git a/app/src/Controller/EmailAddressesController.php b/app/src/Controller/EmailAddressesController.php index ab962178c..9113c879d 100644 --- a/app/src/Controller/EmailAddressesController.php +++ b/app/src/Controller/EmailAddressesController.php @@ -48,7 +48,7 @@ class EmailAddressesController extends MVEAController { public function forceVerify(string $id) { try { - $addr = $this->EmailAddresses->forceVerify((int)$id, $this->RegistryAuth->getPersonID($this->getCOID())); + $addr = $this->EmailAddresses->forceVerify((int)$id); $this->Flash->success(__d('result', 'EmailAddresses.verify.manual', [$addr])); } catch(Exception $e) { diff --git a/app/src/Controller/NotificationsController.php b/app/src/Controller/NotificationsController.php index 5160eda4c..c6a388608 100644 --- a/app/src/Controller/NotificationsController.php +++ b/app/src/Controller/NotificationsController.php @@ -35,7 +35,7 @@ class NotificationsController extends StandardController { public $paginate = [ 'order' => [ - 'Notifications.comment' => 'asc' + 'Notifications.modified' => 'desc' ] ]; diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php index 223d5863c..c3ff383dc 100644 --- a/app/src/Controller/PetitionsController.php +++ b/app/src/Controller/PetitionsController.php @@ -69,6 +69,14 @@ public function beforeRender(EventInterface $event) { $this->set('vv_bc_parent_primarykey', $this->Petitions->EnrollmentFlows->getPrimaryKey()); } + if($this->request->getParam('action') == 'view') { + $id = $this->request->getParam('pass.0'); + + if($id) { + $this->set('vv_enrollee_name', $this->Petitions->getEnrolleeName((int)$id)); + } + } + return parent::beforeRender($event); } @@ -85,17 +93,30 @@ public function calculatePermission(): bool { $authorized = false; - // We're currently only used for finalize + if($action == 'continue') { + // If we're called here it's because we have an authenticated user step. + // (The token for unauthenticated users was validated by willHandleAuth()). + + $currentActor = $this->getCurrentActor( + petitionId: $this->nextStep['petition']->id, + stepId: $this->nextStep['step']->id + ); - if($action == 'finalize') { + $authorized = in_array($this->nextStep['step']->actor_type, $currentActor['roles']); + } elseif($action == 'finalize' && ($this->nextStep['finalize'] === true)) { // If we're using token auth, we checked the token in willHandleAuth(), // so all we really need to do here is compare the actor roles (including // for actors authenticated via the web server) against the role for the // last step. // willHandleAuth() already checked that we have a valid Petition ID, and - // also set $this->nextStep - $currentActor = $this->getCurrentActor((int)$this->request->getParam('pass.0')); + // also set $this->nextStep. We need to specifically request the permissions + // for the previous step to ensure Approver permissions are calculated properly. + + $currentActor = $this->getCurrentActor( + petitionId: $this->nextStep['petition']->id, + stepId: $this->nextStep['lastStep']->id + ); $authorized = in_array($this->nextStep['lastStep']->actor_type, $currentActor['roles']); } @@ -358,6 +379,25 @@ public function resume(string $id) { } } + /** + * Terminate an in-progress Petition. + * + * @since COmanage Registry v5.2.0 + * @param string $id Petition ID + */ + + public function terminate(string $id) { + try { + $this->Petitions->terminate((int)$id); + $this->Flash->success(__d('result', 'Petitions.terminated')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } + /** * Indicate whether this Controller will handle some or all authnz. * @@ -386,20 +426,39 @@ public function willHandleAuth(\Cake\Event\EventInterface $event): string { } 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); + // We can't reliably determine the actor type yet since we haven't necessarily + // run authentication. In particular, a new enrollee clicking a continue link + // sent out of band and an approver clicking a continue link sent out of band + // will look the same to us most likely (unless the approver happend to login + // already). + + // We are basically just issuing a redirect here (the target of which will + // implement authnz anyway), but there is a small bit of information leakage + // possible by introspecting the redirect target, so we try to make sure we at + // least have some sort of authenticated user. + + $this->nextStep = $this->Petitions->EnrollmentFlows->calculateNextStep($petitionId); + + if($this->nextStep['petition']->useToken($this->nextStep['step']->actor_type)) { + // A token is required, so make sure we have one and that it validates + + $actorInfo = $this->getCurrentActor($petitionId); + + if($actorInfo['token_ok']) { + // We can return 'yes' without calling calculatePermission since we're + // just issuing a redirect, and presumably the person with the token already + // more or less knows the state of the Petition. - if($actorInfo['type'] == 'anonymous') { - if(!$actorInfo['token_ok']) { + return 'yes'; + } else { $this->llog('trace', "Token validation failed for Petition " . $petitionId); return 'notauth'; } - } + } else { + // No token is needed, but we do need authentication to run. - // We'll allow any authenticated user through since continue is basically - // a redirect - return 'yes'; + return 'authz'; + } } 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. diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 07afec6e2..2e33fef2c 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -654,6 +654,17 @@ public function index() { $query = $table->findIndexed($query); } + if(method_exists($table, 'filterIndexByCO')) { + // Sometimes (in particular for Artifacts) we want to filter the index by CO + // but CO is not a direct primary link. We could try to figure out the CO + // via primary links, but that gets complicated especially for artifacts which + // have multiple primary links. We could look up each entity in the result + // set, but that's a lot of work and messes up pagination. So instead we let + // the model figure out the best way to do it. + + $query = $table->filterIndexByCO($query, $this->getCOID()); + } + // Fetch the data and paginate $paginationLimit = $this->getValue(ApplicationStateEnum::PaginationLimit, DEF_SEARCH_LIMIT); $resultSet = $this->paginate($query, [ diff --git a/app/src/Controller/StandardEnrollerController.php b/app/src/Controller/StandardEnrollerController.php index ebe7b4446..e61423499 100644 --- a/app/src/Controller/StandardEnrollerController.php +++ b/app/src/Controller/StandardEnrollerController.php @@ -93,9 +93,24 @@ public function calculatePermission(): bool { return false; } - $actorInfo = $this->getCurrentActor((int)$petitionId); - $this->petition = $actorInfo['petition']; + // We need the Step configuration to properly calculate the current Actor + // for Approval Steps. + + $modelsName = $this->name; + $modelId = $this->request->getParam('pass.0'); + + if(!$modelId) { + $this->llog('error', "Model ID missing from request"); + return false; + } + + $stepConfig = $this->$modelsName->get($modelId, ['contain' => ['EnrollmentFlowSteps' => ['EnrollmentFlows']]]); + $this->set('vv_step_config', $stepConfig); + $this->set('vv_title', $stepConfig['enrollment_flow_step']['enrollment_flow']['name']); + $actorInfo = $this->getCurrentActor((int)$petitionId, $stepConfig->enrollment_flow_step->id); + $this->petition = $actorInfo['petition']; + // We only accept anonymous requests for 'dispatch', and only if the token matches. // We'll further check authorization below. if($actorInfo['type'] == 'anonymous') { @@ -116,18 +131,6 @@ public function calculatePermission(): bool { if($action == 'dispatch') { // We already validated the petition state in willHandleAuth - $modelsName = $this->name; - $modelId = $this->request->getParam('pass.0'); // XXX check if empty - - if(!$modelId) { - $this->llog('error', "Model ID missing from request"); - return false; - } - - $stepConfig = $this->$modelsName->get($modelId, ['contain' => ['EnrollmentFlowSteps' => ['EnrollmentFlows']]]); - $this->set('vv_step_config', $stepConfig); - $this->set('vv_title', $stepConfig['enrollment_flow_step']['enrollment_flow']['name']); - // Check that the current actor has the role required for this step. // Note that role validation has already been performed for anonymous access // via tokens (via getcurrentActor) so we don't have to recheck that here. @@ -137,6 +140,12 @@ public function calculatePermission(): bool { $this->llog('trace', "Authorizing access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id); return true; } + + // As a special case, we allow the Platform adminstrator to perform all actions. + if(in_array('cmpadmin', $actorInfo['roles'])) { + $this->llog('trace', "Authorizing access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id) . " for platform administrator"; + return true; + } } elseif($action == 'display') { // XXX need to replace this with better logic return true; diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 01bd08bfb..dc5799bde 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -64,5 +64,8 @@ class ActionEnum extends StandardEnum { const PersonPipelineStarted = 'SCPL'; const PersonRoleRelinked = 'LCPR'; const PersonStatusRecalculated = 'RCPS'; + const PetitionCreated = 'CPPC'; + const PetitionFinalized = 'CPPF'; + const PetitionUpdated = 'CPUP'; const ReferenceIdentifierObtained = 'OIDR'; } \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php b/app/src/Lib/Enum/AllTernaryEnum.php similarity index 79% rename from app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php rename to app/src/Lib/Enum/AllTernaryEnum.php index 4416de0f5..1fbaab310 100644 --- a/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php +++ b/app/src/Lib/Enum/AllTernaryEnum.php @@ -1,6 +1,6 @@ cache['actor'])) { - return $this->cache['actor']; + protected function getCurrentActor(?int $petitionId=null, ?int $stepId=null): array { + // We only check the cache if we have a Petition ID, see below. We also track + // the stepId since we might get different results for different Steps (in particular + // for Approvers). + + if($petitionId && !empty($this->cache['actor'][$stepId ?? "null"])) { + return $this->cache['actor'][$stepId ?? "null"]; } $ret = [ @@ -81,6 +86,9 @@ protected function getCurrentActor(?int $petitionId=null): array { try { $ret['person_id'] = $Identifiers->lookupPersonByLogin($this->getCOID(), $ret['identifier']); + + // We also store platform administator as a role to faciliate superuser access. + // (We don't currently recognize CO Admins the same way pending further requirements.) } catch(RecordNotFoundException $e) { $ret['person_id'] = null; } @@ -90,6 +98,14 @@ protected function getCurrentActor(?int $petitionId=null): array { } else { $ret['type'] = 'identifier'; } + + if($this->RegistryAuth->isPlatformAdmin()) { + // We don't define an Enum for this because we currently don't support + // configuring a Step to be runnable only by a Platform Administrator. + // (Note a Platform Admin will have a valid identifier, but may not have + // a person_id if they're not also registered in the current CO.) + $ret['roles'][] = 'cmpadmin'; + } } if($petitionId) { @@ -109,6 +125,17 @@ protected function getCurrentActor(?int $petitionId=null): array { if($ret['person_id'] === $petition->enrollee_person_id) { $ret['roles'][] = EnrollmentActorEnum::Enrollee; } + + // Only People (as opposed to authenticated, unregistered Enrollees) + // can be Approvers, so check here. We only check for the current Step + // because different Steps can have different Approvers (and we don't + // currently have a use case for "Any Approver for the entire Petition"). + + if($stepId) { + if($Petitions->isApprover(id: $petitionId, stepId: $stepId, personId: $ret['person_id'])) { + $ret['roles'][] = EnrollmentActorEnum::Approver; + } + } } elseif($ret['type'] == 'identifier' && !empty($ret['identifier'])) { if($ret['identifier'] === $petition->petitioner_identifier) { $ret['roles'][] = EnrollmentActorEnum::Petitioner; @@ -219,8 +246,8 @@ protected function transitionToStep(int $petitionId, bool $start=false) { $stepInfo = $EnrollmentFlows->calculateNextStep($petitionId); $petition = $stepInfo['petition']; - - $coId = $EnrollmentFlows->findCoForRecord($petition->enrollment_flow_id); + $flow = $EnrollmentFlows->get($petition->enrollment_flow_id); + $coId = $flow->co_id; /* no need to to this, we don't cache on start() if($start) { @@ -228,12 +255,91 @@ protected function transitionToStep(int $petitionId, bool $start=false) { unset($this->cache['actor']); }*/ - $actorInfo = $this->getCurrentActor($petitionId); + // Approvers vary according to the current step. If there is no current step, we + // want the approvers from the last step so finalization can run. Note that + // because approvers can vary from step to step, two consecutive Approval steps + // that use two different Approvers Groups will generate a handoff (unless the + // current Actor is in both Groups). + + $stepId = ($stepInfo['step']->id ?? ($stepInfo['lastStep']->id ?? null)); + + $actorInfo = $this->getCurrentActor($petitionId, $stepId); + + // Before we process the handoff, we take care of some other tasks that we want + // to run before any redirect is issued. First, issue any Notifications configured + // on either the Enrollment Flow or the _previous_ Step. (If we're transitioning + // from start, there are no notifications to send.) + + if(!$start) { + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + if(!empty($flow->notification_group_id)) { + // There is a Notification Group on the Flow, make sure we have a Message Template + + if(!empty($flow->notification_message_template_id)) { + $template = $MessageTemplates->get($flow->notification_message_template_id); + + $template->setContextPetition($petition); + $template->setContextEnrollmentFlowSteps($stepInfo['lastStep'], $stepInfo['step']); + + $MessageTemplates->Notifications->register( + subjectPersonId: $petition->enrollee_person_id, + subjectGroupId: null, + actorPersonId: $actorInfo['person_id'], + recipientPersonId: null, + recipientGroupId: $flow->notification_group_id, + action: ActionEnum::PetitionUpdated, + comment: __d('result', 'Petitions.step.completed', [$petition->id, $stepInfo['lastStep']->id]), + messageTemplate: $template, + // We'll set the source to be the URL to the Petition itself + source: [ + 'controller' => 'petitions', + 'action' => 'view', + $petitionId + ], + mustResolve: false + ); + } else { + $this->log('debug', "Enrollment Flow " . $flow->id . " has a Notification Group configured, but no Message Template"); + } + } + + if(!empty($stepInfo['lastStep']->notification_group_id)) { + // There is a Notification Group on this Step. Note we do _not_ check 'lastStep' + // since if we're finalizing notifications will be handled by PetitionsTable::finalize. + + if(!empty($stepInfo['lastStep']->notification_message_template_id)) { + $template = $MessageTemplates->get($stepInfo['lastStep']->notification_message_template_id); + + $template->setContextPetition($petition); + $template->setContextEnrollmentFlowSteps($stepInfo['lastStep'], $stepInfo['step']); + + $MessageTemplates->Notifications->register( + subjectPersonId: $petition->enrollee_person_id, + subjectGroupId: null, + actorPersonId: $actorInfo['person_id'], + recipientPersonId: null, + recipientGroupId: $stepInfo['lastStep']->notification_group_id, + action: ActionEnum::PetitionUpdated, + comment: __d('result', 'Petitions.step.completed', [$petition->id, $stepInfo['lastStep']->id]), + messageTemplate: $template, + // We'll set the source to be the URL to the Petition itself + source: [ + 'controller' => 'petitions', + 'action' => 'view', + $id + ], + mustResolve: false + ); + } else { + $this->log('debug', "Enrollment Flow Step " . $stepInfo['step']->id . " has a Notification Group configured, but no Message Template"); + } + } + } - // 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. + // Next, 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.) @@ -277,6 +383,23 @@ protected function transitionToStep(int $petitionId, bool $start=false) { // Note that we only permit a single non-authenticated email address since // we don't support different anonymous petitioners and enrollees. + // Pull the MessageTemplate and attach context to it. + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Message Template is _not_ a required field in the Enrollment Flow Step data model + // because steps can transition without it. However, for approval notifications to be + // sent we need a Message Template. We can't easily enforce this properly at configuration + // time, but at run time this will throw an Exception. + + if(empty($stepInfo['step']->message_template_id)) { + // Throw a more helpful error than "Record not found" + throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ])); + } + + $template = $MessageTemplates->get($stepInfo['step']->message_template_id); + + $template->setContextPetition($petition); + if($petition->useToken($nextActorType)) { // We only have an enrollee_email field to use since either the petitioner _is_ // the enrollee (in which case that address is sufficient, eg: self signup), @@ -286,47 +409,68 @@ protected function transitionToStep(int $petitionId, bool $start=false) { $token = $EnrollmentFlows->Petitions->getToken($petitionId); // For simplicity, we just inject the continue URL into the message. - $entryUrl = [ + + $template->setContextEntryUrl([ + 'plugin' => null, 'controller' => 'petitions', 'action' => 'continue', $petition->id, '?' => [ - 'token' => $token //$this->requestParam('token') + 'token' => $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 - - $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. + // Because we're calling DeliveryUtilities directly we need to generate the + // message from the template here. + + $template->generateMessage(); + if(!DeliveryUtilities::sendEmailToAddress( coId: $coId, recipient: $petition->enrollee_email, - subject: $msg['subject'], - body_text: $msg['body_text'], - body_html: $msg['body_html'] + subject: $template->getMessagePart('subject'), + body_text: $template->getMessagePart('body_text'), + body_html: $template->getMessagePart('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) + // For simplicity, we just inject the continue URL. Note that SUBJECT_NAME + // (and subjectPersonId) is (probably) not available yet, at least for new + // enrollments where a Person hasn't been created yet. -debug("Handing off to actor type " . $nextActorType . " would send a notitication to visit " - . \Cake\Routing\Router::url(url: $stepInfo['url'], full: true)); + $template->setContextEntryUrl([ + 'plugin' => null, + 'controller' => 'petitions', + 'action' => 'continue', + $petition->id, + ]); + + $Notifications = TableRegistry::getTableLocator()->get('Notifications'); + + // Although we register the Notification here, the Plugin must resolve it + // since we don't know at what point the Plugin will consider the Notification + // handled. + + $Notifications->register( + subjectPersonId: $petition->enrollee_person_id, + subjectGroupId: null, + actorPersonId: $actorInfo['person_id'], + recipientPersonId: null, + recipientGroupId: $EnrollmentFlows->Petitions->approverGroupId($petition->id, $stepId), + action: ActionEnum::PetitionUpdated, + comment: __d('information', 'Petitions.pending.approval', [$petition->id, $stepInfo['step']->description]), + // Message Template is _not_ a required field in the Enrollment Flow Step data model + // because steps can transition without it. However, for approval notifications to be + // sent we need a Message Template. We can't easily enforce this properly at configuration + // time, but at run time DeliveryUtilities::sendEmailFromTemplate will throw an error. + messageTemplate: $template, + source: $stepInfo['url'], + mustResolve: true + ); } // Redirect to a landing page indicating that no further action is required at this time diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php index 8b7c7db42..66039d9d3 100644 --- a/app/src/Lib/Util/DeliveryUtilities.php +++ b/app/src/Lib/Util/DeliveryUtilities.php @@ -35,6 +35,7 @@ use Cake\Mailer\Message; use Cake\Mailer\Transport\SmtpTransport; use Cake\ORM\TableRegistry; +use App\Model\Entity\MessageTemplate; class DeliveryUtilities { use \App\Lib\Traits\LabeledLogTrait; @@ -153,67 +154,41 @@ public static function sendEmailToAddress( * if $subject and $body are used intead. * * @since COmanage Registry v5.0.0 - * @param int $messageTemplateId Message Template ID - * @param int $personId Recipient Person ID - * @param string $address Recipient Email Address - * @param Person $subjectPerson Subject Person, including Primary Name - * @param Notification $notification Notification - * @param string $code Verification code - * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc") + * @param MessageTemplate $template Message Template + * @param int $personId Recipient Person ID + * @param string $address Recipient Email Address + * @param int $groupId Recipient Group ID + * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc") */ public static function sendEmailFromTemplate( - int $messageTemplateId, + MessageTemplate $template, ?int $personId=null, ?string $address=null, - ?\App\Model\Entity\Person $subjectPerson=null, - ?\App\Model\Entity\Notification $notification=null, - ?string $code=null + ?int $groupId=null ): array { - $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); - - $messageTemplate = $MessageTemplates->get($messageTemplateId); - - // Generate the message from the template - $message = $MessageTemplates->generateMessage( - id: $messageTemplateId, - subjectPerson: $subjectPerson, - notification: $notification, - code: $code - ); - - if($notification) { - // Since we have the notification, we'll store the message here rather than - // make the calling code do it. - - $notification->email_subject = $message['subject']; - $notification->email_body_text = $message['body_text']; - $notification->email_body_html = $message['body_html']; - - $Notifications = TableRegistry::getTableLocator()->get('Notifications'); - $Notifications->save($notification); - } + $template->generateMessage(); if($personId) { return self::sendEmailToPerson( personId: $personId, - subject: $message['subject'], - body_text: $message['body_text'] ?? "", - body_html: $message['body_html'] ?? "", - cc: $messageTemplate->cc, - bcc: $messageTemplate->bcc, - replyTo: $messageTemplate->reply_to + subject: $template->getMessagePart('subject'), + body_text: $template->getMessagePart('body_text'), + body_html: $template->getMessagePart('body_html'), + cc: $template->cc, + bcc: $template->bcc, + replyTo: $template->reply_to ); } else { self::sendEmailToAddress( - coId: $messageTemplate->co_id, + coId: $template->co_id, recipient: $address, - subject: $message['subject'], - body_text: $message['body_text'] ?? "", - body_html: $message['body_html'] ?? "", - cc: $messageTemplate->cc, - bcc: $messageTemplate->bcc, - replyTo: $messageTemplate->reply_to + subject: $template->getMessagePart('subject'), + body_text: $template->getMessagePart('body_text'), + body_html: $template->getMessagePart('body_html'), + cc: $template->cc, + bcc: $template->bcc, + replyTo: $template->reply_to ); return [ diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php index 10f790ec9..581a2430d 100644 --- a/app/src/Lib/Util/PaginatedSqlIterator.php +++ b/app/src/Lib/Util/PaginatedSqlIterator.php @@ -34,7 +34,9 @@ class PaginatedSqlIterator implements \Iterator { // This is suitable for iterating large datasets sequentially, but not so // good for random pagination (eg: via the UI) of large datasets. - // Number of results to pull at one time, for now this is not configurable + // Number of results to pull at one time, for now this is not configurable, + // though note that when $filter is used the actual result set may be smaller + // on any given page load. private $pageSize = 100; // For now we only iterate over id, which we know is indexed and is an integer @@ -61,10 +63,34 @@ class PaginatedSqlIterator implements \Iterator { // Options for find() private $options = []; - public function __construct($table, $conditions=null, $options=[]) { + // Filter to apply on individual results. + + // Why use a Filter instead of $conditions? Primarily to facilitate the encapsulation of + // logic, rather than copy/paste. $filter will be passed entities, which can be worked with + // in standard Cake style, avoiding the need to rewrite logic in direct $conditions. + // Given that PaginatedSqlIterator is usually called in the context of processing (large + // sets of) records anyway, there is already a cost being incurred in processing time, and + // the savings in most cases of optimizing the database query will be trivial. Where they + // are non-trivial, don't use a filter. Note that (additionally) $count will no longer be + // accurate when $filter is used (it will be an upper bound instead). + private $filter = null; + + /** + * Construct a new PaginatedSqlIterator. + * + * @since COmanage Registry v3.3.0 + * @param Table $table Table + * @param array $condititions Query condittions (use direct queries only, avoid joins due to ChangelogBehavior complications) + * @param array $options Options to pass to cake find() + * @param callable $filter Optional filter to apply to individual results + * @return PaginatedSqlIterator + */ + + public function __construct($table, $conditions=null, $options=[], $filter=null) { $this->table = $table; $this->conditions = $conditions; $this->options = $options; + $this->filter = $filter; $this->position = 0; } @@ -161,9 +187,32 @@ protected function loadPage(): void { $this->maxid = $max->id; } // else no remaining rows. valid() will return false. - - // Convert the result set to an array for our own iterator use - $this->results = $resultSet->toArray(); + + if($this->filter) { + // Build our result cache by manually applying the callback to each result + // and converting that result to an array. + + foreach($resultSet as $k => $entity) { + // We expect a simple boolean true/false from $filter + $filter = $this->filter; + + if($filter($entity)) { + $this->results[] = $entity; + } + } + + if(empty($this->results) && $max) { + // If we didn't get at least one non-filtered result and we're not at the + // end of the table then current() won't return correctly. Proactively call + // loadPage() again. + + $this->loadPage(); + } + } else { + // Convert the result set to an array for our own iterator use + // (Note this is an array of entities, not an array of arrays) + $this->results = $resultSet->toArray(); + } } /** diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index ba4da3cd7..571e39b60 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -33,6 +33,31 @@ use \Cake\Utility\Inflector; class StringUtilities { + /** + * Converts a token by adding dashes for improved readability. + * + * @since COmanage Registry v5.2.0 + * @param string $token The token to be formatted + * @param int $jump Characters to skip before adding a dash + * @return string The formatted token with dashes + */ + + public static function addDashesToToken(string $token, int $jump = 4): string { + // Insert some dashes to improve readability + $dtoken = ''; + + for($i = 0, $iMax = strlen($token); $i < $iMax; $i++) { + $dtoken .= $token[$i]; + + if((($i + 1) % $jump == 0) + && ($i + 1 < strlen($token))) { + $dtoken .= '-'; + } + } + + return $dtoken; + } + /** * Determine the foreign key name to point to a Cake Class Name (eg: foo_id for Foo). * diff --git a/app/src/Model/Entity/Group.php b/app/src/Model/Entity/Group.php index b960892c3..d60936101 100644 --- a/app/src/Model/Entity/Group.php +++ b/app/src/Model/Entity/Group.php @@ -96,6 +96,7 @@ public function isSystem(): bool { GroupTypeEnum::ActiveMembers, GroupTypeEnum::Admins, GroupTypeEnum::AllMembers, + GroupTypeEnum::Approvers, GroupTypeEnum::Owners ]); } diff --git a/app/src/Model/Entity/MessageTemplate.php b/app/src/Model/Entity/MessageTemplate.php index a5a0ce58f..a87a62a13 100644 --- a/app/src/Model/Entity/MessageTemplate.php +++ b/app/src/Model/Entity/MessageTemplate.php @@ -30,6 +30,8 @@ namespace App\Model\Entity; use Cake\ORM\Entity; +use Cake\ORM\TableRegistry; +use Cake\Routing\Router; class MessageTemplate extends Entity { use \App\Lib\Traits\ReadOnlyEntityTrait; @@ -39,4 +41,222 @@ class MessageTemplate extends Entity { 'id' => false, 'slug' => false, ]; + + // Unlike most Entities, MessageTemplate has a bit more going on here. The idea is that + // code that needs to send messages from templates can retrieve a MessageTemplate + // configuration from the database, then attach context to it and pass the entity around + // (eg: to Notifications and DeliveryUtilities) instead of passing a bunch of context. + // The MessageTemplate itself then creates the populated messages. (This requires the + // Entity to call other Tables, which isn't the standard pattern, but makes the code + // that relies on templates much cleaner.) + + // (Attaching the context to the Table instead complicates things when a single page action + // could use multiple Templates.) + + protected ?string $ctx_code = null; + protected ?array $ctx_entryUrl = null; + protected ?Notification $ctx_notification = null; + protected ?Petition $ctx_petition = null; + protected ?EnrollmentFlowStep $ctx_last_step = null; + protected ?EnrollmentFlowStep $ctx_next_step = null; + protected ?Person $ctx_subjectPerson = null; + + // We also cache the generated message, which is less clunky than returning an array + // of the essage components + + protected array $msg = [ + 'subject' => "", + 'body_html' => "", + 'body_text' => "" + ]; + + /** + * Generate a message based on this Message Template, using the provided entities to + * perform variable substitution. + * + * @since COmanage Registry v5.2.0 + * @return void + */ + + public function generateMessage(): void { + // We generate "" instead of null by default for compatibility with DeliveryUtilities + + // First build an array of supported substitutions for which appropriate context was provided. + + $substitutions = []; + + // Lookup the CO Name + $Cos = TableRegistry::getTableLocator()->get('Cos'); + + $co = $Cos->get($this->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if($this->ctx_code) { + $substitutions['VERIFICATION_CODE'] = $this->ctx_code; + } + + if($this->ctx_entryUrl) { + $substitutions['ENTRY_URL'] = Router::url( + array_merge($this->ctx_entryUrl, ['_full' => true]) + ); + } + + if($this->ctx_notification) { + $substitutions['NOTIFICATION_COMMENT'] = $this->ctx_notification->comment; + $substitutions['NOTIFICATION_SOURCE'] = $this->ctx_notification->source; + } + + if($this->ctx_petition) { + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $substitutions['ENROLLEE_EMAIL'] = $this->ctx_petition->enrollee_email; + $substitutions['ENROLLEE_NAME'] = $Petitions->getEnrolleeName($this->ctx_petition->id) + ?? __d('field', 'Petitions.enrollee.new'); + $substitutions['PETITION_URL'] = Router::url([ + 'plugin' => null, + 'controller' => 'petitions', + 'action' => 'view', + $this->ctx_petition->id, + '_full' => true + ]); + + if($this->ctx_petition->cou_id) { + $cou = $Cos->Cous->get($this->ctx_petition->cou_id); + + $substitutions['COU_NAME'] = $cou->name; + } + } + + if($this->ctx_last_step) { + $substitutions['EF_LAST_STEP_DESC'] = $this->ctx_last_step->description; + } + + if($this->ctx_next_step) { + $substitutions['EF_NEXT_STEP_DESC'] = $this->ctx_next_step->description; + } + + if($this->ctx_subjectPerson && !empty($this->ctx_subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $this->ctx_subjectPerson->primary_name->full_name; + } + + // Finally run the substitutions through each of the supported parts + + foreach(array_keys($this->msg) as $part) { + if(!empty($this->$part)) { + // Process the (@SUBSTITUTIONS) for this part + $searchKeys = []; + $replaceVals = []; + + foreach(array_keys($substitutions) as $k) { + $searchKeys[] = "(@" . $k . ")"; + $replaceVals[] = $substitutions[$k] ?? "(?)"; + } + + $this->msg[$part] = str_replace($searchKeys, $replaceVals, $this->$part); + } + } + } + + /** + * Obtain the post-substitution message part, available after generateMessage() + * is called. + * + * @since COmanage Registry v5.2.0 + * @param string $part "subject", "body_html", or "body_text" + * @return string Requested message part + */ + + public function getMessagePart(string $part): string { + return $this->msg[$part]; + } + + /** + * Set the code for the Message Template context. + * + * @since COmanage Registry v5.2.0 + * @param string $code Code, eg for Email verification + * @return MessageTemplate + */ + + public function setContextCode(string $code) { + $this->ctx_code = $code; + + return $this; + } + + /** + * Set the Enrollment Flow Step for the Message Template context. + * + * @since COmanage Registry v5.2.0 + * @param EnrollmentFlowStep $lastStep Enrollment Flow Step that just completed + * @param EnrollmentFlowStep $nextStep Enrollment Flow Step that is next to run + * @return MessageTemplate + */ + + public function setContextEnrollmentFlowSteps( + ?EnrollmentFlowStep $lastStep, + ?EnrollmentFlowStep $nextStep + ) { + $this->ctx_last_step = $lastStep; + $this->ctx_next_step = $nextStep; + + return $this; + } + + /** + * Set the entry URL for the Message Template context. + * + * @since COmanage Registry v5.2.0 + * @param array $url Cake URL array for notification re-entry + * @return MessageTemplate + */ + + public function setContextEntryUrl(array $url) { + $this->ctx_entryUrl = $url; + + return $this; + } + + /** + * Set the Notification for the Message Template context. + * + * @since COmanage Registry v5.2.0 + * @param Notification $notification Notification + * @return MessageTemplate + */ + + public function setContextNotification(Notification $notification) { + $this->ctx_notification = $notification; + + return $this; + } + + /** + * Set the Petition for the Message Template context. + * + * @since COmanage Registry v5.2.0 + * @param Petition $petition Petition + * @return MessageTemplate + */ + + public function setContextPetition(Petition $petition) { + $this->ctx_petition = $petition; + + return $this; + } + + /** + * Set the Subject Person for the Message Template context. + * + * @since COmanage Registry v5.2.0 + * @param Person $subjectPerson Subject Person with PrimaryName + * @return MessageTemplate + */ + + public function setContextSubjectPerson(Person $subjectPerson) { + $this->ctx_subjectPerson = $subjectPerson; + + return $this; + } } \ No newline at end of file diff --git a/app/src/Model/Entity/Petition.php b/app/src/Model/Entity/Petition.php index 161c75285..5090acd77 100644 --- a/app/src/Model/Entity/Petition.php +++ b/app/src/Model/Entity/Petition.php @@ -58,9 +58,10 @@ public function isComplete(): bool { PetitionStatusEnum::Denied, PetitionStatusEnum::Duplicate, PetitionStatusEnum::Failed, - PetitionStatusEnum::Finalized + PetitionStatusEnum::Finalized, // A Finalizing Petition is NOT complete // PetitionStatusEnum::Finalizing + PetitionStatusEnum::Terminated ]); } diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 05a572f67..b3332ba86 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -220,15 +220,11 @@ public function getDeliveryAddress(int $personId): string { * * @since COmanage Registry v5.0.0 * @param int $id EmailAddress ID - * @param int $actorPersonId Actor Person ID * @return string The verified Email Address * @throws InvalidArgumentException */ - public function forceVerify( - int $id, - int $actorPersonId, - ): string { + public function forceVerify(int $id): string { $email = $this->get($id); // We only permit Email Addresses associated with a Person (not External Identity) diff --git a/app/src/Model/Table/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php index 3de024281..295b8e2d9 100644 --- a/app/src/Model/Table/EnrollmentFlowStepsTable.php +++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php @@ -66,8 +66,20 @@ public function initialize(array $config): void { $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); // Define associations + $this->belongsTo('ApproverGroups') + ->setClassName('Groups') + ->setForeignKey('approver_group_id') + ->setProperty('approver_group'); $this->belongsTo('EnrollmentFlows'); $this->belongsTo('MessageTemplates'); + $this->belongsTo('NotificationGroups') + ->setClassName('Groups') + ->setForeignKey('notification_group_id') + ->setProperty('notification_group'); + $this->belongsTo('NotificationMessageTemplates') + ->setClassName('MessageTemplates') + ->setForeignKey('notification_message_template_id') + ->setProperty('notification_message_template'); $this->hasMany('PetitionStepResults'); $this->setPluginRelations(); @@ -84,11 +96,24 @@ public function initialize(array $config): void { 'type' => 'enum', 'class' => 'EnrollmentActorEnum' ], + 'approverGroups' => [ + 'type' => 'select', + 'model' => 'Groups' + ], 'messageTemplates' => [ 'type' => 'select', 'model' => 'MessageTemplates', 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentHandoff] ], + 'notificationGroups' => [ + 'type' => 'select', + 'model' => 'Groups' + ], + 'notificationMessageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentStepCompleted] + ], 'plugins' => [ 'type' => 'plugin', 'pluginType' => 'enroller' @@ -240,6 +265,20 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('actor_type'); + $validator->add('approver_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('approver_group_id'); + /* + // An approvers group is required when the actor_type is Approver + $validator->notEmptyString( + field: 'approver_group_id', + when: function($context) { + return (!empty($context['data']['actor_type']) + && ($context['data']['actor_type'] == EnrollmentActorEnum::Approver)); + } + );*/ + $validator->add('message_template_id', [ 'content' => ['rule' => 'isInteger'] ]); @@ -250,6 +289,16 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('redirect_on_handoff'); + $validator->add('notification_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('notification_group_id'); + + $validator->add('notification_message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('notification_message_template_id'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/EnrollmentFlowsTable.php b/app/src/Model/Table/EnrollmentFlowsTable.php index 2151eacd8..7a3e20fee 100644 --- a/app/src/Model/Table/EnrollmentFlowsTable.php +++ b/app/src/Model/Table/EnrollmentFlowsTable.php @@ -80,7 +80,19 @@ public function initialize(array $config): void { // Property is set so ruleValidateCO can find it. We don't use the // _id suffix to match Cake's default pattern. ->setProperty('authz_group'); - + $this->belongsTo('NotificationGroups') + ->setClassName('Groups') + ->setForeignKey('notification_group_id') + ->setProperty('notification_group'); + $this->belongsTo('FinalizationMessageTemplates') + ->setClassName('MessageTemplates') + ->setForeignKey('finalization_message_template_id') + ->setProperty('finalization_message_template'); + $this->belongsTo('NotificationMessageTemplates') + ->setClassName('MessageTemplates') + ->setForeignKey('notification_message_template_id') + ->setProperty('notification_message_template'); + $this->hasMany('Petitions'); $this->hasMany('EnrollmentFlowSteps') ->setDependent(true) @@ -98,6 +110,20 @@ public function initialize(array $config): void { 'type' => 'enum', 'class' => 'EnrollmentAuthzEnum' ], + 'finalizationMessageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentFinalization] + ], + 'notificationGroups' => [ + 'type' => 'select', + 'model' => 'Groups' + ], + 'notificationMessageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentStepCompleted] + ], 'statuses' => [ 'type' => 'enum', 'class' => 'TemplateableStatusEnum' @@ -151,9 +177,9 @@ public function initialize(array $config): void { * @since COmanage Registry v5.1.0 * @param int $petitionId Petition ID * @return array url: URL to redirect to - * step: EnrollmentFlowStep + * step: EnrollmentFlowStep (null if finalize is true) * finalize: True if there are no further steps - * lastStep: If finalize is true, the last EnrollmentFlowStep + * lastStep: The prior EnrollmentFlowStep (the one before 'step') * petition: The Petition entity * @throws InvalidArgumentException */ @@ -202,6 +228,8 @@ public function calculateNextStep(int $petitionId): array { throw new \InvalidArgumentException(__d('error', 'EnrollmentFlowSteps.none')); } + $prior = null; + foreach($steps as $step) { if(!array_key_exists($step->id, $results)) { // We do not have an array for this step, so it is the next step @@ -217,15 +245,17 @@ public function calculateNextStep(int $petitionId): array { 'action' => 'dispatch', $step->$pluginName->id, '?' => [ - 'petition_id' => $petition->id + 'petition_id' => $petition->id ] ], 'step' => $step, 'finalize' => false, - 'lastStep' => null, + 'lastStep' => $prior, 'petition' => $petition ]; } + + $prior = $step; } // If we didn't find a Step, it's time to Finalize the Petition. @@ -301,6 +331,21 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('redirect_on_finalize'); + $validator->add('finalization_message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('finalization_message_template_id'); + + $validator->add('notification_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('notification_group_id'); + + $validator->add('notification_message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('notification_message_template_id'); + return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php index b9a8fda8a..4d5252edb 100644 --- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -154,6 +154,19 @@ public function initialize(array $config): void { ]); } + /** + * Modify an index Query to specify how to filter on the requested CO. + * + * @since COmanage Registry v5.2.0 + * @param Query $query Query object + * @param int $coId CO ID to filter on + * @return Query Modified query + */ + + public function filterIndexByCO(Query $query, int $coId): Query { + return $query->where(['ExternalIdentitySources.co_id' => $coId]); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 4df6db0cd..73304dcb2 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -95,6 +95,8 @@ public function initialize(array $config): void { ->setClassName('Groups') ->setForeignKey('owners_group_id'); + $this->hasMany('EnrollmentFlowSteps') + ->setForeignKey('notification_group_id'); $this->hasMany('GroupMembers') ->setDependent(true) ->setCascadeCallbacks(true); @@ -290,6 +292,14 @@ public function addDefaults(int $coId, int $couId=null, bool $rename=false): boo 'status' => SuspendableStatusEnum::Active, 'cou_id' => ($couId ?: null) ], + ':approvers' => array( + 'group_type' => GroupTypeEnum::Approvers, + 'auto' => false, + 'description' => __d('field', 'Groups.desc.approvers', [$couName ?: $co->name]), + 'open' => false, + 'status' => SuspendableStatusEnum::Active, + 'cou_id' => ($couId ?: null) + ), ':members:active' => [ 'group_type' => GroupTypeEnum::ActiveMembers, 'auto' => true, @@ -506,28 +516,114 @@ public function getAdminGroupId(int $coId): int { return $g->id; } + /** + * Get the Approvers Group for a CO or COU. + * + * @since COmanage Registry v5.2.0 + * @param int $coId CO ID + * @param int $couId COU ID + * @return int Group ID + * @throws \Cake\Datasource\Exception\RecordNotFoundException + */ + + public function getApproversGroupId(?int $coId=null, ?int $couId=null): int { + $whereClause = [ + 'status' => SuspendableStatusEnum::Active, + 'group_type' => GroupTypeEnum::Approvers + ]; + + if($coId) { + $whereClause['co_id'] = $coId; + } + + if($couId) { + $whereClause['cou_id'] = $couId; + } else { + // Make sure not to pull the CO level Approvers group + $whereClause['cou_ID IS'] = null; + } + + $group = $this->find()->where($whereClause)->firstOrFail(); + + return $group->id; + } + /** * Obtain an iterator for all members of the requested Group. - * + * * @since COmanage Registry v5.0.0 - * @param int $id Group ID - * @param int $groupNestingId If provided, only members due to this Group Nesting ID - * @return PaginatedSqlIterator Iterator for GroupMembers + * @param int $id Group ID + * @param int $groupNestingId If provided, only members due to this Group Nesting ID + * @param bool $valid If true, only return members with valid validity dates + * @param bool $active If true, only return members with an active Person status + * @param bool $activeGroup If true, the Group itself must be Active + * @return PaginatedSqlIterator Iterator for GroupMembers + * @throws InvalidArgumentException */ - public function getMembers(int $id, int $groupNestingId=null): PaginatedSqlIterator { + public function getMembers( + int $id, + int $groupNestingId=null, + bool $valid=true, + bool $active=true, + bool $activeGroup=true + ): PaginatedSqlIterator { $conditions = [ 'group_id' => $id, -// XXX add check for valid_from/through and test -// 'valid_from' -// 'valid_through' ]; + + $filter = null; + + if($activeGroup) { + // Do a separate check on the Group itself since we only need to do that once + + $group = $this->get($id); + + if($group->status != SuspendableStatusEnum::Active) { + throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Groups', [1]), $id])); + } + } if($groupNestingId) { $conditions['group_nesting_id'] = $groupNestingId; } + + if($active || $valid) { + // We handle these constraints via filters to avoid reproducing application + // logic in multiple places. This means the Paginator count will be an upper + // bound instead of an exact count, but we don't need that here. + + // Retrieve the PeopleTable once and pass it to the callback to use. + $People = TableRegistry::getTableLocator()->get('People'); + + $filter = function ($entity) use ($People, $active, $valid) { + // $entity is a GroupMember object from the result set. + + if($active) { + // Retrieve the Person to check its status + + // person_id is a required field, so this get() shouldn't fail. + // If it does, an exception will be thrown. + $person = $People->get($entity->person_id); + + if(!$person->isActive()) { + return false; + } + } + + if($valid && !$entity->isValid()) { + return false; + } + + return true; + }; + } - return new PaginatedSqlIterator($this->GroupMembers->getTarget(), $conditions); + return new PaginatedSqlIterator( + table: $this->GroupMembers->getTarget(), + conditions: $conditions, + filter: $filter + ); } /** @@ -541,7 +637,7 @@ public function getMembers(int $id, int $groupNestingId=null): PaginatedSqlItera */ public function getMembersViaNesting(int $id, int $groupNestingId): PaginatedSqlIterator { - return $this->getMembers($id, $groupNestingId); + return $this->getMembers($id, $groupNestingId, false, false, false); } /** @@ -718,7 +814,7 @@ protected function reconcileAutomaticGroup(\Cake\Datasource\EntityInterface $ent // First, we pull the current members of the Group, and for each member // make sure they are still eligible. - $iterator = $this->getMembers($entity->id); + $iterator = $this->getMembers($entity->id, null, false, false, false); foreach($iterator as $k => $groupMember) { if(!empty($entity->cou_id)) { @@ -840,7 +936,7 @@ protected function reconcileNestedMemberships(\Cake\Datasource\EntityInterface $ // to check here.) foreach($groupNestings->toArray() as $groupNesting) { - $iterator = $this->getMembers($groupNesting->group_id); + $iterator = $this->getMembers($groupNesting->group_id, null, false, false, false); foreach($iterator as $k => $sourceGroupMember) { $this->GroupMembers->syncNestedMembership($sourceGroupMember->person_id, diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 052e29705..348ffdb2e 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -72,6 +72,7 @@ class IdentifiersTable extends Table { 'reference', 'pairwiseid', 'subjectid', + // sorid is now localized as "source key" but we still use "sorid" for compatibility 'sorid', 'uid' ] @@ -202,6 +203,31 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + + /** + * Look up a Group ID from an identifier and identifier type ID. + * Only active Identifiers can be used for lookups. + * + * @param int $typeId Identifier Type ID + * @param string $identifier Identifier + * + * @return int Group ID + * @since COmanage Registry v5.2.0 + */ + + public function lookupGroup(int $typeId, string $identifier): int { + // Note this function signature is intentionally the same as lookupPerson() + $id = $this->find() + ->where([ + 'identifier' => $identifier, + 'type_id' => $typeId, + 'status' => SuspendableStatusEnum::Active, + 'group_id IS NOT NULL' + ]) + ->firstOrFail(); + + return $id->group_id; + } /** * Look up a Person ID from an identifier and identifier type ID. diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php index 903897660..92e18d66a 100644 --- a/app/src/Model/Table/MessageTemplatesTable.php +++ b/app/src/Model/Table/MessageTemplatesTable.php @@ -32,11 +32,13 @@ use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; -use Cake\ORM\TableRegistry; use Cake\Validation\Validator; use App\Lib\Enum\MessageFormatEnum; use App\Lib\Enum\MessageTemplateContextEnum; use App\Lib\Enum\SuspendableStatusEnum; +use \App\Model\Entity\Notification; +use \App\Model\Entity\Person; +use \App\Model\Entity\Petition; class MessageTemplatesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -46,7 +48,7 @@ class MessageTemplatesTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - + /** * Perform Cake Model initialization. * @@ -65,6 +67,8 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); $this->hasMany('EnrollmentFlowSteps'); + $this->hasMany('EnrollmentFlowStepNotifications') + ->setForeignKey('notification_message_template_id'); $this->hasMany('Notifications'); $this->setDisplayField('description'); @@ -101,85 +105,6 @@ public function initialize(array $config): void { ] ]); } - - /** - * 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 - * @param string $code Verification code - * @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, - ?string $code=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)) { - $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; - } - - $substitutions['VERIFICATION_CODE'] = $code; - - // 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 - $searchKeys = []; - $replaceVals = []; - - foreach(array_keys($substitutions) as $k) { - $searchKeys[] = "(@" . $k . ")"; - $replaceVals[] = $substitutions[$k] ?? "(?)"; - } - - $ret[$part] = str_replace($searchKeys, $replaceVals, $template->$part); - } - } - - return $ret; - } /** * Set validation rules. diff --git a/app/src/Model/Table/NotificationsTable.php b/app/src/Model/Table/NotificationsTable.php index 7ff140422..6ede4b5a0 100644 --- a/app/src/Model/Table/NotificationsTable.php +++ b/app/src/Model/Table/NotificationsTable.php @@ -29,13 +29,17 @@ namespace App\Model\Table; +use Cake\Event\EventInterface; use Cake\Mailer\Mailer; +use Cake\ORM\Query; use Cake\ORM\Table; use Cake\ORM\TableRegistry; +use Cake\Routing\Router; use Cake\Validation\Validator; use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\NotificationStatusEnum; use \App\Lib\Util\DeliveryUtilities; +use \App\Model\Entity\MessageTemplate; use \App\Model\Entity\Notification; class NotificationsTable extends Table { @@ -81,6 +85,10 @@ public function initialize(array $config): void { ->setClassName('People') ->setForeignKey('resolver_person_id') ->setProperty('resolver_person'); + $this->belongsTo('SubjectGroups') + ->setClassName('Groups') + ->setForeignKey('subject_group_id') + ->setProperty('subject_group'); $this->belongsTo('SubjectPeople') ->setClassName('People') ->setForeignKey('subject_person_id') @@ -89,12 +97,21 @@ public function initialize(array $config): void { $this->setDisplayField('comment'); $this->setPrimaryLink([ + // The subject could be null, eg during an Enrollment Flow where no Person has + // been allocated yet, so we also accept recipients as primary keys (since there + // has to be at least one recipient within the CO) 'subject_person_id' => 'People', - 'subject_group_id' => 'Groups' + 'subject_group_id' => 'Groups', + 'recipient_person_id' => 'People', + 'recipient_group_id' => 'Groups' ]); $this->setRequiresCO(true); $this->setAllowLookupPrimaryLink(['acknowledge', 'cancel', 'resend']); + // These are required for the link to work from the Artifacts page + $this->setAllowUnkeyedPrimaryCO(['index']); + $this->setAllowEmptyPrimaryLink(['index']); + $this->setAutoViewVars([ 'statuses' => [ 'type' => 'enum', @@ -102,10 +119,19 @@ public function initialize(array $config): void { ] ]); + $this->setIndexContains([ + 'RecipientGroups', + 'RecipientPeople' => ['PrimaryName'], + 'SubjectGroups', + 'SubjectPeople' => ['PrimaryName'] + ]); + $this->setViewContains([ 'ActorPeople' => ['PrimaryName'], + 'RecipientGroups', 'RecipientPeople' => ['PrimaryName'], 'ResolverPeople' => ['PrimaryName'], + 'SubjectGroups', 'SubjectPeople' => ['PrimaryName'] ]); @@ -141,7 +167,23 @@ public function acknowledge( int $id, int $personId ) { - $this->processResolution($id, NotificationStatusEnum::Acknowledged, $personId); + $this->processResolution($this->get($id), NotificationStatusEnum::Acknowledged, $personId); + } + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.2.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) { + if(!empty($data['source'] && is_array($data['source']))) { + // Convert the URL array to a string so we can store it in the database + $data['source'] = Router::url(url: $data['source'], full: true); + } } /** @@ -157,21 +199,23 @@ public function cancel( int $id, int $personId ) { - $this->processResolution($id, NotificationStatusEnum::Canceled, $personId); + $this->processResolution($this->get($id), NotificationStatusEnum::Canceled, $personId); } /** * Deliver a notification. * * @since COmanage Registry v5.0.0 - * @param Notification $notification Notification - * @param int $actorPersonId Person ID that caused the Notification to be generated - * @param bool Return true on success + * @param Notification $notification Notification + * @param MessageTemplate $messageTemplate MessageTemplate + * @param int $actorPersonId Person ID that caused the Notification to be generated + * @param bool Return true on success */ public function deliver( - Notification $notification, - int $actorPersonId + Notification $notification, + MessageTemplate $messageTemplate, + ?int $actorPersonId=null ) { // We call this function "deliver" because ultimately we might support other // mechanisms besides email, but for now this basically just delivers the @@ -182,48 +226,98 @@ public function deliver( throw new \InvalidArgumentException(__d('error', 'Notifications.notify.status')); } - $result = DeliveryUtilities::sendEmailFromTemplate( - personId: $notification->recipient_person_id, - messageTemplateId: $notification->message_template_id, - subjectPerson: !empty($notification->subject_person_id) - ? $this->SubjectPeople->get($notification->subject_person_id) - : null, - notification: $notification - ); + // Construct the message content. We do this here rather than rely on DeliveryUtilities + // so we can update the Notification, and so DeliveryUtilities doesn't have to generate + // the message multiple times when there are multiple recipients. - // If we are redelivering, DeliveryUtilities will update the existing Notification - // but changelog will maintain the previously sent messages + $messageTemplate->generateMessage(); - if(!empty($result['recipient'])) { - // Create a History Record + // Update the notification with the message content + $notification->email_subject = $messageTemplate->getMessagePart('subject'); + $notification->email_body_text = $messageTemplate->getMessagePart('body_text'); + $notification->email_body_html = $messageTemplate->getMessagePart('body_html'); - $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); + $this->save($notification); - $HistoryRecords->recordForPerson( - personId: $notification->recipient_person_id, - action: ActionEnum::NotificationDelivered, - comment: __d('result', 'Notifications.delivered', [$notification->id, $result['recipient']]), - actorPersonId: $actorPersonId + // If we have a recipient Group, convert it to a list of People + $recipients = []; + + if(!empty($notification->recipient_group_id)) { + // The iterator will only return active, valid members. Since notification groups + // are typically small, we simply store the member IDs in $recipients and do the + // processing below. + + $iterator = $this->RecipientGroups->getMembers($notification->recipient_group_id); + + foreach($iterator as $k => $gm) { + $recipients[] = $gm->person_id; + } + } + + if(!empty($notification->recipient_person_id)) { + $recipients[] = $notification->recipient_person_id; + } + + $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); + + foreach($recipients as $rpid) { + $result = DeliveryUtilities::sendEmailToPerson( + personId: $rpid, + subject: $messageTemplate->getMessagePart('subject'), + body_text: $messageTemplate->getMessagePart('body_text'), + body_html: $messageTemplate->getMessagePart('body_html'), + cc: $messageTemplate->cc, + bcc: $messageTemplate->bcc, + replyTo: $messageTemplate->reply_to ); + + if(!empty($result['recipient'])) { + // Create a History Record + + $HistoryRecords->recordForPerson( + personId: $rpid, + action: ActionEnum::NotificationDelivered, + comment: __d('result', 'Notifications.delivered', [$notification->id, $result['recipient']]), + actorPersonId: $actorPersonId + ); + } } return true; } + /** + * Modify an index Query to specify how to filter on the requested CO. + * + * @since COmanage Registry v5.2.0 + * @param Query $query Query object + * @param int $coId CO ID to filter on + * @return Query Modified query + */ + + public function filterIndexByCO(Query $query, int $coId): Query { + return $query->where([ + 'OR' => [ + 'RecipientPeople.co_id' => $coId, + 'RecipientGroups.co_id' => $coId + ] + ]); + } + /** * Process the resolution of a Notification. * * @since COmanage Registry v5.0.0 - * @param int $id Notification ID - * @param string $resolution NotificationStatusEnum - * @param int $resolverPersonId Resolver Person ID + * @param Notification $notification Notification + * @param NotificationStatusEnum $resolution NotificationStatusEnum + * @param int $resolverPersonId Resolver Person ID * @throws InvalidArgumentException */ protected function processResolution( - int $id, - string $resolution, - int $resolverPersonId + Notification $notification, + string $resolution, + ?int $resolverPersonId ) { $actions = [ NotificationStatusEnum::Acknowledged => ActionEnum::NotificationAcknowledged, @@ -235,17 +329,15 @@ protected function processResolution( // implemented via buildRules(), but for now we'll just put it here since all // resolutions are handled via this function. - $obj = $this->get($id); - // Resolutions require a current corresponding status if($resolution == NotificationStatusEnum::Acknowledged - && $obj->status != NotificationStatusEnum::PendingAcknowledgment) { + && $notification->status != NotificationStatusEnum::PendingAcknowledgment) { throw new \InvalidArgumentException(_d('error', 'Notifications.acknowledge')); } elseif($resolution == NotificationStatusEnum::Canceled - && !$obj->canCancel()) { + && !$notification->canCancel()) { throw new \InvalidArgumentException(__d('error', 'Notifications.cancel')); } elseif($resolution == NotificationStatusEnum::Resolved - && $obj->status != NotificationStatusEnum::PendingResolution) { + && $notification->status != NotificationStatusEnum::PendingResolution) { throw new \InvalidArgumentException(__d('error', 'Notifications.resolve')); } elseif(!isset($actions[$resolution])) { // This status is not a valid resolution @@ -254,22 +346,25 @@ protected function processResolution( // Update the Notification - $obj->status = $resolution; - $obj->resolver_person_id = $resolverPersonId; - $obj->resolution_time = date('Y-m-d H:i:s'); + $notification->status = $resolution; + $notification->resolver_person_id = $resolverPersonId; + $notification->resolution_time = date('Y-m-d H:i:s'); - $this->save($obj); + $this->save($notification); - // Create a History Record + // Create a History Record, though note during Enrollment Flows we won't necessarily + // have a Subject Person, in which case there's no point recording History. - $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); + if(!empty($notification->subject_person_id)) { + $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); - $HistoryRecords->recordForPerson( - personId: $obj->subject_person_id, - action: $actions[$resolution], - comment: __d('result', 'Notifications.'.$resolution, [$obj->comment]), - actorPersonId: $resolverPersonId - ); + $HistoryRecords->recordForPerson( + personId: $notification->subject_person_id, + action: $actions[$resolution], + comment: __d('result', 'Notifications.'.$resolution, [$notification->comment]), + actorPersonId: $resolverPersonId + ); + } // If a notification is resolved (not acknowledged) and had a receipient group, // send email to the non-actor group members notifying them it was resolved. @@ -281,43 +376,41 @@ protected function processResolution( * Register a Notification. * * @since COmanage Registry v5.0.0 - * @param int $subjectPersonId Person ID the Notification is about - * @param int $subjectGroupId Group ID the Notification is about - * @param int $actorPersonId Person ID that caused the Notification to be generated - * @param int $recipientPersonId Person ID to receive the Notification (either this or $recipientGroupId must be specified) - * @param int $recipientGroupId Group ID to receive the Notification (either this or $recipientPersonId must be specified) - * @param string $action Action code related to this Notification - * @param string $comment Summary human readable comment for this Notification - * @param int $messageTemplateId Message Template ID to be used when sending email for this Notification - * @param mixed $source Source URL for this Notification, either as a string or a Cake URL array - * @param bool $mustResolve If true, this Notification must be resolved, it cannot be acknowledged - * @return array Array of Notification IDs (one per Notification recipient) + * @param int $subjectPersonId Person ID the Notification is about + * @param int $subjectGroupId Group ID the Notification is about + * @param int $actorPersonId Person ID that caused the Notification to be generated + * @param int $recipientPersonId Person ID to receive the Notification (either this or $recipientGroupId must be specified) + * @param int $recipientGroupId Group ID to receive the Notification (either this or $recipientPersonId must be specified) + * @param string $action Action code related to this Notification + * @param string $comment Summary human readable comment for this Notification + * @param MessageTemplate $messageTemplate Message Template to be used when sending email for this Notification + * @param mixed $source Source URL for this Notification, either as a string or a Cake URL array + * @param bool $mustResolve If true, this Notification must be resolved, it cannot be acknowledged + * @return array Array of Notification IDs (one per Notification recipient) */ public function register( - int $subjectPersonId, - ?int $subjectGroupId, - int $actorPersonId, - ?int $recipientPersonId, - ?int $recipientGroupId, - string $action, - string $comment, - int $messageTemplateId, - mixed $source, - bool $mustResolve=false + ?int $subjectPersonId, + ?int $subjectGroupId, + ?int $actorPersonId, + ?int $recipientPersonId, + ?int $recipientGroupId, + string $action, + string $comment, + MessageTemplate $messageTemplate, + mixed $source, + bool $mustResolve=false ): array { $obj = $this->newEntity([ 'subject_person_id' => $subjectPersonId, -// XXX subject group ID not yet implemented - 'subject_group_id' => null, + 'subject_group_id' => $subjectGroupId, 'actor_person_id' => $actorPersonId, 'recipient_person_id' => $recipientPersonId, -// XXX recipient group ID not yet implemented 'recipient_group_id' => $recipientGroupId, 'resolver_person_id' => null, 'action' => $action, 'comment' => $comment, - 'message_template_id' => $messageTemplateId, + 'message_template_id' => $messageTemplate->id, 'source' => $source, 'status' => ($mustResolve ? NotificationStatusEnum::PendingResolution @@ -326,20 +419,67 @@ public function register( $this->saveOrFail($obj); + // Attach the Notification to the Message Template + $messageTemplate->setContextNotification($obj); + + if($subjectPersonId) { + // Retrieve and attach the Subject Person + $messageTemplate->setContextSubjectPerson( + $this->SubjectPeople->get($subjectPersonId, ['contain' => 'PrimaryName']) + ); + } + // Note the various message fields will be set by DeliveryUtilities. - $this->deliver($obj, $actorPersonId); + $this->deliver($obj, $messageTemplate, $actorPersonId); return [ $obj->id ]; } /** * Resolve a notification. - */ + * public function resolve( - + int $id ) { - echo 'hello'; + // XXX call processResolution() + }*/ + + /** + * Resolve all outstanding notifications from the specified source. + * + * @since COmanage Registry v5.2.0 + * @param mixed $source Source array or URL, exactly matching what was provided previously to register() + * @param int $resolverCoPersonId Person ID who resolved the notification + * @param NotificationStatusEnum $resolution NotificationStatusEnum + */ + + public function resolveFromSource( + mixed $source, + string $resolution=NotificationStatusEnum::Resolved, + ?int $resolverPersonId, + ) { + $sourceUrl = $source; + + if(is_array($sourceUrl)) { + $sourceUrl = Router::url(url: $source, full: true); + } + + // Pull any Notifications from $source that are Pending Resolution + $notifications = $this->find() + ->where([ + 'source' => $sourceUrl, + 'status' => NotificationStatusEnum::PendingResolution + ]) + ->all(); + + foreach($notifications as $n) { + $this->processResolution( + $n, + $resolution, + $resolverPersonId + ); + } } /** @@ -356,16 +496,15 @@ public function validationDefault(Validator $validator): Validator { $schema = $this->getSchema(); // XXX one of subject_person_id or subject_group_id should be required, -// update these rules when subject_group_id is implemented $validator->add('subject_person_id', [ 'content' => ['rule' => 'isInteger'] ]); - $validator->notEmptyString('subject_person_id'); + $validator->allowEmptyString('subject_person_id'); - // $validator->add('subject_group_id', [ - // 'content' => ['rule' => 'isInteger'] - // ]); - // $validator->notEmptyString('subject_group_id'); + $validator->add('subject_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('subject_group_id'); $validator->add('actor_person_id', [ 'content' => ['rule' => 'isInteger'] diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index cce7f4456..275c58328 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -269,7 +269,9 @@ public function initialize(array $config): void { 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], 'provision' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin', 'selfMember'] + // selfMember removed pending further discussion around permissions + // 'view' => ['platformAdmin', 'coAdmin', 'selfMember'] + 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php index 43b6b69f7..b67199cc0 100644 --- a/app/src/Model/Table/PetitionsTable.php +++ b/app/src/Model/Table/PetitionsTable.php @@ -29,12 +29,14 @@ namespace App\Model\Table; +use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\ORM\TableRegistry; use Cake\Utility\Inflector; use Cake\Validation\Validator; use \App\Lib\Enum\ActionEnum; +use \App\Lib\Enum\EnrollmentActorEnum; use \App\Lib\Enum\PetitionActionEnum; use \App\Lib\Enum\PetitionStatusEnum; use \App\Lib\Enum\ProvisioningContextEnum; @@ -102,7 +104,15 @@ public function initialize(array $config): void { $this->setPrimaryLink('enrollment_flow_id'); $this->setRequiresCO(true); - $this->setAllowLookupPrimaryLink(['continue', 'finalize', 'pending', 'result', 'resume']); + $this->setAllowLookupPrimaryLink([ + 'continue', + 'finalize', + 'pending', + 'result', + 'resume', + 'terminate' + ]); + $this->setRedirectGoal(action: 'terminate', goal: 'self'); // These are required for the link to work from the Artifacts page $this->setAllowUnkeyedPrimaryCO(['index']); @@ -116,10 +126,16 @@ public function initialize(array $config): void { ]); $viewContainsRelations = [ - 'EnrollmentFlows' => ['EnrollmentFlowSteps' => ['sort' => ['ordr' => 'ASC']]], + 'EnrollmentFlows' => ['EnrollmentFlowSteps' => + // This magic will pass the Step plugin configuration to each Cell automatically + array_merge($this->EnrollmentFlows->EnrollmentFlowSteps->getPluginRelations(), + ['sort' => ['ordr' => 'ASC']]) + ], 'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], 'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], - 'PetitionHistoryRecords', + 'PetitionHistoryRecords' => [ + 'ActorPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']] + ], 'PetitionStepResults', ]; @@ -177,7 +193,10 @@ public function initialize(array $config): void { // resume renders a landing page, the admin can copy a URL and resend it // to the appropriate actor if the actor is not also an admin 'resume' => ['platformAdmin', 'coAdmin'], - 'view' => ['platformAdmin', 'coAdmin'] + // terminate an in-progress Petition + 'terminate' => ['platformAdmin', 'coAdmin'], + // Any approver for the associated Enrollment Flow can view the entire Petition + 'view' => ['platformAdmin', 'coAdmin', 'approver'] ], // Actions that are permitted on readonly entities (besides view) 'readOnly' => ['result'], @@ -207,6 +226,42 @@ public function initialize(array $config): void { ); } + /** + * Find the Approver Group for the specified Step for this Petition. + * + * @since COmanage Registry v5.2.0 + * @param int $id Petition ID + * @param int $stepId Enrollment Flow Step ID + * @return int Group ID + * @return bool true if $personId is an Approver for this Step for this Petition, false otherwise + */ + + public function approverGroupId(int $id, int $stepId): int { + $EnrollmentFlowSteps = TableRegistry::getTableLocator()->get("EnrollmentFlowSteps"); + $Groups = TableRegistry::getTableLocator()->get("Groups"); + + $petition = $this->get($id); + + $step = $EnrollmentFlowSteps->get($stepId); + + if(!empty($step->approver_group_id)) { + // If there is an Approver Group set, use that. + + return $step->approver_group_id; + } else { + if(!empty($petition->cou_id)) { + // If there is a COU set on the Petition (which is why this function is here and + // not in EnrollmentFlowStepsTable) use the Approvers Group for that COU. + + return $Groups->getApproversGroupId(couId: $petition->cou_id); + } else { + // $personId must be a member of the Approvers Group for the CO. + + return $Groups->getApproversGroupId(coId: $this->calculateCoForRecord($petition)); + } + } + } + /** * Assign Identifiers for a Petition. * @@ -325,7 +380,7 @@ public function derive(int $id) { */ public function finalize(int $id) { - $petition = $this->get($id); + $petition = $this->get($id, ['contain' => 'EnrollmentFlows']); if($petition->isComplete()) { throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); @@ -334,7 +389,7 @@ public function finalize(int $id) { // Update the Petition status and create a History Record. $petition->status = PetitionStatusEnum::Finalized; - $this->saveOrFail($petition); + $this->saveOrFail($petition, ['associated' => false]); $this->PetitionHistoryRecords->record( petitionId: $petition->id, @@ -343,6 +398,76 @@ public function finalize(int $id) { comment: __d('result', 'Petitions.finalized') // actorPersonId ); + + if(!empty($petition->enrollment_flow->finalization_message_template_id)) { + // A finalization Message Template was specified, use it to notify the Enrollee. + // We use the enrollee_email address, if populated, otherwise we generate a + // Notification to the Enrollee Person (which may or may not have a deliverable + // email address). + + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + $template = $MessageTemplates->get($petition->enrollment_flow->finalization_message_template_id); + + $template->setContextPetition($petition); + + if(!empty($petition->enrollee_email)) { + // 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. + + // Because we're calling DeliveryUtilities directly we need to generate the message + // from the template here. + + $template->generateMessage(); + + if(!DeliveryUtilities::sendEmailToAddress( + coId: $coId, + recipient: $petition->enrollee_email, + subject: $template->getMessagePart('subject'), + body_text: $template->getMessagePart('body_text'), + body_html: $template->getMessagePart('body_html') + )) { + throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead? + } + } elseif(!empty($petition->enrollee_person)) { + // Register a Notification. + + $Notifications = TableRegistry::getTableLocator()->get('Notifications'); + + $Notifications->register( + subjectPersonId: $petition->enrollee_person_id, + subjectGroupId: null, + actorPersonId: null, //$actorInfo['person_id'], + recipientPersonId: $petition->enrollee_person_id, + recipientGroupId: null, + action: ActionEnum::PetitionFinalized, + comment: __d('result', 'Petitions.finalized'), + messageTemplate: $template, + // We'll set the source to be the URL to the Petition itself + source: [ + 'controller' => 'petitions', + 'action' => 'view', + $id + ], + mustResolve: false + ); + } else { + $this->llog('debug', "Cannot send finalization notification for Petition " . $id . " due to lack of email or enrollee Person rocerd"); + } + } + } + + /** + * Modify an index Query to specify how to filter on the requested CO. + * + * @since COmanage Registry v5.2.0 + * @param Query $query Query object + * @param int $coId CO ID to filter on + * @return Query Modified query + */ + + public function filterIndexByCO(Query $query, int $coId): Query { + return $query->where(['EnrollmentFlows.co_id' => $coId]); } /** @@ -385,6 +510,57 @@ public function flagDuplicate(int $id, ?int $stepId=null, ?string $comment=null) return true; } + /** + * Determine the Name associated with the Enrollee for this Petition. Not all Petitions + * will have Enrollee Names, and some Petitions could have more than one Name. This + * function will try to find a suitable Name, but no guarantees can be made as to the + * result; different values can be returned across subsequent calls, especially if the + * Petition state changes. + * + * @since COmanage Registry v5.2.0 + * @param int $id Petition ID + * @return string A name if found, null otherwise + */ + + public function getEnrolleeName(int $id): ?string { + $ret = null; + + // First see if there is an Enrollee Person associated with the Petition, which would + // be the case for (eg) account linking. If so, use their Primary Name. + + $petition = $this->get($id, ['contain' => [ + 'EnrolleePeople' => 'PrimaryName', + 'PetitionStepResults' => [ + 'EnrollmentFlowSteps' => $this->PetitionStepResults->EnrollmentFlowSteps->getPluginRelations() + ] + ]]); + + if(!empty($petition->enrollee_person->primary_name)) { + return $petition->enrollee_person->primary_name->full_name; + } + + // Next walk through the Petition Step Results (ie: the completed Steps) and query the + // associated plugins for a Name. We'll stop at the first one we find, which might or + // might not be the correct thing to do under all circumstances. Note the step results + // are returned in a non-deterministic order. + + foreach($petition->petition_step_results as $psr) { + $PluginTable = TableRegistry::getTableLocator()->get($psr->enrollment_flow_step->plugin); + + if(method_exists($PluginTable, "enrolleeName")) { + $pmodel = StringUtilities::pluginToEntityField($psr->enrollment_flow_step->plugin); + + $name = $PluginTable->enrolleeName($psr->enrollment_flow_step->$pmodel, $petition->id); + + if(!empty($name)) { + return $name; + } + } + } + + return $ret; + } + /** * Perform Petition hydration. * @@ -539,6 +715,74 @@ public function getToken(int $id): string { return $petition->token; } + /** + * Determine if a Person can approve the specified Step of the specified Petition. + * + * @since COmanage Registry v5.2.0 + * @param int $id Petition ID + * @param int $stepId Enrollment Flow Step ID + * @param int $personId Person ID + * @return bool true if $personId is an Approver for this Step for this Petition, false otherwise + */ + + public function isApprover(int $id, int $stepId, int $personId): bool { + // This function is here and not in EnrollmentFlowStepsTable because the Approvers Group + // can be influenced by the COU ID of the Petition. + + // $EnrollmentFlowSteps = TableRegistry::getTableLocator()->get("EnrollmentFlowSteps"); + + $step = $this->EnrollmentFlows->EnrollmentFlowSteps->get($stepId); + + if($step->actor_type == EnrollmentActorEnum::Approver) { + $GroupMembers = TableRegistry::getTableLocator()->get("GroupMembers"); + + return $GroupMembers->isMember( + groupId: $this->approverGroupId($id, $stepId), + personId: $personId + ); + } + + return false; + } + + /** + * Determine if a Person can Approve any Step for the specified Petition.. + * + * @since COmanage Registry v5.2.0 + * @param int $id Petition ID + * @param int $personId Person ID + * @return bool true if $personId is an Approver for this Flow, false otherwise + */ + + public function isApproverForFlow(int $id, int $personId): bool { + // This function is here and not in EnrollmentFlowStepsTable because the Approvers Group + // can be influenced by the COU ID of the Petition. + + $petition = $this->get($id); + + $steps = $this->EnrollmentFlows->EnrollmentFlowSteps + ->find() + ->where([ + 'enrollment_flow_id' => $petition->enrollment_flow_id, + 'status' => SuspendableStatusEnum::Active + ]) + ->order(['EnrollmentFlowSteps.ordr' => 'ASC']) + ->all(); + + foreach($steps as $step) { + if($step->actor_type == EnrollmentActorEnum::Approver) { + if($this->EnrollmentFlows->EnrollmentFlowSteps->ApproverGroups->GroupMembers->isMember( + groupId: $this->approverGroupId($id, $step->id), + personId: $personId + )) { + return true; + } + } + } + + return false; + } + /** * Run Provisioning for a Petition. * @@ -635,6 +879,25 @@ public function start( return $petition; } + /** + * Terminate a Petition. + * + * @since COmanage Registry v5.2.0 + * @param int $id Petition ID + */ + + public function terminate(int $id) { + $petition = $this->get($id); + + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + $petition->status = PetitionStatusEnum::Terminated; + + $this->save($petition); + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/VerificationsTable.php b/app/src/Model/Table/VerificationsTable.php index d17867d61..49de805a6 100644 --- a/app/src/Model/Table/VerificationsTable.php +++ b/app/src/Model/Table/VerificationsTable.php @@ -38,6 +38,7 @@ use App\Lib\Enum\VerificationMethodEnum; use App\Lib\Random\RandomString; use App\Lib\Util\DeliveryUtilities; +use App\Lib\Util\StringUtilities; use App\Model\Entity\Verification; use Random\RandomException; @@ -297,10 +298,15 @@ public function requestCodeForPetition( // Send the verification message + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + $template = $MessageTemplates->get($messageTemplateId); + + $template->setContextCode(StringUtilities::addDashesToToken($code)); + DeliveryUtilities::sendEmailFromTemplate( - address: $mail, - messageTemplateId: $messageTemplateId, - code: $this->tokenToD($code) + template: $template, + address: $mail ); // We'll try to record history, but most likely it'll fail due to lack of a Person @@ -442,32 +448,6 @@ public function verifyFromPetition(int $id, int $emailAddressId) { $this->saveOrFail($newVerification); } - - /** - * Converts a token by adding dashes for improved readability. - * - * @param string $token The token to be formatted - * @param int $jump Characters to skip before adding a dash - * @return string The formatted token with dashes - * @since COmanage Registry v5.2.0 - */ - public function tokenToD(string $token, int $jump = 4): string - { - // Insert some dashes to improve readability - $dtoken = ''; - - for($i = 0, $iMax = strlen($token); $i < $iMax; $i++) { - $dtoken .= $token[$i]; - - if((($i + 1) % $jump == 0) - && ($i + 1 < strlen($token))) { - $dtoken .= '-'; - } - } - - return $dtoken; - } - /** * Set validation rules. * diff --git a/app/templates/EnrollmentFlowSteps/fields.inc b/app/templates/EnrollmentFlowSteps/fields.inc index 6664a3c00..ffe5137ec 100644 --- a/app/templates/EnrollmentFlowSteps/fields.inc +++ b/app/templates/EnrollmentFlowSteps/fields.inc @@ -25,16 +25,77 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ +use \App\Lib\Enum\EnrollmentActorEnum; +?> + + +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'actor_type', + 'fieldOptions' => [ + 'onChange' => 'updateGadgets()' + ] + ]]); + + foreach (['approver_group_id', + 'message_template_id', + 'redirect_on_handoff' + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'notification_group_id', + 'fieldOptions' => [ + 'onChange' => 'updateGadgets()' + ] + ]]); + + foreach (['notification_message_template_id' ] as $field) { print $this->element('form/listItem', [ 'arguments' => [ diff --git a/app/templates/EnrollmentFlows/fields.inc b/app/templates/EnrollmentFlows/fields.inc index 2b6018ace..411633f7c 100644 --- a/app/templates/EnrollmentFlows/fields.inc +++ b/app/templates/EnrollmentFlows/fields.inc @@ -33,7 +33,7 @@ declare(strict_types = 1); function updateGadgets(isPageLoad) { // Hide or show gadgets according to current state - var authz = document.getElementById('authz-type').value; + //var authz = document.getElementById('authz-type').value; // Handle page interaction /* CFM-31 not yet implemented @@ -49,6 +49,14 @@ declare(strict_types = 1); }*/ hideFields(['authz-cou-id', 'authz-group-id'], isPageLoad); + + var notificationGroup = document.getElementById('notification-group-id').value; + + if(notificationGroup) { + showFields(['notification-message-template-id'], isPageLoad); + } else { + hideFields(['notification-message-template-id'], isPageLoad); + } } function jsLocalOnLoad() { @@ -84,6 +92,24 @@ if($vv_action == 'add' || $vv_action == 'edit') { 'collect_enrollee_email', 'redirect_on_duplicate', 'redirect_on_finalize', + 'finalization_message_template_id' + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'notification_group_id', + 'fieldOptions' => [ + 'onChange' => 'updateGadgets()' + ] + ] + ]); + + foreach (['notification_message_template_id' ] as $field) { print $this->element('form/listItem', [ 'arguments' => [ diff --git a/app/templates/GroupMembers/columns.inc b/app/templates/GroupMembers/columns.inc index 512771713..92c07e715 100644 --- a/app/templates/GroupMembers/columns.inc +++ b/app/templates/GroupMembers/columns.inc @@ -40,6 +40,12 @@ $indexColumns = [ // XXX not clear how to sort on submodel, but maybe just leave it for UX revisions? // 'sortable' => 'PrimaryName.family' ], + 'status' => [ + 'type' => 'enum', + 'model' => 'person', + 'field' => 'status', + 'class' => 'StatusEnum' + ], 'source' => [ 'type' => 'closure', 'function' => function ($entity) { diff --git a/app/templates/Notifications/columns.inc b/app/templates/Notifications/columns.inc index eb43ede49..407345ed0 100644 --- a/app/templates/Notifications/columns.inc +++ b/app/templates/Notifications/columns.inc @@ -26,9 +26,22 @@ */ $indexColumns = [ + 'id' => [ + 'type' => 'echo' + ], 'action' => [ 'type' => 'echo' ], + 'subject_person_id' => [ + 'type' => 'relatedLink', + 'action' => 'edit', + 'label' => __d('field', 'Notifications.subject_person_id'), + 'model' => 'subject_person', + 'submodel' => 'primary_name', + 'field' => 'full_name' +// XXX see comments in whichever controller this came from about sorting on given vs family +// 'sortable' => 'PrimaryName.family' + ], 'comment' => [ 'type' => 'link' ], diff --git a/app/templates/Notifications/fields.inc b/app/templates/Notifications/fields.inc index 449c5da13..769b6428b 100644 --- a/app/templates/Notifications/fields.inc +++ b/app/templates/Notifications/fields.inc @@ -78,6 +78,20 @@ if($vv_action == 'view') { ] ]); + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'subject_group_id', + 'status' => $vv_obj->subject_group->name ?? '', + 'link' => !empty($vv_obj->subject_group) + ? ['url' => [ + 'controller' => 'groups', + 'action' => 'edit', + $vv_obj->subject_group->id + ]] + : [] + ] + ]); + print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'recipient_person_id', @@ -92,6 +106,20 @@ if($vv_action == 'view') { ] ]); + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'recipient_group_id', + 'status' => $vv_obj->recipient_group->name ?? '', + 'link' => !empty($vv_obj->recipient_group) + ? ['url' => [ + 'controller' => 'groups', + 'action' => 'edit', + $vv_obj->recipient_group->id + ]] + : [] + ] + ]); + print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'actor_person_id', diff --git a/app/templates/Petitions/fields-nav.inc b/app/templates/Petitions/fields-nav.inc index 1609fca83..962ecc198 100644 --- a/app/templates/Petitions/fields-nav.inc +++ b/app/templates/Petitions/fields-nav.inc @@ -26,6 +26,22 @@ */ $topLinks = [ + // Note we don't need an 'if' test for cancel or resume because the generalized permission + // calculation will call isReadOnly() (and not insert these items when true) which is sufficient. + [ + 'icon' => 'cancel', + 'order' => 'Default', + // We localize the text as "terminate" to make the confirmation dialog less confusing + 'label' => __d('operation', 'terminate'), + 'link' => [ + 'action' => 'terminate', + $vv_obj->id + ], + 'confirm' => [ + 'dg_body_txt' => __d('operation', 'Petitions.terminate.confirm', [$vv_obj->id]), + 'dg_confirm_btn' => __d('operation', 'terminate') + ] + ], [ 'icon' => 'resume', 'order' => 'Default', diff --git a/app/templates/Petitions/fields.inc b/app/templates/Petitions/fields.inc index c3efb180f..c732f6175 100644 --- a/app/templates/Petitions/fields.inc +++ b/app/templates/Petitions/fields.inc @@ -209,6 +209,19 @@ if (!empty($vv_obj?->petitioner_person?->id)) {
  • +
  • + +
    +
    +
    + +
    +
    +
    + +
    +
    +
  • @@ -222,6 +235,19 @@ if (!empty($vv_obj?->petitioner_person?->id)) {
  • +
  • + +
    +
    +
    + +
    +
    +
    + enrollee_identifier ?> +
    +
    +
  • @@ -325,10 +351,31 @@ if (!empty($vv_obj?->petitioner_person?->id)) { petition_history_records as $hr): ?> - - - - + + action //__d('enumeration','PetitionActionEnum.' . $hr['action']) + ?> + + + actor_person_id) + ? $this->Html->link( + $hr->actor_person->primary_name->full_name, + [ + 'controller' => 'people', + 'action' => 'edit', + $hr->actor_person_id + ] + ) + : "" + ?> + + comment ?> + created ?> diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 419d99486..5d4583567 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -436,8 +436,13 @@ } break; case 'enum': - if($entity->$col) { - // XXX Need to add badging - see index.php in Match +// XXX Need to add badging - see index.php in Match + if(!empty($cfg['model']) && !empty($cfg['field'])) { + $m = $cfg['model']; + $f = $cfg['field']; + + print __d('enumeration', $cfg['class'].'.'.$entity->$m->$f) . $suffix; + } elseif($entity->$col) { print __d('enumeration', $cfg['class'].'.'.$entity->$col) . $suffix; } break;