Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.44% covered (warning)
67.44%
116 / 172
53.85% covered (warning)
53.85%
7 / 13
CRAP
n/a
0 / 0
wpcom_write_is_write_first_site
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
wpcom_write_should_show_post_publish_survey
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
wpcom_write_survey_source
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
wpcom_write_get_survey_answers
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
wpcom_write_get_survey_strings
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
wpcom_write_enqueue_post_publish_survey_assets
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
wpcom_write_render_post_publish_survey
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
3
wpcom_write_survey_blog_id
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
wpcom_write_store_survey_response
20.00% covered (danger)
20.00%
4 / 20
0.00% covered (danger)
0.00%
0 / 1
24.43
wpcom_write_ajax_mark_survey_shown
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
wpcom_write_neutralize_csv_formula
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
wpcom_write_build_survey_response
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
wpcom_write_ajax_submit_survey
89.66% covered (warning)
89.66%
26 / 29
0.00% covered (danger)
0.00%
0 / 1
10.11
1<?php
2/**
3 * Write — one-question survey after a writer's first Write publish.
4 *
5 * Shown once per user on the published post the editor redirects to, tagged with
6 * WPCOM_WRITE_PUBLISHED_MARKER. Writers who arrived through the write-first signup
7 * flow have no earlier WordPress.com editor to compare against, so they get a
8 * plain "how was it" instead of the comparison. See CM-892.
9 *
10 * The choice goes to Tracks so responses segment against the wpcom_write_editor_*
11 * funnel; the choice and any free text go to `marketing_survey_responses`, both
12 * carrying one response ID so the halves can be rejoined.
13 *
14 * @package automattic/jetpack-mu-wpcom
15 */
16
17use Automattic\Jetpack\Connection\Client;
18use Automattic\Jetpack\Connection\Manager as Connection_Manager;
19use Automattic\Jetpack\Jetpack_Mu_Wpcom\Common;
20
21if ( ! defined( 'ABSPATH' ) ) {
22    exit;
23}
24
25/**
26 * Survey ID under which responses are stored in `marketing_survey_responses`.
27 *
28 * Must be registered in wpcom's Survey_Helper: an unregistered ID stores fine
29 * but analyses as nothing.
30 */
31const WPCOM_WRITE_SURVEY_ID = 'write-first-publish';
32
33/**
34 * User meta recording that the survey has been shown, so it appears only once.
35 *
36 * Written when the card is actually revealed, not when it renders: on a Coming
37 * Soon site the checklist owns the screen first, and its launch CTA navigates
38 * away without dismissing, so a render is no evidence the writer saw anything.
39 */
40const WPCOM_WRITE_SURVEY_SHOWN_META = '_wpcom_write_first_publish_survey_shown';
41
42/**
43 * User meta recording that a response has been stored.
44 *
45 * The authoritative record of "asked and answered". The shown meta above is a
46 * best-effort ping that can be lost to a dropped request, which would otherwise
47 * let the same writer be surveyed — and stored — twice.
48 */
49const WPCOM_WRITE_SURVEY_SUBMITTED_META = '_wpcom_write_first_publish_survey_submitted';
50
51/**
52 * Nonce action guarding the survey submission.
53 */
54const WPCOM_WRITE_SURVEY_NONCE = 'wpcom_write_survey';
55
56/**
57 * Maximum length of the optional free-text answer, in characters.
58 *
59 * Ours to enforce: wpcom's 50,000-character cap lives on the unauthenticated
60 * feedback-survey endpoint, which neither storage path goes through.
61 */
62const WPCOM_WRITE_SURVEY_MAX_COMMENT_LENGTH = 2000;
63
64/**
65 * Maximum length of the stored entry-point token.
66 *
67 * `sanitize_key()` bounds the character set but not the length, and the value
68 * round-trips through the client, so it needs a cap of its own.
69 */
70const WPCOM_WRITE_SURVEY_MAX_SOURCE_LENGTH = 50;
71
72/**
73 * Whether the current user arrived through the write-first signup flow.
74 *
75 * `site_creation_flow` is set to the flow name at site creation, so a site born
76 * from `/setup/write-on` carries `write-on` for good.
77 *
78 * @return bool True when this site was created by the write-first flow.
79 */
80function wpcom_write_is_write_first_site() {
81    return 'write-on' === get_option( 'site_creation_flow' );
82}
83
84/**
85 * Whether the survey should render on the current request.
86 *
87 * All of the following must hold:
88 *  - the request carries the Write editor's post-publish marker;
89 *  - we're on a single post's front-end view;
90 *  - the viewer has `manage_options`, which the wpcom survey endpoint requires
91 *    of the submitter anyway;
92 *  - the user has not already been shown the survey.
93 *
94 * @return bool True when the survey should be shown.
95 */
96function wpcom_write_should_show_post_publish_survey() {
97    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Display-only marker; mirrors the post-publish checklist's read.
98    if ( ! isset( $_GET[ WPCOM_WRITE_PUBLISHED_MARKER ] ) ) {
99        return false;
100    }
101
102    if ( ! is_singular( 'post' ) ) {
103        return false;
104    }
105
106    if ( ! current_user_can( 'manage_options' ) ) {
107        return false;
108    }
109
110    $user_id = get_current_user_id();
111
112    if ( get_user_meta( $user_id, WPCOM_WRITE_SURVEY_SHOWN_META, true ) ) {
113        return false;
114    }
115
116    // Also honoured here, so a lost shown-ping doesn't re-offer a card whose
117    // submission would then be rejected with nothing to show for it.
118    if ( get_user_meta( $user_id, WPCOM_WRITE_SURVEY_SUBMITTED_META, true ) ) {
119        return false;
120    }
121
122    return true;
123}
124
125/**
126 * The entry point the writer came from, as tagged onto the post-publish redirect.
127 *
128 * Mirrors the `source` on `wpcom_write_editor_open` so responses segment the same
129 * way the funnel does.
130 *
131 * @return string Sanitized source token, or '' when absent.
132 */
133function wpcom_write_survey_source() {
134    // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only attribution parameter, no state change.
135    if ( empty( $_GET['source'] ) || ! is_scalar( $_GET['source'] ) ) {
136        return '';
137    }
138
139    // phpcs:ignore WordPress.Security.NonceVerification.Recommended
140    return sanitize_key( wp_unslash( $_GET['source'] ) );
141}
142
143/**
144 * The answer options for a variant, as slug => label.
145 *
146 * Slugs are stored verbatim as the preset answer key, so they must stay stable
147 * once responses exist.
148 *
149 * @param bool $is_write_first Whether to return the write-first variant.
150 * @return array<string, string> Map of answer slug to translated label.
151 */
152function wpcom_write_get_survey_answers( $is_write_first ) {
153    if ( $is_write_first ) {
154        return array(
155            'easy'        => __( 'Easy', 'jetpack-mu-wpcom' ),
156            'fine'        => __( 'Fine', 'jetpack-mu-wpcom' ),
157            'frustrating' => __( 'Frustrating', 'jetpack-mu-wpcom' ),
158        );
159    }
160
161    return array(
162        'easier'     => __( 'Easier', 'jetpack-mu-wpcom' ),
163        'about_same' => __( 'About the same', 'jetpack-mu-wpcom' ),
164        'harder'     => __( 'Harder', 'jetpack-mu-wpcom' ),
165        'first_post' => __( 'This was my first post', 'jetpack-mu-wpcom' ),
166    );
167}
168
169/**
170 * Translated strings for the survey card.
171 *
172 * On a Coming Soon site publishing lands a private post, so the heading says
173 * "saved" rather than the untrue "live" — as the post-publish checklist does.
174 *
175 * @param bool $is_write_first Whether to return the write-first variant.
176 * @return array<string, string> Map of key -> translated string.
177 */
178function wpcom_write_get_survey_strings( $is_write_first ) {
179    $is_coming_soon = 1 === (int) get_option( 'wpcom_public_coming_soon' );
180
181    return array(
182        'heading'      => $is_coming_soon
183            ? __( 'Your post is saved.', 'jetpack-mu-wpcom' )
184            : __( 'Your post is live.', 'jetpack-mu-wpcom' ),
185        'question'     => $is_write_first
186            ? __( 'How was it?', 'jetpack-mu-wpcom' )
187            : __( "How did writing this compare to the WordPress.com editor you've used before?", 'jetpack-mu-wpcom' ),
188        'commentLabel' => $is_write_first
189            ? __( 'What almost stopped you?', 'jetpack-mu-wpcom' )
190            : __( 'What worked, and what got in your way?', 'jetpack-mu-wpcom' ),
191        'commentHint'  => __( 'Optional', 'jetpack-mu-wpcom' ),
192        'send'         => __( 'Send', 'jetpack-mu-wpcom' ),
193        'skip'         => __( 'No thanks', 'jetpack-mu-wpcom' ),
194        'close'        => __( 'Close', 'jetpack-mu-wpcom' ),
195        'thanks'       => __( 'Thank you — this helps.', 'jetpack-mu-wpcom' ),
196    );
197}
198
199/**
200 * Enqueue the survey assets when it should render.
201 *
202 * @return void
203 */
204function wpcom_write_enqueue_post_publish_survey_assets() {
205    if ( ! wpcom_write_should_show_post_publish_survey() ) {
206        return;
207    }
208
209    wp_enqueue_style(
210        'wpcom-write-post-publish-survey',
211        wpcom_write_asset_url( 'post-publish-survey.css' ),
212        array(),
213        WPCOM_WRITE_VERSION
214    );
215
216    wp_enqueue_script(
217        'wpcom-write-post-publish-survey',
218        wpcom_write_asset_url( 'post-publish-survey.js' ),
219        array(),
220        WPCOM_WRITE_VERSION,
221        true
222    );
223
224    $is_write_first = wpcom_write_is_write_first_site();
225
226    wp_localize_script(
227        'wpcom-write-post-publish-survey',
228        'wpcomWritePostPublishSurvey',
229        array(
230            // admin-ajax because a Simple site serves no REST API at its own hostname.
231            'ajaxUrl'    => admin_url( 'admin-ajax.php' ),
232            'nonce'      => wp_create_nonce( WPCOM_WRITE_SURVEY_NONCE ),
233            // Shared with the stored response so the two halves can be rejoined.
234            'responseId' => wp_generate_uuid4(),
235            'variant'    => $is_write_first ? 'write_first' : 'returning',
236            'source'     => wpcom_write_survey_source(),
237            'blogId'     => wpcom_write_survey_blog_id(),
238        )
239    );
240}
241add_action( 'wp_enqueue_scripts', 'wpcom_write_enqueue_post_publish_survey_assets' );
242
243/**
244 * Output the survey card markup in the footer.
245 *
246 * Plain markup wired up by post-publish-survey.js — no Interactivity API, since
247 * this renders on an arbitrary theme front-end, not the Write editor surface.
248 *
249 * @return void
250 */
251function wpcom_write_render_post_publish_survey() {
252    if ( ! wpcom_write_should_show_post_publish_survey() ) {
253        return;
254    }
255
256    $is_write_first = wpcom_write_is_write_first_site();
257    $strings        = wpcom_write_get_survey_strings( $is_write_first );
258    $answers        = wpcom_write_get_survey_answers( $is_write_first );
259
260    ?>
261    <div class="wpcom-write-pps" role="dialog" aria-modal="true" aria-labelledby="wpcom-write-pps-question" hidden>
262        <div class="wpcom-write-pps__backdrop" data-wpcom-write-pps-dismiss></div>
263        <div class="wpcom-write-pps__card">
264            <button type="button" class="wpcom-write-pps__close" data-wpcom-write-pps-dismiss aria-label="<?php echo esc_attr( $strings['close'] ); ?>">&times;</button>
265            <p class="wpcom-write-pps__heading"><?php echo esc_html( $strings['heading'] ); ?></p>
266            <h2 id="wpcom-write-pps-question" class="wpcom-write-pps__question"><?php echo esc_html( $strings['question'] ); ?></h2>
267            <div class="wpcom-write-pps__answers">
268                <?php foreach ( $answers as $slug => $label ) : ?>
269                    <button type="button" class="wpcom-write-pps__answer" aria-pressed="false" data-wpcom-write-pps-answer="<?php echo esc_attr( $slug ); ?>"><?php echo esc_html( $label ); ?></button>
270                <?php endforeach; ?>
271            </div>
272            <div class="wpcom-write-pps__comment" hidden>
273                <label class="wpcom-write-pps__comment-label" for="wpcom-write-pps-comment">
274                    <?php echo esc_html( $strings['commentLabel'] ); ?>
275                    <span class="wpcom-write-pps__comment-hint"><?php echo esc_html( $strings['commentHint'] ); ?></span>
276                </label>
277                <textarea
278                    id="wpcom-write-pps-comment"
279                    class="wpcom-write-pps__comment-input"
280                    rows="3"
281                    maxlength="<?php echo esc_attr( (string) WPCOM_WRITE_SURVEY_MAX_COMMENT_LENGTH ); ?>"
282                ></textarea>
283                <button type="button" class="wpcom-write-pps__send" data-wpcom-write-pps-send><?php echo esc_html( $strings['send'] ); ?></button>
284            </div>
285            <button type="button" class="wpcom-write-pps__skip" data-wpcom-write-pps-dismiss><?php echo esc_html( $strings['skip'] ); ?></button>
286            <p class="wpcom-write-pps__thanks" role="status" aria-live="polite" hidden><?php echo esc_html( $strings['thanks'] ); ?></p>
287        </div>
288    </div>
289    <?php
290}
291add_action( 'wp_footer', 'wpcom_write_render_post_publish_survey' );
292
293/**
294 * The wpcom blog ID, which Atomic's local `get_current_blog_id()` is not.
295 *
296 * @return int Blog ID, or 0 when the site has no wpcom identity.
297 */
298function wpcom_write_survey_blog_id() {
299    if ( ! class_exists( Connection_Manager::class ) ) {
300        return 0;
301    }
302
303    return (int) Connection_Manager::get_site_id( true );
304}
305
306/**
307 * Store a survey response in wpcom's central `marketing_survey_responses` table.
308 *
309 * Host-dependent transport, mirroring Common\wpcom_record_tracks_event(): Simple
310 * loads the lib in-process, Atomic has no `require_lib()` and goes out through the
311 * connection client. The endpoint is not site-specific, hence the `site_id` param.
312 *
313 * @param array $responses Map of question key to answer (preset slug or array with 'text').
314 * @return bool True when the response was handed to the store.
315 */
316function wpcom_write_store_survey_response( $responses ) {
317    if ( function_exists( 'require_lib' ) ) {
318        require_lib( 'marketing/survey' );
319
320        if ( class_exists( 'Marketing_Survey' ) ) {
321            $result = \Marketing_Survey::submit_survey( get_current_blog_id(), get_current_user_id(), WPCOM_WRITE_SURVEY_ID, $responses );
322
323            return ! empty( $result['success'] );
324        }
325    }
326
327    // The endpoint keys both its capability check and the stored row off `site_id`,
328    // and Atomic's local blog ID is 1 — it has to be the wpcom one.
329    $blog_id = wpcom_write_survey_blog_id();
330
331    if ( ! class_exists( Client::class ) || ! $blog_id ) {
332        return false;
333    }
334
335    $response = Client::wpcom_json_api_request_as_user(
336        '/marketing/survey',
337        'v2',
338        array( 'method' => 'POST' ),
339        array(
340            'site_id'          => $blog_id,
341            'survey_id'        => WPCOM_WRITE_SURVEY_ID,
342            'survey_responses' => $responses,
343        ),
344        'wpcom'
345    );
346
347    return ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response );
348}
349
350/**
351 * Record that the survey card was revealed to this user.
352 *
353 * Fired by the card on reveal so the once-per-user gate closes on a card the
354 * writer actually saw. Best-effort: a dropped request costs one repeat showing,
355 * which is the better failure.
356 *
357 * @return void
358 */
359function wpcom_write_ajax_mark_survey_shown() {
360    check_ajax_referer( WPCOM_WRITE_SURVEY_NONCE, 'nonce' );
361
362    if ( ! current_user_can( 'manage_options' ) ) {
363        wp_send_json_error( array( 'reason' => 'forbidden' ), 403, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
364    }
365
366    update_user_meta( get_current_user_id(), WPCOM_WRITE_SURVEY_SHOWN_META, time() );
367
368    wp_send_json_success( null, 200, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
369}
370add_action( 'wp_ajax_wpcom_write_survey_shown', 'wpcom_write_ajax_mark_survey_shown' );
371
372/**
373 * Neutralize a leading spreadsheet-formula character in free text.
374 *
375 * The stored responses are exported to CSV downstream, where a cell beginning
376 * `=`, `+`, `-`, `@`, tab or CR is evaluated as a formula by spreadsheet apps.
377 * Prefixing with an apostrophe is the standard defence and is reversible — a
378 * consumer that doesn't need it can strip the leading quote.
379 *
380 * Byte-wise on purpose: a multi-byte leading character starts with a byte above
381 * 0x7F and can never collide with these ASCII triggers.
382 *
383 * @param string $text Sanitized free text.
384 * @return string Text safe to place in a CSV cell.
385 */
386function wpcom_write_neutralize_csv_formula( $text ) {
387    if ( '' === $text ) {
388        return $text;
389    }
390
391    $triggers = array( '=', '+', '-', '@', "\t", "\r" );
392
393    return in_array( $text[0], $triggers, true ) ? "'" . $text : $text;
394}
395
396/**
397 * Build the response payload stored against the survey.
398 *
399 * Answers are stored as a preset slug, or array( 'text' => … ) for prose — the
400 * shape wpcom's response formatter reads. The trailing keys are metadata, stored
401 * alongside the answers the way calypso-remove-purchase stores its own.
402 *
403 * Every field is bounded here, because neither storage path passes through the
404 * endpoint that enforces wpcom's response-size cap. All of them arrive from the
405 * client, including the ID we minted and handed out at render.
406 *
407 * @param string $answer         Validated answer slug.
408 * @param string $comment        Optional free-text answer, already sanitized.
409 * @param string $response_id    Shared ID linking this row to its Tracks event.
410 * @param string $source         Entry point the writer came from.
411 * @param bool   $is_write_first Whether this is the write-first variant.
412 * @return array<string, mixed> Payload for Marketing_Survey::submit_survey().
413 */
414function wpcom_write_build_survey_response( $answer, $comment, $response_id, $source, $is_write_first ) {
415    $responses = array(
416        'experience'  => $answer,
417        'variant'     => $is_write_first ? 'write_first' : 'returning',
418        'entry_point' => substr( $source, 0, WPCOM_WRITE_SURVEY_MAX_SOURCE_LENGTH ),
419        // Only ever a uuid4 we generated; anything else is discarded rather than stored.
420        'response_id' => wp_is_uuid( $response_id ) ? $response_id : '',
421    );
422
423    $comment = mb_substr( $comment, 0, WPCOM_WRITE_SURVEY_MAX_COMMENT_LENGTH );
424
425    if ( '' !== $comment ) {
426        // Capped first, so the escape prefix is never what gets truncated away.
427        $responses['comment'] = array( 'text' => wpcom_write_neutralize_csv_formula( $comment ) );
428    }
429
430    return $responses;
431}
432
433/**
434 * Handle a survey submission from the card.
435 *
436 * Logged-in only (`wp_ajax_`, not `wp_ajax_nopriv_`): the card only ever renders
437 * to an authenticated author.
438 *
439 * @return void
440 */
441function wpcom_write_ajax_submit_survey() {
442    check_ajax_referer( WPCOM_WRITE_SURVEY_NONCE, 'nonce' );
443
444    if ( ! current_user_can( 'manage_options' ) ) {
445        wp_send_json_error( array( 'reason' => 'forbidden' ), 403, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
446    }
447
448    $is_write_first = wpcom_write_is_write_first_site();
449    $valid_answers  = array_keys( wpcom_write_get_survey_answers( $is_write_first ) );
450    $answer         = isset( $_POST['answer'] ) ? sanitize_key( wp_unslash( $_POST['answer'] ) ) : '';
451
452    if ( ! in_array( $answer, $valid_answers, true ) ) {
453        wp_send_json_error( array( 'reason' => 'invalid_answer' ), 400, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
454    }
455
456    $comment     = isset( $_POST['comment'] ) ? sanitize_textarea_field( wp_unslash( $_POST['comment'] ) ) : '';
457    $response_id = isset( $_POST['response_id'] ) ? sanitize_text_field( wp_unslash( $_POST['response_id'] ) ) : '';
458    $source      = isset( $_POST['source'] ) ? sanitize_key( wp_unslash( $_POST['source'] ) ) : '';
459
460    // Once per user is enforced here, not only on the render path: nonces replay
461    // for their full lifetime, and a lost shown-ping can re-offer the card.
462    if ( get_user_meta( get_current_user_id(), WPCOM_WRITE_SURVEY_SUBMITTED_META, true ) ) {
463        wp_send_json_error( array( 'reason' => 'already_submitted' ), 409, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
464    }
465
466    $responses = wpcom_write_build_survey_response( $answer, $comment, $response_id, $source, $is_write_first );
467
468    if ( ! wpcom_write_store_survey_response( $responses ) ) {
469        // Recorded here rather than from the card: the browser learns of the failure
470        // only once the request resolves, by which point the writer may well be gone.
471        if ( function_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_record_tracks_event' ) ) {
472            Common\wpcom_record_tracks_event(
473                'wpcom_write_first_publish_survey_store_failed',
474                array(
475                    'reason'      => 'store',
476                    'response_id' => $responses['response_id'],
477                    'entry_point' => $responses['entry_point'],
478                    'variant'     => $responses['variant'],
479                    'blog_id'     => wpcom_write_survey_blog_id(),
480                )
481            );
482        }
483
484        wp_send_json_error( array( 'reason' => 'store_failed' ), 500, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
485    }
486
487    update_user_meta( get_current_user_id(), WPCOM_WRITE_SURVEY_SUBMITTED_META, time() );
488
489    wp_send_json_success( null, 200, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
490}
491add_action( 'wp_ajax_wpcom_write_submit_survey', 'wpcom_write_ajax_submit_survey' );