Skip to content

Commit

Permalink
feature: Add endpoint for campaign statistics
Browse files Browse the repository at this point in the history
  • Loading branch information
kjohnson committed Sep 19, 2024
1 parent 4314f8b commit 3e82c77
Show file tree
Hide file tree
Showing 7 changed files with 307 additions and 16 deletions.
31 changes: 25 additions & 6 deletions src/Campaigns/CampaignDonationQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Give\Campaigns;

use DateTimeInterface;
use Give\Campaigns\Models\Campaign;
use Give\Donations\ValueObjects\DonationMetaKeys;
use Give\Framework\QueryBuilder\JoinQueryBuilder;
Expand Down Expand Up @@ -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.
*
Expand All @@ -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.
Expand All @@ -63,16 +80,18 @@ public function sumIntendedAmount()
*/
public function countDonations(): int
{
return $this->count('donation.ID');
$query = clone $this;
return $query->count('donation.ID');
}

/**
* @unreleased
*/
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');
}

/**
Expand Down
87 changes: 87 additions & 0 deletions src/Campaigns/Routes/CampaignOverviewStatistics.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

namespace Give\Campaigns\Routes;

use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use Give\API\RestRoute;
use Give\Campaigns\CampaignDonationQuery;
use Give\Campaigns\Models\Campaign;
use Give\Framework\Support\Facades\DateTime\Temporal;

class CampaignOverviewStatistics implements RestRoute
{
/** @var string */
protected $endpoint = 'campaign-overview-statistics';

/**
* @unreleased
*/
public function registerRoute()
{
register_rest_route(
'give-api/v2',
$this->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) );
}
}
1 change: 1 addition & 0 deletions src/Campaigns/ServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/Framework/Support/Facades/DateTime/Temporal.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
45 changes: 35 additions & 10 deletions src/Framework/Support/Facades/DateTime/TemporalFacade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
101 changes: 101 additions & 0 deletions tests/Unit/Campaigns/Routes/CampaignOverviewStatisticsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

namespace Give\Tests\Unit\Campaigns\Routes;

use DateTime;
use Give\Campaigns\Models\Campaign;
use Give\Campaigns\Routes\CampaignOverviewStatistics;
use Give\DonationForms\Models\DonationForm;
use Give\Donations\Models\Donation;
use Give\Donations\ValueObjects\DonationStatus;
use Give\Framework\Database\DB;
use Give\Framework\Support\ValueObjects\Money;
use Give\Tests\TestCase;
use Give\Tests\TestTraits\RefreshDatabase;
use WP_REST_Request;

final class CampaignOverviewStatisticsTest extends TestCase
{
use RefreshDatabase;

public function testReturnsAllTimeDonationsStatistics()
{
$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);

$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']);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace Give\Tests\Unit\Framework\Support\Facades\DateTime;

use DateTime;
use DateTimeImmutable;
use Give\Framework\Support\Facades\DateTime\TemporalFacade;
use Give\Tests\TestCase;

final class TemporalFacadeTest extends TestCase
{
public function testImmutableOrCloneReturnsCloneOfDateTimeObject()
{
$dateTime = new DateTime;
$temporal = new TemporalFacade;

$newDateTime = $temporal->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'));
}
}

0 comments on commit 3e82c77

Please sign in to comment.