Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
17.97% |
46 / 256 |
|
8.33% |
1 / 12 |
CRAP | |
0.00% |
0 / 1 |
WelcomeSurvey | |
17.97% |
46 / 256 |
|
8.33% |
1 / 12 |
1544.60 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
getGroup | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
90 | |||
isUnfinished | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
8 | |||
getQuestions | |
85.71% |
18 / 21 |
|
0.00% |
0 / 1 |
11.35 | |||
getQuestionBank | |
0.00% |
0 / 107 |
|
0.00% |
0 / 1 |
30 | |||
handleResponses | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
20 | |||
dismiss | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
saveSurveyData | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
6 | |||
loadSurveyData | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
saveGroup | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
6 | |||
getRedirectUrlQuery | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
addLinkTarget | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments; |
4 | |
5 | use GrowthExperiments\EventLogging\WelcomeSurveyLogger; |
6 | use MediaWiki\Config\ConfigException; |
7 | use MediaWiki\Context\IContextSource; |
8 | use MediaWiki\Html\HtmlHelper; |
9 | use MediaWiki\Json\FormatJson; |
10 | use MediaWiki\Languages\LanguageNameUtils; |
11 | use MediaWiki\User\Options\UserOptionsManager; |
12 | use MediaWiki\Utils\MWTimestamp; |
13 | use stdClass; |
14 | use Wikimedia\LightweightObjectStore\ExpirationAwareness; |
15 | use Wikimedia\RemexHtml\HTMLData; |
16 | use Wikimedia\RemexHtml\Serializer\SerializerNode; |
17 | |
18 | class 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 | } |