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