Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.39% covered (warning)
86.39%
273 / 316
50.00% covered (danger)
50.00%
10 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
EventQuestionsRegistry
86.39% covered (warning)
86.39%
273 / 316
50.00% covered (danger)
50.00%
10 / 20
123.70
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getQuestions
99.17% covered (success)
99.17%
119 / 120
0.00% covered (danger)
0.00%
0 / 1
3
 getQuestionsForTesting
n/a
0 / 0
n/a
0 / 0
2
 overrideQuestionsForTesting
n/a
0 / 0
n/a
0 / 0
2
 getQuestionsForHTMLForm
62.96% covered (warning)
62.96%
17 / 27
0.00% covered (danger)
0.00%
0 / 1
15.08
 extractUserAnswersHTMLForm
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 newAnswerFromHTMLForm
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
12.74
 isPlaceholderValue
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
10.50
 getQuestionsForAPI
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 formatAnswersForAPI
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 extractUserAnswersAPI
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 newAnswerFromAPI
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
12.09
 getQuestionLabelsForOrganizerForm
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getQuestionLabelForStats
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getQuestionOptionsForStats
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getQuestionOptionMessageByID
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getAvailableQuestionNames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 nameToDBID
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 dbIDToName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getNonPIIQuestionLabels
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getNonPIIQuestionIDs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 getParticipantQuestionsToShow
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare( strict_types=1 );
4
5namespace MediaWiki\Extension\CampaignEvents\Questions;
6
7use BadMethodCallException;
8use LogicException;
9use UnexpectedValueException;
10
11class EventQuestionsRegistry {
12    public const SERVICE_NAME = 'CampaignEventsEventQuestionsRegistry';
13
14    public const RADIO_BUTTON_QUESTION_TYPE = 'radio';
15    public const SELECT_QUESTION_TYPE = 'select';
16    public const FREE_TEXT_QUESTION_TYPE = 'text';
17
18    public const MULTIPLE_CHOICE_TYPES = [
19        self::RADIO_BUTTON_QUESTION_TYPE,
20        self::SELECT_QUESTION_TYPE
21    ];
22
23    /**
24     * @var bool Determines whether Wikimedia-specific questions should be shown. In the future, this might be
25     * replaced with a hook for adding custom questions.
26     */
27    private bool $wikimediaQuestionsEnabled;
28
29    /** @var array|null Question overrides used in test. Null means no override. */
30    private ?array $testOverrides = null;
31
32    /**
33     * @param bool $wikimediaQuestionsEnabled
34     */
35    public function __construct( bool $wikimediaQuestionsEnabled ) {
36        $this->wikimediaQuestionsEnabled = $wikimediaQuestionsEnabled;
37    }
38
39    /**
40     * Returns the internal registry with all the question. Each entry in the registry must have the following
41     * properties:
42     *  - name (string): Readable identifier of the question, can be used in forms and APIs (potentially prefixed)
43     *  - db-id (integer): Identifier of the question in the database
44     *  - wikimedia (bool): Whether the question is Wikimedia-specific
45     *  - pii (bool): Whether the question is considered PII
46     *  - stats-label-message (string): Key of a message that should be used when introducing the response statistics
47     *      for this question.
48     *  - non-pii-label-message (string): Key of a message that should be used when introducing the response of
49     *      non-pii responses
50     *  - questionData (array): User-facing properties of the question, with the following keys:
51     *    - type (string, required): Type of the question, must be one of the self::*_QUESTION_TYPE constants
52     *    - label-message (string, required): i18n key for the question label
53     *    - options-messages (array, optional): For multiple-choice questions, the list of possible answers.
54     *      NOTE: For multipe-choice questions, the option 0 must be a placeholder value to let the
55     *      user skip the question.
56     *  - otherOptions (array): List of fields to show conditionally if the parent field has a certain value. Currently,
57     *    this has the following requirements:
58     *      - the parent question must be a multiple-choice question
59     *      - the key of each 'other' element must correspond to the value that the parent question should have in order
60     *        for the 'other' field to be shown
61     *      - the 'other' field must be a free-text field
62     *    Each 'other' option has the following keys:
63     *      - type (string, required): Must be set to self::FREE_TEXT_QUESTION_TYPE, and is only required for robustness
64     *      - placeholder-message (string, required): Placeholder for the text field, required in place of the label
65     * @return array[]
66     */
67    private function getQuestions(): array {
68        if ( $this->testOverrides !== null ) {
69            return $this->testOverrides;
70        }
71
72        $questions = [
73            [
74                'name' => 'gender',
75                'db-id' => 1,
76                'wikimedia' => false,
77                'pii' => true,
78                'stats-label-message' => 'campaignevents-register-question-gender-stats-label',
79                'organizer-label-message' => 'campaignevents-register-question-gender-organizer-label',
80                'questionData' => [
81                    'type' => self::RADIO_BUTTON_QUESTION_TYPE,
82                    'label-message' => 'campaignevents-register-question-gender',
83                    'options-messages' => [
84                        'campaignevents-register-question-gender-option-not-say' => 0,
85                        'campaignevents-register-question-gender-option-man' => 1,
86                        'campaignevents-register-question-gender-option-woman' => 2,
87                        'campaignevents-register-question-gender-option-agender' => 3,
88                        'campaignevents-register-question-gender-option-nonbinary' => 4,
89                        'campaignevents-register-question-gender-option-other' => 5,
90                    ],
91                ],
92            ],
93            [
94                'name' => 'age',
95                'db-id' => 2,
96                'wikimedia' => false,
97                'pii' => true,
98                'stats-label-message' => 'campaignevents-register-question-age-stats-label',
99                'organizer-label-message' => 'campaignevents-register-question-age-organizer-label',
100                'questionData' => [
101                    'type' => self::SELECT_QUESTION_TYPE,
102                    'label-message' => 'campaignevents-register-question-age',
103                    'options-messages' => [
104                        'campaignevents-register-question-age-placeholder' => 0,
105                        'campaignevents-register-question-age-option-under-25' => 1,
106                        'campaignevents-register-question-age-option-25-34' => 2,
107                        'campaignevents-register-question-age-option-35-44' => 3,
108                        'campaignevents-register-question-age-option-45-54' => 4,
109                        'campaignevents-register-question-age-option-55-64' => 5,
110                        'campaignevents-register-question-age-option-65-74' => 6,
111                        'campaignevents-register-question-age-option-75-84' => 7,
112                        'campaignevents-register-question-age-option-85-plus' => 8,
113                    ],
114                ],
115            ],
116            [
117                'name' => 'profession',
118                'db-id' => 3,
119                'wikimedia' => false,
120                'pii' => true,
121                'stats-label-message' => 'campaignevents-register-question-profession-stats-label',
122                'organizer-label-message' => 'campaignevents-register-question-profession-organizer-label',
123                'questionData' => [
124                    'type' => self::SELECT_QUESTION_TYPE,
125                    'label-message' => 'campaignevents-register-question-profession',
126                    'options-messages' => [
127                        'campaignevents-register-question-profession-placeholder' => 0,
128                        'campaignevents-register-question-profession-option-artist-creative' => 1,
129                        'campaignevents-register-question-profession-option-educator' => 2,
130                        'campaignevents-register-question-profession-option-librarian' => 3,
131                        'campaignevents-register-question-profession-option-mass-media' => 4,
132                        'campaignevents-register-question-profession-option-museum-archive' => 5,
133                        'campaignevents-register-question-profession-option-nonprofit' => 6,
134                        'campaignevents-register-question-profession-option-researcher' => 7,
135                        'campaignevents-register-question-profession-option-software-engineer' => 8,
136                        'campaignevents-register-question-profession-option-student' => 9,
137                        'campaignevents-register-question-profession-option-other' => 10,
138                    ],
139                ],
140            ],
141            [
142                'name' => 'confidence',
143                'db-id' => 4,
144                'wikimedia' => true,
145                'pii' => false,
146                'stats-label-message' => 'campaignevents-register-question-confidence-stats-label',
147                'non-pii-label-message' => 'campaignevents-individual-stats-label-message-confidence',
148                'organizer-label-message' => 'campaignevents-register-question-confidence-organizer-label',
149                'questionData' => [
150                    'type' => self::RADIO_BUTTON_QUESTION_TYPE,
151                    'label-message' => 'campaignevents-register-question-confidence-contributing',
152                    'options-messages' => [
153                        'campaignevents-register-question-confidence-contributing-not-say' => 0,
154                        'campaignevents-register-question-confidence-contributing-option-none' => 1,
155                        'campaignevents-register-question-confidence-contributing-option-some-not-confident' => 2,
156                        'campaignevents-register-question-confidence-contributing-option-some-confident' => 3,
157                        'campaignevents-register-question-confidence-contributing-option-confident' => 4,
158                    ],
159                ],
160            ],
161            [
162                'name' => 'affiliate',
163                'db-id' => 5,
164                'wikimedia' => true,
165                'pii' => false,
166                'stats-label-message' => 'campaignevents-register-question-affiliate-stats-label',
167                'non-pii-label-message' => 'campaignevents-individual-stats-label-message-affiliate',
168                'organizer-label-message' => 'campaignevents-register-question-affiliate-organizer-label',
169                'questionData' => [
170                    'type' => self::SELECT_QUESTION_TYPE,
171                    'label-message' => 'campaignevents-register-question-affiliate',
172                    'options-messages' => [
173                        'campaignevents-register-question-affiliate-placeholder' => 0,
174                        'campaignevents-register-question-affiliate-option-affiliate' => 1,
175                        'campaignevents-register-question-affiliate-option-none' => 2,
176                    ],
177                ],
178                'otherOptions' => [
179                    1 => [
180                        'type' => self::FREE_TEXT_QUESTION_TYPE,
181                        'placeholder-message' => 'campaignevents-register-question-affiliate-details-placeholder',
182                    ],
183                ],
184            ],
185        ];
186
187        return array_filter(
188            $questions,
189            fn ( array $question ): bool => !$question['wikimedia'] || $this->wikimediaQuestionsEnabled
190        );
191    }
192
193    /**
194     * @codeCoverageIgnore
195     * @return array[]
196     */
197    public function getQuestionsForTesting(): array {
198        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
199            throw new BadMethodCallException( 'This method can only be used in tests' );
200        }
201        return $this->getQuestions();
202    }
203
204    /**
205     * @codeCoverageIgnore
206     * @param array[] $questions
207     */
208    public function overrideQuestionsForTesting( array $questions ): void {
209        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
210            throw new BadMethodCallException( 'This method can only be used in tests' );
211        }
212        $this->testOverrides = $questions;
213    }
214
215    /**
216     * Returns the questions corresponding to the given question database IDs, in the format accepted by HTMLForm.
217     * Each "child" question is given the CSS class `ext-campaignevents-participant-question-other-option`, which can
218     * be used to style the child field.
219     *
220     * @note This method ignores any IDs not corresponding to known questions.
221     *
222     * @param int[] $questionIDs
223     * @param Answer[] $curAnswers Current user answers, used for the default values
224     * @return array[]
225     */
226    public function getQuestionsForHTMLForm( array $questionIDs, array $curAnswers ): array {
227        $curAnswersByID = [];
228        foreach ( $curAnswers as $answer ) {
229            $curAnswersByID[$answer->getQuestionDBID()] = $answer;
230        }
231        $fields = [];
232        foreach ( $this->getQuestions() as $question ) {
233            $questionID = $question['db-id'];
234            if ( !in_array( $questionID, $questionIDs, true ) ) {
235                continue;
236            }
237            $curAnswer = $curAnswersByID[$questionID] ?? null;
238            $fieldName = 'Question' . ucfirst( $question['name'] );
239            $fields[$fieldName] = $question[ 'questionData' ];
240            if ( $curAnswer ) {
241                $type = $question['questionData']['type'];
242                if ( $type === self::FREE_TEXT_QUESTION_TYPE ) {
243                    $default = $curAnswer->getText();
244                } elseif ( in_array( $type, self::MULTIPLE_CHOICE_TYPES, true ) ) {
245                    $default = $curAnswer->getOption();
246                } else {
247                    throw new UnexpectedValueException( "Unhandled question type $type" );
248                }
249                $fields[$fieldName]['default'] = $default;
250            }
251            foreach ( $question[ 'otherOptions' ] ?? [] as $showIfVal => $optionData ) {
252                $optionName = $fieldName . '_Other' . '_' . $showIfVal;
253                $optionData['hide-if'] = [ '!==', $fieldName, (string)$showIfVal ];
254                $optionData['cssclass'] = 'ext-campaignevents-participant-question-other-option';
255                $fields[$optionName] = $optionData;
256                if ( $curAnswer && $curAnswer->getOption() === $showIfVal ) {
257                    $fields[$optionName]['default'] = $curAnswer->getText();
258                }
259            }
260        }
261        return $fields;
262    }
263
264    /**
265     * Parses an array of form field values from an HTMLForm that was built using getQuestionsForHTMLForm(),
266     * and returns an array of answers to store.
267     *
268     * @param array $formData As given by HTMLForm
269     * @param int[] $enabledQuestionIDs Enabled question for the event, should match the value passed to
270     *   getQuestionsForHTMLForm().
271     * @return Answer[]
272     * @throws InvalidAnswerDataException
273     */
274    public function extractUserAnswersHTMLForm( array $formData, array $enabledQuestionIDs ): array {
275        $answers = [];
276        foreach ( $this->getQuestions() as $question ) {
277            if ( !in_array( $question['db-id'], $enabledQuestionIDs, true ) ) {
278                continue;
279            }
280            $answer = $this->newAnswerFromHTMLForm( $question, $formData );
281            if ( $answer ) {
282                $answers[] = $answer;
283            }
284        }
285        return $answers;
286    }
287
288    /**
289     * @param array $questionSpec Must be an entry in the registry
290     * @param array $formData
291     * @return Answer|null
292     * @throws InvalidAnswerDataException
293     */
294    private function newAnswerFromHTMLForm( array $questionSpec, array $formData ): ?Answer {
295        $fieldName = 'Question' . ucfirst( $questionSpec['name'] );
296        if ( !isset( $formData[$fieldName] ) ) {
297            return null;
298        }
299        $type = $questionSpec['questionData']['type'];
300        $ansValue = $formData[$fieldName];
301        if ( $this->isPlaceholderValue( $type, $ansValue ) ) {
302            return null;
303        }
304        if ( $type === self::FREE_TEXT_QUESTION_TYPE ) {
305            if ( !is_string( $ansValue ) ) {
306                throw new InvalidAnswerDataException( $fieldName );
307            }
308            $ansOption = null;
309            $ansText = $ansValue;
310        } elseif ( in_array( $type, self::MULTIPLE_CHOICE_TYPES, true ) ) {
311            // Note, HTMLForm always uses strings for field values
312            if ( !is_numeric( $ansValue ) ) {
313                throw new InvalidAnswerDataException( $fieldName );
314            }
315            $ansValue = (int)$ansValue;
316            if ( !in_array( $ansValue, $questionSpec['questionData']['options-messages'], true ) ) {
317                throw new InvalidAnswerDataException( $fieldName );
318            }
319            $ansOption = $ansValue;
320            $ansText = null;
321            $otherOptionName = $fieldName . '_Other' . '_' . $ansValue;
322            if ( isset( $questionSpec['otherOptions'][$ansValue] ) && isset( $formData[$otherOptionName] ) ) {
323                $answerOther = $formData[$otherOptionName];
324                if ( $answerOther !== '' ) {
325                    if ( !is_string( $answerOther ) ) {
326                        throw new InvalidAnswerDataException( $otherOptionName );
327                    }
328                    $ansText = $answerOther;
329                }
330            }
331        } else {
332            throw new UnexpectedValueException( "Unhandled question type $type" );
333        }
334
335        return new Answer( $questionSpec['db-id'], $ansOption, $ansText );
336    }
337
338    /**
339     * @param string $questionType
340     * @param mixed $value
341     * @return bool
342     */
343    private function isPlaceholderValue( string $questionType, $value ): bool {
344        switch ( $questionType ) {
345            case self::RADIO_BUTTON_QUESTION_TYPE:
346            case self::SELECT_QUESTION_TYPE:
347                return $value === 0 || $value === '0';
348            case self::FREE_TEXT_QUESTION_TYPE:
349                return $value === '';
350            default:
351                throw new LogicException( 'Unhandled question type' );
352        }
353    }
354
355    /**
356     * Returns the questions corresponding to the given question database IDs, in a format suitable for the API.
357     *
358     * @note This method ignores any IDs not corresponding to known questions.
359     *
360     * @param int[]|null $questionIDs Only include questions with these IDs, or null to include all questions
361     * @return array[] The keys are question IDs, and the values are arrays with the following keys: `name`, `type`,
362     *   `label-message`. `options-messages` is also present if the question is multiple choice. `other-option` is
363     *    also set if the question has additional options. This is an array where the keys are possible values for
364     *    the parent question, and the values are arrays with `type` and `label-message`.
365     */
366    public function getQuestionsForAPI( array $questionIDs = null ): array {
367        $ret = [];
368        foreach ( $this->getQuestions() as $question ) {
369            $questionID = $question['db-id'];
370            if ( $questionIDs !== null && !in_array( $questionID, $questionIDs, true ) ) {
371                continue;
372            }
373            $questionData = $question['questionData'];
374            $ret[$questionID] = [
375                'name' => $question['name'],
376                'type' => $questionData['type'],
377                'label-message' => $questionData['label-message'],
378            ];
379            if ( isset( $questionData['options-messages'] ) ) {
380                $ret[$questionID]['options-messages'] = $questionData['options-messages'];
381            }
382            $otherOptions = [];
383            foreach ( $question[ 'otherOptions' ] ?? [] as $showIfVal => $optionData ) {
384                $otherOptions[$showIfVal] = [
385                    'type' => $optionData['type'],
386                    'label-message' => $optionData['placeholder-message'],
387                ];
388            }
389            if ( $otherOptions ) {
390                $ret[$questionID]['other-options'] = $otherOptions;
391            }
392        }
393        return $ret;
394    }
395
396    /**
397     * Formats a list of answers for use in API (GET) requests. This is the same format expected by
398     * extractUserAnswersAPI().
399     *
400     * @param Answer[] $answers
401     * @param int[] $enabledQuestions IDs of the questions currently enabled for the event
402     * @return array<string,array> Maps question names to arrays containing the following properties:
403     *  - value: ?int, identifier of the chosen option for multiple choice questions
404     *  - other: string (optional), free text of the answer
405     *  - removed: true (optional), only set if the question has since been removed from the event
406     */
407    public function formatAnswersForAPI( array $answers, array $enabledQuestions ): array {
408        $answersByID = [];
409        foreach ( $answers as $answer ) {
410            $answersByID[$answer->getQuestionDBID()] = $answer;
411        }
412        $ret = [];
413        foreach ( $this->getQuestions() as $question ) {
414            $questionID = $question['db-id'];
415            if ( !isset( $answersByID[$questionID] ) ) {
416                continue;
417            }
418            $answer = $answersByID[$questionID];
419            $option = $answer->getOption();
420            $formattedAnswer = [
421                'value' => $option,
422            ];
423            if ( $answer->getText() !== null ) {
424                $formattedAnswer['other'] = $answer->getText();
425            }
426            if ( !in_array( $questionID, $enabledQuestions, true ) ) {
427                $formattedAnswer['removed'] = true;
428            }
429            $ret[$question['name']] = $formattedAnswer;
430        }
431        return $ret;
432    }
433
434    /**
435     * Extracts user answers given in the API format returned by formatAnswersForAPI().
436     *
437     * @param array[] $data
438     * @param int[] $enabledQuestionIDs
439     * @return Answer[]
440     * @throws InvalidAnswerDataException If an answer's value is malformed
441     */
442    public function extractUserAnswersAPI( array $data, array $enabledQuestionIDs ): array {
443        $answers = [];
444        foreach ( $this->getQuestions() as $question ) {
445            if ( !in_array( $question['db-id'], $enabledQuestionIDs, true ) ) {
446                continue;
447            }
448            $questionName = $question['name'];
449            if ( !isset( $data[$questionName] ) ) {
450                continue;
451            }
452            $answer = $this->newAnswerFromAPI( $question, $questionName, $data[$questionName] );
453            if ( $answer ) {
454                $answers[] = $answer;
455            }
456        }
457        return $answers;
458    }
459
460    /**
461     * @param array $questionSpec Must be an entry in the registry
462     * @param string $questionName
463     * @param array $answerData
464     * @return Answer|null
465     * @throws InvalidAnswerDataException If an answer's value is malformed
466     */
467    private function newAnswerFromAPI( array $questionSpec, string $questionName, array $answerData ): ?Answer {
468        $type = $questionSpec['questionData']['type'];
469        $ansValue = $answerData['value'];
470        if ( $this->isPlaceholderValue( $type, $ansValue ) ) {
471            return null;
472        }
473        if ( $type === self::FREE_TEXT_QUESTION_TYPE ) {
474            if ( !is_string( $ansValue ) ) {
475                throw new InvalidAnswerDataException( $questionName );
476            }
477            $ansOption = null;
478            $ansText = $ansValue;
479        } elseif ( in_array( $type, self::MULTIPLE_CHOICE_TYPES, true ) ) {
480            if ( !is_int( $ansValue ) ) {
481                throw new InvalidAnswerDataException( $questionName );
482            }
483            if ( !in_array( $ansValue, $questionSpec['questionData']['options-messages'], true ) ) {
484                throw new InvalidAnswerDataException( $questionName );
485            }
486            $ansOption = $ansValue;
487            $ansText = null;
488            if ( isset( $questionSpec['otherOptions'][$ansValue] ) && isset( $answerData['other'] ) ) {
489                $answerOther = $answerData['other'];
490                if ( $answerOther !== '' ) {
491                    if ( !is_string( $answerOther ) ) {
492                        throw new InvalidAnswerDataException( $questionName );
493                    }
494                    $ansText = $answerData['other'];
495                }
496            }
497        } else {
498            throw new UnexpectedValueException( "Unhandled question type $type" );
499        }
500
501        return new Answer( $questionSpec['db-id'], $ansOption, $ansText );
502    }
503
504    /**
505     * Returns message keys to use as labels for each question in the form shown to organizers when they enable
506     * or edit an event registation. Labels for PII and non-PII questions are provided separately. Question labels
507     * are mapped to question IDs.
508     *
509     * @return string[][]
510     * @phan-return array{pii:array<string,int>,non-pii:array<string,int>}
511     */
512    public function getQuestionLabelsForOrganizerForm(): array {
513        $ret = [ 'pii' => [], 'non-pii' => [] ];
514        foreach ( $this->getQuestions() as $question ) {
515            $key = $question['pii'] ? 'pii' : 'non-pii';
516            $questionMsg = $question['organizer-label-message'];
517            $ret[$key][$questionMsg] = $question['db-id'];
518        }
519        return $ret;
520    }
521
522    /**
523     * Returns the key of a message to be used when introducing stats for the given question.
524     *
525     * @param int $questionID
526     * @return string
527     */
528    public function getQuestionLabelForStats( int $questionID ): string {
529        foreach ( $this->getQuestions() as $question ) {
530            if ( $question['db-id'] === $questionID ) {
531                return $question['stats-label-message'];
532            }
533        }
534        throw new UnknownQuestionException( "Unknown question with ID $questionID" );
535    }
536
537    /**
538     * @param int $questionID
539     * @return array<int,string> Map of [ answer ID => message key ]
540     */
541    public function getQuestionOptionsForStats( int $questionID ): array {
542        foreach ( $this->getQuestions() as $question ) {
543            if ( $question['db-id'] === $questionID ) {
544                $questionData = $question['questionData'];
545                if ( !in_array( $questionData['type'], self::MULTIPLE_CHOICE_TYPES, true ) ) {
546                    throw new LogicException( 'Not implemented' );
547                }
548                $options = array_flip( $questionData['options-messages'] );
549                unset( $options[0] );
550                return $options;
551            }
552        }
553        throw new UnknownQuestionException( "Unknown question with ID $questionID" );
554    }
555
556    /**
557     * @param int $questionID
558     * @param int $optionID
559     * @return string
560     */
561    public function getQuestionOptionMessageByID( int $questionID, int $optionID ): string {
562        foreach ( $this->getQuestions() as $question ) {
563            if ( $question['db-id'] === $questionID ) {
564                $optionMessages = array_flip( $question['questionData']['options-messages'] );
565                if ( !isset( $optionMessages[ $optionID ] ) ) {
566                    throw new UnknownQuestionOptionException( "Unknown option with ID $optionID" );
567                }
568                return $optionMessages[ $optionID ];
569            }
570        }
571        throw new UnknownQuestionException( "Unknown question with ID $questionID" );
572    }
573
574    /**
575     * @return string[]
576     */
577    public function getAvailableQuestionNames(): array {
578        return array_column( $this->getQuestions(), 'name' );
579    }
580
581    /**
582     * Given a question name, returns the corresponding database ID.
583     *
584     * @param string $name
585     * @return int
586     * @throws UnknownQuestionException
587     */
588    public function nameToDBID( string $name ): int {
589        foreach ( $this->getQuestions() as $question ) {
590            if ( $question['name'] === $name ) {
591                return $question['db-id'];
592            }
593        }
594        throw new UnknownQuestionException( "Unknown question name $name" );
595    }
596
597    /**
598     * Given a question database ID, returns its name.
599     *
600     * @param int $dbID
601     * @return string
602     * @throws UnknownQuestionException
603     */
604    public function dbIDToName( int $dbID ): string {
605        foreach ( $this->getQuestions() as $question ) {
606            if ( $question['db-id'] === $dbID ) {
607                return $question['name'];
608            }
609        }
610        throw new UnknownQuestionException( "Unknown question DB ID $dbID" );
611    }
612
613    /**
614     * Returns non PII questions labels.
615     *
616     * @param array $eventQuestions
617     * @return array
618     */
619    public function getNonPIIQuestionLabels( array $eventQuestions ): array {
620        $nonPIIquestionLabels = [];
621        foreach ( $this->getQuestions() as $question ) {
622            if ( in_array( $question[ 'db-id' ], $eventQuestions, true ) && $question['pii'] === false ) {
623                $nonPIIquestionLabels[] = $question['non-pii-label-message'];
624            }
625        }
626        return $nonPIIquestionLabels;
627    }
628
629    /**
630     * Returns non PII questions IDs.
631     *
632     * @param array $eventQuestions
633     * @return array
634     */
635    public function getNonPIIQuestionIDs( array $eventQuestions ): array {
636        $nonPIIquestionIDs = [];
637        foreach ( $this->getQuestions() as $question ) {
638            if ( in_array( $question[ 'db-id' ], $eventQuestions, true ) && $question['pii'] === false ) {
639                $nonPIIquestionIDs[] = $question['db-id'];
640            }
641        }
642        return $nonPIIquestionIDs;
643    }
644
645    /**
646     * Given a list of questions currently enabled for an event and a list of all answers a user has ever provided for
647     * the event, merge the two to get a list of questions that the user should see when registering, or when editing
648     * their registration information.
649     *
650     * @param int[] $enabledQuestions
651     * @param Answer[] $userAnswers
652     * @return int[]
653     */
654    public static function getParticipantQuestionsToShow( array $enabledQuestions, array $userAnswers ): array {
655        $answeredQuestionIDs = array_map( static fn ( Answer $a ) => $a->getQuestionDBID(), $userAnswers );
656        $allQuestions = array_unique( array_merge( $enabledQuestions, $answeredQuestionIDs ) );
657        // Sorting not strictly necessary, just for debugging etc.
658        sort( $allQuestions );
659        return $allQuestions;
660    }
661}