From 3e82c776502e964ac3c7bd41fe97f68b18617da8 Mon Sep 17 00:00:00 2001 From: "Kyle B. Johnson" Date: Thu, 19 Sep 2024 11:27:11 -0400 Subject: [PATCH] feature: Add endpoint for campaign statistics --- src/Campaigns/CampaignDonationQuery.php | 31 ++++-- .../Routes/CampaignOverviewStatistics.php | 87 +++++++++++++++ src/Campaigns/ServiceProvider.php | 1 + .../Support/Facades/DateTime/Temporal.php | 3 + .../Facades/DateTime/TemporalFacade.php | 45 ++++++-- .../Routes/CampaignOverviewStatisticsTest.php | 101 ++++++++++++++++++ .../Facades/DateTime/TemporalFacadeTest.php | 55 ++++++++++ 7 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 src/Campaigns/Routes/CampaignOverviewStatistics.php create mode 100644 tests/Unit/Campaigns/Routes/CampaignOverviewStatisticsTest.php create mode 100644 tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php diff --git a/src/Campaigns/CampaignDonationQuery.php b/src/Campaigns/CampaignDonationQuery.php index 3399ec2b2c..3a65197b4f 100644 --- a/src/Campaigns/CampaignDonationQuery.php +++ b/src/Campaigns/CampaignDonationQuery.php @@ -2,6 +2,7 @@ namespace Give\Campaigns; +use DateTimeInterface; use Give\Campaigns\Models\Campaign; use Give\Donations\ValueObjects\DonationMetaKeys; use Give\Framework\QueryBuilder\JoinQueryBuilder; @@ -36,6 +37,21 @@ public function __construct(Campaign $campaign) $this->where('campaign_forms.campaign_id', $campaign->id); } + /** + * @unreleased + */ + public function between(DateTimeInterface $startDate, DateTimeInterface $endDate): self + { + $query = clone $this; + $query->joinDonationMeta('_give_completed_date', 'completed'); + $query->whereBetween( + 'completed.meta_value', + $startDate->format('Y-m-d H:i:s'), + $endDate->format('Y-m-d H:i:s') + ); + return $query; + } + /** * Returns a calculated sum of the intended amounts (without recovered fees) for the donations. * @@ -45,9 +61,10 @@ public function __construct(Campaign $campaign) */ public function sumIntendedAmount() { - $this->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amount'); - $this->joinDonationMeta('_give_fee_donation_amount', 'intendedAmount'); - return $this->sum( + $query = clone $this; + $query->joinDonationMeta(DonationMetaKeys::AMOUNT, 'amount'); + $query->joinDonationMeta('_give_fee_donation_amount', 'intendedAmount'); + return $query->sum( /** * The intended amount meta and the amount meta could either be 0 or NULL. * So we need to use the NULLIF function to treat the 0 values as NULL. @@ -63,7 +80,8 @@ public function sumIntendedAmount() */ public function countDonations(): int { - return $this->count('donation.ID'); + $query = clone $this; + return $query->count('donation.ID'); } /** @@ -71,8 +89,9 @@ public function countDonations(): int */ public function countDonors(): int { - $this->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorId'); - return $this->count('DISTINCT donorId.meta_value'); + $query = clone $this; + $query->joinDonationMeta(DonationMetaKeys::DONOR_ID, 'donorId'); + return $query->count('DISTINCT donorId.meta_value'); } /** diff --git a/src/Campaigns/Routes/CampaignOverviewStatistics.php b/src/Campaigns/Routes/CampaignOverviewStatistics.php new file mode 100644 index 0000000000..ce795311b6 --- /dev/null +++ b/src/Campaigns/Routes/CampaignOverviewStatistics.php @@ -0,0 +1,87 @@ +endpoint, + [ + [ + 'methods' => 'GET', + 'callback' => [$this, 'handleRequest'], + 'permission_callback' => function () { + return current_user_can('manage_options'); + }, + ], + 'args' => [ + 'campaignId' => [ + 'type' => 'integer', + 'required' => true, + 'validate_callback' => 'is_numeric', + ], + 'rangeInDays' => [ + 'type' => 'integer', + 'required' => false, + 'validate_callback' => 'is_numeric', + 'default' => 0, // Zero to mean "all time". + ], + ], + ] + ); + } + + /** + * @unreleased + */ + public function handleRequest($request) + { + $campaign = Campaign::find($request->get_param('campaignId')); + + $query = new CampaignDonationQuery($campaign); + + if(!$request->get_param('rangeInDays')) { + return [[ + 'amountRaised' => $query->sumIntendedAmount(), + 'donationCount' => $query->countDonations(), + 'donorCount' => $query->countDonors(), + ]]; + } + + $days = $request->get_param('rangeInDays'); + $date = new DateTimeImmutable('now', wp_timezone()); + $interval = DateInterval::createFromDateString("-$days days"); + $period = new DatePeriod($date, $interval, 1); + + return array_map(function($targetDate) use ($query, $interval) { + + $query = $query->between( + Temporal::withStartOfDay($targetDate->add($interval)), + Temporal::withEndOfDay($targetDate) + ); + + return [ + 'amountRaised' => $query->sumIntendedAmount(), + 'donationCount' => $query->countDonations(), + 'donorCount' => $query->countDonors(), + ]; + }, iterator_to_array($period) ); + } +} diff --git a/src/Campaigns/ServiceProvider.php b/src/Campaigns/ServiceProvider.php index f66874cc74..cbc865550b 100644 --- a/src/Campaigns/ServiceProvider.php +++ b/src/Campaigns/ServiceProvider.php @@ -46,6 +46,7 @@ private function registerRoutes() Hooks::addAction('rest_api_init', Routes\CreateCampaign::class, 'registerRoute'); Hooks::addAction('rest_api_init', Routes\GetCampaignsListTable::class, 'registerRoute'); Hooks::addAction('rest_api_init', Routes\DeleteCampaignListTable::class, 'registerRoute'); + Hooks::addAction('rest_api_init', Routes\CampaignOverviewStatistics::class, 'registerRoute'); } /** diff --git a/src/Framework/Support/Facades/DateTime/Temporal.php b/src/Framework/Support/Facades/DateTime/Temporal.php index 4281425e0f..dcffcf91b7 100644 --- a/src/Framework/Support/Facades/DateTime/Temporal.php +++ b/src/Framework/Support/Facades/DateTime/Temporal.php @@ -14,6 +14,9 @@ * @method static string getFormattedDateTime(DateTimeInterface $dateTime) * @method static string getCurrentFormattedDateForDatabase() * @method static DateTimeInterface withoutMicroseconds(DateTimeInterface $dateTime) + * @method static DateTimeInterface withStartOfDay(DateTimeInterface $dateTime) + * @method static DateTimeInterface withEndOfDay(DateTimeInterface $dateTime) + * @method static DateTimeInterface immutableOrClone(DateTimeInterface $dateTime) */ class Temporal extends Facade { diff --git a/src/Framework/Support/Facades/DateTime/TemporalFacade.php b/src/Framework/Support/Facades/DateTime/TemporalFacade.php index 8a277975cd..e35c9368df 100644 --- a/src/Framework/Support/Facades/DateTime/TemporalFacade.php +++ b/src/Framework/Support/Facades/DateTime/TemporalFacade.php @@ -54,26 +54,51 @@ public function getCurrentFormattedDateForDatabase() /** * Immutably returns a new DateTime instance with the microseconds set to 0. * + * @unreleased Extracted new immutableOrClone method. * @since 2.20.0 */ public function withoutMicroseconds(DateTimeInterface $dateTime) { - if ($dateTime instanceof DateTimeImmutable) { - return $dateTime->setTime( + return $this + ->immutableOrClone($dateTime) + ->setTime( $dateTime->format('H'), $dateTime->format('i'), $dateTime->format('s') ); - } + } - $newDateTime = clone $dateTime; + /** + * Immutably returns a new DateTime instance with the time set to the start of the day. + * + * @unreleased + */ + public function withStartOfDay(DateTimeInterface $dateTime): DateTimeInterface + { + return $this + ->immutableOrClone($dateTime) + ->setTime(0, 0, 0, 0); + } - $newDateTime->setTime( - $newDateTime->format('H'), - $newDateTime->format('i'), - $newDateTime->format('s') - ); + /** + * Immutably returns a new DateTime instance with the time set to the end of the day. + * + * @unreleased + */ + public function withEndOfDay(DateTimeInterface $dateTime): DateTimeInterface + { + return $this + ->immutableOrClone($dateTime) + ->setTime(23, 59, 59, 999999); + } - return $newDateTime; + /** + * @unreleased + */ + public function immutableOrClone(DateTimeInterface $dateTime): DateTimeInterface + { + return $dateTime instanceof DateTimeImmutable + ? $dateTime + : clone $dateTime; } } diff --git a/tests/Unit/Campaigns/Routes/CampaignOverviewStatisticsTest.php b/tests/Unit/Campaigns/Routes/CampaignOverviewStatisticsTest.php new file mode 100644 index 0000000000..271d832ee4 --- /dev/null +++ b/tests/Unit/Campaigns/Routes/CampaignOverviewStatisticsTest.php @@ -0,0 +1,101 @@ +create(); + $form = DonationForm::factory()->create(); + + $db = DB::table('give_campaign_forms'); + $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]); + + Donation::factory()->create([ + 'formId' => $form->id, + 'status' => DonationStatus::COMPLETE(), + 'amount' => new Money(1000, 'USD'), + 'createdAt' => new DateTime('-35 days'), + ]); + Donation::factory()->create([ + 'formId' => $form->id, + 'status' => DonationStatus::COMPLETE(), + 'amount' => new Money(1000, 'USD'), + 'createdAt' => new DateTime('-5 days'), + ]); + Donation::factory()->create([ + 'formId' => $form->id, + 'status' => DonationStatus::COMPLETE(), + 'amount' => new Money(1000, 'USD'), + 'createdAt' => new DateTime('now'), + ]); + + $request = new WP_REST_Request('GET', '/give-api/v2/campaign-overview-statistics'); + $request->set_param('campaignId', $campaign->id); + + $route = new CampaignOverviewStatistics; + $response = $route->handleRequest($request); + + $this->assertEquals(3, $response[0]['donorCount']); + $this->assertEquals(3, $response[0]['donationCount']); + $this->assertEquals(30, $response[0]['amountRaised']); + } + + public function testReturnsPeriodStatisticsWithPreviousPeriod() + { + $campaign = Campaign::factory()->create(); + $form = DonationForm::factory()->create(); + + $db = DB::table('give_campaign_forms'); + $db->insert(['form_id' => $form->id, 'campaign_id' => $campaign->id]); + + Donation::factory()->create([ + 'formId' => $form->id, + 'status' => DonationStatus::COMPLETE(), + 'amount' => new Money(1000, 'USD'), + 'createdAt' => new DateTime('-35 days'), + ]); + Donation::factory()->create([ + 'formId' => $form->id, + 'status' => DonationStatus::COMPLETE(), + 'amount' => new Money(1000, 'USD'), + 'createdAt' => new DateTime('-5 days'), + ]); + Donation::factory()->create([ + 'formId' => $form->id, + 'status' => DonationStatus::COMPLETE(), + 'amount' => new Money(1000, 'USD'), + 'createdAt' => new DateTime('now'), + ]); + + $request = new WP_REST_Request('GET', '/give-api/v2/campaign-overview-statistics'); + $request->set_param('campaignId', $campaign->id); + $request->set_param('rangeInDays', 30); + + $route = new CampaignOverviewStatistics; + $response = $route->handleRequest($request); + + $this->assertEquals(2, $response[0]['donorCount']); + $this->assertEquals(2, $response[0]['donationCount']); + $this->assertEquals(20, $response[0]['amountRaised']); + + $this->assertEquals(1, $response[1]['donorCount']); + $this->assertEquals(1, $response[1]['donationCount']); + $this->assertEquals(10, $response[1]['amountRaised']); + } +} diff --git a/tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php b/tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php new file mode 100644 index 0000000000..0c3d21d22a --- /dev/null +++ b/tests/Unit/Framework/Support/Facades/DateTime/TemporalFacadeTest.php @@ -0,0 +1,55 @@ +immutableOrClone($dateTime); + + $this->assertNotSame($dateTime, $newDateTime); + $this->assertInstanceOf(DateTime::class, $newDateTime); + } + + public function testImmutableOrCloneReturnsSameImmutableDateTimeObject() + { + $dateTime = new DateTimeImmutable; + $temporal = new TemporalFacade; + + $newDateTime = $temporal->immutableOrClone($dateTime); + + $this->assertSame($dateTime, $newDateTime); + $this->assertInstanceOf(DateTimeImmutable::class, $newDateTime); + } + + public function testImmutableStartOfDay() + { + $dateTime = new DateTime('2020-01-01 12:34:56'); + $temporal = new TemporalFacade; + + $newDateTime = $temporal->withStartOfDay($dateTime); + + $this->assertNotSame($dateTime, $newDateTime); + $this->assertEquals('2020-01-01 00:00:00', $newDateTime->format('Y-m-d H:i:s')); + } + + public function testImmutableEndOfDay() + { + $dateTime = new DateTime('2020-01-01 12:34:56'); + $temporal = new TemporalFacade; + + $newDateTime = $temporal->withEndOfDay($dateTime); + + $this->assertNotSame($dateTime, $newDateTime); + $this->assertEquals('2020-01-01 23:59:59.999999', $newDateTime->format('Y-m-d H:i:s.u')); + } +}