diff --git a/src/Campaigns/Models/Campaign.php b/src/Campaigns/Models/Campaign.php index bb56e5477b..d504d95930 100644 --- a/src/Campaigns/Models/Campaign.php +++ b/src/Campaigns/Models/Campaign.php @@ -158,6 +158,16 @@ public function delete(): bool return give(CampaignRepository::class)->delete($this); } + /** + * @unreleased + * + * @throws Exception + */ + public function merge(Campaign ...$campaignsToMerge): bool + { + return give(CampaignRepository::class)->mergeCampaigns($this, ...$campaignsToMerge); + } + /** * @unreleased * diff --git a/src/Campaigns/Repositories/CampaignRepository.php b/src/Campaigns/Repositories/CampaignRepository.php index ff8cbba68e..96db261118 100644 --- a/src/Campaigns/Repositories/CampaignRepository.php +++ b/src/Campaigns/Repositories/CampaignRepository.php @@ -262,6 +262,63 @@ public function delete(Campaign $campaign): bool return true; } + /** + * @unreleased + * + * @throws Exception + */ + public function mergeCampaigns(Campaign $destinationCampaign, Campaign ...$campaignsToMerge): bool + { + // Make sure the destination campaign ID will not be included into $campaignsToMergeIds + $campaignsToMergeIds = array_column($campaignsToMerge, 'id'); + if ($key = array_search($destinationCampaign->id, $campaignsToMergeIds)) { + unset($campaignsToMergeIds[$key]); + } + + Hooks::doAction('givewp_campaigns_merging', $destinationCampaign, $campaignsToMergeIds); + + DB::query('START TRANSACTION'); + + try { + // Convert $campaignsToMergeIds to string to use it in the queries + $campaignsToMergeIdsString = implode(', ', $campaignsToMergeIds); + + // Migrate revenue entries from campaigns to merge to the destination campaign + DB::query( + DB::prepare("UPDATE " . DB::prefix('give_revenue') . " SET campaign_id = %d WHERE campaign_id IN ($campaignsToMergeIdsString)", + [ + $destinationCampaign->id, + ]) + ); + + // Migrate forms from campaigns to merge to the destination campaign + DB::query( + DB::prepare("UPDATE " . DB::prefix('give_campaign_forms') . " SET is_default = 0, campaign_id = %d WHERE campaign_id IN ($campaignsToMergeIdsString)", + [ + $destinationCampaign->id, + ]) + ); + + // Delete campaigns to merge now that we already migrated the necessary data to the destination campaign + DB::query("DELETE FROM " . DB::prefix('give_campaigns') . " WHERE id IN ($campaignsToMergeIdsString)"); + } catch (Exception $exception) { + DB::query('ROLLBACK'); + + Log::error('Failed merging campaigns into destination campaign', [ + 'campaignsToMergeIds' => $campaignsToMergeIds, + 'destinationCampaign' => compact('destinationCampaign'), + ]); + + throw new $exception('Failed merging campaigns into destination campaign'); + } + + DB::query('COMMIT'); + + Hooks::doAction('givewp_campaigns_merged', $destinationCampaign, $campaignsToMergeIds); + + return true; + } + /** * @unreleased */ diff --git a/src/Campaigns/Routes/MergeCampaigns.php b/src/Campaigns/Routes/MergeCampaigns.php new file mode 100644 index 0000000000..1999fa3638 --- /dev/null +++ b/src/Campaigns/Routes/MergeCampaigns.php @@ -0,0 +1,65 @@ + WP_REST_Server::EDITABLE, + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + ], + 'campaignsToMergeIds' => [ + 'type' => 'array', + 'required' => true, + 'items' => [ + 'type' => 'integer', + ], + ], + ], + ] + ); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function handleRequest(WP_REST_Request $request): WP_REST_Response + { + $destinationCampaign = Campaign::find($request->get_param('id')); + $campaignsToMerge = Campaign::query()->whereIn('id', $request->get_param('campaignsToMergeIds'))->getAll(); + + $campaignsMerged = $destinationCampaign->merge(...$campaignsToMerge); + + return new WP_REST_Response($campaignsMerged); + } +} diff --git a/src/Campaigns/ServiceProvider.php b/src/Campaigns/ServiceProvider.php index e46e6baa92..bcbc832dd1 100644 --- a/src/Campaigns/ServiceProvider.php +++ b/src/Campaigns/ServiceProvider.php @@ -56,6 +56,7 @@ private function registerRoutes() Hooks::addAction('rest_api_init', Routes\GetCampaignsListTable::class, 'registerRoute'); Hooks::addAction('rest_api_init', Routes\DeleteCampaignListTable::class, 'registerRoute'); Hooks::addAction('rest_api_init', Routes\GetCampaignStatistics::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\MergeCampaigns::class, 'registerRoute'); } /** @@ -138,7 +139,7 @@ private function setupCampaignForms() if ( ! defined('GIVE_IS_ALL_STATS_COLUMNS_ASYNC_ON_ADMIN_FORM_LIST_VIEWS')) { define('GIVE_IS_ALL_STATS_COLUMNS_ASYNC_ON_ADMIN_FORM_LIST_VIEWS', false); } - + Hooks::addAction('save_post_give_forms', AddCampaignFormFromRequest::class, 'optionBasedFormEditor', 10, 3); Hooks::addAction('givewp_donation_form_created', AddCampaignFormFromRequest::class, 'visualFormBuilder'); Hooks::addAction('givewp_campaign_created', CreateDefaultCampaignForm::class); diff --git a/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx b/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx index 5159559cdf..5b4028e4b9 100644 --- a/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx +++ b/src/Campaigns/resources/admin/components/CampaignFormModal/index.tsx @@ -9,7 +9,7 @@ import { GoalInputAttributes, GoalTypeOption as GoalTypeOptionType, } from './types'; -import {useEffect, useRef, useState} from 'react'; +import {useRef, useState} from 'react'; import {Currency, Upload} from '../Inputs'; import { AmountFromSubscriptionsIcon, @@ -146,7 +146,7 @@ export default function CampaignFormModal({isOpen, handleClose, apiSettings, tit const goal = watch('goal'); const getFormModalTitle = () => { - switch(step) { + switch (step) { case 1: return __('Tell us about your fundraising cause', 'give'); case 2: @@ -154,7 +154,7 @@ export default function CampaignFormModal({isOpen, handleClose, apiSettings, tit } return null; - } + }; const goalInputAttributes: {[selectedGoalType: string]: GoalInputAttributes} = { amount: { @@ -395,7 +395,7 @@ export default function CampaignFormModal({isOpen, handleClose, apiSettings, tit ) : ( diff --git a/src/FormMigration/Controllers/MigrationController.php b/src/FormMigration/Controllers/MigrationController.php index 1fc45cf3ac..5fa685ddd9 100644 --- a/src/FormMigration/Controllers/MigrationController.php +++ b/src/FormMigration/Controllers/MigrationController.php @@ -2,6 +2,7 @@ namespace Give\FormMigration\Controllers; +use Give\Campaigns\Repositories\CampaignRepository; use Give\DonationForms\V2\Models\DonationForm; use Give\FormMigration\Concerns\Blocks\BlockDifference; use Give\FormMigration\DataTransferObjects\FormMigrationPayload; @@ -50,6 +51,12 @@ public function __invoke(DonationForm $formV2) ->process($payload) ->finally(function(FormMigrationPayload $payload) { $payload->formV3->save(); + + // Associate upgraded form to a campaign + $campaignRepository = give(CampaignRepository::class); + $campaign = $campaignRepository->getByFormId($payload->formV2->id); + $campaignRepository->addCampaignForm($campaign, $payload->formV3->id); + Log::info(esc_html__('Form migrated from v2 to v3.', 'give'), $this->debugContext); }); diff --git a/src/FormMigration/Controllers/TransferController.php b/src/FormMigration/Controllers/TransferController.php index cccc2f32df..4810eb050d 100644 --- a/src/FormMigration/Controllers/TransferController.php +++ b/src/FormMigration/Controllers/TransferController.php @@ -2,6 +2,7 @@ namespace Give\FormMigration\Controllers; +use Give\Campaigns\Repositories\CampaignRepository; use Give\DonationForms\V2\Models\DonationForm; use Give\DonationForms\ValueObjects\DonationFormStatus; use Give\FormMigration\Actions\GetMigratedFormId; @@ -35,6 +36,15 @@ public function __invoke(DonationForm $formV2, TransferOptions $options) TransferFormUrl::from($formV2->id)->to($v3FormId); TransferDonations::from($formV2->id)->to($v3FormId); + // Promote upgraded form to default form + $campaignRepository = give(CampaignRepository::class); + $campaign = $campaignRepository->getByFormId($formV2->id); + $defaultForm = $campaign->defaultForm(); + + if ($defaultForm->id === $formV2->id) { + $campaignRepository->updateDefaultCampaignForm($campaign, $v3FormId); + } + if($options->shouldDelete()) { wp_trash_post($formV2->id); } diff --git a/tests/Feature/FormMigration/Controllers/TestMigrationController.php b/tests/Feature/FormMigration/Controllers/TestMigrationController.php index a2f628632e..74d10d575c 100644 --- a/tests/Feature/FormMigration/Controllers/TestMigrationController.php +++ b/tests/Feature/FormMigration/Controllers/TestMigrationController.php @@ -28,6 +28,8 @@ public function testShouldMigrateFormV2ToV3(): void { $formV2 = $this->createSimpleDonationForm(); + $this->createCampaignForDonationForm($formV2->id); + $request = $this->getMockRequest(WP_REST_Server::CREATABLE); $controller = new MigrationController($request); diff --git a/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php b/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php index 4604c83316..ea8c39207a 100644 --- a/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php +++ b/tests/Unit/Campaigns/Repositories/CampaignRepositoryTest.php @@ -8,6 +8,8 @@ use Give\Campaigns\ValueObjects\CampaignStatus; use Give\Campaigns\ValueObjects\CampaignType; use Give\DonationForms\Models\DonationForm; +use Give\Donations\Models\Donation; +use Give\Framework\Database\DB; use Give\Framework\Exceptions\Primitives\InvalidArgumentException; use Give\Framework\Support\Facades\DateTime\Temporal; use Give\Tests\TestCase; @@ -282,8 +284,154 @@ public function testUpdateCampaignFormShouldUpdateDefaultFormToCampaign() $repository->updateDefaultCampaignForm($campaign, $form2->id); //Re-fetch - $campaign = $campaign::find($campaign->id); + $campaign = Campaign::find($campaign->id); $this->assertEquals($form2->id, $campaign->defaultForm()->id); } + + /** + * @unreleased + * + * @throws Exception + */ + public function testMergeCampaignsShouldReturnTrue() + { + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + $repository = new CampaignRepository(); + $merged = $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2); + + $this->assertTrue($merged); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testMergeCampaignsShouldMigrateFormsToDestinationCampaign() + { + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + $formCampaign1 = $campaign1->defaultForm(); + $formCampaign2 = $campaign2->defaultForm(); + + $repository = new CampaignRepository(); + $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2); + + //Re-fetch + $destinationCampaign = Campaign::find($destinationCampaign->id); + + $campaignReturn = $repository->getByFormId($formCampaign1->id); + $this->assertEquals($campaignReturn->getAttributes(), $destinationCampaign->getAttributes()); + + $campaignReturn = $repository->getByFormId($formCampaign2->id); + $this->assertEquals($campaignReturn->getAttributes(), $destinationCampaign->getAttributes()); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testMergeCampaignsShouldMigrateRevenueToDestinationCampaign() + { + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + /** @var Donation $donationCampaign1 */ + $donationCampaign1 = Donation::factory()->create(['formId' => $campaign1->defaultForm()->id]); + /** @var Donation $donationCampaign2 */ + $donationCampaign2 = Donation::factory()->create(['formId' => $campaign2->defaultForm()->id]); + + // TODO Remove this updates clauses when the logic to automatically set the campaign_id in the revenue table entries for new donations is implemented + DB::query( + DB::prepare('UPDATE ' . DB::prefix('give_revenue') . ' SET campaign_id = %d WHERE donation_id = %d', + [ + $campaign1->id, + $donationCampaign1->id, + ]) + ); + DB::query( + DB::prepare('UPDATE ' . DB::prefix('give_revenue') . ' SET campaign_id = %d WHERE donation_id = %d', + [ + $campaign2->id, + $donationCampaign2->id, + ]) + ); + + $repository = new CampaignRepository(); + $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2); + + $revenueEntry = DB::table('give_revenue')->where('donation_id', $donationCampaign1->id)->get(); + $this->assertEquals($destinationCampaign->id, $revenueEntry->campaign_id); + + $revenueEntry = DB::table('give_revenue')->where('donation_id', $donationCampaign2->id)->get(); + $this->assertEquals($destinationCampaign->id, $revenueEntry->campaign_id); + } + + + /** + * @unreleased + * + * @throws Exception + */ + public function testMergeCampaignsShouldDeleteMergedCampaigns() + { + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + $repository = new CampaignRepository(); + $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2); + + $this->assertNull(Campaign::find($campaign1->id)); + $this->assertNull(Campaign::find($campaign2->id)); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testMergeCampaignsShouldKeepDefaultFormFromDestinationCampaign() + { + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + $defaultFormBeforeMerge = $destinationCampaign->defaultForm(); + + $repository = new CampaignRepository(); + $repository->mergeCampaigns($destinationCampaign, $campaign1, $campaign2); + + //Re-fetch + $destinationCampaign = Campaign::find($destinationCampaign->id); + + $countDefaultForm = $destinationCampaign->forms() + ->where('campaign_forms.is_default', true)->count(); + + $this->assertEquals(1, $countDefaultForm); + $this->assertEquals($defaultFormBeforeMerge->id, $destinationCampaign->defaultForm()->id); + } } diff --git a/tests/Unit/Campaigns/Routes/MergeCampaignsTest.php b/tests/Unit/Campaigns/Routes/MergeCampaignsTest.php new file mode 100644 index 0000000000..4da13b0c44 --- /dev/null +++ b/tests/Unit/Campaigns/Routes/MergeCampaignsTest.php @@ -0,0 +1,80 @@ +create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + $request = new WP_REST_Request('PUT', "/give-api/v2/campaigns/$destinationCampaign->id/merge"); + $request->set_query_params( + [ + 'id' => $destinationCampaign->id, + 'campaignsToMergeIds' => [$campaign1->id, $campaign2->id], + ] + ); + + $response = $this->dispatchRequest($request); + $errorCode = $response->get_status(); + + $this->assertEquals(401, $errorCode); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testMergeCampaignsRouteShouldReturnTrueForAdminUsers() + { + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + /** @var Campaign $destinationCampaign */ + $destinationCampaign = Campaign::factory()->create(); + + $newAdminUser = $this->factory()->user->create( + [ + 'role' => 'administrator', + 'user_login' => 'admin38974238473824', + 'user_pass' => 'admin38974238473824', + 'user_email' => 'admin38974238473824@test.com', + ] + ); + wp_set_current_user($newAdminUser); + + $request = new WP_REST_Request('PUT', "/give-api/v2/campaigns/$destinationCampaign->id/merge"); + $request->set_query_params( + [ + 'id' => $destinationCampaign->id, + 'campaignsToMergeIds' => [$campaign1->id, $campaign2->id], + ] + ); + + $response = $this->dispatchRequest($request); + $merged = $response->get_data(); + + $this->assertTrue($merged); + } +} diff --git a/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php b/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php index 16d95bc6af..4a1cd0aacb 100644 --- a/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php +++ b/tests/Unit/DonationForms/TestTraits/LegacyDonationFormAdapter.php @@ -2,6 +2,9 @@ namespace Give\Tests\Unit\DonationForms\TestTraits; +use Exception; +use Give\Campaigns\Models\Campaign; +use Give\Campaigns\Repositories\CampaignRepository; use Give\DonationForms\V2\Models\DonationForm; use Give\DonationForms\V2\Properties\DonationFormLevel; use Give\DonationForms\V2\ValueObjects\DonationFormStatus; @@ -58,4 +61,13 @@ public function getDonationFormModelFromLegacyGiveDonateForm(Give_Donate_Form $g ]); } + /** + * @unreleased + */ + public function createCampaignForDonationForm($formId) + { + $campaign = Campaign::factory()->create(); + give(CampaignRepository::class)->addCampaignForm($campaign, $formId); + } + }