Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.86% covered (warning)
69.86%
102 / 146
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Csv_Export_Scheduler
70.34% covered (warning)
70.34%
102 / 145
14.29% covered (danger)
14.29%
1 / 7
58.71
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
2
 register
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 schedule_export
87.76% covered (warning)
87.76%
43 / 49
0.00% covered (danger)
0.00%
0 / 1
6.07
 process_export_job
86.67% covered (warning)
86.67%
39 / 45
0.00% covered (danger)
0.00%
0 / 1
9.19
 send_error_email
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 schedule_cleanup
20.00% covered (danger)
20.00%
2 / 10
0.00% covered (danger)
0.00%
0 / 1
12.19
 cleanup_old_exports
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
72
1<?php
2/**
3 * CSV Export Scheduler
4 *
5 * Handles scheduling and processing of CSV export jobs via Action Scheduler.
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;
19
20/**
21 * CSV Export Scheduler class.
22 *
23 * @since $$next-version$$
24 */
25class Csv_Export_Scheduler implements Registrable_Interface {
26
27    use Logger_Trait;
28    use Utilities;
29
30    /**
31     * Action hook name for CSV export jobs.
32     */
33    const EXPORT_ACTION_HOOK = 'jetpack_premium_analytics_generate_csv_export';
34
35    /**
36     * Action Scheduler group name.
37     */
38    const ACTION_GROUP = 'jetpack-premium-analytics-csv-export';
39
40    /**
41     * Cleanup hook name.
42     */
43    const CLEANUP_HOOK = 'jetpack_premium_analytics_cleanup_csv_exports';
44
45    /**
46     * Default retention period for CSV export files in seconds (48 hours).
47     */
48    const DEFAULT_RETENTION_PERIOD = 2 * DAY_IN_SECONDS;
49
50    /**
51     * Report registry instance.
52     *
53     * @var Report_Registry
54     */
55    private $registry;
56
57    /**
58     * Data fetcher instance.
59     *
60     * @var Report_Data_Fetcher
61     */
62    private $data_fetcher;
63
64    /**
65     * CSV generator instance.
66     *
67     * @var Report_Csv_Generator
68     */
69    private $csv_generator;
70
71    /**
72     * Email sender instance.
73     *
74     * @var Csv_Export_Email
75     */
76    private $email_sender;
77
78    /**
79     * Constructor.
80     *
81     * @param Report_Registry      $registry      The report registry.
82     * @param Report_Data_Fetcher  $data_fetcher  The data fetcher.
83     * @param Report_Csv_Generator $csv_generator The CSV generator.
84     * @param Csv_Export_Email     $email_sender  The email sender.
85     * @param Logger_Interface     $logger        The logger.
86     */
87    public function __construct(
88        Report_Registry $registry,
89        Report_Data_Fetcher $data_fetcher,
90        Report_Csv_Generator $csv_generator,
91        Csv_Export_Email $email_sender,
92        Logger_Interface $logger
93    ) {
94        $this->registry      = $registry;
95        $this->data_fetcher  = $data_fetcher;
96        $this->csv_generator = $csv_generator;
97        $this->email_sender  = $email_sender;
98        $this->logger        = $logger;
99
100        // Inject logger into email sender if not already set.
101        if ( null === $this->email_sender->get_logger() ) {
102            $this->email_sender->set_logger( $this->logger );
103        }
104    }
105
106    /**
107     * Register hooks.
108     *
109     * @return void
110     */
111    public function register(): void {
112        // Register export action callback.
113        add_action( self::EXPORT_ACTION_HOOK, array( $this, 'process_export_job' ), 10, 4 );
114
115        // Register cleanup action callback. The recurring cleanup is scheduled lazily from
116        // schedule_export() (see below), so we avoid an Action Scheduler DB query on every request.
117        add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_old_exports' ) );
118    }
119
120    /**
121     * Schedule a CSV export job.
122     *
123     * @param string $report_type  The report type.
124     * @param array  $params       Report parameters.
125     * @param int    $user_id      User ID requesting the export.
126     * @param string $user_email   User email for notification.
127     * @return int|\WP_Error Action ID on success, WP_Error on failure.
128     */
129    public function schedule_export( string $report_type, array $params, int $user_id, string $user_email ) {
130        // Validate email format.
131        if ( ! \is_email( $user_email ) ) {
132            return new \WP_Error(
133                'invalid_email',
134                __( 'Invalid email address provided.', 'jetpack-premium-analytics' ),
135                array( 'status' => 400 )
136            );
137        }
138
139        if ( ! function_exists( 'as_enqueue_async_action' ) ) {
140            $this->logger->log_error( 'Action Scheduler is not available', __METHOD__ );
141            return new \WP_Error(
142                'action_scheduler_unavailable',
143                __( 'Action Scheduler is not available. Cannot schedule export.', 'jetpack-premium-analytics' ),
144                array( 'status' => 503 )
145            );
146        }
147
148        // Validate report type.
149        if ( ! $this->registry->is_registered( $report_type ) ) {
150            return new \WP_Error(
151                'invalid_report_type',
152                __( 'Invalid report type.', 'jetpack-premium-analytics' ),
153                array( 'status' => 400 )
154            );
155        }
156
157        try {
158            // Schedule the action.
159            // @phan-suppress-next-line PhanUndeclaredFunction -- Action Scheduler; guarded by function_exists() above.
160            $action_id = as_enqueue_async_action(
161                self::EXPORT_ACTION_HOOK,
162                array(
163                    'report_type' => $report_type,
164                    'params'      => $params,
165                    'user_id'     => $user_id,
166                    'user_email'  => $user_email,
167                ),
168                self::ACTION_GROUP
169            );
170        } catch ( \Throwable $e ) {
171            $this->logger->log_exception( $e, __METHOD__ );
172            return new \WP_Error(
173                'schedule_failed',
174                __( 'Failed to schedule export job.', 'jetpack-premium-analytics' ),
175                array( 'status' => 500 )
176            );
177        }
178
179        if ( ! $action_id ) {
180            $this->logger->log_error( 'Failed to schedule CSV export action', __METHOD__ );
181            return new \WP_Error(
182                'schedule_failed',
183                __( 'Failed to schedule export job.', 'jetpack-premium-analytics' ),
184                array( 'status' => 500 )
185            );
186        }
187
188        $this->logger->log_message(
189            sprintf( 'Scheduled CSV export job %d for report type: %s', $action_id, $report_type ),
190            __METHOD__
191        );
192
193        // Ensure the recurring cleanup exists now that exports are actually being used.
194        $this->schedule_cleanup();
195
196        return $action_id;
197    }
198
199    /**
200     * Process a scheduled export job.
201     *
202     * @param string $report_type The report type.
203     * @param array  $params      Report parameters.
204     * @param int    $user_id     User ID.
205     * @param string $user_email  User email.
206     * @return void
207     * @throws \Exception If export processing fails.
208     * @throws \Throwable If export processing fails.
209     */
210    public function process_export_job( string $report_type, array $params, int $user_id, string $user_email ): void {
211        $this->logger->log_message(
212            sprintf( 'Processing CSV export job for report type: %s, user: %d', $report_type, $user_id ),
213            __METHOD__
214        );
215
216        // Set user context for REST API calls.
217        $previous_user_id = \get_current_user_id();
218        \wp_set_current_user( $user_id );
219
220        try {
221            // Controller drives the data endpoint, requested fields, and merge strategy.
222            $controller = $this->registry->get_controller( $report_type );
223            if ( is_wp_error( $controller ) ) {
224                throw new \Exception( $controller->get_error_message() );
225            }
226
227            // Fetch data.
228            $data = $this->data_fetcher->fetch( $params, $controller );
229            if ( is_wp_error( $data ) ) {
230                throw new \Exception( $data->get_error_message() );
231            }
232
233            // Determine if comparison mode.
234            $is_comparison = $this->is_comparison_request( $params );
235
236            // Interval drives time-series column labels and row formatting.
237            $interval = $params['interval'] ?? null;
238
239            // Get columns.
240            $columns = $this->registry->get_columns( $report_type, $is_comparison, $interval );
241            if ( is_wp_error( $columns ) ) {
242                throw new \Exception( $columns->get_error_message() );
243            }
244
245            // Get row formatter.
246            $formatter = $this->registry->get_row_formatter( $report_type, $interval );
247            if ( is_wp_error( $formatter ) ) {
248                throw new \Exception( $formatter->get_error_message() );
249            }
250
251            // Generate filename.
252            $filename = $this->registry->build_filename( $report_type, $params );
253
254            // Generate CSV file.
255            $file_path = $this->csv_generator->generate( $data, $columns, $formatter, $filename );
256            if ( is_wp_error( $file_path ) ) {
257                throw new \Exception( $file_path->get_error_message() );
258            }
259
260            // Get report label.
261            $report_label = $this->registry->get_label( $report_type );
262            if ( is_wp_error( $report_label ) ) {
263                $report_label = $report_type;
264            }
265
266            // Send email with the CSV as an attachment (no public download URL is exposed).
267            $email_sent = $this->email_sender->send_export_email(
268                $user_email,
269                $report_label,
270                $params,
271                $file_path
272            );
273
274            // The file has been attached and is no longer needed on disk; the daily cleanup is
275            // only a backstop.
276            $this->csv_generator->delete_file( $file_path );
277
278            if ( ! $email_sent ) {
279                throw new \Exception( 'Failed to send export email' );
280            }
281
282            $this->logger->log_message(
283                sprintf( 'CSV export completed and emailed to: %s', $user_email ),
284                __METHOD__
285            );
286
287        } catch ( \Throwable $e ) {
288            $this->logger->log_exception( $e, __METHOD__ );
289
290            // Notify the requester with a generic message (details are logged, not emailed).
291            $this->send_error_email( $user_email, $report_type );
292
293            // Rethrow so Action Scheduler records the action as failed rather than completed.
294            throw $e;
295        } finally {
296            // Always restore the prior user context, including the anonymous/system user (0),
297            // so later actions in the same cron batch do not run as the export requester.
298            \wp_set_current_user( $previous_user_id );
299        }
300    }
301
302    /**
303     * Send a generic export-failure notification (error detail is logged, not emailed).
304     *
305     * @param string $user_email  User email.
306     * @param string $report_type Report type.
307     * @return void
308     */
309    private function send_error_email( string $user_email, string $report_type ): void {
310        $report_label = $this->registry->get_label( $report_type );
311        if ( is_wp_error( $report_label ) ) {
312            $report_label = $report_type;
313        }
314
315        $subject = sprintf(
316            /* translators: %s: Report label */
317            __( 'Export Failed: %s', 'jetpack-premium-analytics' ),
318            $report_label
319        );
320
321        $message = sprintf(
322            /* translators: %s: Report label */
323            __( 'Your export for "%s" could not be completed. Please try again later.', 'jetpack-premium-analytics' ),
324            $report_label
325        );
326
327        wp_mail( $user_email, $subject, $message );
328    }
329
330    /**
331     * Schedule daily cleanup of old export files.
332     *
333     * @return void
334     */
335    public function schedule_cleanup(): void {
336        if ( ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) {
337            return;
338        }
339
340        // Only schedule if not already scheduled.
341        // @phan-suppress-next-line PhanUndeclaredFunction -- Action Scheduler; guarded by function_exists() above.
342        if ( false === as_next_scheduled_action( self::CLEANUP_HOOK, array(), self::ACTION_GROUP ) ) {
343            // @phan-suppress-next-line PhanUndeclaredFunction -- Action Scheduler; guarded by function_exists() above.
344            as_schedule_recurring_action(
345                time(),
346                DAY_IN_SECONDS,
347                self::CLEANUP_HOOK,
348                array(),
349                self::ACTION_GROUP
350            );
351        }
352    }
353
354    /**
355     * Clean up export files older than the retention period.
356     *
357     * @return void
358     */
359    public function cleanup_old_exports(): void {
360        $upload_dir = wp_upload_dir();
361        $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'jetpack-premium-analytics-exports';
362
363        if ( ! is_dir( $export_dir ) ) {
364            return;
365        }
366
367        /**
368         * Filter the CSV export file retention period.
369         *
370         * @param int $retention_seconds Retention period in seconds. Default: 48 hours.
371         */
372        $retention = apply_filters( 'jetpack_premium_analytics_csv_export_retention', self::DEFAULT_RETENTION_PERIOD );
373
374        // glob() can return false on error; normalize to an array before iterating.
375        $files = glob( $export_dir . '/*.csv' );
376        if ( ! is_array( $files ) ) {
377            $files = array();
378        }
379
380        $cutoff  = time() - $retention;
381        $deleted = 0;
382
383        foreach ( $files as $file ) {
384            $mtime = filemtime( $file );
385            if ( false !== $mtime && $mtime < $cutoff ) {
386                if ( wp_delete_file( $file ) ) {
387                    ++$deleted;
388                }
389            }
390        }
391
392        if ( $deleted > 0 ) {
393            $this->logger->log_message(
394                sprintf( 'Cleaned up %d old CSV export files', $deleted ),
395                __METHOD__
396            );
397        }
398    }
399}