Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.98% covered (success)
92.98%
53 / 57
90.00% covered (success)
90.00%
9 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Sync_Status_Tracker
92.98% covered (success)
92.98%
53 / 57
90.00% covered (success)
90.00%
9 / 10
29.29
0.00% covered (danger)
0.00%
0 / 1
 configure
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 enrich_sync_status_response
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 on_sync_processed_actions
42.86% covered (danger)
42.86%
3 / 7
0.00% covered (danger)
0.00%
0 / 1
4.68
 get_analytics_sync_modules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 maybe_set_milestone
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 inject_script_data
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 gating_option
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 gating_milestone
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 milestone_reached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 find_full_sync_end_action
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Analytics-aware sync milestone tracker.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics\Sync;
9
10use Automattic\Jetpack\Sync\Modules;
11
12/**
13 * Listens for the end of the dashboard-gating Jetpack full sync, persists a
14 * one-time milestone option, and exposes that milestone to the frontend both at
15 * page load (via `JetpackScriptData.premium_analytics`) and live on Jetpack's
16 * `/jetpack/v4/sync/status` REST response.
17 *
18 * Which sync gates the dashboard is resolved live from `has_store_data`: the
19 * analytics-module sync ({@see INITIAL_ANALYTICS_SYNC_OPTION}) for store sites,
20 * Jetpack's generic initial full sync ({@see INITIAL_SITE_SYNC_OPTION}) otherwise.
21 * The two are tracked separately so connecting without WooCommerce and activating
22 * it later still waits for store data.
23 *
24 * Why this exists: the extracted dashboard needs to distinguish "first-time
25 * sync, please wait" from "we have data, render it." Jetpack's
26 * /jetpack/v4/sync/status reports current sync state, but not whether the
27 * gating initial sync has completed at least once. The analytics milestone also
28 * lets consumer plugins (e.g. WooCommerce Analytics) fire one-time side-effects
29 * like the full-sync-complete email, via the action hook below.
30 */
31class Sync_Status_Tracker {
32
33    /**
34     * Milestone (unix ts) for the analytics-module initial full sync. Gates the
35     * dashboard when the site has store data. Separate from
36     * {@see INITIAL_SITE_SYNC_OPTION} so activating WooCommerce after a storeless
37     * connect still waits for store data.
38     */
39    const INITIAL_ANALYTICS_SYNC_OPTION = 'jetpack_premium_analytics_initial_analytics_sync_finished';
40
41    /**
42     * Milestone (unix ts) for Jetpack's generic initial full sync. Gates the
43     * dashboard when the site has no store data.
44     */
45    const INITIAL_SITE_SYNC_OPTION = 'jetpack_premium_analytics_initial_site_sync_finished';
46
47    /**
48     * Default sync-module names whose end-of-sync event flips the milestone.
49     * Currently provided by WooCommerce Analytics, which registers a custom
50     * full-sync module under this key. Consumers can extend or override the set
51     * via the `jetpack_premium_analytics_sync_modules` filter — see
52     * {@see get_analytics_sync_modules()}.
53     *
54     * @var string[]
55     */
56    const ANALYTICS_SYNC_MODULES = array( 'woocommerce_analytics' );
57
58    /**
59     * Action hook fired once when the analytics milestone flips (not the storeless
60     * site milestone). Consumer plugins use this to fire one-time side-effects keyed
61     * to store data (emails, tracking events, etc.).
62     *
63     * @var string
64     */
65    const MILESTONE_ACTION = 'jetpack_premium_analytics_initial_full_sync_finished';
66
67    /**
68     * Jetpack core's sync-status REST route. We enrich this existing response with
69     * the milestone rather than registering a dedicated endpoint.
70     */
71    const SYNC_STATUS_ROUTE = '/jetpack/v4/sync/status';
72
73    /**
74     * Wire up the listener, the script-data filter, and the sync-status enricher.
75     *
76     * Idempotent: safe to call more than once.
77     *
78     * @return void
79     */
80    public static function configure() {
81        add_action( 'jetpack_sync_processed_actions', array( self::class, 'on_sync_processed_actions' ) );
82        add_filter( 'jetpack_admin_js_script_data', array( self::class, 'inject_script_data' ) );
83        add_filter( 'rest_post_dispatch', array( self::class, 'enrich_sync_status_response' ), 10, 3 );
84    }
85
86    /**
87     * Append the milestone to Jetpack core's GET /jetpack/v4/sync/status response
88     * so the dashboard can read it on every poll, not just at page load.
89     *
90     * Page-load script-data ({@see inject_script_data()}) is a one-time snapshot;
91     * reading the milestone here keeps it live for in-session completion without a
92     * dedicated endpoint. Only the already-authorized, successful status payload is
93     * touched — other routes and error responses pass through untouched.
94     *
95     * @param mixed $response Result to send to the client. Usually a WP_REST_Response.
96     * @param mixed $server   The REST server instance (unused).
97     * @param mixed $request  The request used to generate the response.
98     * @return mixed
99     */
100    public static function enrich_sync_status_response( $response, $server, $request ) {
101        if ( ! $request instanceof \WP_REST_Request
102            || self::SYNC_STATUS_ROUTE !== $request->get_route()
103            || ! $response instanceof \WP_REST_Response
104            || $response->is_error() ) {
105            return $response;
106        }
107
108        $data = $response->get_data();
109        if ( is_array( $data ) ) {
110            $data['initial_full_sync_finished'] = self::gating_milestone( Configuration::is_woocommerce_active() );
111            $response->set_data( $data );
112        }
113
114        return $response;
115    }
116
117    /**
118     * On every batch of processed sync actions, look for the gating
119     * jetpack_full_sync_end and flip the milestone if it hasn't already fired.
120     *
121     * @param array $actions Processed sync actions.
122     * @return void
123     */
124    public static function on_sync_processed_actions( array $actions ): void {
125        $has_store_data = Configuration::is_woocommerce_active();
126
127        // Bail before the per-batch full-sync lookup ($module->get_status() bypasses
128        // the status cache) once the current mode's milestone is set. Resolving the
129        // mode live means activating WooCommerce later resumes the store milestone.
130        if ( self::milestone_reached( $has_store_data ) ) {
131            return;
132        }
133
134        $module = Modules::get_module( 'full-sync' );
135        if ( ! $module ) {
136            return;
137        }
138        '@phan-var \Automattic\Jetpack\Sync\Modules\Full_Sync_Immediately|\Automattic\Jetpack\Sync\Modules\Full_Sync $module';
139
140        self::maybe_set_milestone( $module->get_status(), $actions, $has_store_data );
141    }
142
143    /**
144     * Resolve the configured sync-module names for analytics.
145     *
146     * @return string[]
147     */
148    public static function get_analytics_sync_modules(): array {
149        /**
150         * Filter the sync-module names whose end-of-sync flips the analytics
151         * milestone. Consumer plugins that register custom full-sync modules
152         * can add their module keys here.
153         *
154         * @param string[] $module_names Default: array( 'woocommerce_analytics' ).
155         */
156        return (array) apply_filters( 'jetpack_premium_analytics_sync_modules', self::ANALYTICS_SYNC_MODULES );
157    }
158
159    /**
160     * Pure-logic counterpart to on_sync_processed_actions(): decide whether the
161     * supplied full-sync status + actions list represents the sync that gates the
162     * dashboard ending, and flip the milestone if so.
163     *
164     * Store site: only a full sync whose config includes an analytics module flips
165     * the milestone, so a generic sync can't open the gate before store data lands.
166     * Storeless site: any initial full sync ending flips it. The milestone written
167     * tracks `$has_store_data`, in lockstep with the option the frontend reads.
168     *
169     * Split out so unit tests can exercise the decision without the Jetpack sync
170     * module registry (the caller resolves `$has_store_data` live).
171     *
172     * @param array $full_status    Result of Full_Sync_Immediately::get_status().
173     * @param array $actions        Processed sync actions.
174     * @param bool  $has_store_data Whether the site has store data to sync (WooCommerce
175     *                              active). Defaults to true: require the analytics module.
176     * @return void
177     */
178    public static function maybe_set_milestone( array $full_status, array $actions, bool $has_store_data = true ): void {
179        if ( self::milestone_reached( $has_store_data ) ) {
180            return;
181        }
182
183        if ( $has_store_data ) {
184            $config = isset( $full_status['config'] ) ? (array) $full_status['config'] : array();
185            $active = array_filter(
186                self::get_analytics_sync_modules(),
187                static function ( $module_name ) use ( $config ) {
188                    return ! empty( $config[ $module_name ] );
189                }
190            );
191            if ( ! $active ) {
192                return;
193            }
194        }
195
196        $end_action = self::find_full_sync_end_action( $actions );
197        if ( ! $end_action ) {
198            return;
199        }
200
201        // The last update_status() call in Full_Sync_Immediately::send() runs
202        // after jetpack_full_sync_end fires, so the action's own timestamp is
203        // the most reliable "finished at" value. Note: Year 2038 problem.
204        $finished_at = isset( $end_action[3] ) ? (int) $end_action[3] : 0;
205        if ( $finished_at <= 0 ) {
206            // Defensive: avoid persisting a zero timestamp, which would equal
207            // the "not yet set" sentinel and cause the listener to re-trigger
208            // on the next batch.
209            return;
210        }
211        $full_status['finished'] = $finished_at;
212        update_option( self::gating_option( $has_store_data ), $finished_at );
213
214        if ( ! $has_store_data ) {
215            // No store data, so store-keyed consumer side-effects (e.g. WooCommerce's
216            // full-sync-complete email) must not fire — only the analytics milestone does.
217            return;
218        }
219
220        /**
221         * Fires once when the analytics-relevant initial full sync completes.
222         *
223         * @param array $full_status Final full-sync status (with `finished` timestamp).
224         */
225        do_action( self::MILESTONE_ACTION, $full_status );
226    }
227
228    /**
229     * Inject the milestone and store-data flag into JetpackScriptData so the
230     * dashboard can read them at page load without an extra HTTP roundtrip.
231     *
232     * `has_store_data` tells the frontend which sync gates the dashboard: the
233     * analytics module when WooCommerce is active, or Jetpack's generic initial
234     * full sync when it is not. Computed live so activating WooCommerce later is
235     * picked up on the next page load.
236     *
237     * @param array $data The script data passed by the assets package.
238     * @return array
239     */
240    public static function inject_script_data( array $data ): array {
241        $has_store_data = Configuration::is_woocommerce_active();
242
243        $data['premium_analytics'] = array(
244            'initial_full_sync_finished' => self::gating_milestone( $has_store_data ),
245            'has_store_data'             => $has_store_data,
246        );
247
248        return $data;
249    }
250
251    /**
252     * The option name whose timestamp gates the dashboard for the given mode.
253     *
254     * @param bool $has_store_data Whether the site has store data (WooCommerce active).
255     * @return string
256     */
257    private static function gating_option( bool $has_store_data ): string {
258        return $has_store_data ? self::INITIAL_ANALYTICS_SYNC_OPTION : self::INITIAL_SITE_SYNC_OPTION;
259    }
260
261    /**
262     * The gating milestone timestamp for the given mode (0 if not yet reached).
263     *
264     * @param bool $has_store_data Whether the site has store data (WooCommerce active).
265     * @return int
266     */
267    private static function gating_milestone( bool $has_store_data ): int {
268        return (int) get_option( self::gating_option( $has_store_data ), 0 );
269    }
270
271    /**
272     * Whether the milestone gating the given mode has fired.
273     *
274     * @param bool $has_store_data Whether the site has store data (WooCommerce active).
275     * @return bool
276     */
277    private static function milestone_reached( bool $has_store_data ): bool {
278        return self::gating_milestone( $has_store_data ) > 0;
279    }
280
281    /**
282     * Find the jetpack_full_sync_end action in a processed-actions list.
283     *
284     * @param array $actions Actions list.
285     * @return array|null
286     */
287    private static function find_full_sync_end_action( array $actions ): ?array {
288        foreach ( $actions as $action ) {
289            if ( isset( $action[0] ) && 'jetpack_full_sync_end' === $action[0] ) {
290                return $action;
291            }
292        }
293        return null;
294    }
295}