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