Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
180 / 180
100.00% covered (success)
100.00%
12 / 12
CRAP
100.00% covered (success)
100.00%
1 / 1
User
100.00% covered (success)
100.00%
180 / 180
100.00% covered (success)
100.00%
12 / 12
37
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createFromData
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 determineIsPasswordEnabled
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 determineBuildUpStartDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRequired
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validateUser
100.00% covered (success)
100.00%
57 / 57
100.00% covered (success)
100.00%
1 / 1
11
 validatePasswordMatchesConfirmation
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 validateUniqueUsername
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 validateUniqueEmail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 createUser
100.00% covered (success)
100.00%
82 / 82
100.00% covered (success)
100.00%
1 / 1
8
 assignAngelTypes
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 validate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Engelsystem\Factories;
6
7use Carbon\CarbonImmutable;
8use DateTimeInterface;
9use Engelsystem\Config\Config;
10use Engelsystem\Config\GoodieType;
11use Engelsystem\Helpers\Authenticator;
12use Engelsystem\Helpers\Carbon;
13use Engelsystem\Http\Exceptions\ValidationException;
14use Engelsystem\Http\Validation\Validator;
15use Engelsystem\Models\AngelType;
16use Engelsystem\Models\Group;
17use Engelsystem\Models\OAuth;
18use Engelsystem\Models\User\Contact;
19use Engelsystem\Models\User\PersonalData;
20use Engelsystem\Models\User\Settings;
21use Engelsystem\Models\User\State;
22use Engelsystem\Models\User\User as EngelsystemUser;
23use Illuminate\Database\Connection;
24use Psr\Log\LoggerInterface;
25use Symfony\Component\HttpFoundation\Session\SessionInterface;
26
27class User
28{
29    public function __construct(
30        private Authenticator $authenticator,
31        private Config $config,
32        private Connection $dbConnection,
33        private LoggerInterface $logger,
34        private SessionInterface $session,
35        private Validator $validator,
36    ) {
37    }
38
39    /**
40     * Takes some arbitrary data, validates it and tries to create a user from it.
41     *
42     * @param Array<string, mixed> $rawData Raw data from which a user should be created
43     * @return EngelsystemUser The user if successful
44     * @throws
45     */
46    public function createFromData(array $rawData): EngelsystemUser
47    {
48        $data = $this->validateUser($rawData);
49        return  $this->createUser($data, $rawData);
50    }
51
52    public function determineIsPasswordEnabled(): bool
53    {
54        $isPasswordEnabled = $this->config->get('enable_password');
55        $oAuthEnablePassword = $this->session->get('oauth2_enable_password');
56
57        if (!is_null($oAuthEnablePassword)) {
58            // o-auth overwrites config
59            $isPasswordEnabled = $oAuthEnablePassword;
60        }
61
62        return $isPasswordEnabled;
63    }
64
65    public function determineBuildUpStartDate(): DateTimeInterface
66    {
67        return $this->config->get('buildup_start') ?? CarbonImmutable::now();
68    }
69
70    private function isRequired(string $key): string
71    {
72        $requiredFields = $this->config->get('required_user_fields');
73        return $requiredFields[$key] ? 'required' : 'optional';
74    }
75
76    /**
77     * @param Array<string, mixed> $rawData
78     * @throws ValidationException
79     */
80    private function validateUser(array $rawData): array
81    {
82        $validationRules = [
83            'username' => 'required|username',
84            'email' => 'required|email',
85            'email_system'  => 'optional|checked',
86            'email_shiftinfo' => 'optional|checked',
87            'email_by_human_allowed' => 'optional|checked',
88            'email_messages' => 'optional|checked',
89            'email_news' => 'optional|checked',
90            'email_goodie' => 'optional|checked',
91            // Using length here, because min/max would validate dect/mobile as numbers.
92            'mobile' => $this->isRequired('mobile') . '|length:0:40',
93        ];
94
95        $isPasswordEnabled = $this->determineIsPasswordEnabled();
96
97        if ($isPasswordEnabled) {
98            $minPasswordLength = $this->config->get('password_min_length');
99            $validationRules['password'] = 'required|length:' . $minPasswordLength;
100            $validationRules['password_confirmation'] = 'required';
101        }
102
103        $isFullNameEnabled = $this->config->get('enable_full_name');
104
105        if ($isFullNameEnabled) {
106            $validationRules['firstname'] = $this->isRequired('firstname') . '|length:0:64';
107            $validationRules['lastname'] = $this->isRequired('lastname') . '|length:0:64';
108        }
109
110        $isPronounEnabled = $this->config->get('enable_pronoun');
111
112        if ($isPronounEnabled) {
113            $validationRules['pronoun'] = $this->isRequired('pronoun') . '|max:15';
114        }
115
116        $isShowMobileEnabled = $this->config->get('enable_mobile_show');
117
118        if ($isShowMobileEnabled) {
119            $validationRules['mobile_show'] = 'optional|checked';
120        }
121
122        $isPlannedArrivalDateEnabled = $this->config->get('enable_planned_arrival');
123
124        if ($isPlannedArrivalDateEnabled) {
125            $isoBuildUpStartDate = $this->determineBuildUpStartDate();
126            /** @var DateTimeInterface|null $tearDownEndDate */
127            $tearDownEndDate = $this->config->get('teardown_end');
128
129            if ($tearDownEndDate) {
130                $validationRules['planned_arrival_date'] = sprintf(
131                    'required|date|between:%s:%s',
132                    $isoBuildUpStartDate->format('Y-m-d'),
133                    $tearDownEndDate->format('Y-m-d')
134                );
135            } else {
136                $validationRules['planned_arrival_date'] = sprintf(
137                    'required|date|min:%s',
138                    $isoBuildUpStartDate->format('Y-m-d'),
139                );
140            }
141        }
142
143        $isDECTEnabled = $this->config->get('enable_dect');
144
145        if ($isDECTEnabled) {
146            // Using length here, because min/max would validate dect/mobile as numbers.
147            $validationRules['dect'] = $this->isRequired('dect') . '|length:0:40';
148        }
149
150        $goodieType = GoodieType::from($this->config->get('goodie_type'));
151        $isGoodieTShirt = $goodieType === GoodieType::Tshirt;
152
153        if ($isGoodieTShirt) {
154            $validationRules['tshirt_size'] = $this->isRequired('tshirt_size') . '|shirt-size';
155        }
156
157        $data = $this->validate($rawData, $validationRules);
158
159        // additional validations
160        $this->validateUniqueUsername($data['username']);
161        $this->validateUniqueEmail($data['email']);
162
163        // simplified e-mail preferences
164        if ($data['email_system']) {
165            $data['email_shiftinfo'] = true;
166            $data['email_messages'] = true;
167            $data['email_news'] = true;
168        }
169
170        if ($isPasswordEnabled) {
171            // Finally, validate that password matches password_confirmation.
172            // The respect keyValue validation does not seem to work.
173            $this->validatePasswordMatchesConfirmation($rawData);
174        }
175
176        return $data;
177    }
178
179    /**
180     * @param Array<string, mixed> $rawData
181     */
182    private function validatePasswordMatchesConfirmation(array $rawData): void
183    {
184        if ($rawData['password'] !== $rawData['password_confirmation']) {
185            throw new ValidationException(
186                (new Validator())->addErrors(['password' => [
187                    'settings.password.confirmation-does-not-match',
188                ]])
189            );
190        }
191    }
192
193    private function validateUniqueUsername(string $username): void
194    {
195        if (EngelsystemUser::whereName($username)->exists()) {
196            throw new ValidationException(
197                (new Validator())->addErrors(['username' => [
198                    'settings.profile.nick.already-taken',
199                ]])
200            );
201        }
202    }
203
204    private function validateUniqueEmail(string $email): void
205    {
206        if (EngelsystemUser::whereEmail($email)->exists()) {
207            throw new ValidationException(
208                (new Validator())->addErrors(['email' => [
209                    'settings.profile.email.already-taken',
210                ]])
211            );
212        }
213    }
214
215    /**
216     * @param Array<string, mixed> $data
217     * @param Array<string, mixed> $rawData
218     */
219    private function createUser(array $data, array $rawData): EngelsystemUser
220    {
221        // Ensure all user entries got created before saving
222        $this->dbConnection->beginTransaction();
223
224        $user = new EngelsystemUser([
225            'name'          => $data['username'],
226            'password'      => '',
227            'email'         => $data['email'],
228            'api_key'       => '',
229            'last_login_at' => null,
230        ]);
231        $user->save();
232
233        $contact = new Contact([
234            'dect'   => $data['dect'] ?? null,
235            'mobile' => $data['mobile'],
236        ]);
237        $contact->user()
238            ->associate($user)
239            ->save();
240
241        $isPlannedArrivalDateEnabled = $this->config->get('enable_planned_arrival');
242        $plannedArrivalDate = null;
243
244        if ($isPlannedArrivalDateEnabled) {
245            $plannedArrivalDate = Carbon::createFromFormat('Y-m-d', $data['planned_arrival_date']);
246        }
247
248        $personalData = new PersonalData([
249            'first_name'           => $data['firstname'] ?? null,
250            'last_name'            => $data['lastname'] ?? null,
251            'pronoun'              => $data['pronoun'] ?? null,
252            'shirt_size'           => $data['tshirt_size'] ?? null,
253            'planned_arrival_date' => $plannedArrivalDate,
254        ]);
255        $personalData->user()
256            ->associate($user)
257            ->save();
258
259        $isShowMobileEnabled = $this->config->get('enable_mobile_show');
260
261        $settings = new Settings([
262            'language'        => $this->session->get('locale') ?? 'en_US',
263            'theme'           => $this->config->get('theme'),
264            'email_human'     => $data['email_by_human_allowed'] ?? false,
265            'email_messages'  => $data['email_messages'] ?? false,
266            'email_goodie'     => $data['email_goodie'] ?? false,
267            'email_shiftinfo' => $data['email_shiftinfo'] ?? false,
268            'email_news'      => $data['email_news'] ?? false,
269            'mobile_show'     => $isShowMobileEnabled && $data['mobile_show'],
270        ]);
271        $settings->user()
272            ->associate($user)
273            ->save();
274
275        $state = new State([]);
276
277        if ($this->config->get('autoarrive')) {
278            $state->arrived = true;
279            $state->arrival_date = CarbonImmutable::now();
280        }
281
282        $state->user()
283            ->associate($user)
284            ->save();
285
286        // Handle OAuth registration
287        if ($this->session->has('oauth2_connect_provider') && $this->session->has('oauth2_user_id')) {
288            $oauth = new OAuth([
289                'provider'      => $this->session->get('oauth2_connect_provider'),
290                'identifier'    => $this->session->get('oauth2_user_id'),
291                'access_token'  => $this->session->get('oauth2_access_token'),
292                'refresh_token' => $this->session->get('oauth2_refresh_token'),
293                'expires_at'    => $this->session->get('oauth2_expires_at'),
294            ]);
295            $oauth->user()
296                ->associate($user)
297                ->save();
298
299            $this->session->remove('oauth2_connect_provider');
300            $this->session->remove('oauth2_user_id');
301            $this->session->remove('oauth2_access_token');
302            $this->session->remove('oauth2_refresh_token');
303            $this->session->remove('oauth2_expires_at');
304        }
305
306        $defaultGroup = Group::find($this->authenticator->getDefaultRole());
307        $user->groups()->attach($defaultGroup);
308
309        auth()->resetApiKey($user);
310        if ($this->determineIsPasswordEnabled() && array_key_exists('password', $data)) {
311            auth()->setPassword($user, $data['password']);
312        }
313
314        $assignedAngelTypeNames = $this->assignAngelTypes($user, $rawData);
315
316        $this->logger->info(
317            'User {user} registered and signed up as: {angeltypes}',
318            [
319                'user' => sprintf('%s (%u)', $user->displayName, $user->id),
320                'angeltypes' => join(', ', $assignedAngelTypeNames),
321            ]
322        );
323
324        $this->dbConnection->commit();
325
326        return $user;
327    }
328
329    private function assignAngelTypes(EngelsystemUser $user, array $rawData): array
330    {
331        $possibleAngelTypes = AngelType::whereHideRegister(false)->get();
332        $assignedAngelTypeNames = [];
333
334        foreach ($possibleAngelTypes as $possibleAngelType) {
335            $angelTypeCheckboxId = 'angel_types_' . $possibleAngelType->id;
336
337            if (array_key_exists($angelTypeCheckboxId, $rawData)) {
338                $user->userAngelTypes()->attach($possibleAngelType);
339                $assignedAngelTypeNames[] = $possibleAngelType->name;
340            }
341        }
342
343        return $assignedAngelTypeNames;
344    }
345
346    private function validate(array $rawData, array $rules): array
347    {
348        $isValid = $this->validator->validate($rawData, $rules);
349
350        if (!$isValid) {
351            throw new ValidationException($this->validator);
352        }
353
354        return $this->validator->getData();
355    }
356}