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 / API / REST / V3 / Routes / Donors / DonorController.php

DonorController.php in GiveWP – Donation Plugin and Fundraising Platform 4.18.0, at src/API/REST/V3/Routes/Donors/DonorController.php

688 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Give\API\REST\V3\Routes\Donors;
4
5 use Give\API\REST\V3\Routes\Donations\ValueObjects\DonationRoute;
6 use Give\API\REST\V3\Routes\Donors\Permissions\DonorPermissions;
7 use Give\API\REST\V3\Routes\Donors\ValueObjects\DonorAnonymousMode;
8 use Give\API\REST\V3\Routes\Donors\ValueObjects\DonorRoute;
9 use Give\API\REST\V3\Routes\Donors\ViewModels\DonorViewModel;
10 use Give\API\REST\V3\Routes\Subscriptions\ValueObjects\SubscriptionRoute;
11 use Give\API\REST\V3\Support\CURIE;
12 use Give\API\REST\V3\Support\Headers;
13 use Give\API\REST\V3\Support\Item;
14 use Give\Donors\DonorsQuery;
15 use Give\Donors\Models\Donor;
16 use Give\Donors\ValueObjects\DonorAddress;
17 use WP_Error;
18 use WP_REST_Controller;
19 use WP_REST_Request;
20 use WP_REST_Response;
21 use WP_REST_Server;
22
23 /**
24 * The methods using snake case like register_routes() are present in the base class,
25 * and the methods using camel case like getSortColumn() are available only on this class.
26 *
27 * @since 4.14.0 Extract permissions logic to separate classes
28 * @since 4.4.0 Extends WP_REST_Controller class and rename methods
29 * @since 4.0.0
30 */
31 class DonorController extends WP_REST_Controller
32 {
33 /**
34 * @since 4.0.0
35 */
36 public function __construct()
37 {
38 $this->namespace = DonorRoute::NAMESPACE;
39 $this->rest_base = DonorRoute::BASE;
40 }
41
42 /**
43 * @since 4.9.0 Move schema key to the route level instead of defining it for each endpoint (which is incorrect)
44 * @since 4.0.0
45 */
46 public function register_routes()
47 {
48 register_rest_route($this->namespace, '/' . $this->rest_base, [
49 [
50 'methods' => WP_REST_Server::READABLE,
51 'callback' => [$this, 'get_items'],
52 'permission_callback' => [$this, 'get_items_permissions_check'],
53 'args' => array_merge($this->get_collection_params(), $this->getSharedParamsForGetMethods()),
54 ],
55 'schema' => [$this, 'get_public_item_schema'],
56 ]);
57
58 register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', [
59 [
60 'methods' => WP_REST_Server::READABLE,
61 'callback' => [$this, 'get_item'],
62 'permission_callback' => [$this, 'get_item_permissions_check'],
63 'args' => array_merge([
64 'id' => [
65 'description' => __(
66 'The donor ID.',
67 'give'
68 ),
69 'type' => 'integer',
70 'required' => true,
71 ],
72 '_embed' => [
73 'description' => __(
74 'Whether to embed related resources in the response. It can be true when we want to embed all available resources, or a string like "givewp:statistics" when we wish to embed only a specific one. Available embeddable resources: givewp:statistics | givewp:donations | givewp:subscriptions. IMPORTANT: Use with caution when setting to true, as donations and subscriptions return 30 items by default, which can result in a large payload.',
75 'give'
76 ),
77 'type' => ['string', 'boolean'],
78 'default' => false,
79 ],
80 'mode' => [
81 'description' => __(
82 'The mode of donations to filter by "live" or "test" (it only gets applied when "_embed" is set).',
83 'give'
84 ),
85 'type' => 'string',
86 'default' => 'live',
87 'enum' => ['live', 'test'],
88 ],
89 'campaignId' => [
90 'description' => __(
91 'The ID of the campaign to filter donors by - zero or empty mean "all campaigns" (it only gets applied when "_embed" is set).',
92 'give'
93 ),
94 'type' => 'integer',
95 'default' => 0,
96 ],
97 ], $this->getSharedParamsForGetMethods()),
98 ],
99 [
100 'methods' => WP_REST_Server::EDITABLE,
101 'callback' => [$this, 'update_item'],
102 'permission_callback' => [$this, 'update_item_permissions_check'],
103 'args' => rest_get_endpoint_args_for_schema($this->get_public_item_schema(), WP_REST_Server::EDITABLE),
104 ],
105 'schema' => [$this, 'get_public_item_schema'],
106 ]);
107 }
108
109 /**
110 * Get list of donors.
111 *
112 * @since 4.14.0 Use Headers::addPagination() helper for pagination headers
113 * @since 4.8.0 Add support for search parameter
114 * @since 4.0.0
115 *
116 * @param WP_REST_Request $request Full details about the request.
117 *
118 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
119 */
120 public function get_items($request)
121 {
122 $page = $request->get_param('page');
123 $perPage = $request->get_param('per_page');
124 $sortColumn = $this->getSortColumn($request->get_param('sort'));
125 $sortDirection = $request->get_param('direction');
126 $includeSensitiveData = $request->get_param('includeSensitiveData');
127 $donorAnonymousMode = new DonorAnonymousMode($request->get_param('anonymousDonors'));
128
129 $query = new DonorsQuery();
130
131 if ($request->get_param('search')) {
132 $query->whereLike('name', '%%' . $request->get_param('search') . '%%');
133 $query->orWhereLike('email', '%%' . $request->get_param('search') . '%%');
134 }
135
136 // Donors only can be donors if they have donations associated with them
137 if ($request->get_param('onlyWithDonations')) {
138 $mode = $request->get_param('mode');
139 $campaignId = $request->get_param('campaignId');
140 $query->whereDonorsHaveDonations($mode, $campaignId, $donorAnonymousMode->isExcluded());
141 }
142
143 $totalQuery = $query->clone();
144 $query
145 ->limit($perPage)
146 ->offset(($page - 1) * $perPage)
147 ->orderBy($sortColumn, $sortDirection);
148
149 $donors = $query->getAll() ?? [];
150 $donors = array_map(function ($donor) use ($includeSensitiveData, $donorAnonymousMode, $request) {
151 $item = (new DonorViewModel($donor))->anonymousMode($donorAnonymousMode)->includeSensitiveData($includeSensitiveData)->exports();
152 $response = $this->prepare_item_for_response($item, $request);
153
154 return $this->prepare_response_for_collection($response);
155 }, $donors);
156
157 $totalDonors = empty($donors) ? 0 : $totalQuery->count();
158 $response = rest_ensure_response($donors);
159 $response = Headers::addPagination($response, $request, $totalDonors, $perPage, $this->rest_base);
160
161 return $response;
162 }
163
164 /**
165 * Get a single donor.
166 *
167 * @since 4.0.0
168 *
169 * @param WP_REST_Request $request Full data about the request.
170 *
171 * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
172 */
173 public function get_item($request)
174 {
175 $donor = Donor::find($request->get_param('id'));
176 $includeSensitiveData = $request->get_param('includeSensitiveData');
177 $donorAnonymousMode = new DonorAnonymousMode($request->get_param('anonymousDonors'));
178
179 if (!$donor || ($donor->isAnonymous() && $donorAnonymousMode->isExcluded())) {
180 return new WP_Error('donor_not_found', __('Donor not found', 'give'), ['status' => 404]);
181 }
182
183 $item = (new DonorViewModel($donor))->anonymousMode($donorAnonymousMode)->includeSensitiveData($includeSensitiveData)->exports();
184 $response = $this->prepare_item_for_response($item, $request);
185
186 return rest_ensure_response($response);
187 }
188
189 /**
190 * Update a single donor.
191 *
192 * @since 4.17.0 Non-admin callers may no longer add unverified email addresses.
193 * @since 4.16.6 Skip readonly schema properties when applying PATCH updates.
194 * @since 4.8.0 Update donor name when firstName or lastName is updated
195 * @since 4.7.0 Add support for updating custom fields
196 * @since 4.4.0
197 *
198 * @return WP_REST_Response|WP_Error
199 */
200 public function update_item($request)
201 {
202 $donor = Donor::find($request->get_param('id'));
203
204 if (!$donor) {
205 return new WP_REST_Response(__('Donor not found', 'give'), 404);
206 }
207
208 $nonEditableFields = array_merge(
209 [
210 'id',
211 'userId',
212 'createdAt',
213 ],
214 array_keys(
215 array_filter(
216 $this->get_item_schema()['properties'] ?? [],
217 static function (array $property): bool {
218 return ! empty($property['readonly']);
219 }
220 )
221 )
222 );
223
224 if (!DonorPermissions::canEdit() && $request->has_param('additionalEmails')) {
225 $allowed = array_merge($donor->additionalEmails ?? [], [$donor->email]);
226 $request->set_param(
227 'additionalEmails',
228 array_values(array_intersect((array)$request->get_param('additionalEmails'), $allowed))
229 );
230 }
231
232 foreach ($request->get_params() as $key => $value) {
233 if (! in_array($key, $nonEditableFields, true)) {
234 if ($donor->hasProperty($key)) {
235 if ($key === 'addresses') {
236 $donor->addresses = array_map(function ($address) {
237 return DonorAddress::fromArray($address);
238 }, $value);
239 continue;
240 }
241
242 if (!$donor->isPropertyTypeValid($key, $value)) {
243 $value = null;
244 }
245
246 $donor->$key = $value;
247 }
248 }
249 }
250
251 if ($request->get_param('firstName') || $request->get_param('lastName')) {
252 $donor->name = trim($donor->firstName . ' ' . $donor->lastName);
253 }
254
255 if ($donor->isDirty()) {
256 $donor->save();
257 }
258
259 $item = (new DonorViewModel($donor))->includeSensitiveData(true)->anonymousMode(DonorAnonymousMode::INCLUDED())->exports();
260 $fieldsUpdate = $this->update_additional_fields_for_object($item, $request);
261
262 if (is_wp_error($fieldsUpdate)) {
263 return $fieldsUpdate;
264 }
265
266 $response = $this->prepare_item_for_response($item, $request);
267
268 return rest_ensure_response($response);
269 }
270
271 /**
272 * @since 4.14.0 Use DonorPermissions class
273 * @since 4.0.0
274 *
275 * @param WP_REST_Request $request
276 *
277 * @return true|WP_Error
278 */
279 public function get_items_permissions_check($request)
280 {
281 return DonorPermissions::validationForGetMethods($request);
282 }
283
284 /**
285 * @since 4.14.0 Use DonorPermissions class
286 * @since 4.0.0
287 *
288 * @param WP_REST_Request $request
289 *
290 * @return true|WP_Error
291 */
292 public function get_item_permissions_check($request)
293 {
294 return DonorPermissions::validationForGetMethods($request);
295 }
296
297 /**
298 * @since 4.14.0 Use DonorPermissions class
299 * @since 4.4.0
300 *
301 * @param WP_REST_Request $request
302 *
303 * @return true|WP_Error
304 */
305 public function update_item_permissions_check($request)
306 {
307 return DonorPermissions::validationForUpdateMethod($request);
308 }
309
310 /**
311 * @since 4.14.0 Add links to donations and subscriptions and format dates as strings using Item::formatDatesForResponse
312 * @since 4.7.0 Add support for adding custom fields to the response
313 * @since 4.4.0
314 */
315 public function prepare_item_for_response($item, $request): WP_REST_Response
316 {
317 $donorId = $request->get_param('id');
318 $mode = $request->get_param('mode');
319 $campaignId = $request->get_param('campaignId');
320 $includeSensitiveData = $request->get_param('includeSensitiveData') ? '1' : '0';
321 $anonymousDonors = $request->get_param('anonymousDonors');
322 $anonymousDonations = $anonymousDonors;
323
324 $self_url = rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $donorId));
325
326 $statistics_url = add_query_arg([
327 'mode' => $mode,
328 'campaignId' => $campaignId,
329 ], $self_url . '/statistics');
330
331 $donations_url = rest_url(sprintf('%s/%s', DonationRoute::NAMESPACE, DonationRoute::BASE));
332 $donations_url = add_query_arg([
333 'donorId' => $donorId,
334 'mode' => $mode,
335 'campaignId' => $campaignId,
336 'includeSensitiveData' => $includeSensitiveData,
337 'anonymousDonations' => $anonymousDonations,
338 'page' => 1,
339 'per_page' => 30,
340 ], $donations_url);
341
342 $subscriptions_url = rest_url(sprintf('%s/%s', SubscriptionRoute::NAMESPACE, SubscriptionRoute::BASE));
343 $subscriptions_url = add_query_arg([
344 'donorId' => $donorId,
345 'mode' => $mode,
346 'campaignId' => $campaignId,
347 'includeSensitiveData' => $includeSensitiveData,
348 'anonymousDonors' => $anonymousDonors,
349 'page' => 1,
350 'per_page' => 30,
351 ], $subscriptions_url);
352
353 $links = [
354 'self' => ['href' => $self_url],
355 CURIE::relationUrl('statistics') => [
356 'href' => $statistics_url,
357 'embeddable' => true,
358 ],
359 CURIE::relationUrl('donations') => [
360 'href' => $donations_url,
361 'embeddable' => true,
362 ],
363 CURIE::relationUrl('subscriptions') => [
364 'href' => $subscriptions_url,
365 'embeddable' => true,
366 ],
367 ];
368
369 $response = new WP_REST_Response(Item::formatDatesForResponse($item, ['createdAt']));
370 $response->add_links($links);
371 $response->data = $this->add_additional_fields_to_object($response->data, $request);
372
373 return $response;
374 }
375
376 /**
377 * @since 4.14.0 Add missing properties to the schema
378 * @since 4.13.0 add schema description
379 * @since 4.9.0 Set proper JSON Schema version
380 * @since 4.7.0 Change title to givewp/donor and add custom fields schema
381 * @since 4.4.0
382 */
383 public function get_item_schema(): array
384 {
385 $schema = [
386 '$schema' => 'http://json-schema.org/draft-04/schema#',
387 'title' => 'givewp/donor',
388 'description' => esc_html__('Donor routes for CRUD operations', 'give'),
389 'type' => 'object',
390 'properties' => [
391 'id' => [
392 'type' => 'integer',
393 'description' => esc_html__('Donor ID', 'give'),
394 'readonly' => true,
395 ],
396 'prefix' => [
397 'type' => ['string', 'null'],
398 'description' => esc_html__('Donor prefix', 'give'),
399 'format' => 'text-field',
400 ],
401 'firstName' => [
402 'type' => 'string',
403 'description' => esc_html__('Donor first name', 'give'),
404 'minLength' => 1,
405 'maxLength' => 128,
406 'errorMessage' => esc_html__('First name is required', 'give'),
407 'format' => 'text-field',
408 'required' => true,
409 ],
410 'lastName' => [
411 'type' => 'string',
412 'description' => esc_html__('Donor last name', 'give'),
413 'minLength' => 1,
414 'maxLength' => 128,
415 'errorMessage' => esc_html__('Last name is required', 'give'),
416 'format' => 'text-field',
417 'required' => true,
418 ],
419 'email' => [
420 'type' => 'string',
421 'description' => esc_html__('Donor email', 'give'),
422 'format' => 'email',
423 'required' => true,
424 ],
425 'additionalEmails' => [
426 'type' => 'array',
427 'description' => esc_html__('Donor additional emails', 'give'),
428 'items' => [
429 'type' => 'string',
430 'format' => 'email',
431 ],
432 ],
433 'phone' => [
434 'type' => ['string', 'null'],
435 'description' => esc_html__('Donor phone', 'give'),
436 'pattern' => '^$|^[\+]?[1-9][\d\s\-\(\)]{7,20}$',
437 ],
438 'company' => [
439 'type' => ['string', 'null'],
440 'description' => esc_html__('Donor company', 'give'),
441 'format' => 'text-field',
442 ],
443 'avatarId' => [
444 'type' => ['integer', 'string', 'null'],
445 'description' => esc_html__('Donor avatar ID', 'give'),
446 'pattern' => '^$|^[0-9]+$',
447 'errorMessage' => esc_html__('Invalid avatar ID', 'give'),
448 ],
449 'addresses' => [
450 'type' => 'array',
451 'description' => esc_html__('Donor addresses', 'give'),
452 'items' => [
453 'type' => 'object',
454 'description' => esc_html__('Donor address', 'give'),
455 'properties' => [
456 'address1' => [
457 'type' => 'string',
458 'description' => esc_html__('Donor address line 1', 'give'),
459 'format' => 'text-field',
460 ],
461 'address2' => [
462 'type' => 'string',
463 'description' => esc_html__('Donor address line 2', 'give'),
464 'format' => 'text-field',
465 ],
466 'city' => [
467 'type' => 'string',
468 'description' => esc_html__('Donor address city', 'give'),
469 'format' => 'text-field',
470 ],
471 'state' => [
472 'type' => 'string',
473 'description' => esc_html__('Donor address state', 'give'),
474 'format' => 'text-field',
475 ],
476 'country' => [
477 'type' => 'string',
478 'description' => esc_html__('Donor address country', 'give'),
479 'format' => 'text-field',
480 ],
481 'zip' => [
482 'type' => 'string',
483 'description' => esc_html__('Donor address zip', 'give'),
484 'format' => 'text-field',
485 ],
486 ],
487 ],
488 ],
489 'customFields' => [
490 'type' => 'array',
491 'readonly' => true,
492 'description' => esc_html__('Custom fields (sensitive data)', 'give'),
493 'items' => [
494 'type' => 'object',
495 'properties' => [
496 'label' => [
497 'type' => 'string',
498 'description' => esc_html__('Field label', 'give'),
499 'format' => 'text-field',
500 ],
501 'value' => [
502 'type' => 'string',
503 'description' => esc_html__('Field value', 'give'),
504 'format' => 'text-field',
505 ],
506 ],
507 ],
508 ],
509 'createdAt' => [
510 'type' => ['string', 'null'],
511 'description' => sprintf(
512 /* translators: %s: WordPress documentation URL */
513 esc_html__('Donor creation date in ISO 8601 format. Follows WordPress REST API date format standards. See %s for more information.', 'give'),
514 '<a href="https://developer.wordpress.org/rest-api/extending-the-rest-api/schema/#format" target="_blank">WordPress REST API Date and Time</a>'
515 ),
516 'format' => 'date-time',
517 'example' => '2025-09-02T20:27:02',
518 'readonly' => true,
519 ],
520 'userId' => [
521 'type' => ['integer', 'null'],
522 'description' => esc_html__('WordPress user ID associated with the donor', 'give'),
523 'readonly' => true,
524 ],
525 'name' => [
526 'type' => 'string',
527 'description' => esc_html__('Donor full name (calculated from firstName and lastName)', 'give'),
528 'readonly' => true,
529 ],
530 'avatarUrl' => [
531 'type' => ['string', 'null'],
532 'description' => esc_html__('URL of the donor avatar image', 'give'),
533 'format' => 'uri',
534 'readonly' => true,
535 ],
536 'wpUserPermalink' => [
537 'type' => ['string', 'null'],
538 'description' => esc_html__('Link to edit the WordPress user associated with the donor', 'give'),
539 'format' => 'uri',
540 'readonly' => true,
541 ],
542 'totalAmountDonated' => [
543 'type' => 'object',
544 'properties' => [
545 'value' => [
546 'type' => 'number',
547 'description' => esc_html__('Total amount donated in decimal format', 'give'),
548 ],
549 'valueInMinorUnits' => [
550 'type' => 'integer',
551 'description' => esc_html__('Total amount donated in minor units (cents)', 'give'),
552 ],
553 'currency' => [
554 'type' => 'string',
555 'format' => 'text-field',
556 'description' => esc_html__('Currency code (e.g., USD, EUR)', 'give'),
557 ],
558 ],
559 'description' => esc_html__('Total amount donated by the donor', 'give'),
560 'readonly' => true,
561 ],
562 'totalNumberOfDonations' => [
563 'type' => 'integer',
564 'description' => esc_html__('Total number of donations made by the donor', 'give'),
565 'readonly' => true,
566 ],
567 ],
568 ];
569
570 return $this->add_additional_fields_schema($schema);
571 }
572
573 /**
574 * @since 4.8.0 Re-add search parameter
575 * @since 4.4.0
576 */
577 public function get_collection_params(): array
578 {
579 $params = parent::get_collection_params();
580
581 $params['page']['default'] = 1;
582 $params['per_page']['default'] = 30;
583
584 // Remove default parameters not being used
585 unset($params['context']);
586
587 $params += [
588 'sort' => [
589 'description' => __('The field by which to sort the donors.', 'give'),
590 'type' => 'string',
591 'default' => 'id',
592 'enum' => [
593 'id',
594 'createdAt',
595 'name',
596 'firstName',
597 'lastName',
598 'totalAmountDonated',
599 'totalNumberOfDonations',
600 ],
601 ],
602 'direction' => [
603 'description' => __('The direction of sorting: ascending (ASC) or descending (DESC).', 'give'),
604 'type' => 'string',
605 'default' => 'DESC',
606 'enum' => ['ASC', 'DESC'],
607 ],
608 'onlyWithDonations' => [
609 'description' => __('Whether to include only donors who have made donations.', 'give'),
610 'type' => 'boolean',
611 'default' => true,
612 ],
613 'mode' => [
614 'description' => __(
615 'The mode of donations to filter by "live" or "test" (it only gets applied when "onlyWithDonations" is set to true).',
616 'give'
617 ),
618 'type' => 'string',
619 'default' => 'live',
620 'enum' => ['live', 'test'],
621 ],
622 'campaignId' => [
623 'description' => __(
624 'The ID of the campaign to filter donors by - zero or empty mean "all campaigns" (it only gets applied when "onlyWithDonations" is set to true).',
625 'give'
626 ),
627 'type' => 'integer',
628 'default' => 0,
629 ],
630 'search' => [
631 'description' => __('Search donors by name or email.', 'give'),
632 'type' => 'string',
633 ],
634 ];
635
636 return $params;
637 }
638
639 /**
640 * @since 4.13.1 cast totalAmountDonated to decimal
641 * @since 4.0.0
642 */
643 public function getSortColumn(string $sortColumn): string
644 {
645 $sortColumnsMap = [
646 'id' => 'id',
647 'createdAt' => 'date_created',
648 'name' => 'name',
649 'firstName' => 'give_donormeta_attach_meta_firstName.meta_value',
650 'lastName' => 'give_donormeta_attach_meta_lastName.meta_value',
651 'totalAmountDonated' => 'CAST(purchase_value AS DECIMAL(10, 2))',
652 'totalNumberOfDonations' => 'purchase_count',
653 ];
654
655 return $sortColumnsMap[$sortColumn];
656 }
657
658 /**
659 * Get shared parameters for GET methods (both collection and item).
660 *
661 * @since 4.4.0
662 *
663 * @return array
664 */
665 private function getSharedParamsForGetMethods(): array
666 {
667 return [
668 'includeSensitiveData' => [
669 'description' => __(
670 'Include or not include data that can be used to contact or locate the donors, such as phone number, email, billing address, etc. (require proper permissions)',
671 'give'
672 ),
673 'type' => 'boolean',
674 'default' => false,
675 ],
676 'anonymousDonors' => [
677 'description' => __(
678 'Exclude, include, or redact data that can be used to identify the donors, such as ID, first name, last name, etc (require proper permissions).',
679 'give'
680 ),
681 'type' => 'string',
682 'default' => 'exclude',
683 'enum' => ['exclude', 'include', 'redact'],
684 ],
685 ];
686 }
687 }
688