diff --git a/libraries/default/InboxProcessors/NotificationInboxProcessor.php b/libraries/default/InboxProcessors/NotificationInboxProcessor.php new file mode 100644 index 0000000..081b424 --- /dev/null +++ b/libraries/default/InboxProcessors/NotificationInboxProcessor.php @@ -0,0 +1,97 @@ +description = __('Notify when data has been changed'); + $this->severity = $this->Inbox::SEVERITY_PRIMARY; + } + + protected function addValidatorRules($validator) + { + return $validator + ->requirePresence('original', 'changed') + ->isArray('original', __('The `original` data must be provided')) + ->isArray('changed', __('The `changed` data must be provided')); + } + + public function create($requestData) { + $this->validateRequestData($requestData); + return parent::create($requestData); + } + + public function getViewVariables($request) + { + $request = $this->__decodeMoreFields($request); + return [ + 'data' => [ + 'original' => $request->data['original'], + 'changed' => $request->data['changed'], + 'summaryTemplate' => $request->data['summaryTemplate'], + 'summaryMessage' => $request->data['summaryMessage'], + 'entityType' => $request->data['entityType'], + 'entityDisplayField' => $request->data['entityDisplayField'], + 'entityViewURL' => $request->data['entityViewURL'], + ], + ]; + } + + public function process($id, $requestData, $inboxRequest) + { + $this->discard($id, $requestData); + return $this->genActionResult( + [], + true, + __('Notification acknowledged'), + [] + ); + } + + public function discard($id, $requestData) + { + return parent::discard($id, $requestData); + } + + private function __decodeMoreFields($request) + { + $decodedRequest = $request; + $decodedRequest->data['original'] = $this->__decodeMoreChangedFields($request->data['original']); + $decodedRequest->data['changed'] = $this->__decodeMoreChangedFields($request->data['changed']); + return $decodedRequest; + } + + private function __decodeMoreChangedFields(array $fieldData): array + { + array_walk($fieldData, function(&$fieldValue, $fieldName) { + if ($fieldName === 'meta_fields') { + $fieldValue = json_decode($fieldValue, true); + } + }); + return $fieldData; + } +} \ No newline at end of file diff --git a/libraries/default/InboxProcessors/templates/Notification/DataChange.php b/libraries/default/InboxProcessors/templates/Notification/DataChange.php new file mode 100644 index 0000000..8cf79b8 --- /dev/null +++ b/libraries/default/InboxProcessors/templates/Notification/DataChange.php @@ -0,0 +1,79 @@ +%s', + h($data['entityViewURL']), + h($data['entityDisplayField']) + ) + ); +} else { + $changedSummary = ''; +} + +$form = $this->element('genericElements/Form/genericForm', [ + 'entity' => null, + 'ajax' => false, + 'raw' => true, + 'data' => [ + 'model' => 'Inbox', + 'fields' => [], + 'submit' => [ + 'action' => $this->request->getParam('action') + ] + ] +]); + +echo $this->Bootstrap->modal([ + 'title' => __('Acknowledge notification'), + 'size' => 'xl', + 'type' => 'confirm', + 'bodyHtml' => sprintf( + '
%s
+
+
+

%s

