diff --git a/app/config/app.php b/app/config/app.php index 0acaef633..8917343bd 100644 --- a/app/config/app.php +++ b/app/config/app.php @@ -204,7 +204,8 @@ */ 'Error' => [ 'errorLevel' => E_ALL, - 'exceptionRenderer' => 'Cake\Error\ExceptionRenderer', +// This is deprecated and could probably be completely removed +// 'exceptionRenderer' => 'Cake\Error\ExceptionRenderer', 'skipLog' => [], 'log' => true, 'trace' => true, @@ -228,6 +229,8 @@ * You can add custom transports (or override existing transports) by adding the * appropriate file to src/Mailer/Transport. Transports should be named * 'YourTransport.php', where 'Your' is the name of the transport. + * + * Note Registry uses dynamic configuration for EmailTransport. */ 'EmailTransport' => [ 'default' => [ @@ -254,6 +257,8 @@ * duplication across your application and makes maintenance and development * easier. Each profile accepts a number of keys. See `Cake\Mailer\Email` * for more information. + * + * Note Registry uses dynamic configuration for Email. */ 'Email' => [ 'default' => [ diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index e19a747ef..b024a06d7 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -93,6 +93,19 @@ } }, + "servers": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {} + }, + "indexes": { + "servers_i1": { "columns": [ "co_id" ] } + } + }, + "co_settings": { "comment": "Table definition not yet complete (CFM-80)", @@ -106,6 +119,8 @@ "default_pronoun_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "default_telephone_number_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, "default_url_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_delivery_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_smtp_server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" } }, "permitted_fields_name": { "type": "string", "size": 160 }, "permitted_fields_telephone_number": { "type": "string", "size": 160 }, "required_fields_address": { "type": "string", "size": 160 }, @@ -131,7 +146,9 @@ "co_settings_i5": { "columns": [ "default_address_type_id" ] }, "co_settings_i6": { "columns": [ "default_pronoun_type_id" ] }, "co_settings_i7": { "columns": [ "default_telephone_number_type_id" ] }, - "co_settings_i8": { "columns": [ "default_url_type_id" ] } + "co_settings_i8": { "columns": [ "default_url_type_id" ] }, + "co_settings_i9": { "columns": [ "email_delivery_address_type_id" ] }, + "co_settings_i10": { "columns": [ "email_smtp_server_id" ] } } }, @@ -521,6 +538,62 @@ } }, + "message_templates": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "status": {}, + "context": {}, + "format": { "type": "string", "size": 4 }, + "subject": { "type": "string", "size": 256 }, + "body_text": { "type": "text" }, + "body_html": { "type": "text" }, + "cc": { "type": "string", "size": 256 }, + "bcc": { "type": "string", "size": 256 }, + "reply_to": { "type": "string", "size": 256 } + }, + "indexes": { + "message_templates_i1": { "columns": [ "co_id" ] } + } + }, + + "notifications": { + "columns": { + "id": {}, + "subject_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "subject_group_id": { "type": "integer", "foreignkey": { "table": "people", "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" } }, + "resolver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, + "action": { + "comment": "revert this to use the library definition after feature-cfm31 merge", + "type": "string", "size": 4 + }, + "comment": {}, + "message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } }, + "source": { "type": "text" }, + "email_subject": { "type": "string", "size": 256 }, + "email_body_text": { "type": "text" }, + "email_body_html": { "type": "text" }, + "resolution_subject": { "type": "string", "size": 256 }, + "resolution_body": { "type": "text" }, + "status": {}, + "notification_time": { "type": "datetime" }, + "resolution_time": { "type": "datetime" } + }, + "indexes": { + "notifications_i1": { "columns": [ "subject_person_id" ] }, + "notifications_i2": { "columns": [ "subject_group_id" ] }, + "notifications_i3": { "columns": [ "recipient_person_id" ] }, + "notifications_i4": { "columns": [ "recipient_group_id" ] }, + "notifications_i5": { "columns": [ "source" ] }, + "notifications_i6": { "needed": false, "columns": [ "actor_person_id" ] }, + "notifications_i7": { "needed": false, "columns": [ "resolver_person_id" ] } + } + }, + "jobs": { "columns": { "id": {}, @@ -570,19 +643,6 @@ } }, - "servers": { - "columns": { - "id": {}, - "co_id": {}, - "description": {}, - "plugin": {}, - "status": {} - }, - "indexes": { - "servers_i1": { "columns": [ "co_id" ] } - } - }, - "identifier_assignments": { "columns": { "id": {}, diff --git a/app/plugins/CoreServer/resources/locales/en_US/core_server.po b/app/plugins/CoreServer/resources/locales/en_US/core_server.po index efa7ef802..ff8cf722f 100644 --- a/app/plugins/CoreServer/resources/locales/en_US/core_server.po +++ b/app/plugins/CoreServer/resources/locales/en_US/core_server.po @@ -22,6 +22,9 @@ # @since COmanage Registry v5.0.0 # @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) +msgid "controller.SmtpServers" +msgstr "{0,plural,=1{SMTP Server} other{SMTP Servers}}" + msgid "controller.SqlServers" msgstr "{0,plural,=1{SQL Server} other{SQL Servers}}" @@ -43,6 +46,31 @@ msgstr "Oracle" msgid "enumeration.RdbmsTypeEnum.PG" msgstr "Postgres" +msgid "field.SmtpServers.default_from" +msgstr "Default From Address" + +msgid "field.SmtpServers.default_reply_to" +msgstr "Default Reply-To Address" + +msgid "field.SmtpServers.hostname" +msgstr "Hostname" + +msgid "field.SmtpServers.override_to" +msgstr "Delivery Override" + +msgid "field.SmtpServers.override_to.desc" +msgstr "If set, all outgoing email will only be sent to this address" + +# XXX Temporary? +msgid "field.SmtpServers.password" +msgstr "Password" + +msgid "field.SmtpServers.port" +msgstr "Port" + +msgid "field.SmtpServers.use_tls" +msgstr "Use TLS" + msgid "error.SqlServers.oracle.enabled" msgstr "Oracle support is not enabled" diff --git a/app/plugins/CoreServer/src/Controller/SmtpServersController.php b/app/plugins/CoreServer/src/Controller/SmtpServersController.php new file mode 100644 index 000000000..c472e18df --- /dev/null +++ b/app/plugins/CoreServer/src/Controller/SmtpServersController.php @@ -0,0 +1,40 @@ + [ + 'SmtpServers.hostname' => 'asc' + ] + ]; +} diff --git a/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php b/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php new file mode 100644 index 000000000..f46d72386 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Entity/SmtpServer.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreServer/src/Model/Table/SmtpServersTable.php b/app/plugins/CoreServer/src/Model/Table/SmtpServersTable.php new file mode 100644 index 000000000..58af87da3 --- /dev/null +++ b/app/plugins/CoreServer/src/Model/Table/SmtpServersTable.php @@ -0,0 +1,135 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Servers'); + + $this->setDisplayField('hostname'); + + $this->setPrimaryLink('server_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('server_id'); + + $this->registerStringValidation($validator, $schema, 'hostname', true); + + $validator->add('port', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('port'); + + $this->registerStringValidation($validator, $schema, 'username', false); + + $this->registerStringValidation($validator, $schema, 'password', false); + + $validator->add('use_tls', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('use_tls'); + + $this->registerStringValidation($validator, $schema, 'default_from', true); + $validator->add('default_from', [ + 'format' => ['rule' => 'email'] + ]); + + $this->registerStringValidation($validator, $schema, 'default_reply_to', true); + $validator->add('default_reply_to', [ + 'format' => ['rule' => 'email'] + ]); + + $this->registerStringValidation($validator, $schema, 'override_to', false); + $validator->add('override_to', [ + 'format' => ['rule' => 'email'] + ]); + + return $validator; + } +} diff --git a/app/plugins/CoreServer/src/config/plugin.json b/app/plugins/CoreServer/src/config/plugin.json index 769d9aa12..f01cea921 100644 --- a/app/plugins/CoreServer/src/config/plugin.json +++ b/app/plugins/CoreServer/src/config/plugin.json @@ -1,11 +1,29 @@ { "types": { "server": [ + "SmtpServers", "SqlServers" ] }, "schema": { "tables": { + "smtp_servers": { + "columns": { + "id": {}, + "server_id": {}, + "hostname": { "type": "string", "size": 128 }, + "port": { "type": "integer" }, + "username": { "type": "string", "size": 128 }, + "password": { "type": "string", "size": 80 }, + "use_tls": { "type": "boolean" }, + "default_from": { "type": "string", "size": 256 }, + "default_reply_to": { "type": "string", "size": 256 }, + "override_to": { "type": "string", "size": 256 } + }, + "indexes": { + "smtp_servers_i1": { "columns": [ "server_id"] } + } + }, "sql_servers": { "columns": { "id": {}, diff --git a/app/plugins/CoreServer/templates/SmtpServers/fields.inc b/app/plugins/CoreServer/templates/SmtpServers/fields.inc new file mode 100644 index 000000000..b77ac9220 --- /dev/null +++ b/app/plugins/CoreServer/templates/SmtpServers/fields.inc @@ -0,0 +1,45 @@ +Field->control('hostname'); + + print $this->Field->control('port', ['default' => 587]); + + print $this->Field->control('username'); + + print $this->Field->control('password'); + + print $this->Field->control('use_tls', ['default' => true]); + + print $this->Field->control('default_from'); + + print $this->Field->control('default_reply_to'); + + print $this->Field->control('override_to'); +} diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po index d67e5fbc4..02c58a081 100644 --- a/app/resources/locales/en_US/command.po +++ b/app/resources/locales/en_US/command.po @@ -78,12 +78,12 @@ msgstr "Given Name of initial platform administrator" msgid "opt.admin-username" msgstr "Username of initial platform administrator" +msgid "opt.co_id" +msgstr "CO ID" + msgid "opt.force" msgstr "Force a rerun of setup (only if you know what you are doing)" -msgid "opt.job.co_id" -msgstr "CO ID" - msgid "opt.job.max" msgstr "Maximum number of concurrent queue runners (use with -r)" @@ -105,6 +105,36 @@ msgstr "Run the requested job synchronously (use with -j)" msgid "opt.not" msgstr "Calculate changes but do not apply" +msgid "opt.notify.action" +msgstr "Action Code" + +msgid "opt.notify.actorid" +msgstr "Actor Identifier" + +msgid "opt.notify.comment" +msgstr "Comment" + +msgid "opt.notify.mustresolve" +msgstr "This Notification must be resolved, not acknowledged" + +msgid "opt.notify.recipientid" +msgstr "Recipient Identifier" + +msgid "opt.notify.resolve" +msgstr "Resolve the Notification associated with this source" + +msgid "opt.notify.source" +msgstr "Notification source" + +msgid "opt.notify.subjectid" +msgstr "Subject Identifier" + +msgid "opt.notify.templateid" +msgstr "Message Template ID" + +msgid "opt.notify.type" +msgstr "Type (label) for provided Identifier" + # msgid "se.admin" # msgstr "Creating initial administrator permission" @@ -132,12 +162,12 @@ msgstr "COmanage Platform Administrator" msgid "se.salt" msgstr "Generating salt file {0}" -msgid "opt.test.database.ok" -msgstr "Database connection established" - msgid "opt.test.database.source" msgstr "For database test, the datasource to use" +msgid "opt.test.mail.recipient" +msgstr "For email test, the Person ID (NOT email address) to try sending to" + msgid "opt.test.test" msgstr "Test to perform" diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 5539ba39b..747cd44e0 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -90,9 +90,15 @@ msgstr "{0,plural,=1{Job History Record} other{Job History Records}}" msgid "Jobs" msgstr "{0,plural,=1{Job} other{Jobs}}" +msgid "MessageTemplates" +msgstr "{0,plural,=1{Message Template} other{Message Templates}}" + msgid "Names" msgstr "{0,plural,=1{Name} other{Names}}" +msgid "Notifications" +msgstr "{0,plural,=1{Notification} other{Notifications}}" + msgid "People" msgstr "{0,plural,=1{Person} other{People}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 207110da6..fb8519795 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -258,6 +258,60 @@ msgstr "Identifier" msgid "MatchStrategyEnum.NO" msgstr "No Matching" +msgid "MessageFormatEnum.both" +msgstr "HTML and Plain Text" + +msgid "MessageFormatEnum.html" +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.EF" +msgstr "Enrollment Finalization" + +msgid "MessageTemplateContextEnum.EH" +msgstr "Enrollment Handoff" + +# msgid "MessageTemplateContextEnum.EI" +# msgstr "Enrollment Invitation" + +msgid "MessageTemplateContextEnum.EV" +msgstr "Enrollment Verification" + +msgid "MessageTemplateContextEnum.PL" +msgstr "Plugin" + +msgid "MessageTemplateContextEnum.XN" +msgstr "Expiration Notification" + +msgid "NotificationStatusEnum.A" +msgstr "Acknowledged" + +msgid "NotificationStatusEnum.D" +msgstr "Deleted" + +msgid "NotificationStatusEnum.PA" +msgstr "Pending Acknowledgment" + +msgid "NotificationStatusEnum.PR" +msgstr "Pending Resolution" + +msgid "NotificationStatusEnum.R" +msgstr "Resolved" + +msgid "NotificationStatusEnum.X" +msgstr "Canceled" + msgid "PermittedNameFieldsEnum.given,family" msgstr "Given, Family" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 530055559..f368492f7 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -106,6 +106,18 @@ msgstr "{0}: {1}" msgid "Cos.active" msgstr "Requested CO {0} is not active" +msgid "EmailAddresses.mail.delivery" +msgstr "No verified Email Address is available for Person {0}" + +msgid "EmailAddresses.mail.delivery.type" +msgstr "No verified Email Address of Type {0} is available for Person {1}" + +msgid "EmailAddresses.mail.verified" +msgstr "Email Address is already verified" + +msgid "EmailAddresses.mail.verify.force.person" +msgstr "Email Addresses not associated with People cannot be force verified" + msgid "GroupNestings.active" msgstr "Group {0} is not active and so cannot be nested" @@ -217,6 +229,21 @@ msgstr "{0} not found" msgid "notfound.person" msgstr "No Person or External Identity found" +msgid "Notifications.acknowledge" +msgstr "Notification is not pending acknowledgment and cannot be acknowledged" + +msgid "Notifications.cancel" +msgstr "Notification is not pending and cannot be canceled" + +msgid "Notifications.notify.status" +msgstr "Notification is not pending and cannot be delivered" + +msgid "Notifications.resolve" +msgstr "Notification is not pending resolution and cannot be resolved" + +msgid "Notifications.status" +msgstr "Notification status {0} is not a valid resolution" + msgid "notprov" msgstr "{0} not provided" @@ -232,7 +259,6 @@ msgstr "Permission Denied" msgid "PersonRoles.valid_from.after" msgstr "Valid From date must be earlier than Valid Through date" - msgid "Plugins.inactive" msgstr "The plugin {0} is not active" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index d0a500df0..5afb97386 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -348,6 +348,15 @@ msgstr "Default Telephone Number Type" msgid "CoSettings.default_url_type_id" msgstr "Default URL Type" +msgid "CoSettings.email_delivery_address_type_id" +msgstr "Delivery Email Address Type" + +msgid "CoSettings.email_delivery_address_type_id.desc" +msgstr "If set, when sending email only verified Email Addresses of this type (attached to the Person record) will be used for delivery" + +msgid "CoSettings.email_smtp_server_id" +msgstr "Outgoing SMTP Server" + msgid "CoSettings.permitted_fields_name" msgstr "Name Permitted Fields" @@ -497,6 +506,76 @@ msgstr "Start Summary" msgid "Jobs.start_time" msgstr "Started" + +msgid "MessageTemplates.body_html" +msgstr "Message Body (HTML)" + +msgid "MessageTemplates.body_html.desc" +msgstr "Body for message to be sent. See XXX LINK supported substitutions." + +msgid "MessageTemplates.body_text" +msgstr "Message Body (Plain Text)" + +msgid "MessageTemplates.body_text.desc" +msgstr "Body for message to be sent. See XXX LINK supported substitutions." + +msgid "MessageTemplates.bcc" +msgstr "BCC" + +msgid "MessageTemplates.bcc.desc" +msgstr "Comma separated list of valid email addresses to bcc" + +msgid "MessageTemplates.cc" +msgstr "CC" + +msgid "MessageTemplates.cc.desc" +msgstr "Comma separated list of valid email addresses to cc" + +msgid "MessageTemplates.reply_to" +msgstr "Reply To" + +msgid "MessageTemplates.reply_to.desc" +msgstr "Email Address to insert into the reply-to header" + +msgid "MessageTemplates.format" +msgstr "Message Format" + +msgid "MessageTemplates.subject" +msgstr "Message Subject" + +msgid "MessageTemplates.subject.desc" +msgstr "Subject line for message to be sent. See XXX LINK supported substitutions." + +msgid "Notifications.actor_person_id" +msgstr "Actor" + +msgid "Notifications.email_body_text" +msgstr "Notification Email Body" + +msgid "Notifications.email_subject" +msgstr "Notification Email Subject" + +msgid "Notifications.notification_time" +msgstr "Notification Time" + +msgid "Notifications.recipient_person_id" +msgstr "Recipient" + +msgid "Notifications.resolver_person_id" +msgstr "Resolved By" + +msgid "Notifications.resolution_body" +msgstr "Resolution Email Body" + +msgid "Notifications.resolution_subject" +msgstr "Resolution Email Subject" + +msgid "Notifications.resolution_time" +msgstr "Resolution Time" + +msgid "Notifications.subject_person_id" +msgstr "Subject" + msgid "Pipelines.match_email_address_type_id" msgstr "Email Address Type" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 9127afaee..473c81ebf 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -24,6 +24,9 @@ # Operations (Commands) +msgid "acknowledge" +msgstr "Acknowledge" + msgid "activate" msgstr "Activate" @@ -111,6 +114,9 @@ msgstr "Edit" msgid "edit.a" msgstr "Edit {0}" +msgid "EmailAddresses.verify.force" +msgstr "Force Verify" + msgid "ExternalIdentitySourceRecords.retrieve" msgstr "Retrieve from External Identity Source" @@ -147,6 +153,15 @@ msgstr "Logout" msgid "next" msgstr "Next" +msgid "Notifications.acknowledge.confirm" +msgstr "Acknowledge this notification?" + +msgid "Notifications.cancel.confirm" +msgstr "Cancel this notification?" + +msgid "Notifications.resend.confirm" +msgstr "Resend this notification?" + msgid "none" msgstr "None" @@ -183,6 +198,9 @@ msgstr "Are you sure you want to reconcile this group ({0})?" msgid "remove" msgstr "Remove" +msgid "resend" +msgstr "Resend" + msgid "save" msgstr "Save" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index a28d8ca79..ec5506dec 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -48,6 +48,9 @@ msgstr "{0} {1} Deleted: {2}" msgid "edited.mvea" msgstr "{0} {1} Edited: {2}" +msgid "EmailAddresses.verify.forced" +msgstr "Email Address force verified" + msgid "ExternalIdentities.status.recalculated" msgstr "External Identity status recalculated from {0} to {1}" @@ -108,6 +111,28 @@ msgstr "Job canceled by {0}" msgid "Jobs.registered" msgstr "Started via JobCommand by {0} (uid {1})" +# These are for generating history records +msgid "Notifications.A" +msgstr "Notification acknowledged: {0}" + +msgid "Notifications.R" +msgstr "Notification resolved: {0}" + +msgid "Notifications.X" +msgstr "Notification canceled: {0}" + +msgid "Notifications.acknowledged" +msgstr "Notiication acknowledged" + +msgid "Notifications.canceled" +msgstr "Notification canceled" + +msgid "Notifications.delivered" +msgstr "Notification {0} delivered to {1}" + +msgid "Notifications.resent" +msgstr "Notification resent" + msgid "People.added.pipeline" msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" @@ -157,4 +182,11 @@ msgid "search.results" msgstr "Search Results" msgid "search.retry" -msgstr "Please select an option from a menu, or try your search again." \ No newline at end of file +msgstr "Please select an option from a menu, or try your search again." + +# These are specifically for test command +msgid "test.database.ok" +msgstr "Database connection established" + +msgid "test.mail.ok" +msgstr "Test message sent" diff --git a/app/src/Command/JobCommand.php b/app/src/Command/JobCommand.php index 7be128bb5..97e52b399 100644 --- a/app/src/Command/JobCommand.php +++ b/app/src/Command/JobCommand.php @@ -55,7 +55,7 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar [ 'required' => true, 'short' => 'c', - 'help' => __d('command', 'opt.job.co_id') + 'help' => __d('command', 'opt.co_id') ] )->addOption( 'job', diff --git a/app/src/Command/NotificationCommand.php b/app/src/Command/NotificationCommand.php new file mode 100644 index 000000000..9e34860e7 --- /dev/null +++ b/app/src/Command/NotificationCommand.php @@ -0,0 +1,192 @@ +addOption( + 'coId', + [ + 'required' => true, + 'short' => 'c', + 'help' => __d('command', 'opt.coId') + ] + )->addOption( + 'typeLabel', + [ + 'required' => true, + 'short' => 't', + 'help' => __d('command', 'opt.notify.type') + ] + )->addOption( + 'subjectIdentifier', + [ + 'required' => true, + 'short' => 's', + 'help' => __d('command', 'opt.notify.subjectid') + ] + )->addOption( + 'actorIdentifier', + [ + 'required' => true, + 'short' => 'a', + 'help' => __d('command', 'opt.notify.actorid') + ] + )->addOption( + 'recipientIdentifier', + [ + 'required' => true, + 'short' => 'r', + 'help' => __d('command', 'opt.notify.recipientid') + ] + )->addOption( + 'action', + [ + 'required' => true, + 'short' => 'A', + 'help' => __d('command', 'opt.notify.action') + ] + )->addOption( + 'comment', + [ + 'required' => true, + 'short' => 'C', + 'help' => __d('command', 'opt.notify.comment') + ] + )->addOption( + 'messageTemplateId', + [ + 'required' => true, + 'short' => 'm', + 'help' => __d('command', 'opt.notify.templateid') + ] + )->addOption( + 'source', + [ + 'required' => true, + 'short' => 'S', + 'help' => __d('command', 'opt.notify.source') + ] + )->addOption( + 'mustResolve', + [ + 'required' => false, + 'boolean' => true, + 'short' => 'M', + 'help' => __d('command', 'opt.notify.mustresolve') + ] +// XXX given how many required fields don't apply to -R, maybe this should be a different command or mode? +/*) + )->addOption( + 'resolve', + [ + 'required' => false, + 'boolean' => true, + 'short' => 'R', + 'help' => __d('command', 'opt.notify.resolve') + ]*/ + ); + + return $parser; + } + + /** + * Execute the Notification Command. + * + * @since COmanage Registry v5.0.0 + * @param Arguments $args Command Arguments + * @param ConsoleIo $io Console IO + */ + + public function execute(Arguments $args, ConsoleIo $io) + { + $Identifiers = $this->getTableLocator()->get('Identifiers'); + $Notifications = $this->getTableLocator()->get('Notifications'); + $Types = $this->getTableLocator()->get('Types'); + + $coId = (int)$args->getOption('coId'); + + // Map the type label to a type ID, used for Identifier lookups + + $typeId = $Types->getTypeId($coId, 'Identifiers.type', $args->getOption('typeLabel')); + + // Subject + + $subjectPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('subjectIdentifier')); + + // Actor + + $actorPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('actorIdentifier')); + + // Recipient + + $recipientPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('recipientIdentifier')); + + $io->out("Registering notification:"); + $io->out("- Subject Person ID: " . $subjectPersonId); + $io->out("- Actor Person ID: " . $actorPersonId); + $io->out("- Recipient Person ID: " . $recipientPersonId); + + $notificationIds = $Notifications->register( + subjectPersonId: $subjectPersonId, + subjectGroupId: null, + actorPersonId: $actorPersonId, + recipientPersonId: $recipientPersonId, + recipientGroupId: null, + action: $args->getOption('action'), + comment: $args->getOption('comment'), + messageTemplateId: (int)$args->getOption('messageTemplateId'), + source: $args->getOption('source'), + mustResolve: $args->getOption('mustResolve') + ); + + foreach($notificationIds as $id) { + $io->out("Registered new Notification ID: $id"); + } + + return; + } +} \ No newline at end of file diff --git a/app/src/Command/TestCommand.php b/app/src/Command/TestCommand.php index 6109ae41d..038f94d54 100644 --- a/app/src/Command/TestCommand.php +++ b/app/src/Command/TestCommand.php @@ -34,6 +34,7 @@ use Cake\Console\ConsoleIo; use Cake\Console\ConsoleOptionParser; use Cake\Datasource\ConnectionManager; +use \App\Lib\Util\DeliveryUtilities; class TestCommand extends Command { @@ -52,10 +53,12 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar $parser->addOption('test', [ 'help' => __d('command', 'opt.test.test'), 'short' => 't', - 'choices' => ['database'] + 'choices' => ['database', 'mail'] ])->addOption('datasource', [ 'help' => __d('command', 'opt.test.database.source'), 'default' => 'default' + ])->addOption('recipient', [ + 'help' => __d('command', 'opt.test.mail.recipient') ]); return $parser; @@ -82,17 +85,52 @@ public function execute(Arguments $args, ConsoleIo $io) case 'database': $this->testDatabase($args->getOption('datasource')); break; + case 'mail': + $this->testMail((int)$args->getOption('recipient')); + break; + default: + $io->out("Command $test unknown"); + break; } } /** - * Test database connectivity for the default + * Test database connectivity for the requested datasource. + * + * @since COmanage Registry v5.0.0 + * @param string $source Datasource + * @return int Return Code (CODE_SUCCESS or CODE_ERROR) */ protected function testDatabase(string $source): int { try { $cxn = ConnectionManager::get($source); - $this->io->out(__d('command', 'opt.test.database.ok')); + $this->io->out(__d('result', 'test.database.ok')); + } + catch(\Exception $e) { + $this->io->error($e->getMessage()); + $this->abort(static::CODE_ERROR); + } + + return static::CODE_SUCCESS; + } + + /** + * Test mail delivery to the specified address. + * + * @since COmanage Registry v5.0.0 + * @param int $recipient Recipient Person ID + * @return int Return Code (CODE_SUCCESS or CODE_ERROR) + */ + + protected function testMail(int $recipient): int { + try { + DeliveryUtilities::sendEmailToPerson( + personId: $recipient, + subject: "TestCommand Test Message", + body_text: "This is the test message requested via TestCommand." + ); + $this->io->out(__d('result', 'test.mail.ok')); } catch(\Exception $e) { $this->io->error($e->getMessage()); diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index a5b076ea2..bc00de262 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -614,6 +614,37 @@ public function getMenuPermissions(?int $coId): array { return $permissions; } + /** + * Obtain the Person ID of the currently authenticated user for the specified CO. + * An Exception will be thrown if there is no currently authenticated user, however + * null will be returned if there is a user, but they are not in the requested CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @throws RuntimeException + */ + + public function getPersonID(int $coId): ?int { + // We first need an authenticated Identifier, and it can't be for an API user. + + if(empty($this->authenticatedUser)) { + throw new \RuntimeException("RegistryAuthComponent:getPersonID No authenticated user"); + } + + if($this->authenticatedApiUser) { + throw new \RuntimeException("RegistryAuthComponent::getPersonID Current user is an API user"); + } + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + try { + return $Identifiers->lookupPersonByLogin($coId, $this->authenticatedUser); + } + catch(Cake\Datasource\Exception\RecordNotFoundException $e) { + return null; + } + } + /** * Obtain the set of permissions as provided by the table. * diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 0de92af39..4875fc867 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -102,6 +102,11 @@ public function configuration() { 'controller' => 'identifier_assignments', 'action' => 'index' ], + __d('controller', 'MessageTemplates', [99]) => [ + 'icon' => 'message', + 'controller' => 'message_templates', + 'action' => 'index' + ], __d('controller', 'Pipelines', [99]) => [ 'icon' => 'cable', 'controller' => 'pipelines', diff --git a/app/src/Controller/EmailAddressesController.php b/app/src/Controller/EmailAddressesController.php index c6b63d422..125390b86 100644 --- a/app/src/Controller/EmailAddressesController.php +++ b/app/src/Controller/EmailAddressesController.php @@ -39,4 +39,22 @@ class EmailAddressesController extends MVEAController { 'EmailAddresses.mail' => 'asc' ] ]; + + /** + * Force an Email Address to verified status. + * + * @since COmanage Registry v5.0.0 + */ + + public function forceVerify(string $id) { + try { + $this->EmailAddresses->forceVerify((int)$id, $this->RegistryAuth->getPersonID($this->getCOID())); + $this->Flash->success("Email Address updated"); // XXX I18n + } + catch(Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } } \ No newline at end of file diff --git a/app/src/Controller/MessageTemplatesController.php b/app/src/Controller/MessageTemplatesController.php new file mode 100644 index 000000000..86b4e89b2 --- /dev/null +++ b/app/src/Controller/MessageTemplatesController.php @@ -0,0 +1,41 @@ + [ + 'MessageTemplates.description' => 'asc' + ] + ]; +} \ No newline at end of file diff --git a/app/src/Controller/NotificationsController.php b/app/src/Controller/NotificationsController.php new file mode 100644 index 000000000..5160eda4c --- /dev/null +++ b/app/src/Controller/NotificationsController.php @@ -0,0 +1,98 @@ + [ + 'Notifications.comment' => 'asc' + ] + ]; + + /** + * Acknowledge a Notification. + * + * @since COmanage Registry v5.0.0 + * @param string $id Notification ID + */ + + public function acknowledge(string $id) { + try { + $this->Notifications->acknowledge((int)$id, $this->RegistryAuth->getPersonID($this->getCOID())); + $this->Flash->success(__d('result', 'Notifications.acknowledged')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } + + /** + * Cancel a Notification. + * + * @since COmanage Registry v5.0.0 + * @param string $id Notification ID + */ + + public function cancel(string $id) { + try { + $this->Notifications->cancel((int)$id, $this->RegistryAuth->getPersonID($this->getCOID())); + $this->Flash->success(__d('result', 'Notifications.canceled')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } + + /** + * Resend (deliver) a Notification. + * + * @since COmanage Registry v5.0.0 + * @param string $id Notification ID + */ + + public function resend(string $id) { + try { + $this->Notifications->deliver($this->Notifications->get((int)$id), $this->RegistryAuth->getPersonID($this->getCOID())); + $this->Flash->success(__d('result', 'Notifications.resent')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 1e161626e..b1ee0a7d6 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -361,31 +361,35 @@ public function edit(string $id) { // to save all that stuff by default, so we'll pull a new copy of the // object without the associated data. $saveObj = $table->findById($id)->firstOrFail(); - - // Attempt the update the record - $table->patchEntity($saveObj, $this->request->getData(), $opts); - - // This throws \Cake\ORM\Exception\RolledbackTransactionException if aborted - // in afterSave - if($table->save($saveObj)) { - $this->Flash->success(__d('result', 'saved')); - - // Give the controller an opportunity to set additional Flash messages - if(method_exists($this, "setSupplementalFlash")) { - $this->setSupplementalFlash($obj); - } + + try{ + // Attempt the update the record + $table->patchEntity($saveObj, $this->request->getData(), $opts); - // Trigger provisioning, letting errors bubble up (AR-GMR-5) - if(method_exists($table, "requestProvisioning")) { - $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); - $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); - } + // This throws \Cake\ORM\Exception\RolledbackTransactionException if aborted + // in afterSave + if($table->save($saveObj)) { + $this->Flash->success(__d('result', 'saved')); + + // Give the controller an opportunity to set additional Flash messages + if(method_exists($this, "setSupplementalFlash")) { + $this->setSupplementalFlash($obj); + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($table, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); + $table->requestProvisioning(id: (int)$id, context: ProvisioningContextEnum::Automatic); + } - return $this->generateRedirect($saveObj); + return $this->generateRedirect($saveObj); + } else { + $errors = $saveObj->getErrors(); + } + } catch(\Exception $e) { + $errors = [0 => ['exception' => $e->getMessage()]]; } - $errors = $saveObj->getErrors(); - if(!empty($errors)) { $this->Flash->error(__d('error', 'fields', [ implode(',', array_map(function($v) use ($errors) { diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 8c5d17bab..d13f76c21 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -33,6 +33,7 @@ class ActionEnum extends StandardEnum { // Codes beginning with 'X' (eg: 'XABC') are reserved for local use // Codes beginning with a lowercase 'p' (eg: 'pABC') are reserved for plugin use const CommentAdded = 'CMNT'; + const EmailForceVerified = 'EAFV'; const GroupAdded = 'ACGR'; const GroupDeleted = 'DCGR'; const GroupEdited = 'ECGR'; @@ -46,6 +47,10 @@ class ActionEnum extends StandardEnum { const MVEADeleted = 'DMVE'; const MVEAEdited = 'EMVE'; const NamePrimary = 'PNAM'; + const NotificationAcknowledged = 'NOTA'; + const NotificationCanceled = 'NOTX'; + const NotificationDelivered = 'NOTD'; + const NotificationResolved = 'NOTR'; const PersonAddedPipeline = 'ACPL'; const PersonMatchedPipeline = 'MCPL'; const PersonPipelineComplete = 'CCPL'; diff --git a/app/src/Lib/Enum/MessageFormatEnum.php b/app/src/Lib/Enum/MessageFormatEnum.php new file mode 100644 index 000000000..4bb4f05d9 --- /dev/null +++ b/app/src/Lib/Enum/MessageFormatEnum.php @@ -0,0 +1,36 @@ +llog($level, json_encode($msg, JSON_PRETTY_PRINT)); } + + /** + * Print formatted cli percentage + * + * @since COmanage Registry v5.0.0 + * @param int $done Number of iterations completed + * @param string $total Total number of iterations + * @return string Formated string with line return offset + */ + + public function cliLogPercentage(int $done, int $total): void { + $perc = floor(($done / $total) * 100); + $left = 100 - $perc; + $out = sprintf("\033[0G\033[2K[%'={$perc}s>%-{$left}s] - $perc%% -- $done/$total", "", ""); + fwrite(STDOUT, $out); + } /** * Log a message, with some standard metadata. @@ -55,6 +71,16 @@ public function alog(string $level, ?array $msg) { */ public function llog(string $level, string $msg, int|string $id=null) { + self::slog($level, $msg, $id); + } + + /** + * Static logger, similar to llog(). + * + * @since COmanage Registry v5.0.0 + */ + + public static function slog(string $level, string $msg, int|string $id=null) { $bt = debug_backtrace(0, 2); $m = getmypid() . " " . $bt[1]['class'] . "::" . $bt[1]['function'] @@ -73,20 +99,4 @@ public function llog(string $level, string $msg, int|string $id=null) { Log::write($level, $m); } } - - /** - * Print formatted cli percentage - * - * @since COmanage Registry v5.0.0 - * @param int $done Number of iterations completed - * @param string $total Total number of iterations - * @return string Formated string with line return offset - */ - - public function cliLogPercentage(int $done, int $total): void { - $perc = floor(($done / $total) * 100); - $left = 100 - $perc; - $out = sprintf("\033[0G\033[2K[%'={$perc}s>%-{$left}s] - $perc%% -- $done/$total", "", ""); - fwrite(STDOUT, $out); - } } diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index f78af0d0a..f0360853d 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -464,7 +464,15 @@ public function setCurCoId(int $coId) { } /** - * Set the primary link attribute. + * Set the primary link attribute. Several formats are acceepted: + * + * 1: ('person_id'): Primary link is to People + * 2: (['person_id', 'group_id']): Primary link can be to People _or_ Groups + * 3: (['WidgetPlugin.widget_id']): Primary link is to WidgetPlugin.Widgets + * 4: (['subject_person_id' => 'People']): Primary link field subject_person_id is to model People + * + * Note only the first and third formats can be used without an enclosing array. + * Currently the fourth format cannot be used with plugin notation. * * @since COmanage Registry v5.0.0 * @param mixed $field Primary link attribute, or an array of primary links @@ -472,30 +480,38 @@ public function setCurCoId(int $coId) { public function setPrimaryLink($fields) { if(is_string($fields)) { + // Format 1, convert to format 2/3 $fields = [$fields]; } - foreach($fields as $field) { - $t = null; - - // Calculate the table name for future reference. This could just be - // a simple reference ("person_id" => "People") or it could be in - // plugin notation ("CoreAssigner.format_assigner_id" => "CoreAssigner.FormatAssigners"). - // Note the plugin notation isn't exactly standard (Plugin.field doesn't make sense - // except that we inflect it to something that does). + foreach($fields as $k => $v) { + $field = $k; + $model = $v; - if(preg_match('/^(.*)\.(.*?)_id$/', $field, $f)) { - // Modified plugin notation match - $t = $f[1] . "." . \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[2])); - // We need the key to be the field name, not Plugin.field - $this->primaryLinks[$f[2]."_id"] = $t; - } elseif(preg_match('/^(.*?)_id$/', $field, $f)) { - // Standard foreign key match - $t = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); - $this->primaryLinks[$field] = $t; - } else { - $this->primaryLinks[$field] = null; + if(is_int($k)) { + // Format 2/3, eg [ 0 => 'co_id' ] + + $field = $v; + $model = null; + + if(preg_match('/^(.*)\.(.*?)_id$/', $field, $f)) { + // Format 3, modified plugin notation. This isn't exactly standard + // (Plugin.field doesn't make sense except that we inflect it to something + // that does). + + $model = $f[1] . "." . \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[2])); + // We need the field to be the actual field name, not Plugin.field + $field = $f[2]."_id"; + } elseif(preg_match('/^(.*?)_id$/', $field, $f)) { + // Format 2, standard foreign key match + $model = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); + } else { + // Not clear what this is... + } } + // else format 4, just use as is + + $this->primaryLinks[$field] = $model; } } diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php new file mode 100644 index 000000000..eb6068806 --- /dev/null +++ b/app/src/Lib/Util/DeliveryUtilities.php @@ -0,0 +1,245 @@ +get('CoSettings'); + + $smtp = $CoSettings->getSmtpServer($coId); + + if(empty($smtp)) { + // No SMTP configured + self::slog('debug', "No Outgoing SMTP Server is configured for CO $coId, so no mail will be sent"); + + return false; + } + + // Next figure out the recipient + $to = $recipient; + + if(!empty($smtp->override_to)) { + self::slog('debug', "Overriding deliver address for $recipient to " . $smtp->override_to); + $to = $smtp->override_to; + } + + // We use Message and MailTransport because Mailer only allows both HTML and Text + // when using Layouts, which we don't want to use. + + $message = new Message(); + + $message->setTo($to) + ->setSubject($subject) + ->setFrom($smtp->default_from) + // Use the provided Reply-To if set, else the default + // Note we can't use ?? here because the default value is "", which is not null + ->setReplyTo(!empty($replyTo) ? $replyTo : $smtp->default_reply_to); + + // Overriding the to address suppressess the cc and bcc addresses + if(!empty($cc) && empty($smtp->override_to)) { + $message->setCc($cc); + } + + if(!empty($bcc) && empty($smtp->override_to)) { + $message->setBcc($bcc); + } + + if(!empty($body_text)) { + $message->setBodyText($body_text); + $message->setEmailFormat('text'); + } + + if(!empty($body_html)) { + $message->setBodyHtml($body_html); + $message->setEmailFormat(!empty($body_text) ? 'both' : 'html'); + } + + // This could go in SmtpServer, but for now we only create an SmtpTransport here + $transport = new SmtpTransport([ + 'host' => $smtp->hostname, + 'port' => $smtp->port, + 'username' => $smtp->username, + 'password' => $smtp->password, + 'tls' => $smtp->use_tls + ]); + + $result = $transport->send($message); + + self::slog('debug', "Mail for $to sent successfully"); + } + + /** + * Send an email using a Message Template, accounting for preferred delivery addresses + * and other settings. + * + * Either $personId or $address must be specified. + * + * If $messageTemplateId is specified, it will take precedence over $subject and $body. + * The various entities are used to populate the Message Template, and are not required + * 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 + * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc") + */ + + public static function sendEmailFromTemplate( + int $messageTemplateId, + ?int $personId=null, + ?string $address=null, + ?\App\Model\Entity\Person $subjectPerson=null, + ?\App\Model\Entity\Notification $notification=null + ): array { + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Generate the message from the template + $message = $MessageTemplates->generateMessage( + id: $messageTemplateId, + subjectPerson: $subjectPerson, + notification: $notification + ); + + 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); + } + + return self::sendEmailToPerson( + personId: $personId, + subject: $message['subject'], + body_text: $message['body_text'] ?? "", + body_html: $message['body_html'] ?? "" + ); + } + + /** + * Send an email to a Person, accoungting for available email addresses + * and other settings. + * + * @since COmanage Registry v5.0.0 + * @param int $personId Recipient Person ID + * @param string $subject Message subject + * @param string $body_text Message body (plain text) + * @param string $body_html Message body (HTML) + * @param string $cc Addresses to cc + * @param string $bcc Addresses to bcc + * @param string $replyTo Reply-To address to use, instead of the default + * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc") + */ + + public static function sendEmailToPerson( + int $personId, + string $subject, + string $body_text="", + string $body_html="", + string $cc="", + string $bcc="", + string $replyTo="" + ): array { + // Find a deliverable Email Address for $personId + + $EmailAddresses = TableRegistry::getTableLocator()->get('EmailAddresses'); + + $recipient = $EmailAddresses->getDeliveryAddress($personId); + + self::slog('debug', "Mapped Person ID $personId to $recipient for mail delivery"); + + // We also need the CO ID. This effectively causes the Person to be retrieved twice + // (once by getDeliveryAddress), but that's a rounding error in the overall number + // of queries. + + $People = TableRegistry::getTableLocator()->get('People'); + + $person = $People->get($personId); + + self::sendEmailToAddress( + coId: $person->co_id, + recipient: $recipient, + subject: $subject, + body_text: $body_text, + body_html: $body_html, + cc: $cc, + bcc: $bcc, + replyTo: $replyTo + ); + + return [ + 'recipient' => $recipient + ]; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/EmailAddress.php b/app/src/Model/Entity/EmailAddress.php index 99640e435..a5aff5855 100644 --- a/app/src/Model/Entity/EmailAddress.php +++ b/app/src/Model/Entity/EmailAddress.php @@ -41,4 +41,15 @@ class EmailAddress extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Determine if this Email Address has not been verified. + * + * @since COmanage Registry v5.0.0 + * @return bool true if this is an unverified address, false otherwise + */ + + public function notVerified(): bool { + return !$this->verified; + } } \ No newline at end of file diff --git a/app/src/Model/Entity/MessageTemplate.php b/app/src/Model/Entity/MessageTemplate.php new file mode 100644 index 000000000..a5a0ce58f --- /dev/null +++ b/app/src/Model/Entity/MessageTemplate.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/Notification.php b/app/src/Model/Entity/Notification.php new file mode 100644 index 000000000..901b801c6 --- /dev/null +++ b/app/src/Model/Entity/Notification.php @@ -0,0 +1,79 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity can be canceled. + * + * @since COmanage Registry v5.0.0 + * @return bool true if the entity can be canceled, false otherwise + */ + + public function canCancel(): bool { + return in_array($this->status, [NotificationStatusEnum::PendingAcknowledgment, + NotificationStatusEnum::PendingResolution]); + } + + /** + * Determine if this entity can generate a notification (email). + * + * @since COmanage Registry v5.0.0 + * @return bool true if the entity can be sent, false otherwise + */ + + public function canNotify(): bool { + return in_array($this->status, [NotificationStatusEnum::PendingAcknowledgment, + NotificationStatusEnum::PendingResolution]); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @return boolean True if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // All Notifications are effectively read only, at least from a UI standpoint. + // (Status can sometimes be changed, but only via actions, not directly.) + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/CoSettingsTable.php b/app/src/Model/Table/CoSettingsTable.php index 0f2bccf02..40d93824a 100644 --- a/app/src/Model/Table/CoSettingsTable.php +++ b/app/src/Model/Table/CoSettingsTable.php @@ -43,6 +43,7 @@ namespace App\Model\Table; use \Cake\ORM\Table; +use \Cake\ORM\TableRegistry; use \Cake\Validation\Validator; use \App\Lib\Enum\PermittedNameFieldsEnum; use \App\Lib\Enum\PermittedTelephoneNumberFieldsEnum; @@ -83,18 +84,10 @@ public function initialize(array $config): void { ->setClassName('Types') ->setForeignKey('default_email_address_type_id') ->setProperty('default_email_address_type'); - $this->belongsTo('PersonPickerEmailAddressType') - ->setClassName('Types') - ->setForeignKey('person_picker_email_address_type_id') - ->setProperty('person_picker_email_address_type'); $this->belongsTo('DefaultIdentifierTypes') ->setClassName('Types') ->setForeignKey('default_identifier_type_id') ->setProperty('default_identifier_type'); - $this->belongsTo('PersonPickerIdentifierTypes') - ->setClassName('Types') - ->setForeignKey('person_picker_identifier_type_id') - ->setProperty('person_picker_identifier_type'); $this->belongsTo('DefaultNameTypes') ->setClassName('Types') ->setForeignKey('default_name_type_id') @@ -111,6 +104,22 @@ public function initialize(array $config): void { ->setClassName('Types') ->setForeignKey('default_url_type_id') ->setProperty('default_url_type'); + $this->belongsTo('EmailDeliveryAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_delivery_address_type_id') + ->setProperty('email_delivery_address_type'); + $this->belongsTo('EmailSmtpServers') + ->setClassName('Servers') + ->setForeignKey('email_smtp_server_id') + ->setProperty('email_smtp_server'); + $this->belongsTo('PersonPickerEmailAddressType') + ->setClassName('Types') + ->setForeignKey('person_picker_email_address_type_id') + ->setProperty('person_picker_email_address_type'); + $this->belongsTo('PersonPickerIdentifierTypes') + ->setClassName('Types') + ->setForeignKey('person_picker_identifier_type_id') + ->setProperty('person_picker_identifier_type'); $this->setDisplayField('co_id'); @@ -132,14 +141,6 @@ public function initialize(array $config): void { 'type' => 'type', 'attribute' => 'Identifiers.type' ], - 'personPickerEmailAddressTypes' => [ - 'type' => 'type', - 'attribute' => 'EmailAddresses.type' - ], - 'personPickerIdentifierTypes' => [ - 'type' => 'type', - 'attribute' => 'Identifiers.type' - ], 'defaultNameTypes' => [ 'type' => 'type', 'attribute' => 'Names.type' @@ -156,6 +157,15 @@ public function initialize(array $config): void { 'type' => 'type', 'attribute' => 'Urls.type' ], + 'emailDeliveryAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'emailSmtpServers' => [ + 'type' => 'select', + 'model' => 'Servers', + 'where' => ['plugin' => 'CoreServer.SmtpServers'] + ], 'permittedFieldsNames' => [ 'type' => 'enum', 'class' => 'PermittedNameFieldsEnum' @@ -164,6 +174,14 @@ public function initialize(array $config): void { 'type' => 'enum', 'class' => 'PermittedTelephoneNumberFieldsEnum' ], + 'personPickerEmailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'personPickerIdentifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], 'requiredFieldsAddresses' => [ 'type' => 'enum', 'class' => 'RequiredAddressFieldsEnum' @@ -212,6 +230,8 @@ public function addDefaults(int $coId): int { 'default_pronoun_type_id' => null, 'default_telephone_number_type_id' => null, 'default_url_type_id' => null, + 'email_smtp_server_id' => null, + 'email_delivery_address_type_id' => null, 'permitted_fields_name' => PermittedNameFieldsEnum::HGMFS, 'permitted_fields_telephone_number' => PermittedTelephoneNumberFieldsEnum::CANE, 'person_picker_email_type' => null, @@ -266,6 +286,40 @@ public function addDefaults(int $coId): int { public function generateDisplayField(\App\Model\Entity\CoSetting $entity): string { return __d('controller', 'CoSettings', [99]); } + + /** + * Get the outgoing SMTP Server for the specified CO. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @return SmtpServer SmtpServer, or null if none configured + */ + + public function getSmtpServer(int $coId): ?\CoreServer\Model\Entity\SmtpServer { + // Note CoreServer should always be enabled + + // The initial implementation has a per-CO SmtpServer setting, but at some point + // we might allow the COmanage CO's SmtpServer to either override any CO Setting + // or provide a default if there is no CO Setting. + + $settings = $this->find() + ->where(['CoSettings.co_id' => $coId]) + ->contain(['EmailSmtpServers']) + ->firstOrFail(); + + if(!empty($settings->email_smtp_server)) { + // Because dynamic plugin relations are tricky to query via contain, we just + // make a second query. + + $SmtpServers = TableRegistry::getTableLocator()->get('CoreServer.SmtpServers'); + + return $SmtpServers->find() + ->where(['server_id' => $settings->email_smtp_server->id]) + ->firstOrFail(); + } + + return null; + } /** * Determine if a requested Type is in use as a default via CoSettings. @@ -337,6 +391,16 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('default_url_type_id'); + $validator->add('email_delivery_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('email_delivery_address_type_id'); + + $validator->add('email_smtp_server_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('email_smtp_server_id'); + $validator->add('permitted_name_fields', [ 'content' => ['rule' => ['inList', PermittedNameFieldsEnum::getConstValues()]] ]); @@ -347,6 +411,21 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('permitted_fields_telephone_number'); + $validator->add('person_picker_display_types', [ + 'content' => ['rule' => 'boolean'] + ]); + $validator->allowEmptyString('person_picker_display_types'); + + $validator->add('person_picker_email_type', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_picker_email_type'); + + $validator->add('person_picker_identifier_type', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('person_picker_identifier_type'); + $validator->add('required_fields_address', [ 'content' => ['rule' => ['inList', RequiredAddressFieldsEnum::getConstValues()]] ]); @@ -367,22 +446,6 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->notEmptyString('search_global_limit'); - $validator->add('person_picker_email_type', [ - 'content' => ['rule' => 'isInteger'] - ]); - $validator->allowEmptyString('person_picker_email_type'); - - $validator->add('person_picker_identifier_type', [ - 'content' => ['rule' => 'isInteger'] - ]); - $validator->allowEmptyString('person_picker_identifier_type'); - - - $validator->add('person_picker_display_types', [ - 'content' => ['rule' => 'boolean'] - ]); - $validator->allowEmptyString('person_picker_display_types'); - return $validator; } } \ No newline at end of file diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 717874537..81d0334bc 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -29,8 +29,14 @@ namespace App\Model\Table; +use Cake\Datasource\EntityInterface; +use Cake\Event\EventInterface; use Cake\ORM\Table; +use Cake\ORM\TableRegistry; use Cake\Validation\Validator; +use \App\Lib\Enum\ActionEnum; +use \App\Lib\Enum\ProvisioningContextEnum; +use \App\Model\Entity\EmailAddress; class EmailAddressesTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -102,7 +108,7 @@ public function initialize(array $config): void { $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); $this->setRedirectGoal('self'); - $this->setAllowLookupPrimaryLink(['unfreeze']); + $this->setAllowLookupPrimaryLink(['forceVerify', 'unfreeze']); $this->setEditContains(['ExternalIdentities', 'ExtIdentitySourceRecords']); $this->setAutoViewVars([ @@ -117,6 +123,7 @@ public function initialize(array $config): void { 'entity' => [ 'delete' => ['platformAdmin', 'coAdmin'], 'edit' => ['platformAdmin', 'coAdmin'], + 'forceVerify' => ['platformAdmin', 'coAdmin'], 'unfreeze' => ['platformAdmin', 'coAdmin'], 'view' => ['platformAdmin', 'coAdmin'] ], @@ -129,6 +136,132 @@ public function initialize(array $config): void { ] ]); } + + /** + * Callback after data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event afterMarshal event + * @param Entity Interface $entity Marshalled entity + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function afterMarshal( + EventInterface $event, + EntityInterface $entity, + \ArrayObject $data, + \ArrayObject $options + ) { + if(!empty($entity->person_id) && $entity->isDirty('mail')) { + // AR-EmailAddress-2 Editing an Email Address (but not its Type) associated + // with a Person will revert it to unverified. + + $this->llog('rule', "AR-EmailAddress-2 Flagging email address " . $entity->mail . " for Person " . $entity->person_id . " as unverified due to edit"); + + $entity->verified = false; + $data['verified'] = false; + } + } + + /** + * Get an Email Address suitable for message delivery for the specified Person. + * + * @since COmanage Registry v5.0.0 + * @param int $personId Person ID + * @return string Email address suitable for delivery + * @throws InvalidArgumentException + */ + + public function getDeliveryAddress(int $personId): string { + // We allow a Delivery Email Address Type to be specified via CoSettings, + // but to check we first need to map the Person to a CO. + + $person = $this->People->get($personId); + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + $settings = $CoSettings->find()->where(['co_id' => $person->co_id])->firstOrFail(); + + $whereClause = [ + 'person_id' => $personId, + // AR-EmailAddress-3 Only verified Email Addresses may be used for delivery of messages to a Person. + 'verified' => true + ]; + + if(!empty($settings->email_delivery_address_type_id)) { + $whereClause['type_id'] = $settings->email_delivery_address_type_id; + } + + try { + $email = $this->find() + ->where($whereClause) + ->firstOrFail(); + } + catch(\Cake\Datasource\Exception\RecordNotFoundException $e) { + // This error is probably going to render a lot, so map the type ID to the label + $Types = TableRegistry::getTableLocator()->get('Types'); + $label = $Types->getTypeLabel($settings->email_delivery_address_type_id); + + throw new \InvalidArgumentException( + !empty($settings->email_delivery_address_type_id) + ? __d('error', 'EmailAddresses.mail.delivery.type', [$label, $personId]) + : __d('error', 'EmailAddresses.mail.delivery', [$personId]) + ); + } + + return $email->mail; + } + + /** + * Force an Email Address to verified status. + * + * @since COmanage Registry v5.0.0 + * @param int $id EmailAddress ID + * @param int $actorPersonId Actor Person ID + * @throws InvalidArgumentException + */ + + public function forceVerify( + int $id, + int $actorPersonId, + ) { + $email = $this->get($id); + + // We only permit Email Addresses associated with a Person (not External Identity) + // to be force verified. + + if(empty($email->person_id)) { + // AR-EmailAddress-1 Only Email Addresses associated with a Person may be verified + // by Registry. + throw new \InvalidArgumentException('error', 'EmailAddresses.mail.verify.force.person'); + } + + // Email Addresses that are already verified can't be re-verified. + + if($email->verified) { + throw new \InvalidArgumentException('error', 'EmailAddresses.mail.verified'); + } + + // AR-EmailAddress-4 A frozen Email Address may be verified if it is otherwise + // eligible for verification. + + // Flag the address as verified and record history. + + $email->verified = true; + $this->save($email); + + $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); + + $HistoryRecords->recordForPerson( + personId: $email->person_id, + action: ActionEnum::EmailForceVerified, + comment: __d('result', 'EmailAddresses.verify.forced'), + actorPersonId: $actorPersonId + ); + + // Request Provisioning + $this->requestProvisioning(id: $id, context: ProvisioningContextEnum::Automatic); + } /** * Callback after model save. diff --git a/app/src/Model/Table/HistoryRecordsTable.php b/app/src/Model/Table/HistoryRecordsTable.php index f78350ac3..e3917a7d0 100644 --- a/app/src/Model/Table/HistoryRecordsTable.php +++ b/app/src/Model/Table/HistoryRecordsTable.php @@ -198,25 +198,18 @@ public function recordForPerson(?int $personId, string $comment, ?int $personRoleId=null, ?int $externalIdentityId=null, - ?int $externalIdentityRoleId=null): int { + ?int $externalIdentityRoleId=null, + ?int $actorPersonId=null): int { $record = [ - 'person_id' => $personId, - 'action' => $action, - 'comment' => $comment + 'person_id' => $personId, + 'action' => $action, + 'comment' => $comment, + 'person_role_id' => $personRoleId, + 'external_identity_id' => $externalIdentityId, + 'external_identity_role_id' => $externalIdentityRoleId, + 'actor_person_id' => $actorPersonId ]; - if($personRoleId) { - $record['person_role_id'] = $personRoleId; - } - - if($externalIdentityId) { - $record['external_identity_id'] = $externalIdentityId; - } - - if($externalIdentityRoleId) { - $record['external_identity_role_id'] = $externalIdentityRoleId; - } - $obj = $this->newEntity($record); $this->saveOrFail($obj); @@ -243,6 +236,8 @@ public function validationDefault(Validator $validator): Validator { // We disable validateInput for the comment field since changesToString likes to // include > characters. +// XXX should we maybe filter on input for manual history records? or maybe it's just +// ok since we filter on output anyway... $this->registerStringValidation($validator, $schema, 'comment', required: true, validateInput: false); return $validator; diff --git a/app/src/Model/Table/IdentifierAssignmentsTable.php b/app/src/Model/Table/IdentifierAssignmentsTable.php index bae9fc222..d7865b8df 100644 --- a/app/src/Model/Table/IdentifierAssignmentsTable.php +++ b/app/src/Model/Table/IdentifierAssignmentsTable.php @@ -510,6 +510,11 @@ public function validationDefault(Validator $validator): Validator { ]); // See AR-IdentifierAssignment-1 $validator->allowEmptyString('email_address_type_id'); + + $validator->add('allow_empty', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('allow_empty'); $validator->add('ordr', [ 'content' => ['rule' => 'isInteger'] diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index ecaa2f28d..d557dd80b 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -232,6 +232,33 @@ public function lookupPerson(int $typeId, string $identifier, ?int $coId, bool $ return $id->person_id; } + /** + * Look up a Person from a login Identifier within a CO. Because login Identifiers + * can be of any type, no type ID is required. + * + * @since COmanage Registry v5.0.0 + * @param int $coId CO ID + * @param string $identifier Identifier + * @return int Person ID + * @throws Cake\Datasource\Exception\RecordNotFoundException + */ + + public function lookupPersonByLogin(int $coId, string $identifier): int { + $id = $this->find() + ->where([ + 'Identifiers.identifier' => $identifier, + 'Identifiers.login' => true, + 'Identifiers.status' => SuspendableStatusEnum::Active, + 'Identifiers.person_id IS NOT NULL' + ]) + ->matching('People', function ($q) use($coId) { + return $q->where(['People.co_id' => $coId]); + }) + ->firstOrFail(); + + return $id->person_id; + } + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php new file mode 100644 index 000000000..d6aa26421 --- /dev/null +++ b/app/src/Model/Table/MessageTemplatesTable.php @@ -0,0 +1,223 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->hasMany('Notifications'); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'contexts' => [ + 'type' => 'enum', + 'class' => 'MessageTemplateContextEnum' + ], + 'formats' => [ + 'type' => 'enum', + 'class' => 'MessageFormatEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * 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 Person $subjectPerson Subject Person, including Primary Name + * @param Notification $notification Notification + * @return array 'subject': Message subject + * 'body_text': Plaintext message + * 'body_html': HTML message + */ + + public function generateMessage( + int $id, + \App\Model\Entity\Person $subjectPerson=null, + \App\Model\Entity\Notification $notification=null + ): array { + $ret = [ + 'subject' => null, + 'body_text' => null, + 'body_html' => null + ]; + + // First retrieve the requested template + $template = $this->get($id); + + // Next build an array of supported substitutions for which appropriate + // entities were provided. + + $substitutions = []; + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } + + if($notification) { + $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; + $substitutions['NOTIFICATION_SOURCE'] = $notification->source; + } + + // 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. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('context', [ + 'content' => ['rule' => ['inList', MessageTemplateContextEnum::getConstValues()]] + ]); + $validator->notEmptyString('context'); + + $validator->add('format', [ + 'content' => ['rule' => ['inList', MessageFormatEnum::getConstValues()]] + ]); + $validator->notEmptyString('format'); + + $this->registerStringValidation($validator, $schema, 'subject', true); + + // XXX body_text/body_html required should be dependent on format, maybe + // implement as an AR instead? + $validator->add('body_text', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('body_text'); + + $validator->add('body_html', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('body_html'); + + $this->registerStringValidation($validator, $schema, 'cc', false); + + $this->registerStringValidation($validator, $schema, 'bcc', false); + + $this->registerStringValidation($validator, $schema, 'reply_to', false); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/NotificationsTable.php b/app/src/Model/Table/NotificationsTable.php new file mode 100644 index 000000000..7ff140422 --- /dev/null +++ b/app/src/Model/Table/NotificationsTable.php @@ -0,0 +1,423 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('MessageTemplates'); + $this->belongsTo('ActorPeople') + ->setClassName('People') + ->setForeignKey('actor_person_id') + ->setProperty('actor_person'); + $this->belongsTo('RecipientGroups') + ->setClassName('Groups') + ->setForeignKey('recipient_group_id') + ->setProperty('recipient_group'); + $this->belongsTo('RecipientPeople') + ->setClassName('People') + ->setForeignKey('recipient_person_id') + ->setProperty('recipient_person'); + $this->belongsTo('ResolverPeople') + ->setClassName('People') + ->setForeignKey('resolver_person_id') + ->setProperty('resolver_person'); + $this->belongsTo('SubjectPeople') + ->setClassName('People') + ->setForeignKey('subject_person_id') + ->setProperty('subject_person'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink([ + 'subject_person_id' => 'People', + 'subject_group_id' => 'Groups' + ]); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['acknowledge', 'cancel', 'resend']); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'NotificationStatusEnum' + ] + ]); + + $this->setViewContains([ + 'ActorPeople' => ['PrimaryName'], + 'RecipientPeople' => ['PrimaryName'], + 'ResolverPeople' => ['PrimaryName'], + 'SubjectPeople' => ['PrimaryName'] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'acknowledge' => ['platformAdmin', 'coAdmin'], + 'cancel' => ['platformAdmin', 'coAdmin'], + 'delete' => false, + 'edit' => false, + 'resend' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'readOnly' => ['acknowledge', 'cancel', 'resend'] + ]); + } + + /** + * Acknowledge an outstanding notification. + * + * @since COmanage Registry v5.0.0 + * @param int $id Notification ID + * @param int $personId Person ID of person acknowledging the notification + * @throws InvalidArgumentException + */ + + public function acknowledge( + int $id, + int $personId + ) { + $this->processResolution($id, NotificationStatusEnum::Acknowledged, $personId); + } + + /** + * Cancel an outstanding notification. + * + * @since COmanage Registry v5.0.0 + * @param int $id Notification ID + * @param int $personId Person ID of person canceling the notification + * @throws InvalidArgumentException + */ + + public function cancel( + int $id, + int $personId + ) { + $this->processResolution($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 + */ + + public function deliver( + Notification $notification, + int $actorPersonId + ) { + // We call this function "deliver" because ultimately we might support other + // mechanisms besides email, but for now this basically just delivers the + // notification via email. + + // Only notifications in the correct state can be deliverd. + if(!$notification->canNotify()) { + 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 + ); + + // If we are redelivering, DeliveryUtilities will update the existing Notification + // but changelog will maintain the previously sent messages + + if(!empty($result['recipient'])) { + // Create a History Record + + $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); + + $HistoryRecords->recordForPerson( + personId: $notification->recipient_person_id, + action: ActionEnum::NotificationDelivered, + comment: __d('result', 'Notifications.delivered', [$notification->id, $result['recipient']]), + actorPersonId: $actorPersonId + ); + } + + return true; + } + + /** + * 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 + * @throws InvalidArgumentException + */ + + protected function processResolution( + int $id, + string $resolution, + int $resolverPersonId + ) { + $actions = [ + NotificationStatusEnum::Acknowledged => ActionEnum::NotificationAcknowledged, + NotificationStatusEnum::Canceled => ActionEnum::NotificationCanceled, + NotificationStatusEnum::Resolved => ActionEnum::NotificationResolved + ]; + + // There is some logic here that could be surfaced as Application Rules and/or + // 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) { + throw new \InvalidArgumentException(_d('error', 'Notifications.acknowledge')); + } elseif($resolution == NotificationStatusEnum::Canceled + && !$obj->canCancel()) { + throw new \InvalidArgumentException(__d('error', 'Notifications.cancel')); + } elseif($resolution == NotificationStatusEnum::Resolved + && $obj->status != NotificationStatusEnum::PendingResolution) { + throw new \InvalidArgumentException(__d('error', 'Notifications.resolve')); + } elseif(!isset($actions[$resolution])) { + // This status is not a valid resolution + throw new \InvalidArgumentException(__d('error', 'Notifications.status', [$resolution])); + } + + // Update the Notification + + $obj->status = $resolution; + $obj->resolver_person_id = $resolverPersonId; + $obj->resolution_time = date('Y-m-d H:i:s'); + + $this->save($obj); + + // Create a History Record + + $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords'); + + $HistoryRecords->recordForPerson( + personId: $obj->subject_person_id, + action: $actions[$resolution], + comment: __d('result', 'Notifications.'.$resolution, [$obj->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. + // Do this after the history record is created in case something goes wrong. +// XXX implement with notfication groups + } + + /** + * 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) + */ + + public function register( + int $subjectPersonId, + ?int $subjectGroupId, + int $actorPersonId, + ?int $recipientPersonId, + ?int $recipientGroupId, + string $action, + string $comment, + int $messageTemplateId, + mixed $source, + bool $mustResolve=false + ): array { + $obj = $this->newEntity([ + 'subject_person_id' => $subjectPersonId, +// XXX subject group ID not yet implemented + 'subject_group_id' => null, + '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, + 'source' => $source, + 'status' => ($mustResolve + ? NotificationStatusEnum::PendingResolution + : NotificationStatusEnum::PendingAcknowledgment) + ]); + + $this->saveOrFail($obj); + + // Note the various message fields will be set by DeliveryUtilities. + $this->deliver($obj, $actorPersonId); + + return [ $obj->id ]; + } + + /** + * Resolve a notification. + */ + + public function resolve( + + ) { + echo 'hello'; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + 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->add('subject_group_id', [ + // 'content' => ['rule' => 'isInteger'] + // ]); + // $validator->notEmptyString('subject_group_id'); + + $validator->add('actor_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('actor_person_id'); + + $validator->add('recipient_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('recipient_person_id'); + + $validator->add('resolver_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('resolver_person_id'); + + $this->registerStringValidation($validator, $schema, 'action', true); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + $validator->add('message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('message_template_id'); + + $this->registerStringValidation($validator, $schema, 'source', true); + + $this->registerStringValidation($validator, $schema, 'email_subject', false); + + $this->registerStringValidation($validator, $schema, 'email_body_text', false); + + $this->registerStringValidation($validator, $schema, 'email_body_html', false); + + $this->registerStringValidation($validator, $schema, 'resolution_subject', false); + + $this->registerStringValidation($validator, $schema, 'resolution_body', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', NotificationStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('notification_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('notification_time'); + + $validator->add('resolution_time', [ + 'content' => ['rule' => 'dateTime'] + ]); + $validator->allowEmptyString('resolution_time'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 39de20376..c96b60759 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -110,6 +110,21 @@ public function initialize(array $config): void { $this->hasMany('JobHistoryRecords') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('ActorNotifications') + ->setClassName('Notifications') + ->setForeignKey('actor_person_id') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('ResolverNotifications') + ->setClassName('Notifications') + ->setForeignKey('resolver_person_id') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('SubjectNotifications') + ->setClassName('Notifications') + ->setForeignKey('subject_person_id') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('PersonRoles') ->setDependent(true) ->setCascadeCallbacks(true); @@ -220,6 +235,7 @@ public function initialize(array $config): void { 'HistoryRecords', 'IdentifierAssignments', 'Identifiers', + 'Notifications', 'PersonRoles', 'ProvisioningTargets', 'TelephoneNumbers', diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index 78a658853..9fb3ac2b0 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -1490,6 +1490,20 @@ protected function syncPerson( // There is an existing record, update it (if it changed) _unless_ // the attribute record is frozen. + if($model == 'EmailAddresses' && $found->verified) { + // If the Person Email Address is verified and the EI Email Address + // is _not_, we preserve the verification flag _unless_ the mail address + // has changed. + + // This is effectively a combination of AR-EmailAddress-2 Editing an + // Email Address (but not its Type) associated with a Person will revert + // it to unverified and AR-EmailAddress-4 A frozen Email Address may be + // verified if it is otherwise eligible for verification. + if($newdata['mail'] == $found->mail) { + $newdata['verified'] = $found->verified; + } + } + if($model == 'Names' && $found->primary_name) { // Preserve the primary name flag, if set $newdata['primary_name'] = true; diff --git a/app/src/Model/Table/ServersTable.php b/app/src/Model/Table/ServersTable.php index 6f29d004c..f4c44827a 100644 --- a/app/src/Model/Table/ServersTable.php +++ b/app/src/Model/Table/ServersTable.php @@ -67,6 +67,8 @@ public function initialize(array $config): void { // In general, we don't want to propagate deletes of a Server to its // hasMany dependents since we want to throw an error for the administrator // first. (For deleting a CO, the dependent objects should be deleted first.) + $this->hasMany('CoSettings') + ->setForeignKey('email_smtp_server_id'); $this->hasMany('Pipelines') ->setForeignKey('match_server_id'); diff --git a/app/templates/CoSettings/fields.inc b/app/templates/CoSettings/fields.inc index 983f808ea..5db14134a 100644 --- a/app/templates/CoSettings/fields.inc +++ b/app/templates/CoSettings/fields.inc @@ -74,4 +74,8 @@ if($vv_action == 'edit') { ], 'person_picker_display_fields' ); + + print $this->Field->control('email_smtp_server_id'); + + print $this->Field->control('email_delivery_address_type_id'); } diff --git a/app/templates/EmailAddresses/fields-nav.inc b/app/templates/EmailAddresses/fields-nav.inc index 797d856ba..7ea3bbe8b 100644 --- a/app/templates/EmailAddresses/fields-nav.inc +++ b/app/templates/EmailAddresses/fields-nav.inc @@ -32,4 +32,18 @@ $subnav = [ 'name' => 'person', 'active' => 'person', // default for person. 'external identities' are special cased 'subActive' => 'email_addresses' +]; + +$topLinks = [ + [ + 'icon' => 'verified_user', + 'order' => 'Default', + 'label' => __d('operation', 'EmailAddresses.verify.force'), + 'if' => 'notVerified', + 'link' => [ + 'action' => 'forceVerify', + $vv_obj->id + ], + 'class' => '' + ], ]; \ No newline at end of file diff --git a/app/templates/MessageTemplates/columns.inc b/app/templates/MessageTemplates/columns.inc new file mode 100644 index 000000000..94a348c40 --- /dev/null +++ b/app/templates/MessageTemplates/columns.inc @@ -0,0 +1,43 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum', + 'sortable' => true + ], + 'context' => [ + 'type' => 'enum', + 'class' => 'MessageTemplateContextEnum', + 'sortable' => true + ] +]; \ No newline at end of file diff --git a/app/templates/MessageTemplates/fields.inc b/app/templates/MessageTemplates/fields.inc new file mode 100644 index 000000000..c52d7e61b --- /dev/null +++ b/app/templates/MessageTemplates/fields.inc @@ -0,0 +1,52 @@ + +Field->control('description'); + + print $this->Field->control('status'); + + print $this->Field->control('context'); + + print $this->Field->control('format'); + + print $this->Field->control('subject'); + +// XXX auto show/hide based on format + print $this->Field->control('body_text'); + +// XXX auto show/hide based on format + print $this->Field->control('body_html'); + + print $this->Field->control('cc'); + + print $this->Field->control('bcc'); + + print $this->Field->control('reply_to'); +} diff --git a/app/templates/Notifications/columns.inc b/app/templates/Notifications/columns.inc new file mode 100644 index 000000000..eb43ede49 --- /dev/null +++ b/app/templates/Notifications/columns.inc @@ -0,0 +1,46 @@ + [ + 'type' => 'echo' + ], + 'comment' => [ + 'type' => 'link' + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'NotificationStatusEnum', + 'sortable' => true + ], + 'created' => [ + 'type' => 'datetime' + ], + 'resolution_time' => [ + 'type' => 'datetime' + ] +]; \ No newline at end of file diff --git a/app/templates/Notifications/fields-nav.inc b/app/templates/Notifications/fields-nav.inc new file mode 100644 index 000000000..654b7b7fb --- /dev/null +++ b/app/templates/Notifications/fields-nav.inc @@ -0,0 +1,62 @@ +canCancel()) { + $topLinks[] = [ + 'icon' => 'cancel', + 'order' => 'Default', + 'label' => __d('operation', 'cancel'), + 'link' => [ + 'action' => 'cancel', + $vv_obj->id + ], + 'confirm' => [ + 'dg_body_txt' => __d('operation', 'Notifications.cancel.confirm'), + 'dg_cancel_btn' => __d('enumeration', 'YesBooleanEnum.0'), + 'dg_confirm_btn' => __d('enumeration', 'YesBooleanEnum.1') + ], + 'class' => '' + ]; +} + +if(!empty($vv_obj) && $vv_obj->canNotify()) { + $topLinks[] = [ + 'icon' => 'send', + 'order' => 'Default', + 'label' => __d('operation', 'resend'), + 'link' => [ + 'action' => 'resend', + $vv_obj->id + ], + 'confirm' => [ + 'dg_body_txt' => __d('operation', 'Notifications.resend.confirm'), + 'dg_cancel_btn' => __d('enumeration', 'YesBooleanEnum.0'), + 'dg_confirm_btn' => __d('enumeration', 'YesBooleanEnum.1') + ], + 'class' => '' + ]; +} \ No newline at end of file diff --git a/app/templates/Notifications/fields.inc b/app/templates/Notifications/fields.inc new file mode 100644 index 000000000..6409a736c --- /dev/null +++ b/app/templates/Notifications/fields.inc @@ -0,0 +1,125 @@ + +Field->control('comment'); + + print $this->Field->control('action'); + + print $this->Field->control('source'); + + print $this->Field->control('created'); + + if($vv_obj->status == NotificationStatusEnum::PendingAcknowledgment) { + print $this->Field->statusControl( + fieldName: 'status', + status: __d('enumeration', 'NotificationStatusEnum.PA'), + link: [ + 'url' => [ + 'action' => 'acknowledge', + $vv_obj->id + ], + 'label' => __d('operation', 'acknowledge'), + 'confirm' => __d('operation', 'Notifications.acknowledge.confirm') + ] + ); + } else { + print $this->Field->control('status'); + } + + print $this->Field->statusControl( + fieldName: 'subject_person_id', + status: !empty($vv_obj->subject_person->primary_name) + ? $vv_obj->subject_person->primary_name->full_name + : "", + link: !empty($vv_obj->subject_person) + ? ['url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->subject_person->id + ]] + : [] + ); + + print $this->Field->statusControl( + fieldName: 'recipient_person_id', + status: !empty($vv_obj->recipient_person->primary_name) + ? $vv_obj->recipient_person->primary_name->full_name + : "", + link: !empty($vv_obj->recipient_person) + ? ['url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->recipient_person->id + ]] + : [] + ); + + print $this->Field->statusControl( + fieldName: 'actor_person_id', + status: !empty($vv_obj->actor_person->primary_name) + ? $vv_obj->actor_person->primary_name->full_name + : "", + link: !empty($vv_obj->actor_person) + ? ['url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->actor_person->id + ]] + : [] + ); + + print $this->Field->control('resolution_time'); + + print $this->Field->statusControl( + fieldName: 'resolver_person_id', + status: !empty($vv_obj->resolver_person->primary_name) + ? $vv_obj->resolver_person->primary_name->full_name + : "", + link: !empty($vv_obj->resolver_person) + ? ['url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->resolver_person->id + ]] + : [] + ); + + print $this->Field->control('notification_time'); + + print $this->Field->control('email_subject'); + + print $this->Field->control('email_body_text'); + + print $this->Field->control('resolution_subject'); + + print $this->Field->control('resolution_body'); +} diff --git a/app/templates/People/fields-nav.inc b/app/templates/People/fields-nav.inc index b97d5ec44..e572bde0c 100644 --- a/app/templates/People/fields-nav.inc +++ b/app/templates/People/fields-nav.inc @@ -69,6 +69,20 @@ $topLinks = [ ], 'class' => '' ], + [ + 'icon' => 'notifications', + 'order' => 'Default', + 'label' => __d('controller', 'Notifications', [99]), + 'link' => [ + 'controller' => 'notifications', + 'action' => 'index', + '?' => [ + 'subject_person_id' => $vv_obj->id +// XXX person_id is auto-inserted but isn't needed + ] + ], + 'class' => '' + ], [ 'icon' => 'badge', 'order' => 'Default', diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php index 3dd612797..e0afbbd7a 100644 --- a/app/templates/Standard/add-edit-view.php +++ b/app/templates/Standard/add-edit-view.php @@ -126,6 +126,13 @@ } } + if($perm && !empty($t['if'])) { + // If there's a conditional on the field, test the entity + $f = $t['if']; + + $perm = $vv_obj->$f(); + } + if($perm) { $action_args['vv_actions'][] = [ 'order' => $this->Menu->getMenuOrder($t['order']),