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-export-api.php

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

671 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Import & Export REST API endpoints.
4 *
5 * SureDonation own-data import/export (donations, donors, campaigns, settings).
6 * Distinct from the GiveWP migration API — this reads/writes SureDonation's own
7 * export format. Route handlers are filled in by later tasks; this scaffold
8 * registers the routes and enforces the capability + nonce baseline.
9 *
10 * @package SureDonation
11 * @since 1.3.0
12 */
13
14 namespace SureDonation\Inc\API;
15
16 use SureDonation\Inc\Campaigns\Campaign_Cpt;
17 use SureDonation\Inc\Database\Tables\Donations;
18 use SureDonation\Inc\Helper;
19 use SureDonation\Inc\Import_Export\Config_IO;
20 use SureDonation\Inc\Import_Export\Csv_Exporter;
21 use SureDonation\Inc\Import_Export\Import\Column_Map;
22 use SureDonation\Inc\Import_Export\Import\Csv_File;
23 use SureDonation\Inc\Import_Export\Import\Import_Runner;
24 use WP_Error;
25 use WP_REST_Response;
26 use WP_REST_Server;
27
28 // Exit if accessed directly.
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit;
31 }
32
33 /**
34 * Import & Export API class.
35 *
36 * @since 1.3.0
37 */
38 class Import_Export_API {
39
40 /**
41 * Get Import & Export endpoints.
42 *
43 * Exports are READABLE (filters passed as query args, matching the existing
44 * `/donors/export` route); imports are EDITABLE (they upload and write).
45 *
46 * @return array<string, mixed>
47 * @since 1.3.0
48 */
49 public function get_endpoints() {
50 return [
51 // --- Export (read) ---
52 '/export/donations' => [
53 'methods' => WP_REST_Server::READABLE,
54 'callback' => [ $this, 'export_donations' ],
55 'permission_callback' => [ $this, 'check_permissions' ],
56 ],
57 '/export/donors' => [
58 'methods' => WP_REST_Server::READABLE,
59 'callback' => [ $this, 'export_donors' ],
60 'permission_callback' => [ $this, 'check_permissions' ],
61 ],
62 '/export/campaigns' => [
63 'methods' => WP_REST_Server::READABLE,
64 'callback' => [ $this, 'export_campaigns' ],
65 'permission_callback' => [ $this, 'check_permissions' ],
66 ],
67 '/export/settings' => [
68 'methods' => WP_REST_Server::READABLE,
69 'callback' => [ $this, 'export_settings' ],
70 'permission_callback' => [ $this, 'check_permissions' ],
71 ],
72
73 // --- Import (write) ---
74 '/import/analyze' => [
75 'methods' => WP_REST_Server::CREATABLE,
76 'callback' => [ $this, 'import_analyze' ],
77 'permission_callback' => [ $this, 'check_permissions' ],
78 ],
79 '/import/start' => [
80 'methods' => WP_REST_Server::CREATABLE,
81 'callback' => [ $this, 'import_start' ],
82 'permission_callback' => [ $this, 'check_permissions' ],
83 ],
84 '/import/batch' => [
85 'methods' => WP_REST_Server::CREATABLE,
86 'callback' => [ $this, 'import_batch' ],
87 'permission_callback' => [ $this, 'check_permissions' ],
88 ],
89 '/import/status/(?P<id>[A-Za-z0-9\-]+)' => [
90 'methods' => WP_REST_Server::READABLE,
91 'callback' => [ $this, 'import_status' ],
92 'permission_callback' => [ $this, 'check_permissions' ],
93 ],
94 '/import/campaigns' => [
95 'methods' => WP_REST_Server::CREATABLE,
96 'callback' => [ $this, 'import_campaigns' ],
97 'permission_callback' => [ $this, 'check_permissions' ],
98 ],
99 '/import/settings' => [
100 'methods' => WP_REST_Server::CREATABLE,
101 'callback' => [ $this, 'import_settings' ],
102 'permission_callback' => [ $this, 'check_permissions' ],
103 ],
104 ];
105 }
106
107 /**
108 * Capability + nonce gate for every route.
109 *
110 * Requires `manage_options`. For write methods, also verifies the
111 * `X-WP-Nonce` header (or `_wpnonce` param) against the `wp_rest` action.
112 * Mirrors the check used by the other SureDonation REST controllers.
113 *
114 * @param \WP_REST_Request|null $request Request object.
115 * @return bool|WP_Error True when allowed, WP_Error otherwise.
116 * @since 1.3.0
117 */
118 public function check_permissions( $request = null ) {
119 if ( ! current_user_can( 'manage_options' ) ) {
120 return new WP_Error(
121 'rest_forbidden',
122 __( 'You are not allowed to import or export data.', 'suredonation' ),
123 [ 'status' => rest_authorization_required_code() ]
124 );
125 }
126
127 if ( $request instanceof \WP_REST_Request ) {
128 $method = strtoupper( $request->get_method() );
129 if ( in_array( $method, [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true ) ) {
130 $nonce = $request->get_header( 'X-WP-Nonce' );
131 if ( empty( $nonce ) ) {
132 $nonce_param = $request->get_param( '_wpnonce' );
133 $nonce = is_string( $nonce_param ) ? $nonce_param : '';
134 }
135 if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
136 return new WP_Error(
137 'rest_forbidden',
138 __( 'Invalid or missing nonce.', 'suredonation' ),
139 [ 'status' => 403 ]
140 );
141 }
142 }
143 }
144
145 return true;
146 }
147
148 /**
149 * Export one-time donations to CSV.
150 *
151 * Reads optional filters (payment status, campaign, payment mode, gateway,
152 * and a created_at date range), fetches the matching one-time donations, and
153 * returns the CSV as a string for the client to download. Recurring and
154 * renewal rows are excluded — the free export is one-time only. Custom form
155 * field values in donation_data['fields'] are appended as trailing columns.
156 *
157 * @param \WP_REST_Request $request Request object.
158 * @return WP_REST_Response Response with { success, csv, filename, truncated, total_count, exported }.
159 * @since 1.3.0
160 */
161 public function export_donations( $request ) {
162 $filters = [
163 'status' => sanitize_text_field( (string) $request->get_param( 'status' ) ),
164 'campaign_id' => absint( $request->get_param( 'campaign' ) ),
165 'payment_mode' => sanitize_text_field( (string) $request->get_param( 'mode' ) ),
166 'gateway' => sanitize_text_field( (string) $request->get_param( 'gateway' ) ),
167 'after' => sanitize_text_field( (string) $request->get_param( 'after' ) ),
168 'before' => sanitize_text_field( (string) $request->get_param( 'before' ) ),
169 ];
170
171 // Cap the payload at 10k rows (matches the donor export) and signal
172 // truncation so an admin knows whether the file is complete.
173 $export_cap = 10000;
174 $total_count = Donations::count_for_export( $filters );
175 $truncated = $total_count > $export_cap;
176 $donations = Donations::get_for_export( $filters, $export_cap, 0 );
177
178 // First pass: collect the union of custom-field labels so every row
179 // shares one consistent set of trailing columns.
180 $field_labels = [];
181 foreach ( $donations as $donation ) {
182 foreach ( $this->get_donation_custom_fields( $donation ) as $label => $value ) {
183 if ( ! in_array( $label, $field_labels, true ) ) {
184 $field_labels[] = $label;
185 }
186 }
187 }
188
189 $rows = [];
190 $rows[] = array_merge(
191 Column_Map::standard_donation_export_labels(),
192 $field_labels
193 );
194
195 $title_cache = [];
196 foreach ( $donations as $donation ) {
197 $campaign_id = absint( Helper::get_string_value( $donation['campaign_id'] ?? 0 ) );
198 $form_id = absint( Helper::get_string_value( $donation['form_id'] ?? 0 ) );
199
200 $row = [
201 $donation['id'] ?? '',
202 $campaign_id ? $campaign_id : '',
203 $campaign_id ? $this->resolve_title( $campaign_id, $title_cache ) : '',
204 $form_id ? $form_id : '',
205 $form_id ? $this->resolve_title( $form_id, $title_cache ) : '',
206 $donation['donor_id'] ?? '',
207 $donation['donor_name'] ?? '',
208 $donation['donor_email'] ?? '',
209 $donation['donor_phone'] ?? '',
210 $donation['amount'] ?? '',
211 $donation['fees_covered'] ?? '',
212 $donation['refunded_amount'] ?? '',
213 $donation['currency'] ?? '',
214 $donation['gateway'] ?? '',
215 $donation['payment_status'] ?? '',
216 $donation['payment_mode'] ?? '',
217 $donation['transaction_id'] ?? '',
218 $donation['donation_type'] ?? '',
219 $donation['subscription_id'] ?? '',
220 $donation['subscription_status'] ?? '',
221 ! empty( $donation['parent_subscription_id'] ) ? $donation['parent_subscription_id'] : '',
222 // Untranslated on purpose: this is interchange data, not display
223 // copy, and the importer's to_bool() matches English tokens. A
224 // translated "Ja"/"Oui" here silently imported back as not
225 // anonymous, so an export/import round trip un-masked every
226 // anonymous donor on a localised site. Every other value in this
227 // row is raw for the same reason.
228 ! empty( $donation['is_anonymous'] ) ? 'yes' : 'no',
229 $donation['donor_comment'] ?? '',
230 $donation['ip_address'] ?? '',
231 $donation['created_at'] ?? '',
232 $donation['import_source'] ?? '',
233 ! empty( $donation['import_source_id'] ) ? $donation['import_source_id'] : '',
234 ];
235
236 $field_values = $this->get_donation_custom_fields( $donation );
237 foreach ( $field_labels as $label ) {
238 $row[] = $field_values[ $label ] ?? '';
239 }
240
241 $rows[] = $row;
242 }
243
244 return new WP_REST_Response(
245 [
246 'success' => true,
247 'csv' => Csv_Exporter::build( $rows ),
248 'filename' => 'suredonation-donations-export-' . gmdate( 'Y-m-d' ) . '.csv',
249 'truncated' => $truncated,
250 'total_count' => $total_count,
251 'exported' => count( $donations ),
252 ],
253 200
254 );
255 }
256
257 /**
258 * Extract a donation's submitted custom form-field values as a label => value map.
259 *
260 * @param array<string, mixed> $donation Decoded donation row.
261 * @return array<string, string> Custom-field values keyed by label.
262 * @since 1.3.0
263 */
264 private function get_donation_custom_fields( $donation ) {
265 $out = [];
266 $data = ( isset( $donation['donation_data'] ) && is_array( $donation['donation_data'] ) )
267 ? $donation['donation_data']
268 : [];
269 $fields = ( isset( $data['fields'] ) && is_array( $data['fields'] ) )
270 ? $data['fields']
271 : [];
272
273 foreach ( $fields as $field ) {
274 if ( ! is_array( $field ) || empty( $field['label'] ) ) {
275 continue;
276 }
277 $label = Helper::get_string_value( $field['label'] );
278 if ( '' !== $label ) {
279 $out[ $label ] = Helper::get_string_value( $field['value'] ?? '' );
280 }
281 }
282
283 return $out;
284 }
285
286 /**
287 * Resolve and cache a post title (campaign or form) for the export.
288 *
289 * @param int $post_id Post ID.
290 * @param array<string, string> $cache Title cache, keyed by post id, by reference.
291 * @return string Post title.
292 * @since 1.3.0
293 */
294 private function resolve_title( $post_id, &$cache ) {
295 $key = (string) $post_id;
296 if ( ! isset( $cache[ $key ] ) ) {
297 $cache[ $key ] = (string) get_the_title( $post_id );
298 }
299 return $cache[ $key ];
300 }
301
302 /**
303 * Export donors to CSV.
304 *
305 * Delegates to the existing donor export (Donors_API::export_donors_csv) so
306 * the CSV columns and query stay in one place — the Import & Export tab just
307 * exposes it under the unified /export namespace. Honors the same `search`
308 * and `campaign` query args.
309 *
310 * @param \WP_REST_Request $request Request object.
311 * @return WP_REST_Response|WP_Error CSV payload or error.
312 * @since 1.3.0
313 */
314 public function export_donors( $request ) {
315 return ( new Donors_API() )->export_donors_csv( $request );
316 }
317
318 /**
319 * Export campaigns (with their linked forms) as JSON.
320 *
321 * Optional `ids` query arg (comma-separated) limits the export to specific
322 * campaigns; omit it to export all. The response carries the JSON object the
323 * client downloads as a .json file.
324 *
325 * @param \WP_REST_Request $request Request object.
326 * @return WP_REST_Response Response with { success, data, filename, count }.
327 * @since 1.3.0
328 */
329 public function export_campaigns( $request ) {
330 $ids_param = $request->get_param( 'ids' );
331 $ids = [];
332 if ( is_string( $ids_param ) && '' !== $ids_param ) {
333 $ids = array_filter( array_map( 'absint', explode( ',', $ids_param ) ) );
334 } elseif ( is_array( $ids_param ) ) {
335 $ids = array_filter( array_map( 'absint', $ids_param ) );
336 }
337
338 $campaigns = Config_IO::export_campaigns( $ids );
339
340 return new WP_REST_Response(
341 [
342 'success' => true,
343 'data' => [
344 'type' => 'suredonation-campaigns',
345 'version' => defined( 'SUREDONATION_VER' ) ? SUREDONATION_VER : '',
346 'campaigns' => $campaigns,
347 ],
348 'filename' => 'suredonation-campaigns-export-' . gmdate( 'Y-m-d' ) . '.json',
349 'count' => count( $campaigns ),
350 ],
351 200
352 );
353 }
354
355 /**
356 * Export SureDonation settings as JSON (credentials excluded).
357 *
358 * @return WP_REST_Response Response with { success, data, filename }.
359 * @since 1.3.0
360 */
361 public function export_settings() {
362 return new WP_REST_Response(
363 [
364 'success' => true,
365 'data' => [
366 'type' => 'suredonation-settings',
367 'version' => defined( 'SUREDONATION_VER' ) ? SUREDONATION_VER : '',
368 'settings' => Config_IO::export_settings(),
369 ],
370 'filename' => 'suredonation-settings-export-' . gmdate( 'Y-m-d' ) . '.json',
371 ],
372 200
373 );
374 }
375
376 /**
377 * Analyze an uploaded CSV: store it, read the header + a sample, auto-map
378 * columns to fields, and detect the entity (donations vs donors).
379 *
380 * Returns a token the import start/batch calls reference so the same stored
381 * file is reused without re-uploading.
382 *
383 * @param \WP_REST_Request $request Request object (multipart with a `file`).
384 * @return WP_REST_Response|WP_Error Analysis payload or error.
385 * @since 1.3.0
386 */
387 public function import_analyze( $request ) {
388 $files = $request->get_file_params();
389 $file = isset( $files['file'] ) && is_array( $files['file'] ) ? $files['file'] : null;
390
391 if ( null === $file || empty( $file['tmp_name'] ) || ! empty( $file['error'] ) ) {
392 return new WP_Error( 'suredonation_no_file', __( 'No file was uploaded.', 'suredonation' ), [ 'status' => 400 ] );
393 }
394
395 $name = sanitize_file_name( (string) ( $file['name'] ?? '' ) );
396 if ( ! preg_match( '/\.csv$/i', $name ) ) {
397 return new WP_Error( 'suredonation_invalid_file', __( 'Please upload a .csv file.', 'suredonation' ), [ 'status' => 400 ] );
398 }
399
400 $size = isset( $file['size'] ) ? (int) $file['size'] : 0;
401 if ( $size <= 0 || $size > 20 * MB_IN_BYTES ) {
402 return new WP_Error( 'suredonation_invalid_size', __( 'The file is empty or larger than 20 MB.', 'suredonation' ), [ 'status' => 400 ] );
403 }
404
405 $token = Csv_File::store( (string) $file['tmp_name'] );
406 if ( false === $token ) {
407 return new WP_Error( 'suredonation_store_failed', __( 'Could not process the uploaded file.', 'suredonation' ), [ 'status' => 500 ] );
408 }
409
410 $headers = Csv_File::read_header( $token );
411 if ( empty( $headers ) ) {
412 Csv_File::delete( $token );
413 return new WP_Error( 'suredonation_empty_file', __( 'The file has no header row.', 'suredonation' ), [ 'status' => 400 ] );
414 }
415
416 $entity = sanitize_text_field( (string) $request->get_param( 'entity' ) );
417 if ( ! in_array( $entity, [ 'donations', 'donors' ], true ) ) {
418 $entity = Column_Map::detect_entity( $headers );
419 }
420
421 $mapping = Column_Map::auto_map( $headers, $entity );
422
423 $fields = [];
424 foreach ( Column_Map::fields_for( $entity ) as $key => $def ) {
425 $fields[] = [
426 'field' => $key,
427 'label' => $def['label'],
428 'required' => ! empty( $def['required'] ),
429 ];
430 }
431
432 return new WP_REST_Response(
433 [
434 'success' => true,
435 'token' => $token,
436 'entity' => $entity,
437 'headers' => $headers,
438 'mapping' => $mapping,
439 'fields' => $fields,
440 'sample' => Csv_File::read_sample( $token, 5 ),
441 'total_rows' => Csv_File::count_rows( $token ),
442 ],
443 200
444 );
445 }
446
447 /**
448 * Start a CSV import session from a previously analyzed file.
449 *
450 * @param \WP_REST_Request $request Request with token, entity, mapping, options.
451 * @return WP_REST_Response|WP_Error Session info or error.
452 * @since 1.3.0
453 */
454 public function import_start( $request ) {
455 $token = sanitize_text_field( (string) $request->get_param( 'token' ) );
456 if ( false === Csv_File::path_for( $token ) ) {
457 return new WP_Error( 'suredonation_invalid_token', __( 'The uploaded file could not be found. Please upload it again.', 'suredonation' ), [ 'status' => 400 ] );
458 }
459
460 $entity = sanitize_text_field( (string) $request->get_param( 'entity' ) );
461 if ( ! in_array( $entity, [ 'donations', 'donors' ], true ) ) {
462 return new WP_Error( 'suredonation_invalid_entity', __( 'Invalid import type.', 'suredonation' ), [ 'status' => 400 ] );
463 }
464
465 // Columns are mapped server-side from the file's header row. A
466 // SureDonation export auto-maps in full, so there is no manual mapping
467 // step; a file whose required columns don't resolve is rejected.
468 $headers = Csv_File::read_header( $token );
469 $mapping = Column_Map::auto_map( $headers, $entity );
470
471 $missing = Column_Map::missing_required( $mapping, $entity );
472 if ( ! empty( $missing ) ) {
473 return new WP_Error(
474 'suredonation_unrecognized_csv',
475 sprintf(
476 /* translators: %s: comma-separated required column labels. */
477 __( 'This does not look like a SureDonation export. Required columns were not found: %s. Please upload the CSV exported by SureDonation.', 'suredonation' ),
478 implode( ', ', $missing )
479 ),
480 [ 'status' => 400 ]
481 );
482 }
483
484 $options = [
485 'headers' => $headers,
486 'dry_run' => (bool) $request->get_param( 'dry_run' ),
487 ];
488
489 // Donations are imported into an explicitly chosen campaign.
490 if ( 'donations' === $entity ) {
491 $campaign_id = absint( $request->get_param( 'campaign_id' ) );
492 $campaign = $campaign_id ? get_post( $campaign_id ) : null;
493 if ( ! $campaign instanceof \WP_Post || Campaign_Cpt::POST_TYPE !== $campaign->post_type ) {
494 return new WP_Error( 'suredonation_invalid_campaign', __( 'Please choose a campaign to import the donations into.', 'suredonation' ), [ 'status' => 400 ] );
495 }
496 $options['campaign_id'] = $campaign_id;
497 }
498
499 $total_rows = Csv_File::count_rows( $token );
500 $progress = Import_Runner::create( $entity, $token, $mapping, $options, $total_rows );
501
502 return new WP_REST_Response(
503 [
504 'success' => true,
505 'import_id' => $progress['import_id'],
506 'total_rows' => $total_rows,
507 'batch_size' => Import_Runner::BATCH_SIZE,
508 ],
509 200
510 );
511 }
512
513 /**
514 * Process the next batch of an import session.
515 *
516 * @param \WP_REST_Request $request Request with import_id.
517 * @return WP_REST_Response|WP_Error Progress or error.
518 * @since 1.3.0
519 */
520 public function import_batch( $request ) {
521 $import_id = sanitize_text_field( (string) $request->get_param( 'import_id' ) );
522 $session = Import_Runner::get( $import_id );
523 if ( false === $session ) {
524 return new WP_Error( 'suredonation_invalid_import', __( 'Import session not found or already finished.', 'suredonation' ), [ 'status' => 400 ] );
525 }
526 if ( ! $this->user_owns_session( $session ) ) {
527 return new WP_Error( 'rest_forbidden', __( 'You cannot modify an import started by another user.', 'suredonation' ), [ 'status' => rest_authorization_required_code() ] );
528 }
529 $progress = Import_Runner::run_batch( $import_id );
530 if ( false === $progress ) {
531 return new WP_Error( 'suredonation_invalid_import', __( 'Import session not found or already finished.', 'suredonation' ), [ 'status' => 400 ] );
532 }
533 return new WP_REST_Response( $this->import_progress_response( $progress ), 200 );
534 }
535
536 /**
537 * Whether the current user may act on an import session.
538 *
539 * All callers already hold `manage_options`; this additionally scopes a
540 * session to the admin who started it. Sessions with no recorded owner
541 * fall back to the capability gate.
542 *
543 * @param array<string, mixed> $progress Session payload.
544 * @return bool
545 * @since 1.3.0
546 */
547 private function user_owns_session( $progress ) {
548 $owner = Helper::get_integer_value( $progress['started_by'] ?? 0 );
549 return 0 === $owner || get_current_user_id() === $owner;
550 }
551
552 /**
553 * Read an import session's current status.
554 *
555 * @param \WP_REST_Request $request Request with the id path param.
556 * @return WP_REST_Response|WP_Error Progress or error.
557 * @since 1.3.0
558 */
559 public function import_status( $request ) {
560 $import_id = sanitize_text_field( (string) $request->get_param( 'id' ) );
561 $progress = Import_Runner::get( $import_id );
562 if ( false === $progress ) {
563 return new WP_Error( 'suredonation_invalid_import', __( 'Import session not found.', 'suredonation' ), [ 'status' => 404 ] );
564 }
565 if ( ! $this->user_owns_session( $progress ) ) {
566 return new WP_Error( 'rest_forbidden', __( 'You cannot view an import started by another user.', 'suredonation' ), [ 'status' => rest_authorization_required_code() ] );
567 }
568 return new WP_REST_Response( $this->import_progress_response( $progress ), 200 );
569 }
570
571 /**
572 * Shape an import session's progress for the client.
573 *
574 * @param array<string, mixed> $progress Session payload.
575 * @return array<string, mixed> Trimmed progress view.
576 * @since 1.3.0
577 */
578 private function import_progress_response( $progress ) {
579 $entity = is_string( $progress['entity'] ?? null ) ? $progress['entity'] : '';
580 $results = is_array( $progress['results'] ?? null ) ? $progress['results'] : [];
581 $result = is_array( $results[ $entity ] ?? null ) ? $results[ $entity ] : [];
582
583 $processed = Helper::get_integer_value( $result['imported'] ?? 0 ) + Helper::get_integer_value( $result['skipped'] ?? 0 ) + Helper::get_integer_value( $result['errors'] ?? 0 );
584 $total = Helper::get_integer_value( $progress['total_rows'] ?? 0 );
585 $status = is_string( $progress['status'] ?? null ) ? $progress['status'] : '';
586
587 $percentage = $total > 0 ? min( 100, (int) round( $processed / $total * 100 ) ) : 100;
588 if ( 'complete' === $status ) {
589 $percentage = 100;
590 }
591
592 return [
593 'success' => true,
594 'import_id' => is_string( $progress['import_id'] ?? null ) ? $progress['import_id'] : '',
595 'status' => $status,
596 'entity' => $entity,
597 'total_rows' => $total,
598 'processed' => $processed,
599 'percentage' => $percentage,
600 'results' => $result,
601 'error' => is_string( $progress['error'] ?? null ) ? $progress['error'] : '',
602 ];
603 }
604
605 /**
606 * Import campaigns (with their forms) from an exported JSON payload.
607 *
608 * @param \WP_REST_Request $request Request with a `data` object ({ campaigns: [...] }).
609 * @return WP_REST_Response|WP_Error Result or error.
610 * @since 1.3.0
611 */
612 public function import_campaigns( $request ) {
613 $data = $request->get_param( 'data' );
614 $campaigns = ( is_array( $data ) && is_array( $data['campaigns'] ?? null ) )
615 ? $data['campaigns']
616 : [];
617
618 if ( empty( $campaigns ) ) {
619 return new WP_Error( 'suredonation_no_campaigns', __( 'No campaigns were found in the file.', 'suredonation' ), [ 'status' => 400 ] );
620 }
621
622 $result = Config_IO::import_campaigns( $campaigns );
623
624 return new WP_REST_Response(
625 [
626 'success' => true,
627 'message' => sprintf(
628 /* translators: 1: campaign count, 2: form count. */
629 __( 'Imported %1$d campaigns and %2$d forms.', 'suredonation' ),
630 $result['campaigns'],
631 $result['forms']
632 ),
633 'campaigns' => $result['campaigns'],
634 'forms' => $result['forms'],
635 ],
636 200
637 );
638 }
639
640 /**
641 * Import settings from an exported JSON payload (Merge/Replace, secret-safe).
642 *
643 * @param \WP_REST_Request $request Request with a `data` object ({ settings: {...} }) and `mode`.
644 * @return WP_REST_Response|WP_Error Result or error.
645 * @since 1.3.0
646 */
647 public function import_settings( $request ) {
648 $data = $request->get_param( 'data' );
649 $settings = ( is_array( $data ) && is_array( $data['settings'] ?? null ) )
650 ? $data['settings']
651 : [];
652 $mode = 'replace' === $request->get_param( 'mode' ) ? 'replace' : 'merge';
653
654 if ( empty( $settings ) ) {
655 return new WP_Error( 'suredonation_no_settings', __( 'No settings were found in the file.', 'suredonation' ), [ 'status' => 400 ] );
656 }
657
658 $result = Config_IO::import_settings( $settings, $mode );
659
660 return new WP_REST_Response(
661 [
662 'success' => true,
663 'message' => __( 'Settings imported.', 'suredonation' ),
664 'notice' => __( 'Payment credentials were not imported. Reconnect your gateways under Settings → Payment Methods.', 'suredonation' ),
665 'applied' => $result['applied'],
666 ],
667 200
668 );
669 }
670 }
671