From 28255e315ac30fe89c82d2753ce125d0723a6fb9 Mon Sep 17 00:00:00 2001 From: Craig Williams Date: Mon, 7 Feb 2022 16:20:30 +0000 Subject: [PATCH] Added capability to merge organisations - Added an interface for organisation - Added beforeDelete and beforeMerge callbacks - Added hard/soft delete to organisations --- assets/avsdev/js/widgets/organisations.js | 57 ++++++ locale/en_US/messages.php | 12 ++ routes/organisations.php | 5 + schema/requests/organisation/merge.yaml | 25 +++ src/Controller/OrganisationController.php | 169 +++++++++++++++++- .../Interfaces/OrganisationInterface.php | 85 +++++++++ src/Database/Models/Organisation.php | 83 ++++++++- .../Seeds/OrganisationPermissions.php | 6 + src/ServicesProvider/ServicesProvider.php | 40 +++++ templates/modals/organisation-merge.html.twig | 51 ++++++ templates/tables/organisations.html.twig | 5 + 11 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 schema/requests/organisation/merge.yaml create mode 100644 src/Database/Models/Interfaces/OrganisationInterface.php create mode 100644 templates/modals/organisation-merge.html.twig diff --git a/assets/avsdev/js/widgets/organisations.js b/assets/avsdev/js/widgets/organisations.js index 84d50cf..77847d0 100644 --- a/assets/avsdev/js/widgets/organisations.js +++ b/assets/avsdev/js/widgets/organisations.js @@ -76,6 +76,63 @@ function bindOrganisationButtons(el, options) { attachOrganisationForm(); }); + // Manage organisation merge button + el.find('.js-organisation-merge').click(function(e) { + e.preventDefault(); + + var organisation_slug = $(this).data('slug'); + + $("body").ufModal({ + sourceUrl: site.uri.public + "/modals/organisations/merge", + ajaxParams: { + slug: organisation_slug + }, + msgTarget: $("#alerts-page") + }); + + $("body").on('renderSuccess.ufModal', function(data) { + var modal = $(this).ufModal('getModal'); + var form = modal.find('.js-form'); + + var dropdownTemplate = modal.find('#organisation-select-option').html(); + + // Set up select2 dropdown + var dropdownControl = modal.find('.js-select-new'); + _dropdownTemplateCompiled = Handlebars.compile(dropdownTemplate); + var options = { + ajax: { + url: site.uri.public + '/api/organisations', + processResults: function (data) { + var items = data.rows.filter((i) => i.slug != organisation_slug); + return { + results: items.map((i) => { return { + id: i.slug, + text: i.name, + name: i.name, + description: i.description, + slug: i.slug + }}) + }; + } + }, + placeholder: "Select an organisation to merge with", + selectOnClose: true, + templateResult: $.proxy(function(item) { + // Must wrap this in a jQuery selector to render as HTML + return $(_dropdownTemplateCompiled(item)); + }, this) + }; + dropdownControl.select2(options); + + // Set up form for submission + form.ufForm() + .on("submitSuccess.ufForm", function() { + // Reload page on success + window.location.reload(); + }); + }); + }); + // Delete organisation button el.find('.js-organisation-delete').click(function(e) { e.preventDefault(); diff --git a/locale/en_US/messages.php b/locale/en_US/messages.php index 9a2a0bb..61505b1 100644 --- a/locale/en_US/messages.php +++ b/locale/en_US/messages.php @@ -27,6 +27,10 @@ return [ 'EDIT' => 'Edit organistion', 'UPDATE' => 'Details updated for organistion {{name}}', + 'MERGE' => 'Merge organisation', + 'MERGE_INFORM' => 'Select an organisation from the list below to merge organisation {{name}} into. All users of organisation {{name}} will be moved into the chosen organisation.', + 'MERGE_SUCCESSFUL' => 'Successfully merged organisation {{source}} into organisation {{target}}.', + 'DELETE' => 'Delete organisation', 'DELETE_CONFIRM' => 'Are you sure you want to delete the organisation {{name}}?', 'DELETE_YES' => 'Yes, delete organisation', @@ -55,4 +59,12 @@ return [ 1 => 'Administrator', 2 => 'Administrators', ], + + 'MERGE' => 'Merge', + 'MERGE_INTO' => 'Merge into', + 'MERGE_CANNOT_UNDONE' => 'This action cannot be undone.', + + 'SOURCE_SLUG' => 'Source Slug', + 'TARGET_SLUG' => 'Target Slug', + 'SLUG_NOT_IN_USE' => 'A {{slug}} slug does not exist', ]; diff --git a/routes/organisations.php b/routes/organisations.php index 16ebdef..0e93492 100644 --- a/routes/organisations.php +++ b/routes/organisations.php @@ -31,6 +31,9 @@ $app->group('/api/organisations', function () { $this->put('/o/{slug}', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:update'); $this->delete('/o/{slug}', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:delete'); + + + $this->post('/merge', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:merge'); })->add('authGuard')->add(new NoCache()); $app->group('/modals/organisations', function () { @@ -38,6 +41,8 @@ $app->group('/modals/organisations', function () { $this->get('/edit', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getModalEdit'); + $this->get('/merge', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getModalMerge'); + $this->get('/confirm-delete', 'UserFrosting\Sprinkle\Organisations\Controller\OrganisationController:getModalConfirmDelete'); })->add('authGuard')->add(new NoCache()); diff --git a/schema/requests/organisation/merge.yaml b/schema/requests/organisation/merge.yaml new file mode 100644 index 0000000..73a4699 --- /dev/null +++ b/schema/requests/organisation/merge.yaml @@ -0,0 +1,25 @@ +--- +source_slug: + validators: + required: + label: "&SOURCE_SLUG" + message: VALIDATE.REQUIRED + length: + label: "&SOURCE_SLUG" + min: 1 + max: 255 + message: VALIDATE.LENGTH_RANGE + transformations: + - trim +target_slug: + validators: + required: + label: "&TARGET_SLUG" + message: VALIDATE.REQUIRED + length: + label: "&TARGET_SLUG" + min: 1 + max: 255 + message: VALIDATE.LENGTH_RANGE + transformations: + - trim \ No newline at end of file diff --git a/src/Controller/OrganisationController.php b/src/Controller/OrganisationController.php index f4b6eee..63a3d23 100644 --- a/src/Controller/OrganisationController.php +++ b/src/Controller/OrganisationController.php @@ -298,6 +298,110 @@ class OrganisationController extends SimpleController return $response->withJson([], 200); } + /** + * Processes the request to merge two organisations. + * + * Processes the request from the merge organisation form, checking that: + * 1. The source organisation slug exists; + * 2. The target organisation slug exists; + * 3. The user has permission to merge organisations; + * 4. The submitted data is valid. + * This route requires authentication (and should generally be limited to admins or the root user). + * + * Request type: POST + * + * @see getModalMerge + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws ForbiddenException If user is not authorized to access page + */ + public function merge(Request $request, Response $response, $args) + { + // Get POST parameters + $params = $request->getParsedBody(); + + // Load the request schema + $schema = new RequestSchema('schema://requests/organisation/merge.yaml'); + + // Whitelist and set parameter defaults + $transformer = new RequestDataTransformer($schema); + $data = $transformer->transform($params); + + // Validate, and throw exception on validation errors. + $validator = new ServerSideValidator($schema, $this->ci->translator); + if (!$validator->validate($data)) { + $e = new BadRequestException(); + + foreach ($validator->errors() as $idx => $field) { + foreach ($field as $eidx => $error) { + $e->addUserMessage($error); + } + } + + throw $e; + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + // Get the organisations + $source = $classMapper->getClassMapping('organisation')::where('slug', $data['source_slug'])->first(); + $target = $classMapper->getClassMapping('organisation')::where('slug', $data['target_slug'])->first(); + + // If a organisation doesn't exist, return 404 + if (!$source || !$target) { + throw new BadRequestException(); + } + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + // Access-controlled page + if (!$authorizer->checkAccess($currentUser, 'merge_organisation')) { + throw new ForbiddenException(); + } + + /** @var \UserFrosting\Support\Repository\Repository $config */ + $config = $this->ci->config; + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + $sourceName = $source->name; + + // Begin transaction - DB will be rolled back if an exception occurs + Capsule::transaction(function () use ($source, $sourceName, $target, $currentUser) { + $this->ci->get('organisation.beforeMerge')($source, $target); + + $source->beforeMerge($target, ['currentUser' => $currentUser]); + + $source->delete(); + unset($source); + + // Create activity record + $this->ci->userActivityLogger->info("User {$currentUser->user_name} merged organisation {$sourceName} into {$target->name}.", [ + 'type' => 'organisation_merge', + 'user_id' => $currentUser->id, + ]); + }); + + /** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */ + $ms = $this->ci->alerts; + + $ms->addMessageTranslated('success', 'ORGANISATION.MERGE_SUCCESSFUL', [ + 'source' => $sourceName, + 'target' => $target->name, + ]); + + return $response->withJson([], 200); + } + /** * Processes the request to delete an existing organisation. * @@ -589,7 +693,70 @@ class OrganisationController extends SimpleController ], ]); } - + + /** + * Renders the modal form for merging two organisations. + * + * This does NOT render a complete page. Instead, it renders the HTML for the modal, which can be embedded in other pages. + * This page requires authentication. + * + * Request type: GET + * + * @param Request $request + * @param Response $response + * @param array $args + * + * @throws ForbiddenException If user is not authorized to access page + */ + public function getModalMerge(Request $request, Response $response, $args) + { + // GET parameters + $params = $request->getQueryParams(); + + $organisation = $this->getOrganisationFromParams($params); + + // If the organisation doesn't exist, return 404 + if (!$organisation) { + throw new NotFoundException(); + } + + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = $this->ci->classMapper; + + /** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */ + $authorizer = $this->ci->authorizer; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = $this->ci->currentUser; + + /** @var \UserFrosting\I18n\Translator $translator */ + $translator = $this->ci->translator; + + // Access-controlled resource - check that currentUser has permission to merge organisations. + if (!$authorizer->checkAccess($currentUser, 'organisation_merge')) { + throw new ForbiddenException(); + } + + // Load validation rules + $schema = new RequestSchema('schema://requests/organisation/merge.yaml'); + $validator = new JqueryValidationAdapter($schema, $translator); + + $otherOrganisations = $classMapper->getClassMapping('organisation')::where('id', '!=', $organisation->id)->get(); + + return $this->ci->view->render($response, 'modals/organisation-merge.html.twig', [ + 'organisation' => $organisation, + 'other_organisations' => $otherOrganisations, + 'form' => [ + 'action' => "api/organisations/o/{$organisation->slug}/merge", + 'method' => 'POST', + 'submit_text' => $translator->translate('MERGE'), + ], + 'page' => [ + 'validators' => $validator->rules('json', false), + ], + ]); + } + /** * Renders a page displaying a organisation's information, in read-only mode. diff --git a/src/Database/Models/Interfaces/OrganisationInterface.php b/src/Database/Models/Interfaces/OrganisationInterface.php new file mode 100644 index 0000000..a9760d6 --- /dev/null +++ b/src/Database/Models/Interfaces/OrganisationInterface.php @@ -0,0 +1,85 @@ +withTimestamps(); } + /** + * Delete this organisation from the database, along with any linked objects. + * + * @param bool $hardDelete Set to true to completely remove the organisation and all associated objects. + * + * @return bool true if the deletion was successful, false otherwise. + */ + public function delete($hardDelete = false) + { + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = static::$ci->classMapper; + + if ($hardDelete) { + static::$ci->get('organisation.beforeDelete')($this); + + // Remove all member associations + $this->members()->detach(); + + // Delete the organisation + $result = $this->forceDelete(); + } else { + // Soft delete the organisation, leaving all associated records alone + $result = parent::delete(); + } + + return $result; + } + + /** + * Performs tasks to be done before an organisation is merged + * + * By default, adds a new sign-in activity and updates any legacy hash. + * + * @param mixed[] $params Optional array of parameters used for this event handler. + * + * @todo Transition to Laravel Event dispatcher to handle this + */ + public function beforeMerge($target, $params = []) + { + /** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */ + $classMapper = static::$ci->classMapper; + + /** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */ + $currentUser = static::$ci->authenticator->user(); + + /** @var \UserFrosting\Sprinkle\???? $activityLogger */ + $activityLogger = static::$ci->userActivityLogger; + + $logEntry = function($activityLogger, $currentUser, $user, $target) { + $activityLogger->info("User {$currentUser->user_name} moved user {$user->user_name} from organisation {$this->name} into {$target->name}.", [ + 'type' => 'organisation_merge', + 'user_id' => $currentUser->id, + ]); + $activityLogger->info("User {$user->user_name} was removed from the organisation {$this->name} by {$currentUser->user_name} during an organisation merge.", [ + 'type' => 'group_leave', + 'user_id' => $user->id, + ]); + $activityLogger->info("User {$user->user_name} was added to the organisation {$target->name} by {$currentUser->user_name} during an organisation merge.", [ + 'type' => 'group_join', + 'user_id' => $user->id, + ]); + }; + + // Move all the users from this organisation to the target + $this->members()->each(function ($user) use ($target, $activityLogger, $currentUser, $logEntry) { + $this->members()->detach($user); // NOTE: Should this record be retained? Or is the activity log enough of an audit? + $target->members()->attach($user, ['flag_admin' => false]); + $logEntry($activityLogger, $currentUser, $user, $target); + }); + $this->administrators()->each(function ($user) use ($target, $activityLogger, $currentUser, $logEntry) { + $this->administrators()->detach($user); // NOTE: Should this record be retained? Or is the activity log enough of an audit? + $target->administrators()->attach($user, ['flag_admin' => true]); + $logEntry($activityLogger, $currentUser, $user, $target); + }); + $this->save(); + $target->save(); + + return $this; + } + /** * Joins the organisation's member count, so we can do things like sort, search, paginate, etc. * diff --git a/src/Database/Seeds/OrganisationPermissions.php b/src/Database/Seeds/OrganisationPermissions.php index 9bd25f3..a668ad4 100644 --- a/src/Database/Seeds/OrganisationPermissions.php +++ b/src/Database/Seeds/OrganisationPermissions.php @@ -61,6 +61,12 @@ class OrganisationPermissions extends BaseSeed 'conditions' => 'always()', 'description' => 'Edit basic properties of any organisation.', ]), + 'merge_organisations' => new Permission([ + 'slug' => 'merge_organisations', + 'name' => 'Merge two organisations', + 'conditions' => 'always()', + 'description' => 'Merge two organisations together, including all the members.', + ]), 'delete_organisation' => new Permission([ 'slug' => 'delete_organisation', 'name' => 'Delete organisation', diff --git a/src/ServicesProvider/ServicesProvider.php b/src/ServicesProvider/ServicesProvider.php index ea5b803..22eea78 100644 --- a/src/ServicesProvider/ServicesProvider.php +++ b/src/ServicesProvider/ServicesProvider.php @@ -12,6 +12,7 @@ namespace UserFrosting\Sprinkle\Organisations\ServicesProvider; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; +use UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterface as OrganisationInterface; /** * Registers services for the organisation sprinkle. @@ -40,5 +41,44 @@ class ServicesProvider return $classMapper; }); + + + /* + * Returns a callback that handles merging any organisation objects. + * + * @return callable + */ + $container['organisation.beforeMerge'] = function ($c) { + /* + * This method is invoked when an organisation is about to be merged + * + * Returns a callback that handles re-owning any organisation objects. + * Throwing exceptions is allowed but not recommended. This method is triggered within a Capsule context. + * @param \UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterfaces $source Organisation merging from + * @param \UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterfaces $target Organisation merging towards + */ + return function (OrganisationInterface $source, OrganisationInterface $target) use ($c) { + + }; + }; + + /* + * Returns a callback that handles hard deleting an organisation (clean up any associated objects). + * + * @return callable + */ + $container['organisation.beforeDelete'] = function ($c) { + /* + * This method is invoked when an organisation is about to be merged + * + * Returns a callback that handles re-owning any organisation objects. + * Throwing exceptions is allowed but not recommended. This method is triggered within a Capsule context. + * @param \UserFrosting\Sprinkle\Organisations\Database\Models\Interfaces\OrganisationInterfaces $organisation Organisation about to be deleted + */ + return function (OrganisationInterface $organisation) use ($c) { + + }; + }; + } } diff --git a/templates/modals/organisation-merge.html.twig b/templates/modals/organisation-merge.html.twig new file mode 100644 index 0000000..8e08ed6 --- /dev/null +++ b/templates/modals/organisation-merge.html.twig @@ -0,0 +1,51 @@ +{% extends "modals/modal.html.twig" %} + +{% block modal_title %}{{translate("ORGANISATION.MERGE")}}{% endblock %} + +{% block modal_body %} +
+ {% include "forms/csrf.html.twig" %} + +
+
+
+

{{translate("ORGANISATION.MERGE_INFORM", {name: organisation.name})}}
{{translate("MERGE_CANNOT_UNDONE")}}

+
+ + +
+
+
+
+
+ +
+
+ +
+
+
+ +{# This contains a series of +{% endverbatim %} + + + +{% endblock %} diff --git a/templates/tables/organisations.html.twig b/templates/tables/organisations.html.twig index 868b0c2..d6975ce 100644 --- a/templates/tables/organisations.html.twig +++ b/templates/tables/organisations.html.twig @@ -70,6 +70,11 @@ {% endverbatim %}{{translate("ORGANISATION.EDIT")}}{% verbatim %} +
  • + + {% endverbatim %}{{translate("ORGANISATION.MERGE")}}{% verbatim %} + +
  • {% endverbatim %}{{translate("ORGANISATION.DELETE")}}{% verbatim %}