PluginProbe
GiveWP – Donation Plugin and Fundraising Platform / 4.18.0
GiveWP – Donation Plugin and Fundraising Platform v4.18.0
4.18.0 4.17.0 4.16.9 4.16.8.1 4.16.8 4.16.7.2 4.16.7.1 4.16.7 4.16.6.1 4.16.6 4.16.5.1 4.16.5 4.16.4 4.16.3 4.16.2 4.16.1 4.16.0 4.15.5 4.15.4 4.15.3 4.15.2 4.15.1 4.15.0 2.3.0 2.3.1 All 257 releases
give / src / Donations / Endpoints / ListDonations.php

ListDonations.php in GiveWP – Donation Plugin and Fundraising Platform 4.18.0, at src/Donations/Endpoints/ListDonations.php

403 lines 13.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Give\Donations\Endpoints;
4
5 use Closure;
6 use Give\Donations\ListTable\DonationsListTable;
7 use Give\Donations\ValueObjects\DonationMetaKeys;
8 use Give\Donations\ValueObjects\DonationMode;
9 use Give\Donations\ValueObjects\DonationStatus;
10 use Give\Framework\Database\DB;
11 use Give\Framework\ListTable\Exceptions\ColumnIdCollisionException;
12 use Give\Framework\QueryBuilder\QueryBuilder;
13 use WP_REST_Request;
14 use WP_REST_Response;
15
16 /**
17 * @since 4.0.0 replace form with campaignId.
18 * @since 3.4.0 The class is extendable
19 */
20 class ListDonations extends Endpoint
21 {
22 /**
23 * @var string
24 */
25 protected $endpoint = 'admin/donations';
26
27 /**
28 * @var WP_REST_Request
29 */
30 protected $request;
31
32 /**
33 * @var DonationsListTable
34 */
35 protected $listTable;
36
37 /**
38 * @since 4.12.0 Updated status parameter to accept multiple comma-separated values
39 * @since 4.6.0 add status parameter to filter donations by status
40 * @since 3.4.0
41 * @access public
42 */
43 public function __construct(DonationsListTable $listTable)
44 {
45 $this->listTable = $listTable;
46 }
47
48 /**
49 * @inheritDoc
50 *
51 * @since 4.12.0 Add format parameter to start and end dates, replacing custom validation callback
52 * @since 3.4.0
53 */
54 public function registerRoute()
55 {
56 register_rest_route(
57 'give-api/v2',
58 $this->endpoint,
59 [
60 [
61 'methods' => 'GET',
62 'callback' => [$this, 'handleRequest'],
63 'permission_callback' => [$this, 'permissionsCheck'],
64 ],
65 'args' => [
66 'page' => [
67 'type' => 'integer',
68 'required' => false,
69 'default' => 1,
70 'minimum' => 1
71 ],
72 'perPage' => [
73 'type' => 'integer',
74 'required' => false,
75 'default' => 30,
76 'minimum' => 1
77 ],
78 'campaignId' => [
79 'type' => 'integer',
80 'required' => false,
81 'default' => 0
82 ],
83 'search' => [
84 'type' => 'string',
85 'required' => false,
86 'sanitize_callback' => 'sanitize_text_field',
87 ],
88 'start' => [
89 'type' => 'string',
90 'required' => false,
91 'format' => 'date-time'
92 ],
93 'end' => [
94 'type' => 'string',
95 'required' => false,
96 'format' => 'date-time'
97 ],
98 'donor' => [
99 'type' => 'string',
100 'required' => false,
101 'sanitize_callback' => 'sanitize_text_field',
102 ],
103 'sortColumn' => [
104 'type' => 'string',
105 'required' => false,
106 'sanitize_callback' => 'sanitize_text_field',
107 ],
108 'sortDirection' => [
109 'type' => 'string',
110 'required' => false,
111 'enum' => [
112 'asc',
113 'desc',
114 ],
115 ],
116 'locale' => [
117 'type' => 'string',
118 'required' => false,
119 'default' => get_locale(),
120 ],
121 'testMode' => [
122 'type' => 'boolean',
123 'required' => false,
124 'default' => give_is_test_mode(),
125 ],
126 'return' => [
127 'type' => 'string',
128 'required' => false,
129 'default' => 'columns',
130 'enum' => [
131 'model',
132 'columns',
133 ],
134 ],
135 'status' => [
136 'type' => 'array',
137 'required' => false,
138 'items' => [
139 'type' => 'string',
140 'enum' => array_values(DonationStatus::toArray()),
141 ],
142 'description' => 'Filter donations by status. Accepts comma-separated list of DonationStatus values (e.g., "pending,publish,trash"). If not provided, excludes trash donations by default.'
143 ],
144 ],
145 ]
146 );
147 }
148
149 /**
150 * @since 2.24.0 Change this to use the new ListTable class
151 * @since 2.20.0
152 *
153 * @param WP_REST_Request $request
154 *
155 * @return WP_REST_Response
156 * @throws ColumnIdCollisionException
157 */
158 public function handleRequest(WP_REST_Request $request): WP_REST_Response
159 {
160 $this->request = $request;
161
162 $donations = $this->getDonations();
163 $donationsCount = $this->getTotalDonationsCount();
164 $totalPages = (int)ceil($donationsCount / $this->request->get_param('perPage'));
165
166 if ('model' === $this->request->get_param('return')) {
167 $items = $donations;
168 } else {
169 $this->listTable->items($donations, $this->request->get_param('locale') ?? '');
170 $items = $this->listTable->getItems();
171 }
172
173 return new WP_REST_Response(
174 [
175 'items' => $items,
176 'totalItems' => $donationsCount,
177 'totalPages' => $totalPages,
178 ]
179 );
180 }
181
182 /**
183 * @since 4.18.0 Select the page of IDs first, then hydrate, so deep pages do not join every row
184 * @since 2.24.0 Replace Query Builder with Donations model
185 * @since 2.21.0
186 *
187 * @return array
188 */
189 public function getDonations(): array
190 {
191 $page = $this->request->get_param('page');
192 $perPage = $this->request->get_param('perPage');
193 $sortColumns = $this->listTable->getSortColumnById($this->request->get_param('sortColumn') ?: 'id');
194 $sortDirection = $this->request->get_param('sortDirection') ?: 'desc';
195
196 // Resolve the page of IDs against the posts table with only the meta the filters and sort
197 // need, then hydrate those rows. Paging the fully joined model query scans every row.
198 $idQuery = DB::table('posts')->select(
199 ['ID', 'id'],
200 ['post_date', 'createdAt'],
201 ['post_modified', 'updatedAt'],
202 ['post_status', 'status']
203 );
204 list($idQuery, $dependencies) = $this->getWhereConditions($idQuery);
205 $dependencies = array_merge($dependencies, $this->getSortDependencies($sortColumns));
206
207 if ($dependencies) {
208 $idQuery->attachMeta(
209 'give_donationmeta',
210 'ID',
211 'donation_id',
212 ...DonationMetaKeys::getColumnsForAttachMetaQueryFromArray($dependencies)
213 );
214 }
215
216 foreach ($sortColumns as $sortColumn) {
217 $idQuery->orderBy($sortColumn, $sortDirection);
218 }
219
220 $ids = array_column($idQuery->limit($perPage)->offset(($page - 1) * $perPage)->getAll() ?: [], 'id');
221
222 if (!$ids) {
223 return [];
224 }
225
226 $query = give()->donations->prepareQuery()->whereIn('ID', $ids);
227
228 foreach ($sortColumns as $sortColumn) {
229 $query->orderBy($sortColumn, $sortDirection);
230 }
231
232 return $query->getAll() ?: [];
233 }
234
235 /**
236 * Meta keys a sort expression references, so the ID query can attach them.
237 *
238 * @since 4.18.0
239 *
240 * @param string[] $sortColumns
241 *
242 * @return DonationMetaKeys[]
243 */
244 private function getSortDependencies(array $sortColumns): array
245 {
246 $sortSql = implode(' ', $sortColumns);
247
248 return array_values(array_filter(DonationMetaKeys::values(), static function (DonationMetaKeys $key) use ($sortSql) {
249 return strpos($sortSql, $key->getKeyAsCamelCase()) !== false;
250 }));
251 }
252
253 /**
254 * @since 4.18.0 Drop the GROUP BY on mode, which made count() return the size of one mode group
255 * @since 2.24.0 Replace Query Builder with Donations model
256 * @since 2.21.0
257 *
258 * @return int
259 */
260 public function getTotalDonationsCount(): int
261 {
262 list($query, $dependencies) = $this->getWhereConditions(DB::table('posts'));
263
264 if ($dependencies) {
265 $query->attachMeta(
266 'give_donationmeta',
267 'ID',
268 'donation_id',
269 ...DonationMetaKeys::getColumnsForAttachMetaQueryFromArray($dependencies)
270 );
271 }
272
273 return $query->count();
274 }
275
276 /**
277 * @since 4.18.0
278 */
279 private function donationIdsWithNamePrefix(string $value): Closure
280 {
281 return $this->donationIdsWithMetaPrefix([DonationMetaKeys::FIRST_NAME, DonationMetaKeys::LAST_NAME], $value);
282 }
283
284 /**
285 * Subquery for donation IDs whose meta value starts with the search term. A prefix match on
286 * the (meta_key, meta_value) index replaces a leading-wildcard LIKE across joined meta tables,
287 * which had to scan every row for the key.
288 *
289 * @since 4.18.0
290 *
291 * @param string[] $metaKeys
292 */
293 private function donationIdsWithMetaPrefix(array $metaKeys, string $value): Closure
294 {
295 $prefix = DB::esc_like($value) . '%';
296
297 return static function (QueryBuilder $builder) use ($metaKeys, $prefix) {
298 $builder
299 ->select('donation_id')
300 ->from('give_donationmeta')
301 ->whereIn('meta_key', $metaKeys)
302 ->where('meta_value', $prefix, 'LIKE');
303 };
304 }
305
306 /**
307 * @since 4.18.0 Match name and email searches by prefix through indexed subqueries, and filter test mode the same way instead of HAVING
308 * @since 4.12.0 Updated status filtering to accept multiple comma-separated values
309 * @since 4.8.0 Added support for subscriptionId parameter to filter donations
310 * @since 4.6.0 add status status condition to filter donations
311 * @since 3.4.0 Make this method protected so it can be extended
312 * @since 3.2.0 Updated query to account for possible null and empty values for _give_payment_mode meta
313 * @since 2.24.0 Remove joins as it uses ModelQueryBuilder and change clauses to use attach_meta
314 * @since 2.21.0
315 *
316 * @param QueryBuilder $query
317 *
318 * @return array{0: QueryBuilder, 1: array<DonationMetaKeys>}
319 */
320 protected function getWhereConditions(QueryBuilder $query): array
321 {
322 $search = $this->request->get_param('search');
323 $start = $this->request->get_param('start');
324 $end = $this->request->get_param('end');
325 $donor = $this->request->get_param('donor');
326 $testMode = $this->request->get_param('testMode');
327 $campaignId = $this->request->get_param('campaignId');
328 $subscriptionId = $this->request->get_param('subscriptionId');
329 $status = $this->request->get_param('status');
330 $dependencies = [];
331
332 $query->where('post_type', 'give_payment');
333
334 if (!empty($status)) {
335 $query->whereIn('post_status', $status);
336 } else {
337 // Default behavior: exclude trash donations
338 $query->where('post_status', DonationStatus::TRASH, '<>');
339 }
340
341 if ($search) {
342 if (ctype_digit($search)) {
343 $query->where('id', $search);
344 } elseif (strpos($search, '@') !== false) {
345 $query->whereIn('ID', $this->donationIdsWithMetaPrefix([DonationMetaKeys::EMAIL], $search));
346 } else {
347 $query->whereIn('ID', $this->donationIdsWithNamePrefix($search));
348 }
349 }
350
351 if ($donor) {
352 if (ctype_digit($donor)) {
353 $query
354 ->where('give_donationmeta_attach_meta_donorId.meta_value', $donor);
355 $dependencies[] = DonationMetaKeys::DONOR_ID();
356 } else {
357 $query->whereIn('ID', $this->donationIdsWithNamePrefix($donor));
358 }
359 }
360
361 if ($campaignId) {
362 $query
363 ->where('give_donationmeta_attach_meta_campaignId.meta_value', $campaignId);
364 $dependencies[] = DonationMetaKeys::CAMPAIGN_ID();
365 }
366
367 if ($subscriptionId) {
368 $query
369 ->where('give_donationmeta_attach_meta_subscriptionId.meta_value', $subscriptionId);
370 $dependencies[] = DonationMetaKeys::SUBSCRIPTION_ID();
371 }
372
373 if ($start && $end) {
374 $query->whereBetween('post_date', $start, $end);
375 } elseif ($start) {
376 $query->where('post_date', $start, '>=');
377 } elseif ($end) {
378 $query->where('post_date', $end, '<=');
379 }
380
381 // Test-mode donations carry a mode meta row; live donations may have none. A subquery on
382 // (meta_key, meta_value) is index-friendly, unlike a LEFT JOIN with an IS NULL OR condition.
383 $testModeDonationIds = static function (QueryBuilder $builder) {
384 $builder
385 ->select('donation_id')
386 ->from('give_donationmeta')
387 ->where('meta_key', DonationMetaKeys::MODE)
388 ->where('meta_value', DonationMode::TEST);
389 };
390
391 if ($testMode) {
392 $query->whereIn('ID', $testModeDonationIds);
393 } else {
394 $query->whereNotIn('ID', $testModeDonationIds);
395 }
396
397 return [
398 $query,
399 $dependencies,
400 ];
401 }
402 }
403