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 / onboarding-api.php

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

535 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Onboarding REST endpoints.
4 *
5 * Routes (under `suredonation/v1`):
6 * - GET /onboarding/get-status — return { completed: 'yes'|'no' }
7 * - POST /onboarding/set-status — write completion + optional analytics
8 * - POST /onboarding/create-campaign — create a published suredonation_cmpgn
9 * - POST /onboarding/user-details — persist lead capture (free-only step)
10 * - POST /onboarding/set-tour-seen — mark the campaign guided tour seen (per-user meta)
11 * - POST /onboarding/set-tour-progress — persist the tour's resume step (per-user meta)
12 * - POST /onboarding/track-tour — fire the one-time "tour shown" analytics event
13 *
14 * @package SureDonation
15 */
16
17 namespace SureDonation\Inc\API;
18
19 use SureDonation\Inc\Campaigns\Campaign_Cpt;
20 use SureDonation\Inc\Helper;
21 use SureDonation\Inc\Onboarding;
22 use WP_Error;
23 use WP_REST_Request;
24 use WP_REST_Response;
25 use WP_REST_Server;
26
27 if ( ! defined( 'ABSPATH' ) ) {
28 exit;
29 }
30
31 /**
32 * Onboarding REST API endpoints.
33 *
34 * @since 1.0.0
35 */
36 class Onboarding_API {
37 /**
38 * Allowed goal types for the create-campaign endpoint.
39 *
40 * @since 1.0.0
41 * @var array<int,string>
42 */
43 private const GOAL_TYPES = [ 'raised_amount', 'donation_count' ];
44
45 /**
46 * User-meta key for the "campaign guided tour seen" flag.
47 *
48 * Stored per-user (not a site option) so the "seen once" state follows the
49 * user across devices, unlike the onboarding completion flag.
50 *
51 * @since 1.5.0
52 * @var string
53 */
54 public const TOUR_SEEN_META = 'suredonation_campaign_tour_seen';
55
56 /**
57 * User-meta key for the campaign guided tour's resume point.
58 *
59 * Holds the step key the tour should resume at (empty string = no saved
60 * progress). Persisted per-user so an interactive, multi-surface run
61 * survives a page reload or a trip to the form builder / page editor.
62 *
63 * @since 1.5.0
64 * @var string
65 */
66 public const TOUR_PROGRESS_META = 'suredonation_campaign_tour_progress';
67
68 /**
69 * User-meta key arming the first-run tour for a specific campaign.
70 *
71 * Holds the campaign id whose detail page should auto-start the guided tour
72 * the first time it is opened. The onboarding wizard sets this because it
73 * creates a campaign and then exits via a full page reload to the dashboard
74 * / payments screen — so the in-memory `justCreated` navigation flag the SPA
75 * flow relies on never reaches the campaign page. Cleared once the tour is
76 * seen. Absent / 0 = nothing pending.
77 *
78 * @since 1.5.0
79 * @var string
80 */
81 public const TOUR_PENDING_META = 'suredonation_campaign_tour_pending';
82
83 /**
84 * Return endpoint definitions for Rest_Api to register.
85 *
86 * @return array<string,mixed>
87 * @since 1.0.0
88 */
89 public function get_endpoints() {
90 return [
91 '/onboarding/get-status' => [
92 'methods' => WP_REST_Server::READABLE,
93 'callback' => [ $this, 'get_status' ],
94 'permission_callback' => [ $this, 'check_permissions' ],
95 ],
96 '/onboarding/set-status' => [
97 'methods' => WP_REST_Server::EDITABLE,
98 'callback' => [ $this, 'set_status' ],
99 'permission_callback' => [ $this, 'check_permissions' ],
100 ],
101 '/onboarding/create-campaign' => [
102 'methods' => WP_REST_Server::EDITABLE,
103 'callback' => [ $this, 'create_campaign' ],
104 'permission_callback' => [ $this, 'check_permissions' ],
105 ],
106 '/onboarding/user-details' => [
107 'methods' => WP_REST_Server::EDITABLE,
108 'callback' => [ $this, 'save_user_details' ],
109 'permission_callback' => [ $this, 'check_permissions' ],
110 ],
111 '/onboarding/set-tour-seen' => [
112 'methods' => WP_REST_Server::EDITABLE,
113 'callback' => [ $this, 'set_tour_seen' ],
114 'permission_callback' => [ $this, 'check_permissions' ],
115 ],
116 '/onboarding/set-tour-progress' => [
117 'methods' => WP_REST_Server::EDITABLE,
118 'callback' => [ $this, 'set_tour_progress' ],
119 'permission_callback' => [ $this, 'check_permissions' ],
120 ],
121 '/onboarding/track-tour' => [
122 'methods' => WP_REST_Server::EDITABLE,
123 'callback' => [ $this, 'track_tour' ],
124 'permission_callback' => [ $this, 'check_permissions' ],
125 'args' => [
126 // Omitted => "tour shown", the endpoint's original meaning.
127 'event' => [
128 'type' => 'string',
129 'required' => false,
130 'default' => '',
131 'validate_callback' => static function ( $value ) {
132 return in_array(
133 $value,
134 [ '', 'shown', 'completed', 'dismissed', 'opted_out', 'manual_started' ],
135 true
136 );
137 },
138 ],
139 // Free-form step key, only meaningful for `dismissed`. It is
140 // sent on to analytics, so keep it to a bounded slug.
141 'step' => [
142 'type' => 'string',
143 'required' => false,
144 'default' => '',
145 'sanitize_callback' => 'sanitize_key',
146 ],
147 ],
148 ],
149 ];
150 }
151
152 /**
153 * Permission gate. Write requests (POST/PUT/PATCH/DELETE) additionally
154 * require a valid wp_rest nonce, matching Donors_API — onboarding forwards a
155 * lead to the BSF CRM, so the write boundary is pinned explicitly.
156 *
157 * @param \WP_REST_Request<array<string,mixed>>|null $request Current request.
158 * @return bool|\WP_Error
159 * @since 1.0.0
160 */
161 public function check_permissions( $request = null ) {
162 if ( ! current_user_can( 'manage_options' ) ) {
163 return false;
164 }
165
166 if ( $request instanceof \WP_REST_Request ) {
167 $method = strtoupper( $request->get_method() );
168 if ( in_array( $method, [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
169 $nonce = $request->get_header( 'X-WP-Nonce' );
170 if ( empty( $nonce ) ) {
171 $nonce_param = $request->get_param( '_wpnonce' );
172 $nonce = is_string( $nonce_param ) ? $nonce_param : '';
173 }
174 if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
175 return new \WP_Error(
176 'rest_forbidden',
177 __( 'Invalid or missing nonce.', 'suredonation' ),
178 [ 'status' => 403 ]
179 );
180 }
181 }
182 }
183
184 return true;
185 }
186
187 /**
188 * GET /onboarding/get-status.
189 *
190 * @return WP_REST_Response
191 * @since 1.0.0
192 */
193 public function get_status() {
194 return new WP_REST_Response(
195 [
196 'completed' => Onboarding::get_instance()->is_completed() ? 'yes' : 'no',
197 ]
198 );
199 }
200
201 /**
202 * POST /onboarding/set-status.
203 *
204 * @param WP_REST_Request $request Request.
205 * @return WP_REST_Response
206 * @since 1.0.0
207 */
208 public function set_status( $request ) {
209 $completed = $request->get_param( 'completed' );
210 Onboarding::get_instance()->set_completed( 'yes' === $completed ? 'yes' : 'no' );
211
212 return new WP_REST_Response( [ 'success' => true ] );
213 }
214
215 /**
216 * POST /onboarding/create-campaign.
217 *
218 * Creates a published campaign post + writes its meta. Returns the new
219 * campaign id + edit URL so the JS can persist it in onboarding state.
220 *
221 * @param WP_REST_Request $request Request.
222 * @return WP_REST_Response|WP_Error
223 * @since 1.0.0
224 */
225 public function create_campaign( $request ) {
226 $name = sanitize_text_field( (string) $request->get_param( 'name' ) );
227 $goal_type = (string) $request->get_param( 'goal_type' );
228 $description = wp_kses_post( (string) $request->get_param( 'description' ) );
229
230 // Clamp to a non-negative, finite, sane range. The JS already
231 // validates this, but the endpoint is callable directly by any
232 // manage_options user and shouldn't trust client-side bounds.
233 $goal_amount = (float) $request->get_param( 'goal_amount' );
234 if ( ! is_finite( $goal_amount ) || $goal_amount < 0 ) {
235 $goal_amount = 0.0;
236 }
237 // Cap at 1e9 so a stray "1e308" can't poison campaign meta.
238 $goal_amount = min( $goal_amount, 1000000000.0 );
239
240 if ( '' === trim( $name ) ) {
241 return new WP_Error(
242 'suredonation_campaign_name_required',
243 __( 'Campaign name is required.', 'suredonation' ),
244 [ 'status' => 400 ]
245 );
246 }
247
248 if ( ! in_array( $goal_type, self::GOAL_TYPES, true ) ) {
249 $goal_type = 'raised_amount';
250 }
251
252 // Publish the campaign so it behaves like one created via the normal
253 // flow: the save_post_suredonation_cmpgn hook auto-creates its default
254 // donation form, and the campaign becomes selectable in the Donation
255 // Form block (whose query is limited to published campaigns).
256 $result = wp_insert_post(
257 [
258 'post_type' => Campaign_Cpt::POST_TYPE,
259 'post_status' => 'publish',
260 'post_title' => $name,
261 'post_excerpt' => $description,
262 'post_author' => get_current_user_id(),
263 ],
264 true
265 );
266
267 if ( is_wp_error( $result ) ) {
268 return new WP_Error(
269 'suredonation_campaign_create_failed',
270 $result->get_error_message(),
271 [ 'status' => 500 ]
272 );
273 }
274
275 $campaign_id = (int) $result;
276
277 if ( $campaign_id <= 0 ) {
278 return new WP_Error(
279 'suredonation_campaign_create_failed',
280 __( 'Could not create the campaign.', 'suredonation' ),
281 [ 'status' => 500 ]
282 );
283 }
284
285 Helper::update_campaign_meta(
286 $campaign_id,
287 [
288 'goal_type' => $goal_type,
289 'goal_amount' => $goal_amount,
290 ]
291 );
292
293 // Arm the first-run tour for this campaign: the wizard leaves via a full
294 // page reload, so the SPA's transient `justCreated` flag never reaches the
295 // campaign page. The pending pointer makes the tour fire the first time the
296 // user opens this campaign's detail page instead.
297 update_user_meta( get_current_user_id(), self::TOUR_PENDING_META, $campaign_id );
298
299 return new WP_REST_Response(
300 [
301 'success' => true,
302 'campaign_id' => $campaign_id,
303 'edit_url' => admin_url( 'admin.php?page=suredonation#/campaigns/' . $campaign_id ),
304 ]
305 );
306 }
307
308 /**
309 * POST /onboarding/user-details.
310 *
311 * Stores the lead-capture payload under suredonation_options so we
312 * don't re-prompt on subsequent setup passes.
313 *
314 * @param WP_REST_Request $request Request.
315 * @return WP_REST_Response
316 * @since 1.0.0
317 */
318 public function save_user_details( $request ) {
319 $onboarding = Onboarding::get_instance();
320
321 $payload = [
322 'first_name' => sanitize_text_field( (string) $request->get_param( 'first_name' ) ),
323 'last_name' => sanitize_text_field( (string) $request->get_param( 'last_name' ) ),
324 'email' => sanitize_email( (string) $request->get_param( 'email' ) ),
325 'opted_in' => (bool) $request->get_param( 'opted_in' ),
326 ];
327
328 $onboarding->set_user_details( $payload );
329
330 update_site_option(
331 'suredonation_usage_optin',
332 $payload['opted_in'] ? 'yes' : 'no'
333 );
334
335 if ( ! $onboarding->is_lead_sent() && $this->forward_lead_to_crm( $payload ) ) {
336 $onboarding->mark_lead_sent();
337 }
338
339 /**
340 * Fires after onboarding lead-capture details are persisted.
341 *
342 * @since 1.0.0
343 *
344 * @param array<string,mixed> $payload Sanitised payload.
345 */
346 do_action( 'suredonation_onboarding_user_details_saved', $payload );
347
348 return new WP_REST_Response( [ 'success' => true ] );
349 }
350
351 /**
352 * POST /onboarding/set-tour-seen.
353 *
354 * Marks the campaign guided tour as seen for the current user so it does
355 * not reappear on future campaign creations. Written when the user either
356 * completes the tour or opts out via "Don't show again".
357 *
358 * @return WP_REST_Response
359 * @since 1.5.0
360 */
361 public function set_tour_seen() {
362 $user_id = get_current_user_id();
363 update_user_meta( $user_id, self::TOUR_SEEN_META, 'yes' );
364 // The tour is finished with — drop any saved resume point and the
365 // wizard's pending pointer so neither can re-trigger it.
366 delete_user_meta( $user_id, self::TOUR_PROGRESS_META );
367 delete_user_meta( $user_id, self::TOUR_PENDING_META );
368
369 return new WP_REST_Response( [ 'success' => true ] );
370 }
371
372 /**
373 * POST /onboarding/set-tour-progress.
374 *
375 * Persists the step the guided tour should resume at (per-user), so an
376 * interactive run survives a reload or a trip to another screen. Stored as
377 * `"<campaign_id>:<step>"` so a run only resumes on the campaign it started
378 * on. An empty `step` (or missing campaign) clears the saved point.
379 *
380 * @param WP_REST_Request $request Request.
381 * @return WP_REST_Response
382 * @since 1.5.0
383 */
384 public function set_tour_progress( $request ) {
385 $user_id = get_current_user_id();
386 $step = sanitize_key( (string) $request->get_param( 'step' ) );
387 $campaign_id = absint( $request->get_param( 'campaign_id' ) );
388
389 if ( '' === $step || $campaign_id <= 0 ) {
390 delete_user_meta( $user_id, self::TOUR_PROGRESS_META );
391 } else {
392 update_user_meta( $user_id, self::TOUR_PROGRESS_META, $campaign_id . ':' . $step );
393 }
394
395 return new WP_REST_Response( [ 'success' => true ] );
396 }
397
398 /**
399 * POST /onboarding/track-tour.
400 *
401 * Signals a campaign guided-tour analytics moment. With no `event` param this
402 * means "the tour was shown", which is what the endpoint did originally and
403 * what an older script bundle still sends. An `event` param instead reports
404 * how a run ended (completed / dismissed / opted_out) or that a replay was
405 * started manually.
406 *
407 * The actions below fire on every call; the built-in analytics listener
408 * decides what to record and how often (the BSF events tracker dedups by
409 * event name), so repeat calls are cheap.
410 *
411 * @param \WP_REST_Request<array<string,mixed>>|null $request Request object. A
412 * missing request is treated as the bare "shown" signal, so the
413 * endpoint's original no-argument contract still holds.
414 * @return WP_REST_Response
415 * @since 1.5.0
416 */
417 public function track_tour( $request = null ) {
418 // Both params are read up front so the outcome branch below does not have
419 // to re-establish that the request exists.
420 $event = '';
421 $step = '';
422
423 if ( $request instanceof WP_REST_Request ) {
424 $event = Helper::get_string_value( $request->get_param( 'event' ) );
425 $step = Helper::get_string_value( $request->get_param( 'step' ) );
426 }
427
428 if ( '' === $event || 'shown' === $event ) {
429 /**
430 * Fires whenever the campaign guided tour is shown (i.e. on every call
431 * to this endpoint). The built-in listener dedups recording per site;
432 * additional listeners run on each fire and must dedup themselves if
433 * they need once-only behavior.
434 *
435 * @since 1.5.0
436 */
437 do_action( 'suredonation_campaign_tour_shown' );
438
439 return new WP_REST_Response( [ 'success' => true ] );
440 }
441
442 /**
443 * Fires when a campaign guided-tour run ends, or when a manual replay
444 * starts.
445 *
446 * @param string $event The outcome: 'completed', 'dismissed',
447 * 'opted_out' or 'manual_started'.
448 * @param string $step Step key the run ended on; empty when not applicable.
449 * @since 1.5.0
450 */
451 do_action( 'suredonation_campaign_tour_outcome', $event, $step );
452
453 return new WP_REST_Response( [ 'success' => true ] );
454 }
455
456 /**
457 * Generate lead.
458 *
459 * @param array<string,mixed> $payload Sanitised lead-capture payload.
460 * @return bool True when the CRM accepted the lead, false otherwise.
461 * @since 1.1.2
462 */
463 private function forward_lead_to_crm( array $payload ) {
464 $email_raw = $payload['email'] ?? '';
465 $email = is_string( $email_raw ) ? sanitize_email( $email_raw ) : '';
466 if ( empty( $email ) || ! is_email( $email ) ) {
467 return false;
468 }
469
470 $url = 'https://metrics.brainstormforce.com/wp-json/bsf-metrics-server/v1/subscribe';
471
472 if ( defined( 'SUREDONATION_METRICS_ENDPOINT' ) && is_string( SUREDONATION_METRICS_ENDPOINT ) ) {
473 $url = SUREDONATION_METRICS_ENDPOINT;
474 }
475
476 /**
477 * Filters the endpoint.
478 *
479 * @since 1.1.2
480 *
481 * @param string $url Endpoint URL.
482 * @param array<string,mixed> $payload Lead payload being sent.
483 */
484 $filtered = apply_filters( 'suredonation_metrics_subscribe_url', $url, $payload );
485 $url = is_string( $filtered ) ? $filtered : $url;
486
487 if ( '' === $url ) {
488 return false;
489 }
490
491 $first_name = isset( $payload['first_name'] ) && is_string( $payload['first_name'] ) ? $payload['first_name'] : '';
492 $last_name = isset( $payload['last_name'] ) && is_string( $payload['last_name'] ) ? $payload['last_name'] : '';
493 $domain = wp_parse_url( home_url(), PHP_URL_HOST );
494 $domain = is_string( $domain ) ? $domain : '';
495
496 $body = wp_json_encode(
497 [
498 // Lowercase keys satisfy the current BSF Metrics REST args.
499 'email' => $email,
500 'first_name' => $first_name,
501 'last_name' => $last_name,
502 'domain' => $domain,
503 'source' => 'suredonation',
504 // Legacy uppercase keys kept for backward compatibility.
505 'EMAIL' => $email,
506 'FIRSTNAME' => $first_name,
507 'LASTNAME' => $last_name,
508 'DOMAIN' => $domain,
509 ]
510 );
511
512 if ( false === $body ) {
513 return false;
514 }
515
516 // `source` identifies the originating plugin on the shared CRM server.
517 // wp_safe_remote_post with WP's default 5s timeout keeps a slow or
518 // hung endpoint from stalling onboarding completion.
519 $response = wp_safe_remote_post(
520 $url,
521 [
522 'headers' => [ 'Content-Type' => 'application/json' ],
523 'body' => $body,
524 ]
525 );
526
527 if ( is_wp_error( $response ) ) {
528 return false;
529 }
530
531 $code = (int) wp_remote_retrieve_response_code( $response );
532 return in_array( $code, [ 200, 201, 204 ], true );
533 }
534 }
535