PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / trunk
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management vtrunk
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
suredonation / inc / api / campaigns-api.php

campaigns-api.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management trunk, at inc/api/campaigns-api.php

1,082 lines 30.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Campaigns REST API endpoints.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\API;
9
10 use SureDonation\Inc\Helper;
11 use SureDonation\Inc\Campaign_Templates\Campaign_Templates;
12 use SureDonation\Inc\Campaigns\Campaign_Cpt;
13 use WP_Error;
14 use WP_REST_Request;
15 use WP_REST_Response;
16 use WP_REST_Server;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Campaigns API class.
25 *
26 * @since 0.0.1
27 */
28 class Campaigns_API {
29 /**
30 * Get campaign endpoints.
31 *
32 * @return array<string, mixed>
33 * @since 0.0.1
34 */
35 public function get_endpoints() {
36 return [
37 // Get campaigns list & create campaign.
38 '/campaigns' => [
39 [
40 'methods' => WP_REST_Server::READABLE,
41 'callback' => [ $this, 'get_campaigns' ],
42 'permission_callback' => [ $this, 'check_permissions' ],
43 ],
44 [
45 'methods' => WP_REST_Server::CREATABLE,
46 'callback' => [ $this, 'create_campaign' ],
47 'permission_callback' => [ $this, 'check_permissions' ],
48 'args' => [
49 'title' => [
50 'required' => true,
51 'sanitize_callback' => 'sanitize_text_field',
52 ],
53 'description' => [
54 'sanitize_callback' => 'wp_kses_post',
55 ],
56 'goal_type' => [
57 'default' => 'raised_amount',
58 'enum' => [ 'raised_amount', 'donation_count' ],
59 'sanitize_callback' => 'sanitize_text_field',
60 ],
61 'goal_amount' => [
62 'sanitize_callback' => static function ( $value ) {
63 return '' !== $value ? floatval( $value ) : '';
64 },
65 'validate_callback' => static function ( $value ) {
66 if ( '' === $value ) {
67 return true;
68 }
69 $amount = floatval( $value );
70 return $amount >= 0 && $amount <= 999999999.99;
71 },
72 ],
73 'campaign_status' => [
74 'default' => 'active',
75 'enum' => [ 'active', 'paused', 'completed' ],
76 ],
77 'featured_image' => [
78 'sanitize_callback' => 'absint',
79 ],
80 'email_settings' => [
81 'type' => 'object',
82 'sanitize_callback' => [ $this, 'sanitize_email_settings' ],
83 ],
84 'require_terms' => [
85 'type' => 'boolean',
86 'sanitize_callback' => 'rest_sanitize_boolean',
87 ],
88 'terms_text' => [
89 'sanitize_callback' => 'sanitize_text_field',
90 ],
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 },
97 ],
98 ],
99 ],
100 ],
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
122 // Update campaign.
123 '/campaigns/(?P<id>\d+)' => [
124 [
125 'methods' => WP_REST_Server::READABLE,
126 'callback' => [ $this, 'get_campaign' ],
127 'permission_callback' => [ $this, 'check_permissions' ],
128 'args' => [
129 'id' => [
130 'required' => true,
131 'validate_callback' => static function ( $param ) {
132 return is_numeric( $param );
133 },
134 ],
135 ],
136 ],
137 [
138 'methods' => WP_REST_Server::EDITABLE,
139 'callback' => [ $this, 'update_campaign' ],
140 'permission_callback' => [ $this, 'check_permissions' ],
141 'args' => [
142 'id' => [
143 'required' => true,
144 'validate_callback' => static function ( $param ) {
145 return is_numeric( $param );
146 },
147 ],
148 'title' => [
149 'sanitize_callback' => 'sanitize_text_field',
150 ],
151 'description' => [
152 'sanitize_callback' => 'wp_kses_post',
153 ],
154 'goal_type' => [
155 'enum' => [ 'raised_amount', 'donation_count' ],
156 'sanitize_callback' => 'sanitize_text_field',
157 ],
158 'goal_amount' => [
159 'sanitize_callback' => static function ( $value ) {
160 return '' !== $value ? floatval( $value ) : '';
161 },
162 'validate_callback' => static function ( $value ) {
163 if ( '' === $value ) {
164 return true;
165 }
166 $amount = floatval( $value );
167 return $amount >= 0 && $amount <= 999999999.99;
168 },
169 ],
170 'campaign_status' => [
171 'enum' => [ 'active', 'paused', 'completed' ],
172 ],
173 'featured_image' => [
174 'sanitize_callback' => 'absint',
175 ],
176 'email_settings' => [
177 'type' => 'object',
178 'sanitize_callback' => [ $this, 'sanitize_email_settings' ],
179 ],
180 'require_terms' => [
181 'type' => 'boolean',
182 'sanitize_callback' => 'rest_sanitize_boolean',
183 ],
184 'terms_text' => [
185 'sanitize_callback' => 'sanitize_text_field',
186 ],
187 ],
188 ],
189 [
190 'methods' => WP_REST_Server::DELETABLE,
191 'callback' => [ $this, 'delete_campaign' ],
192 'permission_callback' => [ $this, 'check_permissions' ],
193 'args' => [
194 'id' => [
195 'required' => true,
196 'validate_callback' => static function ( $param ) {
197 return is_numeric( $param );
198 },
199 ],
200 ],
201 ],
202 ],
203
204 // Update campaign status.
205 '/campaigns/(?P<id>\d+)/status' => [
206 'methods' => WP_REST_Server::EDITABLE,
207 'callback' => [ $this, 'update_campaign_status' ],
208 'permission_callback' => [ $this, 'check_permissions' ],
209 'args' => [
210 'id' => [
211 'required' => true,
212 'validate_callback' => static function ( $param ) {
213 return is_numeric( $param );
214 },
215 ],
216 'status' => [
217 'required' => true,
218 'enum' => [ 'publish', 'draft', 'trash' ],
219 ],
220 ],
221 ],
222
223 // Duplicate campaign.
224 '/campaigns/(?P<id>\d+)/duplicate' => [
225 'methods' => WP_REST_Server::CREATABLE,
226 'callback' => [ $this, 'duplicate_campaign' ],
227 'permission_callback' => [ $this, 'check_permissions' ],
228 'args' => [
229 'id' => [
230 'required' => true,
231 'validate_callback' => static function ( $param ) {
232 return is_numeric( $param );
233 },
234 ],
235 ],
236 ],
237
238 // Bulk actions.
239 '/campaigns/bulk' => [
240 'methods' => WP_REST_Server::EDITABLE,
241 'callback' => [ $this, 'bulk_action' ],
242 'permission_callback' => [ $this, 'check_permissions' ],
243 'args' => [
244 'action' => [
245 'required' => true,
246 'enum' => [ 'delete', 'publish', 'draft', 'trash' ],
247 ],
248 'ids' => [
249 'required' => true,
250 'validate_callback' => static function ( $param ) {
251 return is_array( $param ) && ! empty( $param );
252 },
253 ],
254 ],
255 ],
256
257 // Get form locations for a campaign.
258 '/campaigns/(?P<id>\d+)/form-locations' => [
259 'methods' => WP_REST_Server::READABLE,
260 'callback' => [ $this, 'get_form_locations' ],
261 'permission_callback' => [ $this, 'check_permissions' ],
262 'args' => [
263 'id' => [
264 'required' => true,
265 'validate_callback' => static function ( $param ) {
266 return is_numeric( $param );
267 },
268 ],
269 ],
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 ],
286 ];
287 }
288
289 /**
290 * Get a single campaign by ID.
291 *
292 * @param WP_REST_Request $request Request object.
293 * @return WP_REST_Response|WP_Error Response object.
294 * @since 0.0.1
295 */
296 public function get_campaign( $request ) {
297 $campaign_id = absint( $request->get_param( 'id' ) );
298
299 // Get the campaign post.
300 $post = get_post( $campaign_id );
301
302 // Check if campaign exists and is of correct type.
303 if ( ! $post || SUREDONATION_POST_TYPE !== $post->post_type ) {
304 return new WP_Error(
305 'campaign_not_found',
306 __( 'Campaign not found.', 'suredonation' ),
307 [ 'status' => 404 ]
308 );
309 }
310
311 // Format and return the campaign data.
312 $campaign = $this->format_campaign( $post );
313
314 return new WP_REST_Response(
315 [
316 'success' => true,
317 'campaign' => $campaign,
318 ],
319 200
320 );
321 }
322
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 /**
361 * Get campaigns list with filters, sorting, and pagination.
362 *
363 * @param WP_REST_Request $request Request object.
364 * @return WP_REST_Response|WP_Error Response object.
365 * @since 0.0.1
366 */
367 public function get_campaigns( $request ) {
368 $page = $request->get_param( 'page' ) ?? 1;
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';
378
379 // Build query args.
380 $args = [
381 'post_type' => SUREDONATION_POST_TYPE,
382 'posts_per_page' => $per_page,
383 'paged' => -1 === $per_page ? 1 : absint( $page ),
384 'orderby' => $this->get_orderby_field( $sort_by ),
385 'order' => strtoupper( $order ),
386 ];
387
388 // Add status filter.
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 ) {
403 $args['post_status'] = $status;
404 } else {
405 $args['post_status'] = [ 'publish', 'draft' ];
406 }
407
408 // Add search.
409 if ( ! empty( $search ) ) {
410 $args['s'] = sanitize_text_field( $search );
411 }
412
413 // Execute query.
414 $query = new \WP_Query( $args );
415
416 // Format campaigns data.
417 $campaigns = [];
418 if ( $query->have_posts() ) {
419 foreach ( $query->posts as $post ) {
420 $post_obj = $post instanceof \WP_Post ? $post : get_post( $post );
421 if ( $post_obj instanceof \WP_Post ) {
422 $campaigns[] = $this->format_campaign( $post_obj );
423 }
424 }
425 }
426
427 // Prepare response.
428 return new WP_REST_Response(
429 [
430 'campaigns' => $campaigns,
431 'pagination' => [
432 'total' => (int) $query->found_posts,
433 'total_pages' => (int) $query->max_num_pages,
434 'per_page' => (int) $per_page,
435 'current' => (int) $page,
436 ],
437 ]
438 );
439 }
440
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 /**
482 * Create a new campaign.
483 *
484 * @param WP_REST_Request $request Request object.
485 * @return WP_REST_Response|WP_Error Response object.
486 * @since 0.0.1
487 */
488 public function create_campaign( $request ) {
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' ) );
493 $goal_type = $request->get_param( 'goal_type' ) ?? 'raised_amount';
494 $goal_amount = $request->get_param( 'goal_amount' );
495 $campaign_status = $request->get_param( 'campaign_status' ) ?? 'active';
496 $featured_image = $request->get_param( 'featured_image' );
497 $template_id = (string) $request->get_param( 'template_id' );
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
518 // Create the campaign post.
519 $post_id = wp_insert_post( $insert_args, true );
520
521 if ( is_wp_error( $post_id ) ) {
522 return new WP_Error(
523 'create_failed',
524 __( 'Failed to create campaign.', 'suredonation' ),
525 [ 'status' => 500 ]
526 );
527 }
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
543 // Save campaign meta as single consolidated meta key.
544 $meta_values = [
545 'goal_type' => $goal_type,
546 'goal_amount' => isset( $goal_amount ) && '' !== $goal_amount ? floatval( $goal_amount ) : 0,
547 'campaign_status' => $campaign_status,
548 ];
549
550 $email_settings = $request->get_param( 'email_settings' );
551 if ( ! empty( $email_settings ) ) {
552 $meta_values['email_settings'] = $email_settings;
553 }
554
555 $require_terms = $request->get_param( 'require_terms' );
556 if ( ! is_null( $require_terms ) ) {
557 $meta_values['require_terms'] = (bool) $require_terms;
558 }
559
560 $terms_text = $request->get_param( 'terms_text' );
561 if ( ! is_null( $terms_text ) ) {
562 $meta_values['terms_text'] = $terms_text;
563 }
564
565 Helper::update_campaign_meta( $post_id, $meta_values );
566
567 // Set featured image if provided.
568 if ( ! empty( $featured_image ) ) {
569 set_post_thumbnail( $post_id, $featured_image );
570 }
571
572 return new WP_REST_Response(
573 [
574 'success' => true,
575 'message' => __( 'Campaign created successfully.', 'suredonation' ),
576 'campaign' => $this->format_campaign( get_post( $post_id ) ),
577 ],
578 201
579 );
580 }
581
582 /**
583 * Update an existing campaign.
584 *
585 * @param WP_REST_Request $request Request object.
586 * @return WP_REST_Response|WP_Error Response object.
587 * @since 0.0.1
588 */
589 public function update_campaign( $request ) {
590 $campaign_id = $request->get_param( 'id' );
591
592 // Check if campaign exists.
593 $campaign = get_post( $campaign_id );
594 if ( ! $campaign || SUREDONATION_POST_TYPE !== $campaign->post_type ) {
595 return new WP_Error(
596 'campaign_not_found',
597 __( 'Campaign not found.', 'suredonation' ),
598 [ 'status' => 404 ]
599 );
600 }
601
602 // Prepare post data.
603 $post_data = [
604 'ID' => $campaign_id,
605 ];
606
607 $title = $request->get_param( 'title' );
608 $description = $request->get_param( 'description' );
609
610 if ( ! empty( $title ) ) {
611 $post_data['post_title'] = $title;
612 }
613
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;
618 }
619
620 // Update post if there are changes.
621 if ( count( $post_data ) > 1 ) {
622 $result = wp_update_post( $post_data, true );
623 if ( is_wp_error( $result ) ) {
624 return new WP_Error(
625 'update_failed',
626 __( 'Failed to update campaign.', 'suredonation' ),
627 [ 'status' => 500 ]
628 );
629 }
630 }
631
632 // Update campaign meta (consolidated single key).
633 $meta_values = [];
634 $featured_image = $request->get_param( 'featured_image' );
635
636 $goal_type = $request->get_param( 'goal_type' );
637 if ( ! is_null( $goal_type ) ) {
638 $meta_values['goal_type'] = $goal_type;
639 }
640
641 $goal_amount = $request->get_param( 'goal_amount' );
642 if ( ! is_null( $goal_amount ) ) {
643 $meta_values['goal_amount'] = '' !== $goal_amount ? floatval( $goal_amount ) : 0;
644 }
645
646 $campaign_status = $request->get_param( 'campaign_status' );
647 if ( ! empty( $campaign_status ) ) {
648 $meta_values['campaign_status'] = $campaign_status;
649 }
650
651 $email_settings = $request->get_param( 'email_settings' );
652 if ( ! is_null( $email_settings ) ) {
653 $meta_values['email_settings'] = $email_settings;
654 }
655
656 $require_terms = $request->get_param( 'require_terms' );
657 if ( ! is_null( $require_terms ) ) {
658 $meta_values['require_terms'] = (bool) $require_terms;
659 }
660
661 $terms_text = $request->get_param( 'terms_text' );
662 if ( ! is_null( $terms_text ) ) {
663 $meta_values['terms_text'] = $terms_text;
664 }
665
666 if ( ! empty( $meta_values ) ) {
667 Helper::update_campaign_meta( $campaign_id, $meta_values );
668 }
669
670 // Update featured image if provided.
671 if ( ! is_null( $featured_image ) ) {
672 if ( empty( $featured_image ) ) {
673 delete_post_thumbnail( $campaign_id );
674 } else {
675 set_post_thumbnail( $campaign_id, $featured_image );
676 }
677 }
678
679 return new WP_REST_Response(
680 [
681 'success' => true,
682 'message' => __( 'Campaign updated successfully.', 'suredonation' ),
683 'campaign' => $this->format_campaign( get_post( $campaign_id ) ),
684 ],
685 200
686 );
687 }
688
689 /**
690 * Update campaign status.
691 *
692 * @param WP_REST_Request $request Request object.
693 * @return WP_REST_Response|WP_Error Response object.
694 * @since 0.0.1
695 */
696 public function update_campaign_status( $request ) {
697 $campaign_id = absint( $request->get_param( 'id' ) );
698 $status = $request->get_param( 'status' );
699
700 // Verify the post exists and belongs to our post type.
701 $campaign = get_post( $campaign_id );
702 if ( ! $campaign || SUREDONATION_POST_TYPE !== $campaign->post_type ) {
703 return new WP_Error(
704 'campaign_not_found',
705 __( 'Campaign not found.', 'suredonation' ),
706 [ 'status' => 404 ]
707 );
708 }
709
710 $result = wp_update_post(
711 [
712 'ID' => $campaign_id,
713 'post_status' => $status,
714 ],
715 true
716 );
717
718 if ( is_wp_error( $result ) ) {
719 return new WP_Error(
720 'update_failed',
721 __( 'Failed to update campaign status.', 'suredonation' ),
722 [ 'status' => 500 ]
723 );
724 }
725
726 return new WP_REST_Response(
727 [
728 'success' => true,
729 'message' => __( 'Campaign status updated successfully.', 'suredonation' ),
730 ],
731 200
732 );
733 }
734
735 /**
736 * Duplicate campaign.
737 *
738 * @param WP_REST_Request $request Request object.
739 * @return WP_REST_Response|WP_Error Response object.
740 * @since 0.0.1
741 */
742 public function duplicate_campaign( $request ) {
743 $campaign_id = $request->get_param( 'id' );
744
745 // Get original campaign.
746 $original = get_post( $campaign_id );
747 if ( ! $original || SUREDONATION_POST_TYPE !== $original->post_type ) {
748 return new WP_Error(
749 'campaign_not_found',
750 __( 'Campaign not found.', 'suredonation' ),
751 [ 'status' => 404 ]
752 );
753 }
754
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.
760 $duplicate_id = wp_insert_post(
761 [
762 'post_type' => $original->post_type,
763 'post_title' => $original->post_title . ' (Copy)',
764 'post_excerpt' => $original->post_excerpt,
765 'post_status' => 'draft',
766 'post_author' => get_current_user_id(),
767 ],
768 true
769 );
770
771 if ( is_wp_error( $duplicate_id ) ) {
772 return new WP_Error(
773 'duplicate_failed',
774 __( 'Failed to duplicate campaign.', 'suredonation' ),
775 [ 'status' => 500 ]
776 );
777 }
778
779 // Copy consolidated campaign meta.
780 $meta = get_post_meta( $campaign_id, Helper::SUREDONATION_CAMPAIGN_META_KEY, true );
781 if ( ! empty( $meta ) ) {
782 update_post_meta( $duplicate_id, Helper::SUREDONATION_CAMPAIGN_META_KEY, $meta );
783 }
784
785 return new WP_REST_Response(
786 [
787 'success' => true,
788 'message' => __( 'Campaign duplicated successfully.', 'suredonation' ),
789 'campaign' => $this->format_campaign( get_post( $duplicate_id ) ),
790 ],
791 201
792 );
793 }
794
795 /**
796 * Delete campaign.
797 *
798 * @param WP_REST_Request $request Request object.
799 * @return WP_REST_Response|WP_Error Response object.
800 * @since 0.0.1
801 */
802 public function delete_campaign( $request ) {
803 $campaign_id = $request->get_param( 'id' );
804
805 $campaign = get_post( $campaign_id );
806
807 if ( ! $campaign || SUREDONATION_POST_TYPE !== $campaign->post_type ) {
808 return new WP_Error(
809 'invalid_campaign',
810 __( 'Invalid campaign ID.', 'suredonation' ),
811 [ 'status' => 404 ]
812 );
813 }
814
815 $result = wp_delete_post( $campaign_id, true );
816
817 if ( ! $result ) {
818 return new WP_Error(
819 'delete_failed',
820 __( 'Failed to delete campaign.', 'suredonation' ),
821 [ 'status' => 500 ]
822 );
823 }
824
825 return new WP_REST_Response(
826 [
827 'success' => true,
828 'message' => __( 'Campaign deleted successfully.', 'suredonation' ),
829 ],
830 200
831 );
832 }
833
834 /**
835 * Bulk action on campaigns.
836 *
837 * @param WP_REST_Request $request Request object.
838 * @return WP_REST_Response|WP_Error Response object.
839 * @since 0.0.1
840 */
841 public function bulk_action( $request ) {
842 $action = $request->get_param( 'action' );
843 $ids = $request->get_param( 'ids' );
844
845 $success_count = 0;
846 $error_count = 0;
847
848 foreach ( $ids as $id ) {
849 $id = absint( $id );
850
851 // Verify each post belongs to our post type before operating on it.
852 $post = get_post( $id );
853 if ( ! $post || SUREDONATION_POST_TYPE !== $post->post_type ) {
854 ++$error_count;
855 continue;
856 }
857
858 if ( 'delete' === $action ) {
859 $result = wp_delete_post( $id, true );
860 if ( $result ) {
861 ++$success_count;
862 } else {
863 ++$error_count;
864 }
865 } else {
866 $result = wp_update_post(
867 [
868 'ID' => $id,
869 'post_status' => $action,
870 ],
871 true
872 );
873 if ( ! is_wp_error( $result ) ) {
874 ++$success_count;
875 } else {
876 ++$error_count;
877 }
878 }
879 }
880
881 return new WP_REST_Response(
882 [
883 'success' => true,
884 'message' => sprintf(
885 // translators: %1$d: success count, %2$d: error count.
886 __( 'Bulk action completed. Success: %1$d, Failed: %2$d', 'suredonation' ),
887 $success_count,
888 $error_count
889 ),
890 'success_count' => $success_count,
891 'error_count' => $error_count,
892 ],
893 200
894 );
895 }
896
897 /**
898 * Sanitize email settings.
899 *
900 * @param array<string, mixed> $settings Email settings to sanitize.
901 * @return array<string, mixed> Sanitized email settings.
902 * @since 0.0.1
903 */
904 public function sanitize_email_settings( $settings ) {
905 if ( empty( $settings ) || ! is_array( $settings ) ) {
906 return [];
907 }
908
909 $sanitized = [];
910
911 // Sanitize boolean.
912 $sanitized['enabled'] = ! empty( $settings['enabled'] );
913
914 // Sanitize text fields.
915 if ( isset( $settings['subject'] ) ) {
916 $sanitized['subject'] = sanitize_text_field( Helper::get_string_value( $settings['subject'] ) );
917 }
918
919 if ( isset( $settings['from_name'] ) ) {
920 $sanitized['from_name'] = sanitize_text_field( Helper::get_string_value( $settings['from_name'] ) );
921 }
922
923 // Sanitize email fields.
924 if ( isset( $settings['from_email'] ) ) {
925 $sanitized['from_email'] = sanitize_email( Helper::get_string_value( $settings['from_email'] ) );
926 }
927
928 if ( isset( $settings['reply_to'] ) ) {
929 $sanitized['reply_to'] = sanitize_email( Helper::get_string_value( $settings['reply_to'] ) );
930 }
931
932 // Sanitize email body (allow HTML).
933 if ( isset( $settings['email_body'] ) ) {
934 $sanitized['email_body'] = wp_kses_post( Helper::get_string_value( $settings['email_body'] ) );
935 }
936
937 return $sanitized;
938 }
939
940 /**
941 * Get pages/posts where donation form block is used for this campaign.
942 *
943 * @param WP_REST_Request $request Request object.
944 * @return WP_REST_Response|WP_Error Response object.
945 * @since 0.0.1
946 */
947 public function get_form_locations( $request ) {
948 $campaign_id = absint( $request->get_param( 'id' ) );
949
950 // Check if campaign exists.
951 $campaign = get_post( $campaign_id );
952 if ( ! $campaign || SUREDONATION_POST_TYPE !== $campaign->post_type ) {
953 return new WP_Error(
954 'campaign_not_found',
955 __( 'Campaign not found.', 'suredonation' ),
956 [ 'status' => 404 ]
957 );
958 }
959
960 global $wpdb;
961
962 // Search for posts containing the donation form block for this campaign.
963 // The block stores campaignId in its attributes like: "campaignId":123.
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 . '}%';
970
971 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
972 $posts = $wpdb->get_results(
973 $wpdb->prepare(
974 "SELECT ID, post_title, post_type, post_status, post_modified
975 FROM %i
976 WHERE ( post_content LIKE %s OR post_content LIKE %s )
977 AND post_status IN ('publish', 'draft', 'pending', 'private')
978 AND post_type IN ('page', 'post', 'suredonation_cmpgn')
979 ORDER BY post_modified DESC",
980 $wpdb->posts,
981 $search_pattern,
982 $alt_pattern
983 )
984 );
985
986 $locations = [];
987 if ( $posts ) {
988 foreach ( $posts as $post ) {
989 $edit_url = admin_url( 'post.php?post=' . $post->ID . '&action=edit' );
990 $view_url = get_permalink( $post->ID );
991
992 $locations[] = [
993 'id' => (int) $post->ID,
994 'title' => $post->post_title,
995 'type' => $post->post_type,
996 'status' => $post->post_status,
997 'modified_at' => $post->post_modified,
998 'edit_url' => $edit_url,
999 'view_url' => 'publish' === $post->post_status ? $view_url : null,
1000 ];
1001 }
1002 }
1003
1004 return new WP_REST_Response(
1005 [
1006 'success' => true,
1007 'locations' => $locations,
1008 ],
1009 200
1010 );
1011 }
1012
1013 /**
1014 * Check if user has permission to manage campaigns.
1015 *
1016 * @return bool True if user has permission.
1017 * @since 0.0.1
1018 */
1019 public function check_permissions() {
1020 return current_user_can( 'manage_options' );
1021 }
1022
1023 /**
1024 * Format campaign data for API response.
1025 *
1026 * @param \WP_Post|null $post Post object.
1027 * @return array<string,mixed> Formatted campaign data.
1028 * @since 0.0.1
1029 */
1030 private function format_campaign( $post ) {
1031 if ( ! $post instanceof \WP_Post ) {
1032 return [];
1033 }
1034 // Get real-time stats from donations database table.
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' );
1038
1039 return [
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 : '',
1062 ];
1063 }
1064
1065 /**
1066 * Get orderby field based on sort parameter.
1067 *
1068 * @param string $sort_by Sort parameter.
1069 * @return string WordPress orderby field.
1070 * @since 0.0.1
1071 */
1072 private function get_orderby_field( $sort_by ) {
1073 $orderby_map = [
1074 'date' => 'date',
1075 'title' => 'title',
1076 'status' => 'post_status',
1077 ];
1078
1079 return $orderby_map[ $sort_by ] ?? 'date';
1080 }
1081 }
1082