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

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

392 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API endpoints for the GiveWP migration tool.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\API;
9
10 use SureDonation\Inc\Import\Givewp\Csv_Parser;
11 use SureDonation\Inc\Import\Givewp\Importer;
12 use SureDonation\Inc\Import\Givewp\Session;
13 use SureDonation\Inc\Import\Givewp\Source;
14 use SureDonation\Inc\Traits\Get_Instance;
15 use WP_Error;
16 use WP_REST_Request;
17 use WP_REST_Response;
18 use WP_REST_Server;
19
20 // Exit if accessed directly.
21 defined( 'ABSPATH' ) || exit;
22
23 /**
24 * Import_Givewp_API class.
25 *
26 * Registers a small surface of routes under suredonation/v1/import/givewp/...
27 * Each route is admin-only (manage_options).
28 *
29 * @since 1.0.0
30 */
31 class Import_Givewp_API {
32 use Get_Instance;
33
34 /**
35 * Get endpoints to register with the central Rest_Api orchestrator.
36 *
37 * @return array<string,mixed>
38 * @since 1.0.0
39 */
40 public function get_endpoints() {
41 return [
42 '/import/givewp/counts' => [
43 [
44 'methods' => WP_REST_Server::READABLE,
45 'callback' => [ $this, 'get_counts' ],
46 'permission_callback' => [ $this, 'check_permissions' ],
47 ],
48 ],
49 '/import/givewp/preview' => [
50 [
51 'methods' => WP_REST_Server::READABLE,
52 'callback' => [ $this, 'get_preview' ],
53 'permission_callback' => [ $this, 'check_permissions' ],
54 ],
55 ],
56 '/import/givewp/start' => [
57 [
58 'methods' => WP_REST_Server::CREATABLE,
59 'callback' => [ $this, 'start' ],
60 'permission_callback' => [ $this, 'check_permissions' ],
61 'args' => [
62 'campaign_ids' => [
63 'type' => 'array',
64 'items' => [ 'type' => 'integer' ],
65 ],
66 'include_standalone_donors' => [ 'type' => 'boolean' ],
67 ],
68 ],
69 ],
70 '/import/givewp/batch' => [
71 [
72 'methods' => WP_REST_Server::CREATABLE,
73 'callback' => [ $this, 'run_batch' ],
74 'permission_callback' => [ $this, 'check_permissions' ],
75 'args' => [
76 'import_id' => [
77 'type' => 'string',
78 'required' => true,
79 'sanitize_callback' => 'sanitize_text_field',
80 ],
81 ],
82 ],
83 ],
84 '/import/givewp/csv' => [
85 [
86 'methods' => WP_REST_Server::CREATABLE,
87 'callback' => [ $this, 'csv_upload' ],
88 'permission_callback' => [ $this, 'check_permissions' ],
89 ],
90 ],
91 ];
92 }
93
94 /**
95 * POST /import/givewp/csv
96 *
97 * Accepts a multipart file upload (field name `file`) containing a
98 * GiveWP CSV export, parses it via Csv_Parser, and returns aggregated
99 * results.
100 *
101 * @param WP_REST_Request $request Request.
102 * @return WP_REST_Response|WP_Error
103 * @since 1.0.0
104 */
105 public function csv_upload( $request ) {
106 unset( $request );
107
108 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- REST permission_callback already enforces capability and X-WP-Nonce.
109 if ( empty( $_FILES['file'] ) ) {
110 return new WP_Error(
111 'no_file',
112 __( 'No file uploaded.', 'suredonation' ),
113 [ 'status' => 400 ]
114 );
115 }
116
117 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- $_FILES handled by WP-native helpers below.
118 $file = $_FILES['file'];
119
120 // PHP-side upload error code (UPLOAD_ERR_OK = 0).
121 if ( isset( $file['error'] ) && UPLOAD_ERR_OK !== (int) $file['error'] ) {
122 return new WP_Error(
123 'upload_error',
124 __( 'The upload did not complete successfully.', 'suredonation' ),
125 [ 'status' => 400 ]
126 );
127 }
128
129 if ( ! isset( $file['tmp_name'] ) || '' === $file['tmp_name'] || ! is_uploaded_file( $file['tmp_name'] ) ) {
130 return new WP_Error(
131 'invalid_upload',
132 __( 'Invalid file upload.', 'suredonation' ),
133 [ 'status' => 400 ]
134 );
135 }
136
137 // Extension + MIME allowlist. The client-side picker enforces
138 // `.csv`, but the REST route is also reachable directly with
139 // any payload by an authenticated admin — keep the server-side
140 // gate so a misclick (or a custom client) can't slip a non-CSV
141 // blob into the parser.
142 $name = isset( $file['name'] ) ? (string) $file['name'] : '';
143 $ext = strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) );
144 $type = isset( $file['type'] ) ? (string) $file['type'] : '';
145 $allowed_mimes = [ 'text/csv', 'application/vnd.ms-excel', 'application/csv' ];
146 if ( 'csv' !== $ext || ( '' !== $type && ! in_array( $type, $allowed_mimes, true ) ) ) {
147 return new WP_Error(
148 'invalid_extension',
149 __( 'Only .csv files are supported.', 'suredonation' ),
150 [ 'status' => 400 ]
151 );
152 }
153
154 $results = Csv_Parser::get_instance()->parse_donations( $file['tmp_name'] );
155
156 return rest_ensure_response(
157 [
158 'status' => 'complete',
159 'results' => [
160 'donations' => $results,
161 ],
162 ]
163 );
164 }
165
166 /**
167 * Capability check. Write requests (POST/PUT/PATCH/DELETE) additionally
168 * require a valid wp_rest nonce, matching Donors_API — these endpoints bulk
169 * import into custom tables, so the write boundary is pinned explicitly.
170 *
171 * @param \WP_REST_Request<array<string,mixed>>|null $request Current request.
172 * @return bool|\WP_Error
173 * @since 1.0.0
174 */
175 public function check_permissions( $request = null ) {
176 if ( ! current_user_can( 'manage_options' ) ) {
177 return false;
178 }
179
180 if ( $request instanceof \WP_REST_Request ) {
181 $method = strtoupper( $request->get_method() );
182 if ( in_array( $method, [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
183 $nonce = $request->get_header( 'X-WP-Nonce' );
184 if ( empty( $nonce ) ) {
185 $nonce_param = $request->get_param( '_wpnonce' );
186 $nonce = is_string( $nonce_param ) ? $nonce_param : '';
187 }
188 if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
189 return new \WP_Error(
190 'rest_forbidden',
191 __( 'Invalid or missing nonce.', 'suredonation' ),
192 [ 'status' => 403 ]
193 );
194 }
195 }
196 }
197
198 return true;
199 }
200
201 /**
202 * GET /import/givewp/counts
203 *
204 * @param WP_REST_Request $request Request.
205 * @return WP_REST_Response|WP_Error
206 * @since 1.0.0
207 */
208 public function get_counts( $request ) {
209 unset( $request );
210 return rest_ensure_response( Importer::get_instance()->get_counts() );
211 }
212
213 /**
214 * GET /import/givewp/preview
215 *
216 * Per-form aggregate breakdown the UI shows in the two-step migration
217 * flow. The admin picks one or more campaigns from this list, then
218 * POST /start with the chosen `campaign_ids`.
219 *
220 * @param WP_REST_Request $request Request.
221 * @return WP_REST_Response|WP_Error
222 * @since 1.0.0
223 */
224 public function get_preview( $request ) {
225 unset( $request );
226
227 $source = Source::get_instance();
228 if ( ! $source->has_givewp_data() ) {
229 return new WP_Error(
230 'no_givewp_data',
231 __( 'No GiveWP data was found on this site — nothing to preview.', 'suredonation' ),
232 [ 'status' => 400 ]
233 );
234 }
235
236 return rest_ensure_response( $source->get_campaigns_preview() );
237 }
238
239 /**
240 * POST /import/givewp/start
241 *
242 * @param WP_REST_Request $request Request.
243 * @return WP_REST_Response|WP_Error
244 * @since 1.0.0
245 */
246 public function start( $request ) {
247 $importer = Importer::get_instance();
248 $counts = $importer->get_counts();
249
250 if ( empty( $counts['has_data'] ) ) {
251 return new WP_Error(
252 'no_givewp_data',
253 __( 'No GiveWP data was found on this site — nothing to migrate.', 'suredonation' ),
254 [ 'status' => 400 ]
255 );
256 }
257
258 $campaign_ids = $request->get_param( 'campaign_ids' );
259 $campaign_ids = is_array( $campaign_ids )
260 ? array_values( array_unique( array_filter( array_map( 'absint', $campaign_ids ) ) ) )
261 : [];
262
263 if ( empty( $campaign_ids ) ) {
264 return new WP_Error(
265 'no_campaigns_selected',
266 __( 'Select at least one campaign to migrate.', 'suredonation' ),
267 [ 'status' => 400 ]
268 );
269 }
270
271 $options = [
272 'campaign_ids' => $campaign_ids,
273 'include_standalone_donors' => (bool) $request->get_param( 'include_standalone_donors' ),
274 ];
275
276 $progress = Session::get_instance()->create( $options );
277
278 if ( empty( $progress ) ) {
279 return new WP_Error(
280 'no_phases',
281 __( 'No import phases were registered for this session.', 'suredonation' ),
282 [ 'status' => 400 ]
283 );
284 }
285
286 return rest_ensure_response(
287 [
288 'import_id' => isset( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
289 'phases' => isset( $progress['phases'] ) ? $progress['phases'] : [],
290 'total_items' => $this->estimate_total_items( $progress['phases'], $campaign_ids, $options['include_standalone_donors'] ),
291 'counts' => $counts['counts'],
292 ]
293 );
294 }
295
296 /**
297 * POST /import/givewp/batch
298 *
299 * @param WP_REST_Request $request Request.
300 * @return WP_REST_Response|WP_Error
301 * @since 1.0.0
302 */
303 public function run_batch( $request ) {
304 $import_id = (string) $request->get_param( 'import_id' );
305 $progress = Importer::get_instance()->run_batch( $import_id );
306
307 if ( false === $progress ) {
308 return new WP_Error(
309 'invalid_session',
310 __( 'Import session not found or already completed.', 'suredonation' ),
311 [ 'status' => 404 ]
312 );
313 }
314
315 return rest_ensure_response( $this->shape_progress_response( $progress ) );
316 }
317
318 /**
319 * Shape a session progress payload into the response body the UI consumes.
320 *
321 * @param array $progress Session progress.
322 * @return array
323 * @since 1.0.0
324 */
325 private function shape_progress_response( $progress ) {
326 $phases = isset( $progress['phases'] ) && is_array( $progress['phases'] ) ? $progress['phases'] : [];
327 $current_index = isset( $progress['current_phase'] ) ? (int) $progress['current_phase'] : 0;
328 $current_phase = isset( $phases[ $current_index ] ) ? (string) $phases[ $current_index ] : '';
329
330 return [
331 'import_id' => isset( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
332 'status' => isset( $progress['status'] ) ? (string) $progress['status'] : 'running',
333 'phases' => $phases,
334 'current_phase' => $current_phase,
335 'phase_index' => $current_index,
336 'offset' => isset( $progress['offset'] ) ? (int) $progress['offset'] : 0,
337 'results' => isset( $progress['results'] ) ? $progress['results'] : [],
338 'options' => isset( $progress['options'] ) ? $progress['options'] : [],
339 'started_at' => isset( $progress['started_at'] ) ? (string) $progress['started_at'] : '',
340 'completed_at' => isset( $progress['completed_at'] ) ? (string) $progress['completed_at'] : '',
341 ];
342 }
343
344 /**
345 * Estimate total items the session will process for progress-bar maths.
346 *
347 * Sums per-form donations / donors / subscriptions across the selected
348 * campaigns (plus the standalone-donor count when that phase is opted
349 * in) from the preview aggregate query — the same numbers the UI just
350 * showed the admin, so the progress denominator matches what they
351 * expect.
352 *
353 * @param array $phases Active phases for this session.
354 * @param array $campaign_ids Selected form IDs.
355 * @param bool $include_standalone_donors Whether the standalone-donors phase is opted in.
356 * @return int
357 * @since 1.0.0
358 */
359 private function estimate_total_items( $phases, $campaign_ids, $include_standalone_donors ) {
360 $preview = Source::get_instance()->get_campaigns_preview();
361 $campaigns = isset( $preview['campaigns'] ) && is_array( $preview['campaigns'] ) ? $preview['campaigns'] : [];
362 $selected_lookup = array_flip( array_map( 'intval', $campaign_ids ) );
363
364 $has_campaigns_phase = in_array( 'campaigns', $phases, true );
365 $has_donations_phase = in_array( 'donations', $phases, true );
366 $has_subscriptions_phase = in_array( 'subscriptions', $phases, true );
367
368 $total = 0;
369 foreach ( $campaigns as $row ) {
370 $form_id = isset( $row['form_id'] ) ? (int) $row['form_id'] : 0;
371 if ( ! isset( $selected_lookup[ $form_id ] ) ) {
372 continue;
373 }
374 if ( $has_campaigns_phase ) {
375 ++$total;
376 }
377 if ( $has_donations_phase ) {
378 $total += isset( $row['donations'] ) ? (int) $row['donations'] : 0;
379 }
380 if ( $has_subscriptions_phase ) {
381 $total += isset( $row['subscriptions'] ) ? (int) $row['subscriptions'] : 0;
382 }
383 }
384
385 if ( $include_standalone_donors && in_array( 'standalone_donors', $phases, true ) ) {
386 $total += isset( $preview['standalone_donors'] ) ? (int) $preview['standalone_donors'] : 0;
387 }
388
389 return $total;
390 }
391 }
392