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

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

372 lines 11.9 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 Charitable migration tool.
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\API;
9
10 use SureDonation\Inc\Import\Charitable\Csv_Parser;
11 use SureDonation\Inc\Import\Charitable\Importer;
12 use SureDonation\Inc\Import\Charitable\Session;
13 use SureDonation\Inc\Import\Charitable\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_Charitable_API class.
25 *
26 * Registers a small surface of routes under suredonation/v1/import/charitable/...
27 * Each route is admin-only (manage_options).
28 *
29 * @since 1.0.0
30 */
31 class Import_Charitable_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/charitable/counts' => [
43 [
44 'methods' => WP_REST_Server::READABLE,
45 'callback' => [ $this, 'get_counts' ],
46 'permission_callback' => [ $this, 'check_permissions' ],
47 ],
48 ],
49 '/import/charitable/preview' => [
50 [
51 'methods' => WP_REST_Server::READABLE,
52 'callback' => [ $this, 'get_preview' ],
53 'permission_callback' => [ $this, 'check_permissions' ],
54 ],
55 ],
56 '/import/charitable/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/charitable/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/charitable/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/charitable/csv
96 *
97 * Accepts a multipart file upload (field name `file`) containing a
98 * Charitable 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.
168 *
169 * @return bool
170 * @since 1.0.0
171 */
172 public function check_permissions() {
173 return current_user_can( 'manage_options' );
174 }
175
176 /**
177 * GET /import/charitable/counts
178 *
179 * @param WP_REST_Request $request Request.
180 * @return WP_REST_Response|WP_Error
181 * @since 1.0.0
182 */
183 public function get_counts( $request ) {
184 unset( $request );
185 return rest_ensure_response( Importer::get_instance()->get_counts() );
186 }
187
188 /**
189 * GET /import/charitable/preview
190 *
191 * Per-form aggregate breakdown the UI shows in the two-step migration
192 * flow. The admin picks one or more campaigns from this list, then
193 * POST /start with the chosen `campaign_ids`.
194 *
195 * @param WP_REST_Request $request Request.
196 * @return WP_REST_Response|WP_Error
197 * @since 1.0.0
198 */
199 public function get_preview( $request ) {
200 unset( $request );
201
202 $source = Source::get_instance();
203 if ( ! $source->has_charitable_data() ) {
204 return new WP_Error(
205 'no_charitable_data',
206 __( 'No Charitable data was found on this site — nothing to preview.', 'suredonation' ),
207 [ 'status' => 400 ]
208 );
209 }
210
211 return rest_ensure_response( $source->get_campaigns_preview() );
212 }
213
214 /**
215 * POST /import/charitable/start
216 *
217 * @param WP_REST_Request $request Request.
218 * @return WP_REST_Response|WP_Error
219 * @since 1.0.0
220 */
221 public function start( $request ) {
222 $importer = Importer::get_instance();
223 $counts = $importer->get_counts();
224
225 if ( empty( $counts['has_data'] ) ) {
226 return new WP_Error(
227 'no_charitable_data',
228 __( 'No Charitable data was found on this site — nothing to migrate.', 'suredonation' ),
229 [ 'status' => 400 ]
230 );
231 }
232
233 $campaign_ids = $request->get_param( 'campaign_ids' );
234 $campaign_ids = is_array( $campaign_ids )
235 ? array_values( array_unique( array_filter( array_map( 'absint', $campaign_ids ) ) ) )
236 : [];
237
238 if ( empty( $campaign_ids ) ) {
239 return new WP_Error(
240 'no_campaigns_selected',
241 __( 'Select at least one campaign to migrate.', 'suredonation' ),
242 [ 'status' => 400 ]
243 );
244 }
245
246 $options = [
247 'campaign_ids' => $campaign_ids,
248 'include_standalone_donors' => (bool) $request->get_param( 'include_standalone_donors' ),
249 ];
250
251 $progress = Session::get_instance()->create( $options );
252
253 if ( empty( $progress ) ) {
254 return new WP_Error(
255 'no_phases',
256 __( 'No import phases were registered for this session.', 'suredonation' ),
257 [ 'status' => 400 ]
258 );
259 }
260
261 return rest_ensure_response(
262 [
263 'import_id' => isset( $progress['import_id'] ) && is_scalar( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
264 'phases' => $progress['phases'] ?? [],
265 'total_items' => $this->estimate_total_items(
266 isset( $progress['phases'] ) && is_array( $progress['phases'] ) ? array_map( 'strval', $progress['phases'] ) : [],
267 $campaign_ids,
268 $options['include_standalone_donors']
269 ),
270 'counts' => $counts['counts'],
271 ]
272 );
273 }
274
275 /**
276 * POST /import/charitable/batch
277 *
278 * @param WP_REST_Request $request Request.
279 * @return WP_REST_Response|WP_Error
280 * @since 1.0.0
281 */
282 public function run_batch( $request ) {
283 $param = $request->get_param( 'import_id' );
284 $import_id = is_scalar( $param ) ? (string) $param : '';
285 $progress = Importer::get_instance()->run_batch( $import_id );
286
287 if ( false === $progress ) {
288 return new WP_Error(
289 'invalid_session',
290 __( 'Import session not found or already completed.', 'suredonation' ),
291 [ 'status' => 404 ]
292 );
293 }
294
295 return rest_ensure_response( $this->shape_progress_response( $progress ) );
296 }
297
298 /**
299 * Shape a session progress payload into the response body the UI consumes.
300 *
301 * @param array<string, mixed> $progress Session progress.
302 * @return array<string, mixed>
303 * @since 1.0.0
304 */
305 private function shape_progress_response( $progress ) {
306 $phases = isset( $progress['phases'] ) && is_array( $progress['phases'] ) ? $progress['phases'] : [];
307 $current_index = isset( $progress['current_phase'] ) && is_numeric( $progress['current_phase'] ) ? (int) $progress['current_phase'] : 0;
308 $current_phase = isset( $phases[ $current_index ] ) && is_scalar( $phases[ $current_index ] ) ? (string) $phases[ $current_index ] : '';
309
310 return [
311 'import_id' => isset( $progress['import_id'] ) && is_scalar( $progress['import_id'] ) ? (string) $progress['import_id'] : '',
312 'status' => isset( $progress['status'] ) && is_scalar( $progress['status'] ) ? (string) $progress['status'] : 'running',
313 'phases' => $phases,
314 'current_phase' => $current_phase,
315 'phase_index' => $current_index,
316 'offset' => isset( $progress['offset'] ) && is_numeric( $progress['offset'] ) ? (int) $progress['offset'] : 0,
317 'results' => $progress['results'] ?? [],
318 'options' => $progress['options'] ?? [],
319 'started_at' => isset( $progress['started_at'] ) && is_scalar( $progress['started_at'] ) ? (string) $progress['started_at'] : '',
320 'completed_at' => isset( $progress['completed_at'] ) && is_scalar( $progress['completed_at'] ) ? (string) $progress['completed_at'] : '',
321 ];
322 }
323
324 /**
325 * Estimate total items the session will process for progress-bar maths.
326 *
327 * Sums per-form donations / donors / subscriptions across the selected
328 * campaigns (plus the standalone-donor count when that phase is opted
329 * in) from the preview aggregate query — the same numbers the UI just
330 * showed the admin, so the progress denominator matches what they
331 * expect.
332 *
333 * @param array<int, string> $phases Active phases for this session.
334 * @param array<int, int> $campaign_ids Selected form IDs.
335 * @param bool $include_standalone_donors Whether the standalone-donors phase is opted in.
336 * @return int
337 * @since 1.0.0
338 */
339 private function estimate_total_items( $phases, $campaign_ids, $include_standalone_donors ) {
340 $preview = Source::get_instance()->get_campaigns_preview();
341 $campaigns = isset( $preview['campaigns'] ) && is_array( $preview['campaigns'] ) ? $preview['campaigns'] : [];
342 $selected_lookup = array_flip( array_map( 'intval', $campaign_ids ) );
343
344 $has_campaigns_phase = in_array( 'campaigns', $phases, true );
345 $has_donations_phase = in_array( 'donations', $phases, true );
346 $has_subscriptions_phase = in_array( 'subscriptions', $phases, true );
347
348 $total = 0;
349 foreach ( $campaigns as $row ) {
350 $form_id = isset( $row['form_id'] ) && is_numeric( $row['form_id'] ) ? (int) $row['form_id'] : 0;
351 if ( ! isset( $selected_lookup[ $form_id ] ) ) {
352 continue;
353 }
354 if ( $has_campaigns_phase ) {
355 ++$total;
356 }
357 if ( $has_donations_phase ) {
358 $total += isset( $row['donations'] ) && is_numeric( $row['donations'] ) ? (int) $row['donations'] : 0;
359 }
360 if ( $has_subscriptions_phase ) {
361 $total += isset( $row['subscriptions'] ) && is_numeric( $row['subscriptions'] ) ? (int) $row['subscriptions'] : 0;
362 }
363 }
364
365 if ( $include_standalone_donors && in_array( 'standalone_donors', $phases, true ) ) {
366 $total += isset( $preview['standalone_donors'] ) ? (int) $preview['standalone_donors'] : 0;
367 }
368
369 return $total;
370 }
371 }
372