PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/api/campaigns-api.php +249 -83 0.0.1 → 1.6.1 View file →
@@ -7,8 +7,10 @@
7 7
8 8 namespace SureDonation\Inc\API;
9 9
10 10 use SureDonation\Inc\Helper;
11 +use SureDonation\Inc\Campaign_Templates\Campaign_Templates;
12 +use SureDonation\Inc\Campaigns\Campaign_Cpt;
11 13 use WP_Error;
12 14 use WP_REST_Request;
13 15 use WP_REST_Response;
14 16 use WP_REST_Server;
@@ -43,21 +45,21 @@
43 45 'methods' => WP_REST_Server::CREATABLE,
44 46 'callback' => [ $this, 'create_campaign' ],
45 47 'permission_callback' => [ $this, 'check_permissions' ],
46 48 'args' => [
47 - 'title' => [
49 + 'title' => [
48 50 'required' => true,
49 51 'sanitize_callback' => 'sanitize_text_field',
50 52 ],
51 - 'description' => [
53 + 'description' => [
52 54 'sanitize_callback' => 'wp_kses_post',
53 55 ],
54 - 'goal_type' => [
56 + 'goal_type' => [
55 57 'default' => 'raised_amount',
56 58 'enum' => [ 'raised_amount', 'donation_count' ],
57 59 'sanitize_callback' => 'sanitize_text_field',
58 60 ],
59 - 'goal_amount' => [
61 + 'goal_amount' => [
60 62 'sanitize_callback' => static function ( $value ) {
61 63 return '' !== $value ? floatval( $value ) : '';
62 64 },
63 65 'validate_callback' => static function ( $value ) {
@@ -67,34 +69,57 @@
67 69 $amount = floatval( $value );
68 70 return $amount >= 0 && $amount <= 999999999.99;
69 71 },
70 72 ],
71 - 'campaign_status' => [
73 + 'campaign_status' => [
72 74 'default' => 'active',
73 75 'enum' => [ 'active', 'paused', 'completed' ],
74 76 ],
75 - 'featured_image' => [
77 + 'featured_image' => [
76 78 'sanitize_callback' => 'absint',
77 79 ],
78 - 'email_settings' => [
80 + 'email_settings' => [
79 81 'type' => 'object',
80 82 'sanitize_callback' => [ $this, 'sanitize_email_settings' ],
81 83 ],
82 - 'allow_fees_coverage' => [
84 + 'require_terms' => [
83 85 'type' => 'boolean',
84 86 'sanitize_callback' => 'rest_sanitize_boolean',
85 87 ],
86 - 'require_terms' => [
87 - 'type' => 'boolean',
88 - 'sanitize_callback' => 'rest_sanitize_boolean',
88 + 'terms_text' => [
89 + 'sanitize_callback' => 'sanitize_text_field',
89 90 ],
90 - 'terms_text' => [
91 - 'sanitize_callback' => 'sanitize_text_field',
91 + 'template_id' => [
92 + 'sanitize_callback' => 'sanitize_key',
93 + 'validate_callback' => static function ( $value ) {
94 + // Empty ⇒ scratch. Otherwise must resolve to a known template.
95 + return '' === $value || Campaign_Templates::get_instance()->exists( $value );
96 + },
92 97 ],
93 98 ],
94 99 ],
95 100 ],
96 101
102 + // List campaign templates for the picker.
103 + '/campaign-templates' => [
104 + [
105 + 'methods' => WP_REST_Server::READABLE,
106 + 'callback' => [ $this, 'get_campaign_templates' ],
107 + 'permission_callback' => [ $this, 'check_permissions' ],
108 + ],
109 + ],
110 +
111 + // Analytics ping fired when the template picker is opened. Separate
112 + // from the listing route above because that one is fetched on every
113 + // campaigns-page load, not on open, so it cannot stand in for intent.
114 + '/campaign-templates/track-picker' => [
115 + [
116 + 'methods' => WP_REST_Server::EDITABLE,
117 + 'callback' => [ $this, 'track_template_picker' ],
118 + 'permission_callback' => [ $this, 'check_permissions' ],
119 + ],
120 + ],
121 +
97 122 // Update campaign.
98 123 '/campaigns/(?P<id>\d+)' => [
99 124 [
100 125 'methods' => WP_REST_Server::READABLE,
@@ -113,25 +138,25 @@
113 138 'methods' => WP_REST_Server::EDITABLE,
114 139 'callback' => [ $this, 'update_campaign' ],
115 140 'permission_callback' => [ $this, 'check_permissions' ],
116 141 'args' => [
117 - 'id' => [
142 + 'id' => [
118 143 'required' => true,
119 144 'validate_callback' => static function ( $param ) {
120 145 return is_numeric( $param );
121 146 },
122 147 ],
123 - 'title' => [
148 + 'title' => [
124 149 'sanitize_callback' => 'sanitize_text_field',
125 150 ],
126 - 'description' => [
151 + 'description' => [
127 152 'sanitize_callback' => 'wp_kses_post',
128 153 ],
129 - 'goal_type' => [
154 + 'goal_type' => [
130 155 'enum' => [ 'raised_amount', 'donation_count' ],
131 156 'sanitize_callback' => 'sanitize_text_field',
132 157 ],
133 - 'goal_amount' => [
158 + 'goal_amount' => [
134 159 'sanitize_callback' => static function ( $value ) {
135 160 return '' !== $value ? floatval( $value ) : '';
136 161 },
137 162 'validate_callback' => static function ( $value ) {
@@ -141,27 +166,23 @@
141 166 $amount = floatval( $value );
142 167 return $amount >= 0 && $amount <= 999999999.99;
143 168 },
144 169 ],
145 - 'campaign_status' => [
170 + 'campaign_status' => [
146 171 'enum' => [ 'active', 'paused', 'completed' ],
147 172 ],
148 - 'featured_image' => [
173 + 'featured_image' => [
149 174 'sanitize_callback' => 'absint',
150 175 ],
151 - 'email_settings' => [
176 + 'email_settings' => [
152 177 'type' => 'object',
153 178 'sanitize_callback' => [ $this, 'sanitize_email_settings' ],
154 179 ],
155 - 'allow_fees_coverage' => [
180 + 'require_terms' => [
156 181 'type' => 'boolean',
157 182 'sanitize_callback' => 'rest_sanitize_boolean',
158 183 ],
159 - 'require_terms' => [
160 - 'type' => 'boolean',
161 - 'sanitize_callback' => 'rest_sanitize_boolean',
162 - ],
163 - 'terms_text' => [
184 + 'terms_text' => [
164 185 'sanitize_callback' => 'sanitize_text_field',
165 186 ],
166 187 ],
167 188 ],
@@ -246,8 +267,23 @@
246 267 },
247 268 ],
248 269 ],
249 270 ],
271 +
272 + // Create (seed) the campaign page layout.
273 + '/campaigns/(?P<id>\d+)/page' => [
274 + 'methods' => WP_REST_Server::CREATABLE,
275 + 'callback' => [ $this, 'create_campaign_page' ],
276 + 'permission_callback' => [ $this, 'check_permissions' ],
277 + 'args' => [
278 + 'id' => [
279 + 'required' => true,
280 + 'validate_callback' => static function ( $param ) {
281 + return is_numeric( $param );
282 + },
283 + ],
284 + ],
285 + ],
250 286 ];
251 287 }
252 288
253 289 /**
@@ -284,8 +320,45 @@
284 320 );
285 321 }
286 322
287 323 /**
324 + * Create (seed) the default campaign page layout for a campaign.
325 + *
326 + * Idempotent: seeding only runs when the campaign has no page content yet, so
327 + * an existing, user-edited page is never overwritten. Returns the campaign in
328 + * its post-seed state so the admin can flip the button to "View Campaign".
329 + *
330 + * @param WP_REST_Request $request Request object.
331 + * @return WP_REST_Response|WP_Error Response object.
332 + * @since 1.0.0
333 + */
334 + public function create_campaign_page( $request ) {
335 + $campaign_id = absint( $request->get_param( 'id' ) );
336 + $post = get_post( $campaign_id );
337 +
338 + if ( ! $post || SUREDONATION_POST_TYPE !== $post->post_type ) {
339 + return new WP_Error(
340 + 'campaign_not_found',
341 + __( 'Campaign not found.', 'suredonation' ),
342 + [ 'status' => 404 ]
343 + );
344 + }
345 +
346 + \SureDonation\Inc\Campaigns\Campaign_Page::seed_if_empty( $campaign_id );
347 +
348 + // Refresh the post so the response reflects the seeded content.
349 + $post = get_post( $campaign_id );
350 +
351 + return new WP_REST_Response(
352 + [
353 + 'success' => true,
354 + 'campaign' => $this->format_campaign( $post ),
355 + ],
356 + 200
357 + );
358 + }
359 +
360 + /**
288 361 * Get campaigns list with filters, sorting, and pagination.
289 362 *
290 363 * @param WP_REST_Request $request Request object.
291 364 * @return WP_REST_Response|WP_Error Response object.
@@ -292,25 +365,42 @@
292 365 * @since 0.0.1
293 366 */
294 367 public function get_campaigns( $request ) {
295 368 $page = $request->get_param( 'page' ) ?? 1;
296 - $per_page = $request->get_param( 'per_page' ) ?? 20;
297 - $search = $request->get_param( 'search' ) ?? '';
298 - $status = $request->get_param( 'status' ) ?? 'all';
299 - $sort_by = $request->get_param( 'sort_by' ) ?? 'date';
300 - $order = $request->get_param( 'order' ) ?? 'desc';
369 + $per_page = (int) ( $request->get_param( 'per_page' ) ?? 20 );
370 + $per_page = -1 === $per_page ? -1 : absint( $per_page );
371 + if ( 0 === $per_page ) {
372 + $per_page = 20;
373 + }
374 + $search = $request->get_param( 'search' ) ?? '';
375 + $status = $request->get_param( 'status' ) ?? 'all';
376 + $sort_by = $request->get_param( 'sort_by' ) ?? 'date';
377 + $order = $request->get_param( 'order' ) ?? 'desc';
301 378
302 379 // Build query args.
303 380 $args = [
304 381 'post_type' => SUREDONATION_POST_TYPE,
305 - 'posts_per_page' => absint( $per_page ),
306 - 'paged' => absint( $page ),
382 + 'posts_per_page' => $per_page,
383 + 'paged' => -1 === $per_page ? 1 : absint( $page ),
307 384 'orderby' => $this->get_orderby_field( $sort_by ),
308 385 'order' => strtoupper( $order ),
309 386 ];
310 387
311 388 // Add status filter.
312 - if ( 'all' !== $status ) {
389 + if ( 'paused' === $status ) {
390 + // "Paused" is a campaign business status stored inside the campaign
391 + // meta JSON (not a WP post status), so match published campaigns whose
392 + // meta marks them paused.
393 + $args['post_status'] = 'publish';
394 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Admin-only campaign list filter.
395 + $args['meta_query'] = [
396 + [
397 + 'key' => Helper::SUREDONATION_CAMPAIGN_META_KEY,
398 + 'value' => '"campaign_status":"paused"',
399 + 'compare' => 'LIKE',
400 + ],
401 + ];
402 + } elseif ( 'all' !== $status ) {
313 403 $args['post_status'] = $status;
314 404 } else {
315 405 $args['post_status'] = [ 'publish', 'draft' ];
316 406 }
@@ -348,8 +438,48 @@
348 438 );
349 439 }
350 440
351 441 /**
442 + * List the available campaign templates for the picker.
443 + *
444 + * @param WP_REST_Request $request Request object.
445 + * @return WP_REST_Response Response object.
446 + * @since 1.5.0
447 + */
448 + public function get_campaign_templates( $request ) {
449 + unset( $request );
450 +
451 + return new WP_REST_Response(
452 + [
453 + 'templates' => Campaign_Templates::get_instance()->get_all(),
454 + ]
455 + );
456 + }
457 +
458 + /**
459 + * Record that the campaign template picker was opened.
460 + *
461 + * The action fires on every call; the analytics listener dedups, so this only
462 + * ever records once per site. Cheap enough to call on each open.
463 + *
464 + * @param WP_REST_Request $request Request object.
465 + * @return WP_REST_Response Response object.
466 + * @since 1.5.0
467 + */
468 + public function track_template_picker( $request ) {
469 + unset( $request );
470 +
471 + /**
472 + * Fires when a user opens the campaign template picker.
473 + *
474 + * @since 1.5.0
475 + */
476 + do_action( 'suredonation_campaign_template_picker_opened' );
477 +
478 + return new WP_REST_Response( [ 'success' => true ] );
479 + }
480 +
481 + /**
352 482 * Create a new campaign.
353 483 *
354 484 * @param WP_REST_Request $request Request object.
355 485 * @return WP_REST_Response|WP_Error Response object.
@@ -355,26 +485,39 @@
355 485 * @return WP_REST_Response|WP_Error Response object.
356 486 * @since 0.0.1
357 487 */
358 488 public function create_campaign( $request ) {
359 - $title = $request->get_param( 'title' );
360 - $description = $request->get_param( 'description' );
489 + $title = $request->get_param( 'title' );
490 + // Normalize to string so an omitted description does not become a null
491 + // post_excerpt (the column is NOT NULL and would fail the insert).
492 + $description = Helper::get_string_value( $request->get_param( 'description' ) );
361 493 $goal_type = $request->get_param( 'goal_type' ) ?? 'raised_amount';
362 494 $goal_amount = $request->get_param( 'goal_amount' );
363 495 $campaign_status = $request->get_param( 'campaign_status' ) ?? 'active';
364 496 $featured_image = $request->get_param( 'featured_image' );
497 + $template_id = (string) $request->get_param( 'template_id' );
365 498
499 + // Build the insert args. The description is stored as the excerpt so
500 + // post_content stays reserved for the campaign page layout.
501 + $insert_args = [
502 + 'post_type' => SUREDONATION_POST_TYPE,
503 + 'post_title' => $title,
504 + 'post_excerpt' => $description,
505 + 'post_status' => 'publish',
506 + 'post_author' => get_current_user_id(),
507 + ];
508 +
509 + // Record the chosen template via meta_input so it is set BEFORE the
510 + // save_post seeding hooks fire (they run during wp_insert_post). A
511 + // post-insert update_post_meta would be too late.
512 + if ( '' !== $template_id ) {
513 + $insert_args['meta_input'] = [
514 + Campaign_Cpt::META_TEMPLATE_ID => $template_id,
515 + ];
516 + }
517 +
366 518 // Create the campaign post.
367 - $post_id = wp_insert_post(
368 - [
369 - 'post_type' => SUREDONATION_POST_TYPE,
370 - 'post_title' => $title,
371 - 'post_content' => $description,
372 - 'post_status' => 'publish',
373 - 'post_author' => get_current_user_id(),
374 - ],
375 - true
376 - );
519 + $post_id = wp_insert_post( $insert_args, true );
377 520
378 521 if ( is_wp_error( $post_id ) ) {
379 522 return new WP_Error(
380 523 'create_failed',
@@ -382,8 +525,22 @@
382 525 [ 'status' => 500 ]
383 526 );
384 527 }
385 528
529 + if ( '' !== $template_id ) {
530 + /**
531 + * Fires after a campaign is created from a template.
532 + *
533 + * The id has already been validated against the template registry by
534 + * the route's validate_callback, so listeners receive a known id.
535 + *
536 + * @param string $template_id Template the campaign was created from.
537 + * @param int $post_id The new campaign's post ID.
538 + * @since 1.5.0
539 + */
540 + do_action( 'suredonation_campaign_created_from_template', $template_id, $post_id );
541 + }
542 +
386 543 // Save campaign meta as single consolidated meta key.
387 544 $meta_values = [
388 545 'goal_type' => $goal_type,
389 546 'goal_amount' => isset( $goal_amount ) && '' !== $goal_amount ? floatval( $goal_amount ) : 0,
@@ -394,13 +551,8 @@
394 551 if ( ! empty( $email_settings ) ) {
395 552 $meta_values['email_settings'] = $email_settings;
396 553 }
397 554
398 - $allow_fees_coverage = $request->get_param( 'allow_fees_coverage' );
399 - if ( ! is_null( $allow_fees_coverage ) ) {
400 - $meta_values['allow_fees_coverage'] = (bool) $allow_fees_coverage;
401 - }
402 -
403 555 $require_terms = $request->get_param( 'require_terms' );
404 556 if ( ! is_null( $require_terms ) ) {
405 557 $meta_values['require_terms'] = (bool) $require_terms;
406 558 }
@@ -458,10 +610,12 @@
458 610 if ( ! empty( $title ) ) {
459 611 $post_data['post_title'] = $title;
460 612 }
461 613
462 - if ( ! empty( $description ) ) {
463 - $post_data['post_content'] = $description;
614 + // Use is_null (not empty) so an empty string can clear the description;
615 + // it feeds the seeded campaign page paragraph.
616 + if ( ! is_null( $description ) ) {
617 + $post_data['post_excerpt'] = $description;
464 618 }
465 619
466 620 // Update post if there are changes.
467 621 if ( count( $post_data ) > 1 ) {
@@ -498,13 +652,8 @@
498 652 if ( ! is_null( $email_settings ) ) {
499 653 $meta_values['email_settings'] = $email_settings;
500 654 }
501 655
502 - $allow_fees_coverage = $request->get_param( 'allow_fees_coverage' );
503 - if ( ! is_null( $allow_fees_coverage ) ) {
504 - $meta_values['allow_fees_coverage'] = (bool) $allow_fees_coverage;
505 - }
506 -
507 656 $require_terms = $request->get_param( 'require_terms' );
508 657 if ( ! is_null( $require_terms ) ) {
509 658 $meta_values['require_terms'] = (bool) $require_terms;
510 659 }
@@ -602,14 +751,18 @@
602 751 [ 'status' => 404 ]
603 752 );
604 753 }
605 754
606 - // Create duplicate.
755 + // Create duplicate. The page (post_content) is intentionally NOT copied:
756 + // its blocks carry the original campaign's id, so a fresh page is seeded
757 + // for the duplicate on first publish (or via the Create Campaign Page CTA),
758 + // guaranteeing the correct id and its own default form. The description
759 + // (post_excerpt) is carried over.
607 760 $duplicate_id = wp_insert_post(
608 761 [
609 762 'post_type' => $original->post_type,
610 763 'post_title' => $original->post_title . ' (Copy)',
611 - 'post_content' => $original->post_content,
764 + 'post_excerpt' => $original->post_excerpt,
612 765 'post_status' => 'draft',
613 766 'post_author' => get_current_user_id(),
614 767 ],
615 768 true
@@ -807,9 +960,14 @@
807 960 global $wpdb;
808 961
809 962 // Search for posts containing the donation form block for this campaign.
810 963 // The block stores campaignId in its attributes like: "campaignId":123.
811 - $search_pattern = '%"campaignId":' . $campaign_id . '%';
964 + // Anchor on the value's terminator: without it campaign 12 also matches
965 + // "campaignId":123. Block attributes are JSON, so the id is followed by
966 + // a comma or the closing brace.
967 + $escaped_id = $wpdb->esc_like( '"campaignId":' . $campaign_id );
968 + $search_pattern = '%' . $escaped_id . ',%';
969 + $alt_pattern = '%' . $escaped_id . '}%';
812 970
813 971 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
814 972 $posts = $wpdb->get_results(
815 973 $wpdb->prepare(
@@ -814,14 +972,15 @@
814 972 $posts = $wpdb->get_results(
815 973 $wpdb->prepare(
816 974 "SELECT ID, post_title, post_type, post_status, post_modified
817 975 FROM %i
818 - WHERE post_content LIKE %s
976 + WHERE ( post_content LIKE %s OR post_content LIKE %s )
819 977 AND post_status IN ('publish', 'draft', 'pending', 'private')
820 - AND post_type IN ('page', 'post')
978 + AND post_type IN ('page', 'post', 'suredonation_cmpgn')
821 979 ORDER BY post_modified DESC",
822 980 $wpdb->posts,
823 - $search_pattern
981 + $search_pattern,
982 + $alt_pattern
824 983 )
825 984 );
826 985
827 986 $locations = [];
@@ -872,28 +1031,35 @@
872 1031 if ( ! $post instanceof \WP_Post ) {
873 1032 return [];
874 1033 }
875 1034 // Get real-time stats from donations database table.
876 - $stats = \SureDonation\Inc\Campaigns\Campaign_Stats::get_stats( $post->ID );
877 - $meta = Helper::get_campaign_meta( $post->ID );
1035 + $stats = \SureDonation\Inc\Campaigns\Campaign_Stats::get_stats( $post->ID );
1036 + $meta = Helper::get_campaign_meta( $post->ID );
1037 + $thumbnail_url = get_the_post_thumbnail_url( $post->ID, 'medium' );
878 1038
879 1039 return [
880 - 'id' => $post->ID,
881 - 'title' => $post->post_title,
882 - 'description' => $post->post_content,
883 - 'status' => $stats['campaign_status'],
884 - 'goal_type' => $meta['goal_type'],
885 - 'goal' => $stats['goal_amount'],
886 - 'raised' => $stats['total_raised'],
887 - 'donors' => $stats['donor_count'],
888 - 'progress' => $stats['progress_percentage'],
889 - 'email_settings' => $meta['email_settings'],
890 - 'allow_fees_coverage' => $meta['allow_fees_coverage'],
891 - 'require_terms' => $meta['require_terms'],
892 - 'terms_text' => $meta['terms_text'],
893 - 'created_at' => $post->post_date,
894 - 'modified_at' => $post->post_modified,
895 - 'author' => get_the_author_meta( 'display_name', (int) $post->post_author ),
1040 + 'id' => $post->ID,
1041 + 'title' => $post->post_title,
1042 + 'description' => $post->post_excerpt,
1043 + 'status' => $stats['campaign_status'],
1044 + 'goal_type' => $meta['goal_type'],
1045 + 'goal' => $stats['goal_amount'],
1046 + 'raised' => $stats['total_raised'],
1047 + 'donors' => $stats['donor_count'],
1048 + 'donations' => $stats['donation_count'],
1049 + 'donation_count' => $stats['donation_count'],
1050 + 'progress' => $stats['progress_percentage'],
1051 + 'email_settings' => $meta['email_settings'],
1052 + 'require_terms' => $meta['require_terms'],
1053 + 'terms_text' => $meta['terms_text'],
1054 + 'created_at' => $post->post_date,
1055 + 'modified_at' => $post->post_modified,
1056 + 'author' => get_the_author_meta( 'display_name', (int) $post->post_author ),
1057 + 'has_page' => \SureDonation\Inc\Campaigns\Campaign_Page::has_page( $post->ID ),
1058 + 'permalink' => 'publish' === $post->post_status ? get_permalink( $post->ID ) : null,
1059 + 'edit_link' => admin_url( 'post.php?post=' . $post->ID . '&action=edit' ),
1060 + 'featured_image' => (int) get_post_thumbnail_id( $post->ID ),
1061 + 'featured_image_url' => $thumbnail_url ? $thumbnail_url : '',
896 1062 ];
897 1063 }
898 1064
899 1065 /**