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\Reports\Export\Logging\Logger_Interface;
17use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Logger_Trait;
18use Automattic\Jetpack\PremiumAnalytics\Reports\Export\Support\Utilities;
19use WC_REST_Controller;
20use WP_Error;
21use WP_REST_Request;
22use WP_REST_Response;
23
24/**
25 * CSV Export Controller class.
26 *
27 * @since $$next-version$$
28 */
29class Csv_Export_Controller extends WC_REST_Controller implements Registrable_Interface {
30
31    use Logger_Trait;
32    use Utilities;
33
34    /**
35     * Plugin REST slug, matching the other Premium Analytics controllers.
36     */
37    private const SLUG = 'jetpack-premium-analytics';
38
39    /**
40     * Endpoint namespace.
41     *
42     * @var string
43     */
44    protected $namespace;
45
46    /**
47     * Route base.
48     *
49     * @var string
50     */
51    protected $rest_base;
52
53    /**
54     * Report registry instance.
55     *
56     * @var Report_Registry
57     */
58    private $registry;
59
60    /**
61     * Data fetcher instance.
62     *
63     * @var Report_Data_Fetcher
64     */
65    private $data_fetcher;
66
67    /**
68     * CSV generator instance.
69     *
70     * @var Report_Csv_Generator
71     */
72    private $csv_generator;
73
74    /**
75     * Export scheduler instance.
76     *
77     * @var Csv_Export_Scheduler
78     */
79    private $scheduler;
80
81    /**
82     * Constructor.
83     *
84     * @param Report_Registry      $registry      The report registry.
85     * @param Report_Data_Fetcher  $data_fetcher  The data fetcher.
86     * @param Report_Csv_Generator $csv_generator The CSV generator.
87     * @param Csv_Export_Scheduler $scheduler     The export scheduler.
88     * @param Logger_Interface     $logger        The logger.
89     */
90    public function __construct(
91        Report_Registry $registry,
92        Report_Data_Fetcher $data_fetcher,
93        Report_Csv_Generator $csv_generator,
94        Csv_Export_Scheduler $scheduler,
95        Logger_Interface $logger
96    ) {
97        $this->namespace     = self::SLUG . '/v1';
98        $this->rest_base     = 'reports/csv-export';
99        $this->registry      = $registry;
100        $this->data_fetcher  = $data_fetcher;
101        $this->csv_generator = $csv_generator;
102        $this->scheduler     = $scheduler;
103        $this->logger        = $logger;
104    }
105
106    /**
107     * Register the controller.
108     *
109     * @return void
110     */
111    public function register(): void {
112        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
113    }
114
115    /**
116     * Register REST API routes.
117     *
118     * @return void
119     */
120    public function register_routes(): void {
121        $args = array(
122            array(
123                'methods'             => \WP_REST_Server::CREATABLE,
124                'callback'            => array( $this, 'create_export' ),
125                'permission_callback' => array( $this, 'check_permission' ),
126                'args'                => $this->get_endpoint_args(),
127            ),
128        );
129        // Set separately (not in the literal) to avoid mixing indexed endpoint entries with a keyed value.
130        $args['schema'] = array( $this, 'get_public_item_schema' );
131
132        register_rest_route( $this->namespace, $this->rest_base, $args );
133    }
134
135    /**
136     * Check if user has permission to export reports.
137     *
138     * Must match the capability the analytics proxy enforces (`manage_options` for the
139     * `analytics` prefix in Api_Proxy_Controller); otherwise the route would advertise
140     * access the async data fetch cannot honor, scheduling a job that then fails.
141     *
142     * @return bool True if user has permission.
143     */
144    public function check_permission(): bool {
145        return current_user_can( 'manage_options' );
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' ),
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' ),
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' ),
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' ),
177                'type'              => 'string',
178                'default'           => 'day',
179                'enum'              => array( 'hour', 'day', 'week', 'month', 'quarter', 'year' ),
180                'validate_callback' => 'rest_validate_request_arg',
181            ),
182            'date_type'       => array(
183                'description' => __( 'Date field used to filter orders.', 'jetpack-premium-analytics' ),
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' ),
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' ),
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' ),
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' ),
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        // First validate the basic date format.
239        $validated = rest_validate_request_arg( $value, $request, $param );
240        if ( is_wp_error( $validated ) ) {
241            return $validated;
242        }
243
244        // Check if 'to' date is provided and validate the relationship.
245        $to_date = $request->get_param( 'to' );
246        if ( $to_date ) {
247            $from_timestamp = strtotime( $value );
248            $to_timestamp   = strtotime( $to_date );
249
250            if ( false !== $from_timestamp && false !== $to_timestamp && $from_timestamp >= $to_timestamp ) {
251                return new WP_Error(
252                    'invalid_date_range',
253                    __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ),
254                    array( 'status' => 400 )
255                );
256            }
257        }
258
259        return true;
260    }
261
262    /**
263     * Validate to date parameter.
264     *
265     * @param mixed           $value   The parameter value.
266     * @param WP_REST_Request $request The request object.
267     * @param string          $param   The parameter name.
268     * @return bool|WP_Error True if valid, WP_Error otherwise.
269     */
270    public function validate_to_date( $value, WP_REST_Request $request, string $param ) {
271        // First validate the basic date format.
272        $validated = rest_validate_request_arg( $value, $request, $param );
273        if ( is_wp_error( $validated ) ) {
274            return $validated;
275        }
276
277        $to_timestamp = strtotime( $value );
278
279        // Check that the date is not beyond today (compare at day level, not time level).
280        $to_date_only    = wp_date( 'Y-m-d', $to_timestamp );
281        $today_date_only = current_datetime()->format( 'Y-m-d' );
282
283        if ( $to_date_only > $today_date_only ) {
284            return new WP_Error(
285                'future_date',
286                __( 'The "to" date cannot be later than today.', 'jetpack-premium-analytics' ),
287                array( 'status' => 400 )
288            );
289        }
290
291        // Check if 'from' date is provided and validate the relationship.
292        $from_date = $request->get_param( 'from' );
293        if ( $from_date ) {
294            $from_timestamp = strtotime( $from_date );
295
296            if ( false !== $from_timestamp && false !== $to_timestamp && $from_timestamp >= $to_timestamp ) {
297                return new WP_Error(
298                    'invalid_date_range',
299                    __( 'The "from" date must be before the "to" date.', 'jetpack-premium-analytics' ),
300                    array( 'status' => 400 )
301                );
302            }
303        }
304
305        return true;
306    }
307
308    /**
309     * Validate compare_from date parameter.
310     *
311     * @param mixed           $value   The parameter value.
312     * @param WP_REST_Request $request The request object.
313     * @param string          $param   The parameter name.
314     * @return bool|WP_Error True if valid, WP_Error otherwise.
315     */
316    public function validate_compare_from_date( $value, WP_REST_Request $request, string $param ) {
317        // First validate the basic date format.
318        $validated = rest_validate_request_arg( $value, $request, $param );
319        if ( is_wp_error( $validated ) ) {
320            return $validated;
321        }
322
323        // Check if compare_to date is provided and validate the relationship.
324        $compare_to = $request->get_param( 'compare_to' );
325        if ( $compare_to ) {
326            return $this->validate_compare_period( $value, $compare_to );
327        }
328
329        // If compare_from is provided but compare_to is not, return error.
330        return new WP_Error(
331            'missing_compare_to',
332            __( 'The "compare_to" parameter is required when "compare_from" is provided.', 'jetpack-premium-analytics' ),
333            array( 'status' => 400 )
334        );
335    }
336
337    /**
338     * Validate compare_to date parameter.
339     *
340     * @param mixed           $value   The parameter value.
341     * @param WP_REST_Request $request The request object.
342     * @param string          $param   The parameter name.
343     * @return bool|WP_Error True if valid, WP_Error otherwise.
344     */
345    public function validate_compare_to_date( $value, WP_REST_Request $request, string $param ) {
346        // First validate the basic date format.
347        $validated = rest_validate_request_arg( $value, $request, $param );
348        if ( is_wp_error( $validated ) ) {
349            return $validated;
350        }
351
352        $compare_to_timestamp = strtotime( $value );
353
354        // Check that the date is not beyond today (compare at day level, not time level).
355        $compare_to_date_only = wp_date( 'Y-m-d', $compare_to_timestamp );
356        $today_date_only      = current_datetime()->format( 'Y-m-d' );
357        if ( $compare_to_date_only > $today_date_only ) {
358            return new WP_Error(
359                'future_date',
360                __( 'The "compare_to" date cannot be later than today.', 'jetpack-premium-analytics' ),
361                array( 'status' => 400 )
362            );
363        }
364
365        // Check if compare_from date is provided and validate the relationship.
366        $compare_from = $request->get_param( 'compare_from' );
367        if ( $compare_from ) {
368            return $this->validate_compare_period( $compare_from, $value );
369        }
370
371        // If compare_to is provided but compare_from is not, return error.
372        return new WP_Error(
373            'missing_compare_from',
374            __( 'The "compare_from" parameter is required when "compare_to" is provided.', 'jetpack-premium-analytics' ),
375            array( 'status' => 400 )
376        );
377    }
378
379    /**
380     * Validate the comparison period date order.
381     *
382     * The comparison window does not need to match the original period length: the merge
383     * strategies align by position (time-series) or matching field (ranked) and pad any
384     * gap, so uneven windows are handled downstream without a strict length check (which
385     * also mis-rejected DST-crossing ranges compared by raw seconds).
386     *
387     * @param string $compare_from The compare_from date.
388     * @param string $compare_to   The compare_to date.
389     * @return bool|WP_Error True if valid, WP_Error otherwise.
390     */
391    private function validate_compare_period( string $compare_from, string $compare_to ) {
392        $compare_from_timestamp = strtotime( $compare_from );
393        $compare_to_timestamp   = strtotime( $compare_to );
394
395        if ( false !== $compare_from_timestamp && false !== $compare_to_timestamp && $compare_from_timestamp >= $compare_to_timestamp ) {
396            return new WP_Error(
397                'invalid_compare_date_range',
398                __( 'The "compare_from" date must be before the "compare_to" date.', 'jetpack-premium-analytics' ),
399                array( 'status' => 400 )
400            );
401        }
402
403        return true;
404    }
405
406    /**
407     * Create a CSV export.
408     *
409     * @param WP_REST_Request $request The request object.
410     * @return WP_REST_Response|WP_Error Response or error.
411     */
412    public function create_export( WP_REST_Request $request ) {
413        $report_type     = $request->get_param( 'report_type' );
414        $delivery_method = $request->get_param( 'delivery_method' );
415
416        // Validate the report type. Also enforced by validate_report_type() at the route
417        // layer; kept here as a guard for direct callers.
418        $controller = $this->registry->get_controller( $report_type );
419        if ( is_wp_error( $controller ) ) {
420            return $controller;
421        }
422
423        // Extract request parameters. Controller-specific additional params (date_type,
424        // orderby, limit, etc.) are merged once in Report_Data_Fetcher::fetch().
425        $params = array(
426            'from'         => $request->get_param( 'from' ),
427            'to'           => $request->get_param( 'to' ),
428            'interval'     => $request->get_param( 'interval' ),
429            'compare_from' => $request->get_param( 'compare_from' ),
430            'compare_to'   => $request->get_param( 'compare_to' ),
431        );
432        if ( $request->has_param( 'date_type' ) ) {
433            $params['date_type'] = $request->get_param( 'date_type' );
434        }
435
436        // Handle delivery method.
437        if ( 'email' === $delivery_method ) {
438            return $this->schedule_email_export( $report_type, $params );
439        }
440
441        return $this->generate_download_export( $report_type, $params );
442    }
443
444    /**
445     * Generate and stream CSV for download.
446     *
447     * @param string $report_type The report type.
448     * @param array  $params      Request parameters.
449     * @return WP_REST_Response|WP_Error Response or error.
450     */
451    private function generate_download_export( string $report_type, array $params ) {
452        // Controller drives the data endpoint, requested fields, and merge strategy.
453        $controller = $this->registry->get_controller( $report_type );
454        if ( is_wp_error( $controller ) ) {
455            return $controller;
456        }
457
458        // Fetch data.
459        $data = $this->data_fetcher->fetch( $params, $controller );
460        if ( is_wp_error( $data ) ) {
461            return $data;
462        }
463
464        // Determine if comparison mode.
465        $is_comparison = $this->is_comparison_request( $params );
466
467        // Interval drives time-series column labels and row formatting.
468        $interval = $params['interval'] ?? null;
469
470        // Get columns.
471        $columns = $this->registry->get_columns( $report_type, $is_comparison, $interval );
472        if ( is_wp_error( $columns ) ) {
473            return $columns;
474        }
475
476        // Get row formatter.
477        $formatter = $this->registry->get_row_formatter( $report_type, $interval );
478        if ( is_wp_error( $formatter ) ) {
479            return $formatter;
480        }
481
482        // Generate filename.
483        $filename = $this->registry->build_filename( $report_type, $params );
484
485        // Generate CSV file.
486        $file_path = $this->csv_generator->generate( $data, $columns, $formatter, $filename );
487        if ( is_wp_error( $file_path ) ) {
488            return $file_path;
489        }
490
491        // Stream the file. If streaming fails (headers already sent, missing file), return a
492        // structured error instead of silently deleting and exiting with an empty response.
493        $streamed = $this->csv_generator->stream_file( $file_path, $filename . '.csv' );
494        if ( ! $streamed ) {
495            $this->csv_generator->delete_file( $file_path );
496            return new WP_Error(
497                'csv_stream_failed',
498                __( 'Failed to stream the export file.', 'jetpack-premium-analytics' ),
499                array( 'status' => 500 )
500            );
501        }
502
503        // Clean up file after successful streaming.
504        $this->csv_generator->delete_file( $file_path );
505
506        // The file body has already been written to the output buffer; terminate so the REST
507        // stack does not append a JSON response. (Streaming a file is inherently a non-REST
508        // response; there is no cleaner hook once headers + body are sent.)
509        exit;
510    }
511
512    /**
513     * Schedule email export via Action Scheduler.
514     *
515     * @param string $report_type The report type.
516     * @param array  $params      Request parameters.
517     * @return WP_REST_Response|WP_Error Response or error.
518     */
519    private function schedule_email_export( string $report_type, array $params ) {
520        $user   = wp_get_current_user();
521        $job_id = $this->scheduler->schedule_export( $report_type, $params, $user->ID, $user->user_email );
522
523        if ( is_wp_error( $job_id ) ) {
524            return $job_id;
525        }
526
527        $this->logger->log_message(
528            sprintf( 'Scheduled CSV export job %d for user %d', $job_id, $user->ID ),
529            __METHOD__
530        );
531
532        return new WP_REST_Response(
533            array(
534                'success' => true,
535                'message' => __( 'Export has been scheduled. You will receive an email when it is ready.', 'jetpack-premium-analytics' ),
536                'job_id'  => $job_id,
537            ),
538            202
539        );
540    }
541
542    /**
543     * Get the schema for the endpoint.
544     *
545     * @return array The schema.
546     */
547    public function get_item_schema(): array {
548        return array(
549            '$schema'    => 'http://json-schema.org/draft-04/schema#',
550            'title'      => 'csv-export',
551            'type'       => 'object',
552            'properties' => array(
553                'success' => array(
554                    'description' => __( 'Whether the export was successful.', 'jetpack-premium-analytics' ),
555                    'type'        => 'boolean',
556                    'context'     => array( 'view' ),
557                ),
558                'message' => array(
559                    'description' => __( 'Status message.', 'jetpack-premium-analytics' ),
560                    'type'        => 'string',
561                    'context'     => array( 'view' ),
562                ),
563                'job_id'  => array(
564                    'description' => __( 'Action Scheduler job ID (for email exports).', 'jetpack-premium-analytics' ),
565                    'type'        => 'integer',
566                    'context'     => array( 'view' ),
567                ),
568            ),
569        );
570    }
571}