Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.97% covered (danger)
17.97%
46 / 256
8.33% covered (danger)
8.33%
1 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
WelcomeSurvey
17.97% covered (danger)
17.97%
46 / 256
8.33% covered (danger)
8.33%
1 / 12
1544.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getGroup
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
90
 isUnfinished
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 getQuestions
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
11.35
 getQuestionBank
0.00% covered (danger)
0.00%
0 / 107
0.00% covered (danger)
0.00%
0 / 1
30
 handleResponses
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
20
 dismiss
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 saveSurveyData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 loadSurveyData
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 saveGroup
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getRedirectUrlQuery
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 addLinkTarget
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace GrowthExperiments;
4
5use GrowthExperiments\EventLogging\WelcomeSurveyLogger;
6use MediaWiki\Config\ConfigException;
7use MediaWiki\Context\IContextSource;
8use MediaWiki\Html\HtmlHelper;
9use MediaWiki\Json\FormatJson;
10use MediaWiki\Languages\LanguageNameUtils;
11use MediaWiki\User\Options\UserOptionsManager;
12use MediaWiki\Utils\MWTimestamp;
13use stdClass;
14use Wikimedia\LightweightObjectStore\ExpirationAwareness;
15use Wikimedia\RemexHtml\HTMLData;
16use Wikimedia\RemexHtml\Serializer\SerializerNode;
17
18class WelcomeSurvey {
19
20    private const BLOB_SIZE = 65535;
21
22    public const SURVEY_PROP = 'welcomesurvey-responses';
23
24    public const DEFAULT_SURVEY_GROUP = 'control';
25
26    private IContextSource $context;
27    private bool $allowFreetext;
28    private LanguageNameUtils $languageNameUtils;
29    private UserOptionsManager $userOptionsManager;
30    private bool $ulsInstalled;
31
32    /**
33     * @param IContextSource $context
34     * @param LanguageNameUtils $languageNameUtils
35     * @param UserOptionsManager $userOptionsManager
36     * @param bool $ulsInstalled
37     */
38    public function __construct(
39        IContextSource $context,
40        LanguageNameUtils $languageNameUtils,
41        UserOptionsManager $userOptionsManager,
42        bool $ulsInstalled
43    ) {
44        $this->context = $context;
45        $this->allowFreetext =
46            (bool)$this->context->getConfig()->get( 'WelcomeSurveyAllowFreetextResponses' );
47        $this->languageNameUtils = $languageNameUtils;
48        $this->userOptionsManager = $userOptionsManager;
49        $this->ulsInstalled = $ulsInstalled;
50    }
51
52    /**
53     * Get the name of the experimental group for the current user or
54     * false they are not part of any experiment.
55     *
56     * @param bool $useDefault Use default group from WelcomeSurveyDefaultGroup, if it is defined
57     * @return bool|string
58     * @throws ConfigException
59     */
60    public function getGroup( $useDefault = false ) {
61        $groups = $this->context->getConfig()->get( 'WelcomeSurveyExperimentalGroups' );
62
63        // The group is specified in the URL
64        $request = $this->context->getRequest();
65        // The parameter name ('_group') must match the name of the hidden form field in
66        // SpecialWelcomeSurvey::alterForm()
67        $groupParam = $request->getText( '_group' );
68        if ( isset( $groups[ $groupParam ] ) ) {
69            return $groupParam;
70        }
71
72        // The user was already assigned a group
73        $groupFromProp = $this->loadSurveyData()->_group ?? false;
74        if ( isset( $groups[ $groupFromProp ] ) ) {
75            return $groupFromProp;
76        }
77
78        if ( $useDefault ) {
79            // Fallback to default group if directly visiting Special:WelcomeSurvey
80            return self::DEFAULT_SURVEY_GROUP;
81        }
82
83        // Randomly selecting a group
84        $js = $this->context->getRequest()->getBool( 'client-runs-javascript' );
85        $rand = rand( 0, 99 );
86        foreach ( $groups as $name => $groupConfig ) {
87            $percentage = 0;
88            if ( isset( $groupConfig[ 'percentage' ] ) ) {
89                $percentage = intval( $groupConfig[ 'percentage' ] );
90            }
91
92            if ( $rand < $percentage ) {
93                if ( !$js && isset( $groupConfig[ 'nojs-fallback' ] ) ) {
94                    return $groupConfig[ 'nojs-fallback' ];
95                }
96                return $name;
97            }
98            $rand -= $percentage;
99        }
100
101        return false;
102    }
103
104    /**
105     * True if the context user has not filled out the welcome survey and should
106     * be reminded to do so.
107     * More precisely, true if none of the conditions below are true:
108     * - The user has submitted the survey.
109     * - At least $wgWelcomeSurveyReminderExpiry days have passed since the user has registered.
110     * - The user is past the survey retention period; they might or might not have
111     *   filled out the survey, but if they did, the data was already discarded.
112     * - The user has pressed the "Skip" button on the survey.
113     * - The user is in an A/B test group which is not supposed to get the survey.
114     * @return bool
115     */
116    public function isUnfinished(): bool {
117        $registrationDate = $this->context->getUser()->getRegistration() ?: null;
118        if ( !$registrationDate ) {
119            // User is anon or has registered a long, long time ago when MediaWiki had no logging for it.
120            return false;
121        }
122        $registrationTimestamp = (int)wfTimestamp( TS_UNIX, $registrationDate );
123        $expiryDays = $this->context->getConfig()->get( 'WelcomeSurveyReminderExpiry' );
124        $expirySeconds = $expiryDays * ExpirationAwareness::TTL_DAY;
125        if ( $registrationTimestamp + $expirySeconds < MWTimestamp::now( TS_UNIX ) ) {
126            // The configured reminder expiry has passed.
127            return false;
128        }
129
130        // Survey data can be written by the user via options API so don't assume any structure.
131        $data = $this->loadSurveyData();
132        $group = $data->_group ?? null;
133        $skipped = ( $data->_skip ?? null ) === true;
134        $submitted = ( $data->_submit_date ?? null ) !== null;
135
136        if ( !$data ) {
137            // Either the user is past the data retention period and all data was discarded
138            // (in which case we don't know if they ever filled out the survey but months
139            // have passed since they registered so it's fine to consider the survey as
140            // intentionally not filled out), or WelcomeSurveyHooks::onBeforeWelcomeCreation()
141            // did not run for some reason (maybe the feature wasn't enabled when they registered).
142            return false;
143        } elseif ( $skipped ) {
144            // User chose not to fill out the survey. Somewhat arbitrarily, we consider that
145            // as finishing it.
146            return false;
147        } elseif ( $submitted ) {
148            // User did fill out the survey.
149            return false;
150        }
151
152        $groups = $this->context->getConfig()->get( 'WelcomeSurveyExperimentalGroups' );
153        $questions = $groups[ $group ][ 'questions' ] ?? null;
154        if ( !$questions ) {
155            // This is an A/B test control group, the user should never see the survey.
156            // Or maybe the configuration changed, and this group does not exist anymore,
157            // in which case we'll just ignore it.
158            return false;
159        }
160
161        return true;
162    }
163
164    /**
165     * Get the questions' configuration for the specified group
166     *
167     * @param string $group
168     * @param bool $asKeyedArray True to use the question name as key, false to use a numerical index
169     * @return array Questions configuration
170     * @throws ConfigException
171     */
172    public function getQuestions( $group, $asKeyedArray = true ) {
173        $groups = $this->context->getConfig()->get( 'WelcomeSurveyExperimentalGroups' );
174        if ( !isset( $groups[ $group ] ) ) {
175            return [];
176        }
177
178        $questionNames = $groups[ $group ][ 'questions' ] ?? [];
179        if ( in_array( 'email', $questionNames ) &&
180            !Util::canSetEmail( $this->context->getUser(), '', false )
181        ) {
182            $questionNames = array_diff( $questionNames, [ 'email' ] );
183        }
184        if ( $questionNames !== [] && !in_array( 'privacy-info', $questionNames ) ) {
185            $questionNames[] = 'privacy-info';
186        }
187
188        if ( $this->allowFreetext && in_array( 'reason', $questionNames ) ) {
189            // Insert reason-other after reason
190            array_splice( $questionNames, array_search( 'reason', $questionNames ) + 1, 0,
191                'reason-other' );
192        }
193
194        $questions = [];
195        $questionBank = $this->getQuestionBank();
196        foreach ( $questionNames as $questionName ) {
197            if ( $questionBank[$questionName]['disabled'] ?? false ) {
198                continue;
199            }
200            if ( $asKeyedArray ) {
201                $questions[ $questionName ] = $questionBank[ $questionName ];
202            } else {
203                $questions[] = [ 'name' => $questionName ] + $questionBank[ $questionName ];
204            }
205        }
206        return $questions;
207    }
208
209    /**
210     * Bank of questions that can be used on the Welcome survey.
211     * Format is HTMLForm configuration, with two special keys 'placeholder-message'
212     * and 'other-message' for type=select (see SpecialWelcomeSurvey::getFormFields()).
213     *
214     * @see \HTMLForm::$typeMappings for the values to use for "type"
215     * @return array
216     */
217    protected function getQuestionBank(): array {
218        // When free text is enabled, add other-* settings and the reason-other question
219        $reasonOtherSettings = $this->allowFreetext ? [
220            'other-message' => 'welcomesurvey-question-reason-option-other-label',
221        ] : [];
222        $reasonOtherQuestion = $this->allowFreetext ? [
223            'reason-other' => [
224                'type' => 'text',
225                'placeholder-message' => [ 'welcomesurvey-question-reason-other-placeholder',
226                    $this->context->getUser()->getName() ],
227                'size' => 255,
228                'hide-if' => [ '!==', 'reason', 'other' ],
229                'group' => 'reason'
230            ]
231        ] : [];
232        // When free text is disabled, add an "Other" option to the reason question
233        $reasonOtherOption = $this->allowFreetext ? [] : [
234            "welcomesurvey-question-reason-option-other-no-freetext-label" => "other",
235        ];
236        $questions = [
237            "reason" => [
238                "type" => "select",
239                "label-message" => "welcomesurvey-question-reason-label",
240                "options-messages" => [
241                    "welcomesurvey-question-reason-option-edit-typo-label" => "edit-typo",
242                    "welcomesurvey-question-reason-option-edit-info-add-change-label" => "edit-info-add-change",
243                    "welcomesurvey-question-reason-option-add-image-label" => "add-image",
244                    "welcomesurvey-question-reason-option-new-page-label" => "new-page",
245                    "welcomesurvey-question-reason-option-program-participant-label" => "program-participant",
246                    "welcomesurvey-question-reason-option-read-label" => "read",
247                ] + $reasonOtherOption,
248                "placeholder-message" => "welcomesurvey-dropdown-option-select-label",
249                "name" => "reason",
250                "group" => "reason",
251            ] + $reasonOtherSettings
252        ] + $reasonOtherQuestion + [
253            "edited" => [
254                "type" => "select",
255                "label-message" => "welcomesurvey-question-edited-label",
256                "options-messages" => [
257                    "welcomesurvey-question-edited-option-yes-many-label" => "yes-many",
258                    "welcomesurvey-question-edited-option-yes-few-label" => "yes-few",
259                    "welcomesurvey-question-edited-option-no-dunno-label" => "dunno",
260                    "welcomesurvey-question-edited-option-no-other-label" => "no-other",
261                    "welcomesurvey-question-edited-option-dont-remember-label" => "dont-remember",
262                ],
263                "placeholder-message" => "welcomesurvey-dropdown-option-select-label",
264                "group" => "edited",
265            ],
266            "mailinglist" => [
267                "type" => "check",
268                "group" => "email",
269                "label-message" => "welcomesurvey-question-mailinglist-label",
270                "help" => $this->addLinkTarget(
271                    $this->context->msg( 'welcomesurvey-question-mailinglist-help' )->parse()
272                ),
273            ],
274            "languages" => [
275                "type" => "multiselect",
276                "options" => array_flip( $this->languageNameUtils->getLanguageNames() ),
277                "cssclass" => "welcomesurvey-languages",
278                "label-message" => "welcomesurvey-question-languages-label",
279                "dependencies" => [
280                    "modules" => [
281                        "ext.growthExperiments.Account",
282                        "ext.uls.mediawiki"
283                    ]
284                ],
285                "disabled" => false
286            ],
287            "user-research" => [
288                "type" => "check",
289                "label-message" => "welcomesurvey-question-user-research-label",
290                "help" => $this->context->msg( 'welcomesurvey-question-user-research-help' )
291                    ->parse(),
292            ],
293            "privacy-info" => [
294                "type" => "info",
295                "help" => $this->addLinkTarget( $this->context->msg(
296                    'welcomesurvey-privacy-footer-text',
297                    $this->context->getUser()->getName(),
298                    $this->context->getConfig()->get( 'WelcomeSurveyPrivacyStatementUrl' )
299                )->parse() ),
300                "cssclass" => "welcomesurvey-privacy-info"
301            ],
302            "mentor-info" => [
303                "type" => "info",
304                "label-message" => [ "welcomesurvey-question-mentor-info",
305                    $this->context->getUser()->getName() ],
306                "cssclass" => "welcomesurvey-mentor-info",
307                "group" => "email",
308            ],
309            "mentor" => [
310                "type" => "check",
311                "label-message" => [ "welcomesurvey-question-mentor-label",
312                    $this->context->getUser()->getName() ],
313                "cssclass" => "welcomesurvey-mentor-check",
314                "group" => "email",
315            ],
316            "email" => [
317                "type" => "email",
318                "label-message" => "welcomesurvey-question-email-label",
319                "placeholder-message" => "welcomesurvey-question-email-placeholder",
320                "help-message" => "welcomesurvey-question-email-help",
321                "group" => "email",
322            ]
323        ];
324        if ( !$this->ulsInstalled ) {
325            $questions['languages']['disabled'] = true;
326        }
327        return $questions;
328    }
329
330    /**
331     * Save the responses data and/or metadata as appropriate
332     *
333     * @param array $data Responses of the survey questions, keyed by questions' names
334     * @param bool $save True if the user selected to submit their responses,
335     *  false if they chose to skip
336     * @param string $group Name of the group this form is for
337     * @param string $renderDate Timestamp in MW format of when the form was shown
338     */
339    public function handleResponses( $data, $save, $group, $renderDate ) {
340        $user = $this->context->getUser()->getInstanceForUpdate();
341        $submitDate = MWTimestamp::now();
342
343        if ( $save ) {
344            // set email
345            $newEmail = $data[ 'email' ] ?? '';
346            if ( $newEmail ) {
347                $data[ 'email' ] = '[redacted]';
348                if ( Util::canSetEmail( $user, $newEmail ) ) {
349                    $user->setEmailWithConfirmation( $newEmail );
350                    $user->saveSettings();
351                }
352            }
353
354            $results = $data;
355        } else {
356            $results = [ '_skip' => true ];
357        }
358
359        $counter = ( $this->loadSurveyData()->_counter ?? 0 ) + 1;
360
361        $results = array_merge(
362            $results,
363            [
364                '_group' => $group,
365                '_render_date' => $renderDate,
366                '_submit_date' => $submitDate,
367                '_counter' => $counter,
368            ]
369        );
370        $this->saveSurveyData( $results );
371    }
372
373    /**
374     * Save the survey as skipped.
375     * @return void
376     */
377    public function dismiss() {
378        $group = $this->loadSurveyData()->_group ?? self::DEFAULT_SURVEY_GROUP;
379        $this->handleResponses( [], true, $group, wfTimestampNow() );
380    }
381
382    /**
383     * Store the given survey data for the context user.
384     * @param array $data
385     * @return void
386     */
387    private function saveSurveyData( array $data ): void {
388        $user = $this->context->getUser();
389        $encodedData = FormatJson::encode( $data );
390        if ( strlen( $encodedData ) > self::BLOB_SIZE ) {
391            Util::logText(
392                'Unable to save Welcome survey responses for user {userId} because it is too big.',
393                [ 'userId' => $user->getId() ]
394            );
395            return;
396        }
397        $this->userOptionsManager->setOption( $user, self::SURVEY_PROP, $encodedData );
398        $this->userOptionsManager->saveOptions( $user );
399    }
400
401    /**
402     * Return the survey data stored for the context user.
403     * @return stdClass|null A JSON object with survey data, or null if no data is stored.
404     */
405    private function loadSurveyData(): ?stdClass {
406        $user = $this->context->getUser();
407        $data = FormatJson::decode(
408            $this->userOptionsManager->getOption(
409                $user,
410                self::SURVEY_PROP,
411                ''
412            )
413        );
414        if ( $data !== null && !( $data instanceof stdClass ) ) {
415            // user options can always contain unsanitized user data
416            Util::logText(
417                'Invalid welcome survey data for {user}',
418                [ 'userId' => $user->getId() ]
419            );
420            $data = null;
421        }
422        return $data;
423    }
424
425    /**
426     * This is called right after user account creation and before the user is redirected
427     * to the welcome survey. It ensures that we keep track of which group a user
428     * is part of if they never submit any responses or don't even get the survey.
429     *
430     * @param string $group
431     */
432    public function saveGroup( $group ) {
433        $group = $group ?: 'NONE';
434        $data = [
435            '_group' => $group,
436            '_render_date' => MWTimestamp::now(),
437        ];
438        $this->saveSurveyData( $data );
439    }
440
441    /**
442     * Build the redirect URL for a group and its display format
443     *
444     * @param string $group
445     * @param string $returnTo
446     * @param string $returnToQuery
447     * @return bool|array
448     */
449    public function getRedirectUrlQuery( string $group, string $returnTo, string $returnToQuery ) {
450        $questions = $this->getQuestions( $group );
451        if ( !$questions ) {
452            return false;
453        }
454
455        $request = $this->context->getRequest();
456
457        $welcomeSurveyToken = Util::generateRandomToken();
458        $request->response()->setCookie( WelcomeSurveyLogger::WELCOME_SURVEY_TOKEN,
459            $welcomeSurveyToken, time() + 3600 );
460        return [
461            'returnto' => $returnTo,
462            'returntoquery' => $returnToQuery,
463            'group' => $group,
464            '_welcomesurveytoken' => $welcomeSurveyToken
465        ];
466    }
467
468    /**
469     * Add target=_blank to links in a HTML snippet.
470     * @param string $html
471     * @return string
472     */
473    private function addLinkTarget( string $html ) {
474        return HtmlHelper::modifyElements( $html, static function ( SerializerNode $node ) {
475            return $node->namespace === HTMLData::NS_HTML
476                && $node->name === 'a'
477                && isset( $node->attrs['href'] )
478                && !isset( $node->attrs['target'] );
479        }, static function ( SerializerNode $node ) {
480            $node->attrs['target'] = '_blank';
481            return $node;
482        } );
483    }
484
485}