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 0.1.0
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        add_action( self::EXPORT_ACTION_HOOK, array( $this, 'process_export_job' ), 10, 4 );
113
114        // The recurring cleanup is scheduled lazily from schedule_export(), so this avoids an
115        // Action Scheduler DB query on every request.
116        add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_old_exports' ) );
117    }
118
119    /**
120     * Schedule a CSV export job.
121     *
122     * @param string $report_type  The report type.
123     * @param array  $params       Report parameters.
124     * @param int    $user_id      User ID requesting the export.
125     * @param string $user_email   User email for notification.
126     * @return int|\WP_Error Action ID on success, WP_Error on failure.
127     */
128    public function schedule_export( string $report_type, array $params, int $user_id, string $user_email ) {
129        if ( ! \is_email( $user_email ) ) {
130            return new \WP_Error(
131                'invalid_email',
132                __( 'Invalid email address provided.', 'jetpack-premium-analytics-pkg' ),
133                array( 'status' => 400 )
134            );
135        }
136
137        if ( ! function_exists( 'as_enqueue_async_action' ) ) {
138            $this->logger->log_error( 'Action Scheduler is not available', __METHOD__ );
139            return new \WP_Error(
140                'action_scheduler_unavailable',
141                __( 'Action Scheduler is not available. Cannot schedule export.', 'jetpack-premium-analytics-pkg' ),
142                array( 'status' => 503 )
143            );
144        }
145
146        if ( ! $this->registry->is_registered( $report_type ) ) {
147            return new \WP_Error(
148                'invalid_report_type',
149                __( 'Invalid report type.', 'jetpack-premium-analytics-pkg' ),
150                array( 'status' => 400 )
151            );
152        }
153
154        try {
155            $action_id = as_enqueue_async_action(
156                self::EXPORT_ACTION_HOOK,
157                array(
158                    'report_type' => $report_type,
159                    'params'      => $params,
160                    'user_id'     => $user_id,
161                    'user_email'  => $user_email,
162                ),
163                self::ACTION_GROUP
164            );
165        } catch ( \Throwable $e ) {
166            $this->logger->log_exception( $e, __METHOD__ );
167            return new \WP_Error(
168                'schedule_failed',
169                __( 'Failed to schedule export job.', 'jetpack-premium-analytics-pkg' ),
170                array( 'status' => 500 )
171            );
172        }
173
174        if ( ! $action_id ) {
175            $this->logger->log_error( 'Failed to schedule CSV export action', __METHOD__ );
176            return new \WP_Error(
177                'schedule_failed',
178                __( 'Failed to schedule export job.', 'jetpack-premium-analytics-pkg' ),
179                array( 'status' => 500 )
180            );
181        }
182
183        $this->logger->log_message(
184            sprintf( 'Scheduled CSV export job %d for report type: %s', $action_id, $report_type ),
185            __METHOD__
186        );
187
188        // Ensure the recurring cleanup exists now that exports are actually being used.
189        $this->schedule_cleanup();
190
191        return $action_id;
192    }
193
194    /**
195     * Process a scheduled export job.
196     *
197     * @param string $report_type The report type.
198     * @param array  $params      Report parameters.
199     * @param int    $user_id     User ID.
200     * @param string $user_email  User email.
201     * @return void
202     * @throws \Exception If export processing fails.
203     * @throws \Throwable If export processing fails.
204     */
205    public function process_export_job( string $report_type, array $params, int $user_id, string $user_email ): void {
206        $this->logger->log_message(
207            sprintf( 'Processing CSV export job for report type: %s, user: %d', $report_type, $user_id ),
208            __METHOD__
209        );
210
211        // Set user context for REST API calls.
212        $previous_user_id = \get_current_user_id();
213        \wp_set_current_user( $user_id );
214
215        try {
216            // Controller drives the data endpoint, requested fields, and merge strategy.
217            $controller = $this->registry->get_controller( $report_type );
218            if ( is_wp_error( $controller ) ) {
219                throw new \Exception( $controller->get_error_message() );
220            }
221
222            $data = $this->data_fetcher->fetch( $params, $controller );
223            if ( is_wp_error( $data ) ) {
224                throw new \Exception( $data->get_error_message() );
225            }
226
227            $is_comparison = $this->is_comparison_request( $params );
228
229            // Interval drives time-series column labels and row formatting.
230            $interval = $params['interval'] ?? null;
231
232            $columns = $this->registry->get_columns( $report_type, $is_comparison, $interval );
233            if ( is_wp_error( $columns ) ) {
234                throw new \Exception( $columns->get_error_message() );
235            }
236
237            $formatter = $this->registry->get_row_formatter( $report_type, $interval );
238            if ( is_wp_error( $formatter ) ) {
239                throw new \Exception( $formatter->get_error_message() );
240            }
241
242            $filename = $this->registry->build_filename( $report_type, $params );
243
244            $file_path = $this->csv_generator->generate( $data, $columns, $formatter, $filename );
245            if ( is_wp_error( $file_path ) ) {
246                throw new \Exception( $file_path->get_error_message() );
247            }
248
249            $report_label = $this->registry->get_label( $report_type );
250            if ( is_wp_error( $report_label ) ) {
251                $report_label = $report_type;
252            }
253
254            // Send email with the CSV as an attachment (no public download URL is exposed).
255            $email_sent = $this->email_sender->send_export_email(
256                $user_email,
257                $report_label,
258                $params,
259                $file_path
260            );
261
262            // The file has been attached and is no longer needed on disk; the daily cleanup is
263            // only a backstop.
264            $this->csv_generator->delete_file( $file_path );
265
266            if ( ! $email_sent ) {
267                throw new \Exception( 'Failed to send export email' );
268            }
269
270            $this->logger->log_message(
271                sprintf( 'CSV export completed and emailed to: %s', $user_email ),
272                __METHOD__
273            );
274
275        } catch ( \Throwable $e ) {
276            $this->logger->log_exception( $e, __METHOD__ );
277
278            // Notify the requester with a generic message (details are logged, not emailed).
279            $this->send_error_email( $user_email, $report_type );
280
281            // Rethrow so Action Scheduler records the action as failed rather than completed.
282            throw $e;
283        } finally {
284            // Always restore the prior user context, including the anonymous/system user (0),
285            // so later actions in the same cron batch do not run as the export requester.
286            \wp_set_current_user( $previous_user_id );
287        }
288    }
289
290    /**
291     * Send a generic export-failure notification (error detail is logged, not emailed).
292     *
293     * @param string $user_email  User email.
294     * @param string $report_type Report type.
295     * @return void
296     */
297    private function send_error_email( string $user_email, string $report_type ): void {
298        $report_label = $this->registry->get_label( $report_type );
299        if ( is_wp_error( $report_label ) ) {
300            $report_label = $report_type;
301        }
302
303        $subject = sprintf(
304            /* translators: %s: Report label */
305            __( 'Export Failed: %s', 'jetpack-premium-analytics-pkg' ),
306            $report_label
307        );
308
309        $message = sprintf(
310            /* translators: %s: Report label */
311            __( 'Your export for "%s" could not be completed. Please try again later.', 'jetpack-premium-analytics-pkg' ),
312            $report_label
313        );
314
315        wp_mail( $user_email, $subject, $message );
316    }
317
318    /**
319     * Schedule daily cleanup of old export files.
320     *
321     * @return void
322     */
323    public function schedule_cleanup(): void {
324        if ( ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) {
325            return;
326        }
327
328        if ( false === as_next_scheduled_action( self::CLEANUP_HOOK, array(), self::ACTION_GROUP ) ) {
329            as_schedule_recurring_action(
330                time(),
331                DAY_IN_SECONDS,
332                self::CLEANUP_HOOK,
333                array(),
334                self::ACTION_GROUP
335            );
336        }
337    }
338
339    /**
340     * Clean up export files older than the retention period.
341     *
342     * @return void
343     */
344    public function cleanup_old_exports(): void {
345        $upload_dir = wp_upload_dir();
346        $export_dir = trailingslashit( $upload_dir['basedir'] ) . 'jetpack-premium-analytics-exports';
347
348        if ( ! is_dir( $export_dir ) ) {
349            return;
350        }
351
352        /**
353         * Filter the CSV export file retention period.
354         *
355         * @param int $retention_seconds Retention period in seconds. Default: 48 hours.
356         */
357        $retention = apply_filters( 'jetpack_premium_analytics_csv_export_retention', self::DEFAULT_RETENTION_PERIOD );
358
359        // glob() can return false on error; normalize to an array before iterating.
360        $files = glob( $export_dir . '/*.csv' );
361        if ( ! is_array( $files ) ) {
362            $files = array();
363        }
364
365        $cutoff  = time() - $retention;
366        $deleted = 0;
367
368        foreach ( $files as $file ) {
369            $mtime = filemtime( $file );
370            if ( false !== $mtime && $mtime < $cutoff ) {
371                if ( wp_delete_file( $file ) ) {
372                    ++$deleted;
373                }
374            }
375        }
376
377        if ( $deleted > 0 ) {
378            $this->logger->log_message(
379                sprintf( 'Cleaned up %d old CSV export files', $deleted ),
380                __METHOD__
381            );
382        }
383    }
384}