diff --git a/src/Campaigns/Actions/LoadCampaignsListTableAssets.php b/src/Campaigns/Actions/LoadCampaignsListTableAssets.php index 03f3345c2e..08ad26ae47 100644 --- a/src/Campaigns/Actions/LoadCampaignsListTableAssets.php +++ b/src/Campaigns/Actions/LoadCampaignsListTableAssets.php @@ -3,6 +3,7 @@ namespace Give\Campaigns\Actions; use Give\Campaigns\ListTable\CampaignsListTable; +use Give\Campaigns\ValueObjects\CampaignRoute; use Give\Framework\Support\Facades\Scripts\ScriptAsset; /** @@ -28,7 +29,7 @@ public function __invoke() wp_localize_script($handleName, 'GiveCampaignsListTable', [ - 'apiRoot' => esc_url_raw(rest_url('give-api/v2/campaigns/list-table')), + 'apiRoot' => esc_url_raw(rest_url(CampaignRoute::NAMESPACE . '/campaigns/list-table')), 'apiNonce' => wp_create_nonce('wp_rest'), 'table' => give(CampaignsListTable::class)->toArray(), 'adminUrl' => admin_url(), diff --git a/src/Campaigns/Routes/DeleteCampaignListTable.php b/src/Campaigns/Routes/DeleteCampaignListTable.php index 616f095d80..a0b711e8f1 100644 --- a/src/Campaigns/Routes/DeleteCampaignListTable.php +++ b/src/Campaigns/Routes/DeleteCampaignListTable.php @@ -5,6 +5,7 @@ use Give\API\RestRoute; use Give\Campaigns\ListTable\CampaignsListTable; use Give\Campaigns\Repositories\CampaignRepository; +use Give\Campaigns\ValueObjects\CampaignRoute; use Give\Framework\Exceptions\Primitives\Exception; use WP_Error; use WP_REST_Request; @@ -37,7 +38,7 @@ class DeleteCampaignListTable implements RestRoute public function registerRoute() { register_rest_route( - 'give-api/v2', + CampaignRoute::NAMESPACE, $this->endpoint, [ [ diff --git a/src/Campaigns/Routes/GetCampaignsListTable.php b/src/Campaigns/Routes/GetCampaignsListTable.php index e179ab3450..b041460d61 100644 --- a/src/Campaigns/Routes/GetCampaignsListTable.php +++ b/src/Campaigns/Routes/GetCampaignsListTable.php @@ -6,6 +6,7 @@ use Give\Campaigns\ListTable\CampaignsListTable; use Give\Campaigns\Models\Campaign; use Give\Campaigns\Repositories\CampaignRepository; +use Give\Campaigns\ValueObjects\CampaignRoute; use Give\Framework\QueryBuilder\QueryBuilder; use WP_Error; use WP_REST_Request; @@ -38,7 +39,7 @@ class GetCampaignsListTable implements RestRoute public function registerRoute(): void { register_rest_route( - 'give-api/v2', + CampaignRoute::NAMESPACE, $this->endpoint, [ [ diff --git a/src/Campaigns/ValueObjects/CampaignRoute.php b/src/Campaigns/ValueObjects/CampaignRoute.php index c3a430b843..32cf166494 100644 --- a/src/Campaigns/ValueObjects/CampaignRoute.php +++ b/src/Campaigns/ValueObjects/CampaignRoute.php @@ -16,7 +16,7 @@ */ class CampaignRoute extends Enum { - const NAMESPACE = 'give-api/v2'; + const NAMESPACE = 'givewp/v3'; const CAMPAIGN = 'campaigns/(?P[0-9]+)'; const CAMPAIGNS = 'campaigns'; } diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx index 1de9b3d5b8..6c965b7abd 100644 --- a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/CampaignStats/index.tsx @@ -1,16 +1,16 @@ import {__} from '@wordpress/i18n'; -import {useEffect, useState} from "react"; -import RevenueChart from "../RevenueChart"; -import GoalProgressChart from "../GoalProgressChart"; +import {useEffect, useState} from 'react'; +import RevenueChart from '../RevenueChart'; +import GoalProgressChart from '../GoalProgressChart'; import apiFetch from '@wordpress/api-fetch'; -import { addQueryArgs } from '@wordpress/url'; +import {addQueryArgs} from '@wordpress/url'; import HeaderText from '../HeaderText'; import HeaderSubText from '../HeaderSubText'; -import DefaultFormWidget from "../DefaultForm"; -import {GiveCampaignDetails} from "@givewp/campaigns/admin/components/CampaignDetailsPage/types"; +import DefaultFormWidget from '../DefaultForm'; +import {GiveCampaignDetails} from '@givewp/campaigns/admin/components/CampaignDetailsPage/types'; import {useCampaignEntityRecord} from '@givewp/campaigns/utils'; -import styles from "./styles.module.scss" +import styles from './styles.module.scss'; const campaignId = new URLSearchParams(window.location.search).get('id'); @@ -18,72 +18,77 @@ declare const window: { GiveCampaignDetails: GiveCampaignDetails; } & Window; -const pluck = (array: any[], property: string) => array.map(element => element[property]) +const pluck = (array: any[], property: string) => array.map((element) => element[property]); const filterOptions = [ - { label: __('Today', 'give'), value: 1, description: __('from today', 'give') }, - { label: __('Last 7 days', 'give'), value: 7, description: __('from the last 7 days', 'give') }, - { label: __('Last 30 days', 'give'), value: 30, description: __('from the last 30 days', 'give') }, - { label: __('Last 90 days', 'give'), value: 90, description: __('from the last 90 days', 'give') }, - { label: __('All-time', 'give'), value: 0, description: __('total for all-time', 'give') }, -] + {label: __('Today', 'give'), value: 1, description: __('from today', 'give')}, + {label: __('Last 7 days', 'give'), value: 7, description: __('from the last 7 days', 'give')}, + {label: __('Last 30 days', 'give'), value: 30, description: __('from the last 30 days', 'give')}, + {label: __('Last 90 days', 'give'), value: 90, description: __('from the last 90 days', 'give')}, + {label: __('All-time', 'give'), value: 0, description: __('total for all-time', 'give')}, +]; const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', -}) +}); const CampaignStats = () => { - const [dayRange, setDayRange] = useState(null); const [stats, setStats] = useState([]); const {campaign} = useCampaignEntityRecord(); useEffect(() => { - onDayRangeChange(0) - }, []) + onDayRangeChange(0); + }, []); const onDayRangeChange = async (days: number) => { - setDayRange(days) + setDayRange(days); - apiFetch({path: addQueryArgs( '/give-api/v2/campaigns/' + campaignId +'/statistics', {rangeInDays: days} ) } ) - .then(setStats); - } + apiFetch({path: addQueryArgs('/givewp/v3/campaigns/' + campaignId + '/statistics', {rangeInDays: days})}).then( + setStats + ); + }; - const widgetDescription = filterOptions.find(option => option.value === dayRange)?.description + const widgetDescription = filterOptions.find((option) => option.value === dayRange)?.description; return ( <>
- - - - + + + +
- ) -} + ); +}; const FooterText = ({children}) => { - return ( -
- {children} -
- ) -} + return
{children}
; +}; const DisplayText = ({children}) => { - return ( -
- {children} -
- ) -} + return
{children}
; +}; const StatWidget = ({label, values, description, formatter = null}) => { return ( @@ -92,41 +97,32 @@ const StatWidget = ({label, values, description, formatter = null}) => { {label}
- - {formatter?.format(values[0]) ?? values[0]} - - {!! values[1] && ( - - )} + {formatter?.format(values[0]) ?? values[0]} + {!!values[1] && }
{description}
- ) -} + ); +}; const PercentChangePill = ({value, comparison}) => { + const change = Math.round(100 * ((value - comparison) / comparison)) ?? 0; - const change = Math.round(100 * ((value - comparison) / comparison)) ?? 0 - - const [color, backgroundColor, symbol] = change == 0 - ? ['#060c1a', '#f2f2f2', '⯈'] - : change > 0 + const [color, backgroundColor, symbol] = + change == 0 + ? ['#060c1a', '#f2f2f2', '⯈'] + : change > 0 ? ['#2d802f', '#f2fff3', '⯅'] - : ['#e35f45', '#fff4f2', '⯆'] + : ['#e35f45', '#fff4f2', '⯆']; return ( - + {symbol} {Math.abs(change)}% - ) - -} - + ); +}; const RevenueWidget = () => { return ( @@ -138,10 +134,9 @@ const RevenueWidget = () => { ); -} +}; const GoalProgressWidget = () => { - const {campaign} = useCampaignEntityRecord(); return ( @@ -152,8 +147,8 @@ const GoalProgressWidget = () => { - ) -} + ); +}; const DateRangeFilters = ({options, onSelect, selected}) => { return ( @@ -168,8 +163,7 @@ const DateRangeFilters = ({options, onSelect, selected}) => { ))} - ) -} - + ); +}; export default CampaignStats; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx index 5beed0ab01..385c0e1f8d 100644 --- a/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/Components/RevenueChart.tsx @@ -1,41 +1,42 @@ -import React, {useEffect, useState} from "react"; -import Chart from "react-apexcharts"; -import apiFetch from "@wordpress/api-fetch"; -import {addQueryArgs} from "@wordpress/url"; +import React, {useEffect, useState} from 'react'; +import Chart from 'react-apexcharts'; +import apiFetch from '@wordpress/api-fetch'; +import {addQueryArgs} from '@wordpress/url'; const campaignId = new URLSearchParams(window.location.search).get('id'); const RevenueChart = () => { - const [max, setMax] = useState(0); const [categories, setCategories] = useState([]); - const [series, setSeries] = useState([{name: "Revenue", data: []}]); + const [series, setSeries] = useState([{name: 'Revenue', data: []}]); useEffect(() => { - apiFetch({path: addQueryArgs( '/give-api/v2/campaigns/' + campaignId +'/revenue' ) } ) - .then((data: {date: string, amount: number}[]) => { - - setMax(Math.max(...data.map(item => item.amount)) * 1.1) + apiFetch({path: addQueryArgs('/givewp/v3/campaigns/' + campaignId + '/revenue')}).then( + (data: {date: string; amount: number}[]) => { + setMax(Math.max(...data.map((item) => item.amount)) * 1.1); - setCategories(data.map(item => item.date)) + setCategories(data.map((item) => item.date)); - setSeries([{ - name: "Revenue", - data: data.map(item => item.amount) - }]) - }); - }, []) + setSeries([ + { + name: 'Revenue', + data: data.map((item) => item.amount), + }, + ]); + } + ); + }, []); const options = { chart: { - id: "campaign-revenue", + id: 'campaign-revenue', zoom: { - enabled: false + enabled: false, }, }, xaxis: { categories, - type: 'datetime' as "datetime" | "category" | "numeric", + type: 'datetime' as 'datetime' | 'category' | 'numeric', }, yaxis: { max, @@ -43,8 +44,14 @@ const RevenueChart = () => { stroke: { color: ['#60a1e2'], width: 1.5, - curve: 'smooth' as "straight" | "smooth" | "monotoneCubic" | "stepline" | "linestep" | ("straight" | "smooth" | "monotoneCubic" | "stepline" | "linestep")[], - lineCap: 'butt' as "butt" | "square" | "round", + curve: 'smooth' as + | 'straight' + | 'smooth' + | 'monotoneCubic' + | 'stepline' + | 'linestep' + | ('straight' | 'smooth' | 'monotoneCubic' | 'stepline' | 'linestep')[], + lineCap: 'butt' as 'butt' | 'square' | 'round', }, dataLabels: { enabled: false, @@ -57,35 +64,29 @@ const RevenueChart = () => { { offset: 0, color: '#eee', - opacity: 1 + opacity: 1, }, { offset: 0.6, color: '#b7d4f2', - opacity: 50 + opacity: 50, }, { offset: 100, color: '#f0f7ff', - opacity: 1 - } + opacity: 1, + }, ], ], - } - } + }, + }, }; return ( <> - + - ) -} + ); +}; -export default RevenueChart +export default RevenueChart; diff --git a/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx b/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx index 123b6b673e..e1e86f4abf 100644 --- a/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx +++ b/src/Campaigns/resources/admin/components/CampaignDetailsPage/index.tsx @@ -65,7 +65,7 @@ export default function CampaignsDetailsPage({campaignId}) { useEffect(() => { apiFetch({ - path: `/give-api/v2/campaigns/${campaignId}`, + path: `/givewp/v3/campaigns/${campaignId}`, method: 'OPTIONS', }).then(({schema}: {schema: JSONSchemaType}) => { setResolver({ @@ -74,12 +74,7 @@ export default function CampaignsDetailsPage({campaignId}) { }); }, []); - const { - campaign, - hasResolved, - save, - edit, - } = useCampaignEntityRecord(campaignId); + const {campaign, hasResolved, save, edit} = useCampaignEntityRecord(campaignId); const methods = useForm({ mode: 'onBlur', @@ -107,7 +102,7 @@ export default function CampaignsDetailsPage({campaignId}) { id: 'update-archive-notice', type: 'warning', onDismiss: () => updateStatus('draft'), - content: (onDismiss: Function) => + content: (onDismiss: Function) => , }); }, [campaign?.status]); diff --git a/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx b/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx index bc99544593..c6a9cb35d9 100644 --- a/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx +++ b/src/Campaigns/resources/admin/components/MergeCampaign/Form/index.tsx @@ -48,7 +48,7 @@ export default function MergeCampaignsForm({isOpen, handleClose, title, campaign try { const response = await apiFetch({ - path: addQueryArgs('/give-api/v2/campaigns/' + destinationCampaignId + '/merge', { + path: addQueryArgs('/givewp/v3/campaigns/' + destinationCampaignId + '/merge', { campaignsToMergeIds: campaignsToMergeIds, }), method: 'PATCH', diff --git a/src/Campaigns/resources/entity.ts b/src/Campaigns/resources/entity.ts index cb4409ddcb..67c518e735 100644 --- a/src/Campaigns/resources/entity.ts +++ b/src/Campaigns/resources/entity.ts @@ -8,12 +8,10 @@ dispatch(coreStore).addEntities([ { name: 'campaign', kind: 'givewp', - baseURL: '/give-api/v2/campaigns', + baseURL: '/givewp/v3/campaigns', baseURLParams: {}, plural: 'campaigns', label: __('Campaign', 'give'), - supportsPagination: true - } + supportsPagination: true, + }, ]); - - diff --git a/src/Donations/Controllers/DonationRequestController.php b/src/Donations/Controllers/DonationRequestController.php new file mode 100644 index 0000000000..233cfed589 --- /dev/null +++ b/src/Donations/Controllers/DonationRequestController.php @@ -0,0 +1,161 @@ +get_param('id')); + + if ( ! $donation) { + return new WP_Error('donation_not_found', __('Donation not found', 'give'), ['status' => 404]); + } + + return new WP_REST_Response($this->escDonation($donation)); + } + + /** + * @unreleased + */ + public function getDonations(WP_REST_Request $request): WP_REST_Response + { + $page = $request->get_param('page'); + $perPage = $request->get_param('per_page'); + $sortColumn = $this->getSortColumn($request->get_param('sort')); + $sortDirection = $request->get_param('direction'); + $mode = $request->get_param('mode'); + + $query = Donation::query(); + + if ($campaignId = $request->get_param('campaignId')) { + // Filter by CampaignId + $query->where('give_donationmeta_attach_meta_campaignId.meta_value', $campaignId); + } + + if ( ! $request->get_param('includeAnonymousDonations')) { + // Exclude anonymous donations from results + $query->where('give_donationmeta_attach_meta_anonymous.meta_value', 0); + } + + // Include only current payment "mode" + $query->where('give_donationmeta_attach_meta_mode.meta_value', $mode); + + // Include only valid statuses + $query->whereIn('post_status', ['publish', 'give_subscription']); + + $query + ->limit($perPage) + ->offset(($page - 1) * $perPage) + ->orderBy($sortColumn, $sortDirection); + + $donations = $query->getAll() ?? []; + $donations = array_map([$this, 'escDonation'], $donations); + $totalDonations = empty($donations) ? 0 : Donation::query()->count(); + $totalPages = (int)ceil($totalDonations / $perPage); + + $response = rest_ensure_response($donations); + $response->header('X-WP-Total', $totalDonations); + $response->header('X-WP-TotalPages', $totalPages); + + $base = add_query_arg( + map_deep($request->get_query_params(), function ($value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + return urlencode($value); + }), + rest_url(DonationRoute::DONATIONS) + ); + + if ($page > 1) { + $prevPage = $page - 1; + + if ($prevPage > $totalPages) { + $prevPage = $totalPages; + } + + $response->link_header('prev', add_query_arg('page', $prevPage, $base)); + } + + if ($totalPages > $page) { + $nextPage = $page + 1; + $response->link_header('next', add_query_arg('page', $nextPage, $base)); + } + + return $response; + } + + /** + * @unreleased + */ + public function escDonation(Donation $donation): array + { + if (current_user_can('manage_options')) { + return $donation->toArray(); + } + + $sensitiveData = [ + 'donorIp', + 'email', + 'phone', + 'billingAddress', + ]; + + if ($donation->anonymous) { + $anonymousData = [ + 'donorId', + 'honorific', + 'firstName', + 'lastName', + 'company', + ]; + + $sensitiveData = array_merge($sensitiveData, $anonymousData); + } + + $donation = $donation->toArray(); + foreach ($sensitiveData as $property) { + if (array_key_exists($property, $donation)) { + unset($donation[$property]); + } + } + + return $donation; + } + + /** + * @unreleased + */ + public function getSortColumn(string $sortColumn): string + { + $sortColumnsMap = [ + 'id' => 'ID', + 'createdAt' => 'post_date', + 'updatedAt' => 'post_modified', + 'status' => 'post_status', + 'amount' => 'give_donationmeta_attach_meta_amount.meta_value', + 'feeAmountRecovered' => 'give_donationmeta_attach_meta_feeAmountRecovered.meta_value', + 'donorId' => 'give_donationmeta_attach_meta_donorId.meta_value', + 'firstName' => 'give_donationmeta_attach_meta_firstName.meta_value', + 'lastName' => 'give_donationmeta_attach_meta_lastName.meta_value', + ]; + + return $sortColumnsMap[$sortColumn]; + } +} diff --git a/src/Donations/Routes/RegisterDonationRoutes.php b/src/Donations/Routes/RegisterDonationRoutes.php new file mode 100644 index 0000000000..bbc466896b --- /dev/null +++ b/src/Donations/Routes/RegisterDonationRoutes.php @@ -0,0 +1,132 @@ +donationRequestController = $donationRequestController; + } + + /** + * @unreleased + */ + public function __invoke() + { + $this->registerGetDonation(); + $this->registerGetDonations(); + } + + /** + * Get Donation route + * + * @unreleased + */ + public function registerGetDonation() + { + register_rest_route( + DonationRoute::NAMESPACE, + DonationRoute::DONATION, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->donationRequestController->getDonation($request); + }, + 'permission_callback' => '__return_true', + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + ], + ], + ] + ); + } + + /** + * Get Donations route + * + * @unreleased + */ + public function registerGetDonations() + { + register_rest_route( + DonationRoute::NAMESPACE, + DonationRoute::DONATIONS, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->donationRequestController->getDonations($request); + }, + 'permission_callback' => '__return_true', + ], + 'args' => [ + 'page' => [ + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ], + 'per_page' => [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 1, + 'maximum' => 100, + ], + 'sort' => [ + 'type' => 'string', + 'default' => 'id', + 'enum' => [ + 'id', + 'createdAt', + 'updatedAt', + 'status', + 'amount', + 'feeAmountRecovered', + 'donorId', + 'firstName', + 'lastName', + ], + ], + 'direction' => [ + 'type' => 'string', + 'default' => 'DESC', + 'enum' => ['ASC', 'DESC'], + ], + 'mode' => [ + 'type' => 'string', + 'default' => 'live', + 'enum' => ['live', 'test'], + ], + 'campaignId' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'includeAnonymousDonations' => [ + 'type' => 'boolean', + 'default' => false, + ], + ], + ] + ); + } +} diff --git a/src/Donations/ServiceProvider.php b/src/Donations/ServiceProvider.php index c7b826baff..c9930ee2e9 100644 --- a/src/Donations/ServiceProvider.php +++ b/src/Donations/ServiceProvider.php @@ -56,6 +56,8 @@ public function boot() MoveDonationCommentToDonationMetaTable::class, UnserializeTitlePrefix::class, ]); + + $this->registerRoutes(); } /** @@ -129,4 +131,12 @@ private function addCustomFieldsToDonationDetails() echo (new DonationDetailsController())->show($donationId); }); } + + /** + * @unreleased + */ + private function registerRoutes() + { + Hooks::addAction('rest_api_init', Routes\RegisterDonationRoutes::class); + } } diff --git a/src/Donations/ValueObjects/DonationRoute.php b/src/Donations/ValueObjects/DonationRoute.php new file mode 100644 index 0000000000..756a37f520 --- /dev/null +++ b/src/Donations/ValueObjects/DonationRoute.php @@ -0,0 +1,22 @@ +[0-9]+)'; + const DONATIONS = 'donations'; +} diff --git a/src/Donors/Controllers/DonorRequestController.php b/src/Donors/Controllers/DonorRequestController.php new file mode 100644 index 0000000000..e4647486da --- /dev/null +++ b/src/Donors/Controllers/DonorRequestController.php @@ -0,0 +1,173 @@ +get_param('id')); + + if ( ! $donor) { + return new WP_Error('donor_not_found', __('Donor not found', 'give'), ['status' => 404]); + } + + return new WP_REST_Response($this->escDonor($donor)); + } + + /** + * @unreleased + */ + public function getDonors(WP_REST_Request $request): WP_REST_Response + { + $page = $request->get_param('page'); + $perPage = $request->get_param('per_page'); + $sortColumn = $this->getSortColumn($request->get_param('sort')); + $sortDirection = $request->get_param('direction'); + $mode = $request->get_param('mode'); + + $query = Donor::query(); + + // Donors only can be donors if they have donations associated with them + if ($request->get_param('onlyWithDonations')) { + $query->join(function (JoinQueryBuilder $builder) use ($mode) { + // The donationmeta1.donation_id should be used in other "donationmeta" joins to make sure we are retrieving data from the proper donation + $builder->innerJoin('give_donationmeta', 'donationmeta1') + ->joinRaw("ON donationmeta1.meta_key = '" . DonationMetaKeys::DONOR_ID . "' AND donationmeta1.meta_value = ID"); + + // Include only current payment "mode" + $builder->innerJoin('give_donationmeta', 'donationmeta2') + ->joinRaw("ON donationmeta2.meta_key = '" . DonationMetaKeys::MODE . "' AND donationmeta2.meta_value = '{$mode}' AND donationmeta2.donation_id = donationmeta1.donation_id"); + }); + + + if ($campaignId = $request->get_param('campaignId')) { + // Filter by CampaignId - Donors only can be filtered by campaignId if they donated to a campaign + $query->join(function (JoinQueryBuilder $builder) use ($campaignId) { + $builder->innerJoin('give_donationmeta', 'donationmeta3') + ->joinRaw("ON donationmeta3.meta_key = '" . DonationMetaKeys::CAMPAIGN_ID . "' AND donationmeta3.meta_value = {$campaignId} AND donationmeta3.donation_id = donationmeta1.donation_id"); + }); + } + + if ( ! $request->get_param('includeAnonymousDonations')) { + // Exclude anonymous donors from results - Donors only can be excluded if they made an anonymous donation + $query->join(function (JoinQueryBuilder $builder) { + $builder->innerJoin('give_donationmeta', 'donationmeta4') + ->joinRaw("ON donationmeta4.meta_key = '" . DonationMetaKeys::ANONYMOUS . "' AND donationmeta4.meta_value = 0 AND donationmeta4.donation_id = donationmeta1.donation_id"); + }); + } + + // Make sure the donation is valid + $query->whereIn('donationmeta1.donation_id', function (QueryBuilder $builder) { + $builder + ->select('ID') + ->from('posts') + ->where('post_type', 'give_payment') + ->whereIn('post_status', ['publish', 'give_subscription']) + ->whereRaw("AND ID = donationmeta1.donation_id"); + }); + } + + $query + ->limit($perPage) + ->offset(($page - 1) * $perPage) + ->orderBy($sortColumn, $sortDirection); + + $donors = $query->getAll() ?? []; + $donors = array_map([$this, 'escDonor'], $donors); + $totalDonors = empty($donors) ? 0 : Donor::query()->count(); + $totalPages = (int)ceil($totalDonors / $perPage); + + $response = rest_ensure_response($donors); + $response->header('X-WP-Total', $totalDonors); + $response->header('X-WP-TotalPages', $totalPages); + + $base = add_query_arg( + map_deep($request->get_query_params(), function ($value) { + if (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + return urlencode($value); + }), + rest_url(DonorRoute::DONORS) + ); + + if ($page > 1) { + $prevPage = $page - 1; + + if ($prevPage > $totalPages) { + $prevPage = $totalPages; + } + + $response->link_header('prev', add_query_arg('page', $prevPage, $base)); + } + + if ($totalPages > $page) { + $nextPage = $page + 1; + $response->link_header('next', add_query_arg('page', $nextPage, $base)); + } + + return $response; + } + + /** + * @unreleased + */ + public function escDonor(Donor $donor): array + { + $donor = $donor->toArray(); + + if ( ! current_user_can('manage_options')) { + $sensitiveProperties = [ + 'userId', + 'email', + 'phone', + 'additionalEmails', + ]; + + foreach ($sensitiveProperties as $property) { + if (array_key_exists($property, $donor)) { + unset($donor[$property]); + } + } + } + + return $donor; + } + + /** + * @unreleased + */ + public function getSortColumn(string $sortColumn): string + { + $sortColumnsMap = [ + 'id' => 'id', + 'createdAt' => 'date_created', + 'name' => 'name', + 'firstName' => 'give_donormeta_attach_meta_firstName.meta_value', + 'lastName' => 'give_donormeta_attach_meta_lastName.meta_value', + 'totalAmountDonated' => 'purchase_value', + 'totalNumberOfDonations' => 'purchase_count', + ]; + + return $sortColumnsMap[$sortColumn]; + } +} diff --git a/src/Donors/Routes/RegisterDonorRoutes.php b/src/Donors/Routes/RegisterDonorRoutes.php new file mode 100644 index 0000000000..eeaecd88e9 --- /dev/null +++ b/src/Donors/Routes/RegisterDonorRoutes.php @@ -0,0 +1,134 @@ +donorRequestController = $donorRequestController; + } + + /** + * @unreleased + */ + public function __invoke() + { + $this->registerGetDonor(); + $this->registerGetDonors(); + } + + /** + * Get Donor route + * + * @unreleased + */ + public function registerGetDonor() + { + register_rest_route( + DonorRoute::NAMESPACE, + DonorRoute::DONOR, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->donorRequestController->getDonor($request); + }, + 'permission_callback' => '__return_true', + ], + 'args' => [ + 'id' => [ + 'type' => 'integer', + 'required' => true, + ], + ], + ] + ); + } + + /** + * Get Donors route + * + * @unreleased + */ + public function registerGetDonors() + { + register_rest_route( + DonorRoute::NAMESPACE, + DonorRoute::DONORS, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => function (WP_REST_Request $request) { + return $this->donorRequestController->getDonors($request); + }, + 'permission_callback' => '__return_true', + ], + 'args' => [ + 'page' => [ + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ], + 'per_page' => [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 1, + 'maximum' => 100, + ], + 'sort' => [ + 'type' => 'string', + 'default' => 'id', + 'enum' => [ + 'id', + 'createdAt', + 'name', + 'firstName', + 'lastName', + 'totalAmountDonated', + 'totalNumberOfDonations', + ], + ], + 'direction' => [ + 'type' => 'string', + 'default' => 'DESC', + 'enum' => ['ASC', 'DESC'], + ], + 'mode' => [ + 'type' => 'string', + 'default' => 'live', + 'enum' => ['live', 'test'], + ], + 'onlyWithDonations' => [ + 'type' => 'boolean', + 'default' => true, + ], + 'campaignId' => [ + 'type' => 'integer', + 'default' => 0, + ], + 'includeAnonymousDonations' => [ + 'type' => 'boolean', + 'default' => false, + ], + ], + ] + ); + } +} diff --git a/src/Donors/ServiceProvider.php b/src/Donors/ServiceProvider.php index 74b6d938e5..552357b14c 100644 --- a/src/Donors/ServiceProvider.php +++ b/src/Donors/ServiceProvider.php @@ -66,6 +66,8 @@ public function boot() ]); Hooks::addAction('give_admin_donor_details_updating', UpdateAdminDonorDetails::class, '__invoke', 10, 2); + + $this->registerRoutes(); } /** @@ -104,4 +106,12 @@ protected function enforceDonorsAsUsers() } }, 10, 2); } + + /** + * @unreleased + */ + private function registerRoutes() + { + Hooks::addAction('rest_api_init', Routes\RegisterDonorRoutes::class); + } } diff --git a/src/Donors/ValueObjects/DonorRoute.php b/src/Donors/ValueObjects/DonorRoute.php new file mode 100644 index 0000000000..552e7de2db --- /dev/null +++ b/src/Donors/ValueObjects/DonorRoute.php @@ -0,0 +1,22 @@ +[0-9]+)'; + const DONORS = 'donors'; +} diff --git a/tests/Unit/Donations/Routes/GetDonationRouteTest.php b/tests/Unit/Donations/Routes/GetDonationRouteTest.php new file mode 100644 index 0000000000..01dcd26e31 --- /dev/null +++ b/tests/Unit/Donations/Routes/GetDonationRouteTest.php @@ -0,0 +1,202 @@ +create(['status' => DonationStatus::COMPLETE()]); + + $route = '/' . DonationRoute::NAMESPACE . '/donations/' . $donation->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals($donation->id, $data['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationShouldNotReturnSensitiveData() + { + /** @var Donation $donation */ + $donation = Donation::factory()->create(['status' => DonationStatus::COMPLETE()]); + + $route = '/' . DonationRoute::NAMESPACE . '/donations/' . $donation->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveData = [ + 'donorIp', + 'email', + 'phone', + 'billingAddress', + ]; + + $this->assertEquals(200, $status); + $this->assertEmpty(array_intersect_key($data, array_flip($sensitiveData))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationShouldReturnSensitiveData() + { + $newAdminUser = $this->factory()->user->create( + [ + 'role' => 'administrator', + 'user_login' => 'admin38974238473824', + 'user_pass' => 'admin38974238473824', + 'user_email' => 'admin38974238473824@test.com', + ] + ); + wp_set_current_user($newAdminUser); + + /** @var Donation $donation */ + $donation = Donation::factory()->create(['status' => DonationStatus::COMPLETE()]); + + $route = '/' . DonationRoute::NAMESPACE . '/donations/' . $donation->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveData = [ + 'donorIp', + 'email', + 'phone', + 'billingAddress', + ]; + + $this->assertEquals(200, $status); + $this->assertNotEmpty(array_intersect_key($data, array_flip($sensitiveData))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationShouldNotReturnSensitiveAndAnonymousData() + { + /** @var Donation $donation */ + $donation = Donation::factory()->create(['status' => DonationStatus::COMPLETE(), 'anonymous' => true]); + + $route = '/' . DonationRoute::NAMESPACE . '/donations/' . $donation->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $request->set_query_params( + [ + 'includeAnonymousDonations' => true, + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveAndAnonymousData = [ + // sensitive data + 'donorIp', + 'email', + 'phone', + 'billingAddress', + // anonymous data + 'donorId', + 'honorific', + 'firstName', + 'lastName', + 'company', + ]; + + $this->assertEquals(200, $status); + $this->assertEmpty(array_intersect_key($data, array_flip($sensitiveAndAnonymousData))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationShouldReturnSensitiveAndAnonymousData() + { + $newAdminUser = $this->factory()->user->create( + [ + 'role' => 'administrator', + 'user_login' => 'admin38974238473824', + 'user_pass' => 'admin38974238473824', + 'user_email' => 'admin38974238473824@test.com', + ] + ); + wp_set_current_user($newAdminUser); + + /** @var Donation $donation */ + $donation = Donation::factory()->create(['status' => DonationStatus::COMPLETE(), 'anonymous' => true]); + + $route = '/' . DonationRoute::NAMESPACE . '/donations/' . $donation->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $request->set_query_params( + [ + 'includeAnonymousDonations' => true, + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveAndAnonymousData = [ + // sensitive data + 'donorIp', + 'email', + 'phone', + 'billingAddress', + // anonymous data + 'donorId', + 'honorific', + 'firstName', + 'lastName', + 'company', + ]; + + $this->assertEquals(200, $status); + $this->assertNotEmpty(array_intersect_key($data, array_flip($sensitiveAndAnonymousData))); + } +} diff --git a/tests/Unit/Donations/Routes/GetDonationsRouteTest.php b/tests/Unit/Donations/Routes/GetDonationsRouteTest.php new file mode 100644 index 0000000000..da7481e5b9 --- /dev/null +++ b/tests/Unit/Donations/Routes/GetDonationsRouteTest.php @@ -0,0 +1,536 @@ +createDonation1(); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveProperties = [ + 'donorIp', + 'email', + 'phone', + 'billingAddress', + ]; + + $this->assertEquals(200, $status); + $this->assertEmpty(array_intersect_key($data[0], array_flip($sensitiveProperties))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsShouldReturnSensitiveData() + { + $newAdminUser = $this->factory()->user->create( + [ + 'role' => 'administrator', + 'user_login' => 'admin38974238473824', + 'user_pass' => 'admin38974238473824', + 'user_email' => 'admin38974238473824@test.com', + ] + ); + wp_set_current_user($newAdminUser); + + $this->createDonation1(); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveProperties = [ + 'donorIp', + 'email', + 'phone', + 'billingAddress', + ]; + + $this->assertEquals(200, $status); + $this->assertNotEmpty(array_intersect_key($data[0], array_flip($sensitiveProperties))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsShouldNotReturnSensitiveAndAnonymousData() + { + $this->createDonation1(0, true); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $request->set_query_params( + [ + 'includeAnonymousDonations' => true, + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveAndAnonymousData = [ + // sensitive data + 'donorIp', + 'email', + 'phone', + 'billingAddress', + // anonymous data + 'donorId', + 'honorific', + 'firstName', + 'lastName', + 'company', + ]; + + $this->assertEquals(200, $status); + $this->assertEmpty(array_intersect_key($data[0], array_flip($sensitiveAndAnonymousData))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsShouldReturnSensitiveAndAnonymousData() + { + $newAdminUser = $this->factory()->user->create( + [ + 'role' => 'administrator', + 'user_login' => 'admin38974238473824', + 'user_pass' => 'admin38974238473824', + 'user_email' => 'admin38974238473824@test.com', + ] + ); + wp_set_current_user($newAdminUser); + + $this->createDonation1(0, true); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $request->set_query_params( + [ + 'includeAnonymousDonations' => true, + ] + ); + + $response = $this->dispatchRequest($request); + + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveAndAnonymousData = [ + // sensitive data + 'donorIp', + 'email', + 'phone', + 'billingAddress', + // anonymous data + 'donorId', + 'honorific', + 'firstName', + 'lastName', + 'company', + ]; + + $this->assertEquals(200, $status); + $this->assertNotEmpty(array_intersect_key($data[0], array_flip($sensitiveAndAnonymousData))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsWithPagination() + { + Donation::query()->delete(); + + $donation1 = $this->createDonation1(); + $donation2 = $this->createDonation2(); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 1, + 'direction' => 'ASC', + ] + ); + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + $headers = $response->get_headers(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donation1->id, $data[0]['id']); + $this->assertEquals(2, $headers['X-WP-Total']); + $this->assertEquals(2, $headers['X-WP-TotalPages']); + + $request->set_query_params( + [ + 'page' => 2, + 'per_page' => 1, + 'direction' => 'ASC', + ] + ); + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + $headers = $response->get_headers(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donation2->id, $data[0]['id']); + $this->assertEquals(2, $headers['X-WP-Total']); + $this->assertEquals(2, $headers['X-WP-TotalPages']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsByCampaignId() + { + /** @var Campaign $campaign */ + $campaign = Campaign::factory()->create(); + + $donation1 = $this->createDonation1($campaign->id); + $donation2 = $this->createDonation2($campaign->id); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'campaignId' => $campaign->id, + 'direction' => 'ASC', + ] + ); + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donation1->id, $data[0]['id']); + $this->assertEquals($donation2->id, $data[1]['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsShouldNotReturnAnonymousDonations() + { + Donation::query()->delete(); + + $donation1 = $this->createDonation1(); + + // This anonymous donation should NOT be returned to the data array. + $donation2 = $this->createDonation2(0, true); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donation1->id, $data[0]['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonationsShouldReturnAnonymousDonations() + { + Donation::query()->delete(); + + $donation1 = $this->createDonation1(); + + // This anonymous donation should be returned to the data array. + $donation2 = $this->createDonation2(0, true); + + $route = '/' . DonationRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'includeAnonymousDonations' => true, + 'direction' => 'ASC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donation1->id, $data[0]['id']); + $this->assertEquals($donation2->id, $data[1]['id']); + } + + /** + * @unreleased + * + * @dataProvider sortableColumnsDataProvider + * + * @throws Exception + */ + public function testGetDonationsSortedByColumns($sortableColumn) + { + Donation::query()->delete(); + + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + + + $donation1 = $this->createDonation1($campaign1->id); + $donation2 = $this->createDonation2($campaign1->id); + $donation3 = $this->createDonation3($campaign2->id); + + $route = '/' . DonorRoute::NAMESPACE . '/donations'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + /** + * Ascendant Direction + */ + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 30, + 'sort' => $sortableColumn, + 'direction' => 'ASC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(3, count($data)); + $this->assertEquals($donation1->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donation2->{$sortableColumn}, $data[1][$sortableColumn]); + $this->assertEquals($donation3->{$sortableColumn}, $data[2][$sortableColumn]); + + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 30, + 'sort' => $sortableColumn, + 'direction' => 'ASC', + 'campaignId' => $campaign1->id, // Filtering by campaignId + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donation1->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donation2->{$sortableColumn}, $data[1][$sortableColumn]); + + /** + * Descendant Direction + */ + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 3, + 'sort' => $sortableColumn, + 'direction' => 'DESC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(3, count($data)); + $this->assertEquals($donation3->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donation2->{$sortableColumn}, $data[1][$sortableColumn]); + $this->assertEquals($donation1->{$sortableColumn}, $data[2][$sortableColumn]); + + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 30, + 'sort' => $sortableColumn, + 'direction' => 'DESC', + 'campaignId' => $campaign1->id, // Filtering by campaignId + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donation2->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donation1->{$sortableColumn}, $data[1][$sortableColumn]); + } + + /** + * @unreleased + */ + public function sortableColumnsDataProvider(): array + { + return [ + ['id'], + ['createdAt'], + ['updatedAt'], + ['status'], + ['amount'], + ['feeAmountRecovered'], + ['donorId'], + ['firstName'], + ['lastName'], + ]; + } + + /** + * @unreleased + * + * @throws Exception + */ + private function createDonation1(int $campaignId = 0, bool $anonymous = false): Donation + { + /** @var Donation $donation1 */ + $donation1 = Donation::factory()->create([ + 'status' => DonationStatus::COMPLETE(), + 'anonymous' => $anonymous, + 'amount' => new Money(100, 'USD'), + 'feeAmountRecovered' => new Money(10, 'USD'), + 'firstName' => 'A', + 'lastName' => 'A', + 'mode' => DonationMode::LIVE(), + ]); + + if ($campaignId) { + give()->payment_meta->update_meta($donation1->id, DonationMetaKeys::CAMPAIGN_ID, $campaignId); + } + + return $donation1; + } + + /** + * @unreleased + * + * @throws Exception + */ + private function createDonation2(int $campaignId = 0, bool $anonymous = false): Donation + { + /** @var Donation $donation2 */ + $donation2 = Donation::factory()->create([ + 'status' => DonationStatus::COMPLETE(), + 'anonymous' => $anonymous, + 'amount' => new Money(200, 'USD'), + 'feeAmountRecovered' => new Money(20, 'USD'), + 'firstName' => 'B', + 'lastName' => 'B', + 'mode' => DonationMode::LIVE(), + ]); + + if ($campaignId) { + give()->payment_meta->update_meta($donation2->id, DonationMetaKeys::CAMPAIGN_ID, $campaignId); + } + + return $donation2; + } + + /** + * @unreleased + * + * @throws Exception + */ + private function createDonation3(int $campaignId = 0, bool $anonymous = false): Donation + { + /** @var Donation $donation3 */ + $donation3 = Donation::factory()->create([ + 'status' => DonationStatus::COMPLETE(), + 'anonymous' => $anonymous, + 'amount' => new Money(300, 'USD'), + 'feeAmountRecovered' => new Money(30, 'USD'), + 'firstName' => 'C', + 'lastName' => 'C', + 'mode' => DonationMode::LIVE(), + ]); + + if ($campaignId) { + give()->payment_meta->update_meta($donation3->id, DonationMetaKeys::CAMPAIGN_ID, $campaignId); + } + + return $donation3; + } +} diff --git a/tests/Unit/Donors/Routes/GetDonorRouteTest.php b/tests/Unit/Donors/Routes/GetDonorRouteTest.php new file mode 100644 index 0000000000..260df55a08 --- /dev/null +++ b/tests/Unit/Donors/Routes/GetDonorRouteTest.php @@ -0,0 +1,65 @@ +create(); + + $route = '/' . DonationRoute::NAMESPACE . '/donors/' . $donor->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals($donor->id, $data['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorShouldNotReturnSensitiveData() + { + /** @var Donor $donor */ + $donor = Donor::factory()->create(); + + $route = '/' . DonationRoute::NAMESPACE . '/donors/' . $donor->id; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveProperties = [ + 'userId', + 'email', + 'phone', + 'additionalEmails', + ]; + + $this->assertEquals(200, $status); + $this->assertEmpty(array_intersect_key($data, $sensitiveProperties)); + } +} diff --git a/tests/Unit/Donors/Routes/GetDonorsRouteTest.php b/tests/Unit/Donors/Routes/GetDonorsRouteTest.php new file mode 100644 index 0000000000..e9bd596bbc --- /dev/null +++ b/tests/Unit/Donors/Routes/GetDonorsRouteTest.php @@ -0,0 +1,545 @@ +create(); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'onlyWithDonations' => false, + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveProperties = [ + 'userId', + 'email', + 'phone', + 'additionalEmails', + ]; + + $this->assertEquals(200, $status); + $this->assertEmpty(array_intersect_key($data[0], array_flip($sensitiveProperties))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsShouldReturnSensitiveData() + { + $newAdminUser = $this->factory()->user->create( + [ + 'role' => 'administrator', + 'user_login' => 'admin38974238473824', + 'user_pass' => 'admin38974238473824', + 'user_email' => 'admin38974238473824@test.com', + ] + ); + wp_set_current_user($newAdminUser); + + Donor::factory()->create(); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'onlyWithDonations' => false, + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $sensitiveProperties = [ + 'userId', + 'email', + 'phone', + 'additionalEmails', + ]; + + $this->assertEquals(200, $status); + $this->assertNotEmpty(array_intersect_key($data[0], array_flip($sensitiveProperties))); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsShouldReturnOnlyDonorsWithDonations() + { + DB::query("DELETE FROM " . DB::prefix('give_donors')); + + /** @var Campaign $campaign */ + $campaign = Campaign::factory()->create(); + + $donor1 = $this->createDonor1WithDonation($campaign->id); + $donor2 = Donor::factory()->create(); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'onlyWithDonations' => true, + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donor1->id, $data[0]['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsShouldReturnDonorsWithOrWithoutDonations() + { + DB::query("DELETE FROM " . DB::prefix('give_donors')); + + /** @var Campaign $campaign */ + $campaign = Campaign::factory()->create(); + + $donor1 = $this->createDonor1WithDonation($campaign->id); + $donor2 = Donor::factory()->create(); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'onlyWithDonations' => false, + 'direction' => 'ASC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donor1->id, $data[0]['id']); + $this->assertEquals($donor2->id, $data[1]['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsWithPagination() + { + DB::query("DELETE FROM " . DB::prefix('give_donors')); + + /** @var Donor $donor1 */ + $donor1 = Donor::factory()->create(); + + /** @var Donor $donor2 */ + $donor2 = Donor::factory()->create(); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + $request->set_query_params( + [ + 'onlyWithDonations' => false, + 'page' => 1, + 'per_page' => 1, + 'direction' => 'ASC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + $headers = $response->get_headers(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donor1->id, $data[0]['id']); + $this->assertEquals(2, $headers['X-WP-Total']); + $this->assertEquals(2, $headers['X-WP-TotalPages']); + $request->set_query_params( + [ + 'onlyWithDonations' => false, + 'page' => 2, + 'per_page' => 1, + 'direction' => 'ASC', + ] + ); + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + $headers = $response->get_headers(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donor2->id, $data[0]['id']); + $this->assertEquals(2, $headers['X-WP-Total']); + $this->assertEquals(2, $headers['X-WP-TotalPages']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsByCampaignId() + { + Donation::query()->delete(); + + /** @var Campaign $campaign */ + $campaign = Campaign::factory()->create(); + + $donor1 = $this->createDonor1WithDonation($campaign->id); + $donor2 = $this->createDonor2WithDonation($campaign->id); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'campaignId' => $campaign->id, + 'direction' => 'ASC', + ] + ); + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donor1->id, $data[0]['id']); + $this->assertEquals($donor2->id, $data[1]['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsShouldNotReturnAnonymousDonors() + { + Donation::query()->delete(); + + /** @var Campaign $campaign */ + $campaign = Campaign::factory()->create(); + + $donor1 = $this->createDonor1WithDonation($campaign->id); + $donor2 = $this->createDonor2WithDonation($campaign->id, true); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(1, count($data)); + $this->assertEquals($donor1->id, $data[0]['id']); + } + + /** + * @unreleased + * + * @throws Exception + */ + public function testGetDonorsShouldReturnAnonymousDonors() + { + Donation::query()->delete(); + + /** @var Campaign $campaign */ + $campaign = Campaign::factory()->create(); + + $donor1 = $this->createDonor1WithDonation($campaign->id); + $donor2 = $this->createDonor2WithDonation($campaign->id, true); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + $request->set_query_params( + [ + 'includeAnonymousDonations' => true, + 'direction' => 'ASC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donor1->id, $data[0]['id']); + $this->assertEquals($donor2->id, $data[1]['id']); + } + + /** + * @unreleased + * + * @dataProvider sortableColumnsDataProvider + * + * @throws Exception + */ + public function testGetDonorsSortedByColumns($sortableColumn) + { + DB::query("DELETE FROM " . DB::prefix('give_donors')); + + /** @var Campaign $campaign1 */ + $campaign1 = Campaign::factory()->create(); + + /** @var Campaign $campaign2 */ + $campaign2 = Campaign::factory()->create(); + + $donor1 = $this->createDonor1WithDonation($campaign1->id); + $donor2 = $this->createDonor2WithDonation($campaign1->id); + $donor3 = $this->createDonor3WithDonation($campaign2->id); + + $route = '/' . DonorRoute::NAMESPACE . '/donors'; + $request = new WP_REST_Request(WP_REST_Server::READABLE, $route); + + /** + * Ascendant Direction + */ + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 30, + 'sort' => $sortableColumn, + 'direction' => 'ASC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(3, count($data)); + $this->assertEquals($donor1->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donor2->{$sortableColumn}, $data[1][$sortableColumn]); + $this->assertEquals($donor3->{$sortableColumn}, $data[2][$sortableColumn]); + + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 30, + 'sort' => $sortableColumn, + 'direction' => 'ASC', + 'campaignId' => $campaign1->id, // Filtering by campaignId + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donor1->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donor2->{$sortableColumn}, $data[1][$sortableColumn]); + + /** + * Descendant Direction + */ + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 3, + 'sort' => $sortableColumn, + 'direction' => 'DESC', + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(3, count($data)); + $this->assertEquals($donor3->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donor2->{$sortableColumn}, $data[1][$sortableColumn]); + $this->assertEquals($donor1->{$sortableColumn}, $data[2][$sortableColumn]); + + $request->set_query_params( + [ + 'page' => 1, + 'per_page' => 30, + 'sort' => $sortableColumn, + 'direction' => 'DESC', + 'campaignId' => $campaign1->id, // Filtering by campaignId + ] + ); + + $response = $this->dispatchRequest($request); + + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertEquals(200, $status); + $this->assertEquals(2, count($data)); + $this->assertEquals($donor2->{$sortableColumn}, $data[0][$sortableColumn]); + $this->assertEquals($donor1->{$sortableColumn}, $data[1][$sortableColumn]); + } + + /** + * @unreleased + */ + public function sortableColumnsDataProvider(): array + { + return [ + ['id'], + ['createdAt'], + ['name'], + ['firstName'], + ['lastName'], + ['totalAmountDonated'], + ['totalNumberOfDonations'], + ]; + } + + /** + * @unreleased + * + * @throws Exception + */ + private function createDonor1WithDonation(int $campaignId = 0, bool $anonymous = false): Donor + { + /** @var Donation $donation1 */ + $donation1 = Donation::factory()->create([ + 'status' => DonationStatus::COMPLETE(), + 'anonymous' => $anonymous, + 'mode' => DonationMode::LIVE(), + ]); + $donor1 = $donation1->donor; + + $donor1->firstName = 'A'; + $donor1->lastName = 'A'; + $donor1->name = 'A A'; + $donor1->totalAmountDonated = new Money(100, 'USD'); + $donor1->totalNumberOfDonations = 1; + $donor1->save(); + + give()->payment_meta->update_meta($donation1->id, DonationMetaKeys::DONOR_ID, $donor1->id); + + if ($campaignId) { + give()->payment_meta->update_meta($donation1->id, DonationMetaKeys::CAMPAIGN_ID, $campaignId); + } + + return Donor::find($donor1->id); + } + + /** + * @unreleased + * + * @throws Exception + */ + private function createDonor2WithDonation(int $campaignId = 0, bool $anonymous = false): Donor + { + /** @var Donation $donation2 */ + $donation2 = Donation::factory()->create([ + 'status' => DonationStatus::COMPLETE(), + 'anonymous' => $anonymous, + 'mode' => DonationMode::LIVE(), + ]); + $donor2 = $donation2->donor; + + $donor2->firstName = 'B'; + $donor2->lastName = 'B'; + $donor2->name = 'B B'; + $donor2->totalAmountDonated = new Money(200, 'USD'); + $donor2->totalNumberOfDonations = 2; + $donor2->save(); + + give()->payment_meta->update_meta($donation2->id, DonationMetaKeys::DONOR_ID, $donor2->id); + + if ($campaignId) { + give()->payment_meta->update_meta($donation2->id, DonationMetaKeys::CAMPAIGN_ID, $campaignId); + } + + return Donor::find($donor2->id); + } + + /** + * @unreleased + * + * @throws Exception + */ + private function createDonor3WithDonation(int $campaignId = 0, bool $anonymous = false): Donor + { + /** @var Donation $donation3 */ + $donation3 = Donation::factory()->create([ + 'status' => DonationStatus::COMPLETE(), + 'anonymous' => $anonymous, + 'mode' => DonationMode::LIVE(), + ]); + $donor3 = $donation3->donor; + + $donor3->firstName = 'C'; + $donor3->lastName = 'C'; + $donor3->name = 'C C'; + $donor3->totalAmountDonated = new Money(300, 'USD'); + $donor3->totalNumberOfDonations = 3; + $donor3->save(); + + give()->payment_meta->update_meta($donation3->id, DonationMetaKeys::DONOR_ID, $donor3->id); + + if ($campaignId) { + give()->payment_meta->update_meta($donation3->id, DonationMetaKeys::CAMPAIGN_ID, $campaignId); + } + + return Donor::find($donor3->id); + } +}