Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
60.58% covered (warning)
60.58%
146 / 241
40.00% covered (danger)
40.00%
6 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csv_Export_Controller
60.83% covered (warning)
60.83%
146 / 240
40.00% covered (danger)
40.00%
6 / 15
179.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 register
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register_routes
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 check_permission
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_endpoint_args
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
1
 validate_report_type
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 validate_from_date
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
6.01
 validate_to_date
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
7
 validate_compare_from_date
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 validate_compare_to_date
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
20
 validate_compare_period
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 create_export
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
4.00
 generate_download_export
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
56
 schedule_email_export
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 get_item_schema
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * CSV Export REST API Controller
4 *
5 * Handles REST API requests for CSV report exports.
6 *
7 * @package Automattic\Jetpack\PremiumAnalytics\Reports\Export
8 */
9
10declare( strict_types=1 );
11
12namespace Automattic\Jetpack\PremiumAnalytics\Reports\Export;
13
14defined( 'ABSPATH' ) || exit;
15
16use Automattic\Jetpack\PremiumAnalytics\Capabilities;
17use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Logging\Logger_Interface;
18use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait;
19use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Utilities;
20use WC_REST_Controller;
21use WP_Error;
22use WP_REST_Request;
23use WP_REST_Response;
24
25/**
26 * CSV Export Controller class.
27 *
28 * @since 0.1.0
29 */
30class Csv_Export_Controller extends WC_REST_Controller implements Registrable_Interface {
31
32    use Logger_Trait;
33    use Utilities;
34
35    /**
36     * Plugin REST slug, matching the other Premium Analytics controllers.
37     */
38    private const SLUG = 'jetpack-premium-analytics';
39
40    /**
41     * Endpoint namespace.
42     *
43     * @var string
44     */
45    protected $namespace;
46
47    /**
48     * Route base.
49     *
50     * @var string
51     */
52    protected $rest_base;
53
54    /**
55     * Report registry instance.
56     *
57     * @var Report_Registry
58     */
59    private $registry;
60
61    /**
62     * Data fetcher instance.
63     *
64     * @var Report_Data_Fetcher
65     */
66    private $data_fetcher;
67
68    /**
69     * CSV generator instance.
70     *
71     * @var Report_Csv_Generator
72     */
73    private $csv_generator;
74
75    /**
76     * Export scheduler instance.
77     *
78     * @var Csv_Export_Scheduler
79     */
80    private $scheduler;
81
82    /**
83     * Constructor.
84     *
85     * @param Report_Registry      $registry      The report registry.
86     * @param Report_Data_Fetcher  $data_fetcher  The data fetcher.
87     * @param Report_Csv_Generator $csv_generator The CSV generator.
88     * @param Csv_Export_Scheduler $scheduler     The export scheduler.
89     * @param Logger_Interface     $logger        The logger.
90     */
91    public function __construct(
92        Report_Registry $registry,
93        Report_Data_Fetcher $data_fetcher,
94        Report_Csv_Generator $csv_generator,
95        Csv_Export_Scheduler $scheduler,
96        Logger_Interface $logger
97    ) {
98        $this->namespace     = self::SLUG . '/v1';
99        $this->rest_base     = 'reports/csv-export';
100        $this->registry      = $registry;
101        $this->data_fetcher  = $data_fetcher;
102        $this->csv_generator = $csv_generator;
103        $this->scheduler     = $scheduler;
104        $this->logger        = $logger;
105    }
106
107    /**
108     * Register the controller.
109     *
110     * @return void
111     */
112    public function register(): void {
113        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
114    }
115
116    /**
117     * Register REST API routes.
118     *
119     * @return void
120     */
121    public function register_routes(): void {
122        $args = array(
123            array(
124                'methods'             => \WP_REST_Server::CREATABLE,
125                'callback'            => array( $this, 'create_export' ),
126                'permission_callback' => array( $this, 'check_permission' ),
127                'args'                => $this->get_endpoint_args(),
128            ),
129        );
130        // Set separately (not in the literal) to avoid mixing indexed endpoint entries with a keyed value.
131        $args['schema'] = array( $this, 'get_public_item_schema' );
132
133        register_rest_route( $this->namespace, $this->rest_base, $args );
134    }
135
136    /**
137     * Check if user has permission to export reports.
138     *
139     * Must match the capability the analytics proxy enforces (Api_Proxy_Controller's `analytics`
140     * prefix) â€” otherwise the route advertises access the async data fetch can't honor.
141     *
142     * @return bool True if user has permission.
143     */
144    public function check_permission(): bool {
145        return Capabilities::current_user_can_view_store_reports();
146    }
147
148    /**
149     * Get endpoint arguments.
150     *
151     * @return array Endpoint arguments.
152     */
153    private function get_endpoint_args(): array {
154        return array(
155            'report_type'     => array(
156                'description'       => __( 'The type of report to export.', 'jetpack-premium-analytics-pkg' ),
157                'type'              => 'string',
158                'required'          => true,
159                'validate_callback' => array( $this, 'validate_report_type' ),
160            ),
161            'from'            => array(
162                'description'       => __( 'Start date for the report period (ISO 8601 format).', 'jetpack-premium-analytics-pkg' ),
163                'type'              => 'string',
164                'format'            => 'date-time',
165                'required'          => true,
166                'validate_callback' => array( $this, 'validate_from_date' ),
167            ),
168            'to'              => array(
169                'description'       => __( 'End date for the report period (ISO 8601 format).', 'jetpack-premium-analytics-pkg' ),
170                'type'              => 'string',
171                'format'            => 'date-time',
172                'required'          => true,
173                'validate_callback' => array( $this, 'validate_to_date' ),
174            ),
175            'interval'        => array(
176                'description'       => __( 'Time interval for grouping data.', 'jetpack-premium-analytics-pkg' ),
177                'type'              => 'string',
178                'default'           => 'day',
179                'enum'              => array( 'hour', 'day', 'week', 'month', 'year' ),
180                'validate_callback' => 'rest_validate_request_arg',
181            ),
182            'date_type'       => array(
183                'description' => __( 'Date field used to filter orders.', 'jetpack-premium-analytics-pkg' ),
184                'type'        => 'string',
185                'enum'        => array( 'created', 'paid', 'completed' ),
186            ),
187            'compare_from'    => array(
188                'description'       => __( 'Start date for comparison period (ISO 8601 format).', 'jetpack-premium-analytics-pkg' ),
189                'type'              => 'string',
190                'format'            => 'date-time',
191                'validate_callback' => array( $this, 'validate_compare_from_date' ),
192            ),
193            'compare_to'      => array(
194                'description'       => __( 'End date for comparison period (ISO 8601 format).', 'jetpack-premium-analytics-pkg' ),
195                'type'              => 'string',
196                'format'            => 'date-time',
197                'validate_callback' => array( $this, 'validate_compare_to_date' ),
198            ),
199            'delivery_method' => array(
200                'description' => __( 'Delivery method for the export.', 'jetpack-premium-analytics-pkg' ),
201                'type'        => 'string',
202                'default'     => 'download',
203                'enum'        => array( 'download', 'email' ),
204            ),
205        );
206    }
207
208    /**
209     * Validate report type parameter.
210     *
211     * @param mixed $value The parameter value.
212     * @return bool|WP_Error True if valid, WP_Error otherwise.
213     */
214    public function validate_report_type( $value ) {
215        if ( ! is_string( $value ) || ! $this->registry->is_registered( $value ) ) {
216            return new WP_Error(
217                'invalid_report_type',
218                sprintf(
219                    /* translators: %s: Report type */
220                    __( 'Invalid report type: %s', 'jetpack-premium-analytics-pkg' ),
221                    is_string( $value ) ? $value : wp_json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
222                ),
223                array( 'status' => 400 )
224            );
225        }
226        return true;
227    }
228
229    /**
230     * Validate from date parameter.
231     *
232     * @param mixed           $value   The parameter value.
233     * @param WP_REST_Request $request The request object.
234     * @param string          $param   The parameter name.
235     * @return bool|WP_Error True if valid, WP_Error otherwise.
236     */
237    public function validate_from_date( $value, WP_REST_Request $request, string $param ) {
238        $validated = rest_validate_request_arg( $value, $request, $param );
239        if ( is_wp_error( $validated ) ) {
240            return $validated;
241        }
242
243        $to_date = $request->get_param( 'to' );
244        if ( $to_date ) {
245            $from_timestamp = strtotime( $value );
246            $to_timestamp   = strtotime( $to_date );
247
248            if ( false !== $from_timestamp && false !== $to_timestamp && $from_timestamp >= $to_timestamp ) {
249                return new WP_Error(
250                    'invalid_date_range',
251                    __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics-pkg' ),
252                    array( 'status' => 400 )
253                );
254            }
255        }
256
257        return true;
258    }
259
260    /**
261     * Validate to date parameter.
262     *
263     * @param mixed           $value   The parameter value.
264     * @param WP_REST_Request $request The request object.
265     * @param string          $param   The parameter name.
266     * @return bool|WP_Error True if valid, WP_Error otherwise.
267     */
268    public function validate_to_date( $value, WP_REST_Request $request, string $param ) {
269        $validated = rest_validate_request_arg( $value, $request, $param );
270        if ( is_wp_error( $validated ) ) {
271            return $validated;
272        }
273
274        $to_timestamp = strtotime( $value );
275
276        // Compare at day level, not time level.
277        $to_date_only    = wp_date( 'Y-m-d', $to_timestamp );
278        $today_date_only = current_datetime()->format( 'Y-m-d' );
279
280        if ( $to_date_only > $today_date_only ) {
281            return new WP_Error(
282                'future_date',
283                __( 'The "to" date cannot be later than today.', 'jetpack-premium-analytics-pkg' ),
284                array( 'status' => 400 )
285            );
286        }
287
288        $from_date = $request->get_param( 'from' );
289        if ( $from_date ) {
290            $from_timestamp = strtotime( $from_date );
291
292            if ( false !== $from_timestamp && false !== $to_timestamp && $from_timestamp >= $to_timestamp ) {
293                return new WP_Error(
294                    'invalid_date_range',
295                    __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics-pkg' ),
296                    array( 'status' => 400 )
297                );
298            }
299        }
300
301        return true;
302    }
303
304    /**
305     * Validate compare_from date parameter.
306     *
307     * @param mixed           $value   The parameter value.
308     * @param WP_REST_Request $request The request object.
309     * @param string          $param   The parameter name.
310     * @return bool|WP_Error True if valid, WP_Error otherwise.
311     */
312    public function validate_compare_from_date( $value, WP_REST_Request $request, string $param ) {
313        $validated = rest_validate_request_arg( $value, $request, $param );
314        if ( is_wp_error( $validated ) ) {
315            return $validated;
316        }
317
318        $compare_to = $request->get_param( 'compare_to' );
319        if ( $compare_to ) {
320            return $this->validate_compare_period( $value, $compare_to );
321        }
322
323        return new WP_Error(
324            'missing_compare_to',
325            __( 'The "compare_to" parameter is required when "compare_from" is provided.', 'jetpack-premium-analytics-pkg' ),
326            array( 'status' => 400 )
327        );
328    }
329
330    /**
331     * Validate compare_to date parameter.
332     *
333     * @param mixed           $value   The parameter value.
334     * @param WP_REST_Request $request The request object.
335     * @param string          $param   The parameter name.
336     * @return bool|WP_Error True if valid, WP_Error otherwise.
337     */
338    public function validate_compare_to_date( $value, WP_REST_Request $request, string $param ) {
339        $validated = rest_validate_request_arg( $value, $request, $param );
340        if ( is_wp_error( $validated ) ) {
341            return $validated;
342        }
343
344        $compare_to_timestamp = strtotime( $value );
345
346        // Compare at day level, not time level.
347        $compare_to_date_only = wp_date( 'Y-m-d', $compare_to_timestamp );
348        $today_date_only      = current_datetime()->format( 'Y-m-d' );
349        if ( $compare_to_date_only > $today_date_only ) {
350            return new WP_Error(
351                'future_date',
352                __( 'The "compare_to" date cannot be later than today.', 'jetpack-premium-analytics-pkg' ),
353                array( 'status' => 400 )
354            );
355        }
356
357        $compare_from = $request->get_param( 'compare_from' );
358        if ( $compare_from ) {
359            return $this->validate_compare_period( $compare_from, $value );
360        }
361
362        return new WP_Error(
363            'missing_compare_from',
364            __( 'The "compare_from" parameter is required when "compare_to" is provided.', 'jetpack-premium-analytics-pkg' ),
365            array( 'status' => 400 )
366        );
367    }
368
369    /**
370     * Validate the comparison period date order.
371     *
372     * The comparison window need not match the original period's length: merge strategies align
373     * by position/field and pad gaps, and a strict check also mis-rejected DST-crossing ranges.
374     *
375     * @param string $compare_from The compare_from date.
376     * @param string $compare_to   The compare_to date.
377     * @return bool|WP_Error True if valid, WP_Error otherwise.
378     */
379    private function validate_compare_period( string $compare_from, string $compare_to ) {
380        $compare_from_timestamp = strtotime( $compare_from );
381        $compare_to_timestamp   = strtotime( $compare_to );
382
383        if ( false !== $compare_from_timestamp && false !== $compare_to_timestamp && $compare_from_timestamp >= $compare_to_timestamp ) {
384            return new WP_Error(
385                'invalid_compare_date_range',
386                __( 'The "compare_from" date must be before the "compare_to" date.', 'jetpack-premium-analytics-pkg' ),
387                array( 'status' => 400 )
388            );
389        }
390
391        return true;
392    }
393
394    /**
395     * Create a CSV export.
396     *
397     * @param WP_REST_Request $request The request object.
398     * @return WP_REST_Response|WP_Error Response or error.
399     */
400    public function create_export( WP_REST_Request $request ) {
401        $report_type     = $request->get_param( 'report_type' );
402        $delivery_method = $request->get_param( 'delivery_method' );
403
404        // Also enforced by validate_report_type() at the route layer; kept for direct callers.
405        $controller = $this->registry->get_controller( $report_type );
406        if ( is_wp_error( $controller ) ) {
407            return $controller;
408        }
409
410        // Controller-specific params (orderby, limit, â€¦) are merged in Report_Data_Fetcher::fetch().
411        $params = array(
412            'from'         => $request->get_param( 'from' ),
413            'to'           => $request->get_param( 'to' ),
414            'interval'     => $request->get_param( 'interval' ),
415            'compare_from' => $request->get_param( 'compare_from' ),
416            'compare_to'   => $request->get_param( 'compare_to' ),
417        );
418        if ( $request->has_param( 'date_type' ) ) {
419            $params['date_type'] = $request->get_param( 'date_type' );
420        }
421
422        if ( 'email' === $delivery_method ) {
423            return $this->schedule_email_export( $report_type, $params );
424        }
425
426        return $this->generate_download_export( $report_type, $params );
427    }
428
429    /**
430     * Generate and stream CSV for download.
431     *
432     * @param string $report_type The report type.
433     * @param array  $params      Request parameters.
434     * @return WP_REST_Response|WP_Error Response or error.
435     */
436    private function generate_download_export( string $report_type, array $params ) {
437        $controller = $this->registry->get_controller( $report_type );
438        if ( is_wp_error( $controller ) ) {
439            return $controller;
440        }
441
442        $data = $this->data_fetcher->fetch( $params, $controller );
443        if ( is_wp_error( $data ) ) {
444            return $data;
445        }
446
447        $is_comparison = $this->is_comparison_request( $params );
448
449        $interval = $params['interval'] ?? null;
450
451        $columns = $this->registry->get_columns( $report_type, $is_comparison, $interval );
452        if ( is_wp_error( $columns ) ) {
453            return $columns;
454        }
455
456        $formatter = $this->registry->get_row_formatter( $report_type, $interval );
457        if ( is_wp_error( $formatter ) ) {
458            return $formatter;
459        }
460
461        $filename = $this->registry->build_filename( $report_type, $params );
462
463        $file_path = $this->csv_generator->generate( $data, $columns, $formatter, $filename );
464        if ( is_wp_error( $file_path ) ) {
465            return $file_path;
466        }
467
468        // Stream the file. If streaming fails (headers already sent, missing file), return a
469        // structured error instead of silently deleting and exiting with an empty response.
470        $streamed = $this->csv_generator->stream_file( $file_path, $filename . '.csv' );
471        if ( ! $streamed ) {
472            $this->csv_generator->delete_file( $file_path );
473            return new WP_Error(
474                'csv_stream_failed',
475                __( 'Failed to stream the export file.', 'jetpack-premium-analytics-pkg' ),
476                array( 'status' => 500 )
477            );
478        }
479
480        $this->csv_generator->delete_file( $file_path );
481
482        // The file body is already in the output buffer; terminate so the REST stack does not
483        // append a JSON response.
484        exit;
485    }
486
487    /**
488     * Schedule email export via Action Scheduler.
489     *
490     * @param string $report_type The report type.
491     * @param array  $params      Request parameters.
492     * @return WP_REST_Response|WP_Error Response or error.
493     */
494    private function schedule_email_export( string $report_type, array $params ) {
495        $user   = wp_get_current_user();
496        $job_id = $this->scheduler->schedule_export( $report_type, $params, $user->ID, $user->user_email );
497
498        if ( is_wp_error( $job_id ) ) {
499            return $job_id;
500        }
501
502        $this->logger->log_message(
503            sprintf( 'Scheduled CSV export job %d for user %d', $job_id, $user->ID ),
504            __METHOD__
505        );
506
507        return new WP_REST_Response(
508            array(
509                'success' => true,
510                'message' => __( 'Export has been scheduled. You will receive an email when it is ready.', 'jetpack-premium-analytics-pkg' ),
511                'job_id'  => $job_id,
512            ),
513            202
514        );
515    }
516
517    /**
518     * Get the schema for the endpoint.
519     *
520     * @return array The schema.
521     */
522    public function get_item_schema(): array {
523        return array(
524            '$schema'    => 'http://json-schema.org/draft-04/schema#',
525            'title'      => 'csv-export',
526            'type'       => 'object',
527            'properties' => array(
528                'success' => array(
529                    'description' => __( 'Whether the export was successful.', 'jetpack-premium-analytics-pkg' ),
530                    'type'        => 'boolean',
531                    'context'     => array( 'view' ),
532                ),
533                'message' => array(
534                    'description' => __( 'Status message.', 'jetpack-premium-analytics-pkg' ),
535                    'type'        => 'string',
536                    'context'     => array( 'view' ),
537                ),
538                'job_id'  => array(
539                    'description' => __( 'Action Scheduler job ID (for email exports).', 'jetpack-premium-analytics-pkg' ),
540                    'type'        => 'integer',
541                    'context'     => array( 'view' ),
542                ),
543            ),
544        );
545    }
546}