+
%s
+
%s
+
+
', + $form, + $changedSummary, + $this->Bootstrap->card([ + 'headerText' => __('Original values'), + 'bodyHTML' => $this->element('genericElements/SingleViews/Fields/jsonField', ['field' => ['raw' => $data['original']]]) + ]), + $this->Bootstrap->card([ + 'headerText' => __('Changed values'), + 'bodyHTML' => $this->element('genericElements/SingleViews/Fields/jsonField', ['field' => ['raw' => $data['changed']]]) + ]) + ), + 'confirmText' => __('Acknowledge & Discard'), + 'confirmIcon' => 'check', + // 'confirmFunction' => 'submitAck' +]); +?> + + + + + \ No newline at end of file diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index 9e8aef8..238dc46 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -122,6 +122,7 @@ class AppController extends Controller $this->set('loggedUser', $this->ACL->getUser()); $this->set('roleAccess', $this->ACL->getRoleAccess(false, false)); } + Configure::write('loggedUser', $user); } else if ($this->ParamHandler->isRest()) { throw new MethodNotAllowedException(__('Invalid user credentials.')); } diff --git a/src/Controller/Component/CRUDComponent.php b/src/Controller/Component/CRUDComponent.php index 602f637..7d7e612 100644 --- a/src/Controller/Component/CRUDComponent.php +++ b/src/Controller/Component/CRUDComponent.php @@ -457,6 +457,7 @@ class CRUDComponent extends Component } $entity->setDirty('meta_fields', true); + $entity->_metafields_to_delete = $metaFieldsToDelete; return ['entity' => $entity, 'metafields_to_delete' => $metaFieldsToDelete]; } diff --git a/src/Model/Behavior/NotifyAdminsBehavior.php b/src/Model/Behavior/NotifyAdminsBehavior.php new file mode 100644 index 0000000..62db8fb --- /dev/null +++ b/src/Model/Behavior/NotifyAdminsBehavior.php @@ -0,0 +1,270 @@ + [ + 'notifySiteAdmins' => 'notifySiteAdmins', + 'notifySiteAdminsForEntity' => 'notifySiteAdminsForEntity', + ] + ]; + + /** @var AuditLog|null */ + private $Inbox; + /** @var User|null */ + private $Users; + /** @var InboxProcessors|null */ + private $InboxProcessors; + + public function initialize(array $config): void + { + $this->Inbox = TableRegistry::getTableLocator()->get('Inbox'); + if($this->table() instanceof UsersTable) { + $this->Users = $this->table(); + } else { + $this->Users = TableRegistry::getTableLocator()->get('Users'); + } + } + + public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + if (!$this->isNotificationAllowed($event, $entity, $options)) { + return; + } + $this->notifySiteAdminsForEntityChange($entity); + } + + public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options): void + { + if (!$this->isNotificationAllowed($event, $entity, $options)) { + return; + } + $this->notifySiteAdminsForEntityDeletion($entity); + } + + public function isNotificationAllowed(EventInterface $event, EntityInterface $entity, ArrayObject $options): bool + { + $loggedUser = Configure::read('loggedUser'); + if (empty($loggedUser) || !empty($loggedUser['role']['perm_admin']) || !empty($loggedUser['role']['perm_sync'])) { + return false; + } + return true; + } + + public function notifySiteAdminsForEntityChange(EntityInterface $entity): void + { + $watchedFields = !empty($this->getConfig()['fields']) ? $this->getConfig()['fields'] : $entity->getVisible(); + $originalFields = []; + $changedFields = $entity->extract($watchedFields); + $titleTemplate = 'New {0} `{1}` created'; + $title = __( + $titleTemplate, + Inflector::singularize($entity->getSource()), + $entity->get($this->table()->getDisplayField()) + ); + $message = __('New {0}', Inflector::singularize($entity->getSource())); + if (!$entity->isNew()) { + $originalFields = $this->_getOriginalChangedFields($entity, $watchedFields); + if ($entity->table()->hasBehavior('MetaFields') && !empty($entity->meta_fields)) { + $originalFields['meta_fields'] = array_merge( + $originalFields['meta_fields'], + $this->_getDeletedMetafields($entity) + ); + } + $changedFields = $entity->extract(array_keys($originalFields)); + if ($entity->table()->hasBehavior('MetaFields') && !empty($entity->meta_fields)) { + $originalFields['meta_fields'] = array_map(function($metaField) { + $originalValues = $metaField->getOriginalValues($metaField->getVisible()); + $metaField->set($originalValues); + return $metaField; + }, $originalFields['meta_fields']); + $originalFields['meta_fields'] = $this->_massageMetaField($entity, $originalFields['meta_fields']); + $changedFields['meta_fields'] = $this->_massageMetaField($entity, $changedFields['meta_fields']); + } + $titleTemplate = '{0} {1} modified'; + $title = __( + $titleTemplate, + Inflector::singularize($entity->getSource()), + $entity->get($this->table()->getDisplayField()) + ); + $message = __n( + '{0} field was updated', + '{0} fields were updated', + count($changedFields), + count($changedFields) + ); + } + $data = [ + 'original' => $this->_serializeFields($originalFields), + 'changed' => $this->_serializeFields($changedFields), + 'summaryTemplate' => $titleTemplate, + 'summaryMessage' => $message, + 'entityType' => Inflector::singularize($entity->getSource()), + 'entityDisplayField' => $entity->get($this->table()->getDisplayField()), + 'entityViewURL' => Router::url([ + 'controller' => Inflector::underscore($entity->getSource()), + 'action' => 'view', + $entity->id + ]), + ]; + $this->notifySiteAdmins($entity->getSource(), $title, $message, $data); + } + + public function notifySiteAdminsForEntityDeletion(EntityInterface $entity): void + { + $watchedFields = !empty($this->getConfig()['fields']) ? $this->getConfig()['fields'] : $entity->getVisible(); + $originalFields = $entity->extract($watchedFields); + $changedFields = []; + $titleTemplate = 'Deleted {0} `{1}`'; + $title = __( + $titleTemplate, + Inflector::singularize($entity->getSource()), + $entity->get($this->table()->getDisplayField()) + ); + $message = __('{0} deleted', Inflector::singularize($entity->getSource())); + $data = [ + 'original' => $this->_serializeFields($originalFields), + 'changed' => $this->_serializeFields($changedFields), + 'summaryTemplate' => $titleTemplate, + 'summaryMessage' => $message, + 'entityType' => Inflector::singularize($entity->getSource()), + 'entityDisplayField' => $entity->get($this->table()->getDisplayField()), + 'entityViewURL' => Router::url([ + 'controller' => Inflector::underscore($entity->getSource()), + 'action' => 'view', + $entity->id + ]), + ]; + $this->notifySiteAdmins($entity->getSource(), $title, $message, $data); + } + + /** + * Create a message in each site-admin users + * + * @param string $title A quick summary of what that notification is about + * @param string $origin The origin of the notification. For example, could be the model source + * @param string $message Even more free text + * @param array $data data used to generate the view when processing. + * Must contain: `original` array of the data before the change + * Must contain: `changed` array of the data after the change + * Optional: `summary` A text summarizing the change + * Optional: `summaryTemplate`, `summaryMessage`, `entityType`, `entityDisplayField`, `entityViewURL` text used to build a summary of the change + * @return void + */ + public function notifySiteAdmins( + string $origin, + string $title, + string $message = '', + array $data + ): void { + $this->InboxProcessors = $this->InboxProcessors ?: TableRegistry::getTableLocator()->get('InboxProcessors'); + $processor = $this->InboxProcessors->getProcessor('Notification', 'DataChange'); + $siteAdmins = $this->_getSiteAdmins(); + foreach ($siteAdmins as $siteAdmin) { + $notificationData = [ + 'origin' => $origin, + 'title' => $title, + 'user_id' => $siteAdmin->id, + 'message' => $message, + 'data' => $data, + ]; + $processor->create($notificationData); + } + } + + protected function _getSiteAdmins(): array + { + return $this->Users->find() + ->matching('Roles', function(Query $q) { + return $q + ->where(['Roles.perm_admin' => true]); + }) + ->all()->toList(); + } + + protected function _getOriginalChangedFields($entity, array $fields): array + { + $originalChangedFields = $entity->extractOriginalChanged($fields); + $originalChangedFields = array_map(function ($fieldValue) { + if (is_array($fieldValue)) { + return array_filter(array_map(function($fieldValue) { + if (is_subclass_of($fieldValue, 'App\Model\Entity\AppModel')) { + if (!empty($fieldValue->extractOriginalChanged($fieldValue->getVisible()))) { + return $fieldValue; + } else { + return null; + } + } + return $fieldValue; + }, $fieldValue), fn ($v) => !is_null($v)); + } else if (is_subclass_of($fieldValue, 'App\Model\Entity\AppModel')) { + return $fieldValue->extractOriginalChanged($fieldValue); + } + return $fieldValue; + }, $originalChangedFields); + return $originalChangedFields; + } + + protected function _massageMetaField(AppModel $entity, array $metaFields): array + { + $massaged = []; + foreach ($metaFields as $metaField) { + foreach ($entity->MetaTemplates as $template) { + $templateDisplayName = sprintf('%s (v%s)', $template->name, $template->version); + foreach ($template['meta_template_fields'] as $field) { + if ($metaField->meta_template_id == $template->id && $metaField->meta_template_field_id == $field->id) { + if (!empty($massaged[$templateDisplayName][$field['field']])) { + if (!is_array($massaged[$templateDisplayName][$field['field']])) { + $massaged[$templateDisplayName][$field['field']] = [$massaged[$templateDisplayName][$field['field']]]; + } + $massaged[$templateDisplayName][$field['field']][] = $metaField['value']; + } else { + $massaged[$templateDisplayName][$field['field']] = $metaField['value']; + } + break 2; + } + } + } + } + return $massaged; + } + + protected function _getDeletedMetafields(AppModel $entity): array + { + return $entity->_metafields_to_delete ?? []; + } + + protected function _serializeFields($fields): array + { + return array_map(function ($fieldValue) { + if (is_bool($fieldValue)) { + return empty($fieldValue) ? 0 : 1; + } else if (is_array($fieldValue)) { + return json_encode($fieldValue); + } else if (is_object($fieldValue)) { + switch (get_class($fieldValue)) { + case 'Cake\I18n\FrozenTime': + return $fieldValue->i18nFormat('yyyy-mm-dd HH:mm:ss'); + } + } else { + return strval($fieldValue); + } + }, $fields); + } +}