Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
172 / 172
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
User
100.00% covered (success)
100.00%
172 / 172
100.00% covered (success)
100.00%
8 / 8
36
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%
64 / 64
100.00% covered (success)
100.00%
1 / 1
18
 createUser
100.00% covered (success)
100.00%
89 / 89
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
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 in_array($key, $requiredFields) ? '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        // Run validation but don't throw yet - collect all errors first
158        $this->validator->validate($rawData, $validationRules);
159        $data = $this->validator->getData();
160
161        // Check uniqueness for fields that passed basic validation
162        if (isset($data['username']) && EngelsystemUser::whereName($data['username'])->exists()) {
163            $this->validator->addErrors(['username' => ['settings.profile.nick.already-taken']]);
164        }
165
166        if (isset($data['email']) && EngelsystemUser::whereEmail($data['email'])->exists()) {
167            $this->validator->addErrors(['email' => ['settings.profile.email.already-taken']]);
168        }
169
170        if (
171            $isPasswordEnabled
172            && isset($data['password'])
173            && $rawData['password'] !== $rawData['password_confirmation']
174        ) {
175            $this->validator->addErrors(['password' => ['settings.password.confirmation-does-not-match']]);
176        }
177
178        // Now throw if ANY validation errors occurred
179        if (!empty($this->validator->getErrors())) {
180            throw new ValidationException($this->validator);
181        }
182
183        // simplified e-mail preferences
184        if ($data['email_system']) {
185            $data['email_shiftinfo'] = true;
186            $data['email_messages'] = true;
187            $data['email_news'] = true;
188        }
189
190        return $data;
191    }
192
193    /**
194     * @param Array<string, mixed> $data
195     * @param Array<string, mixed> $rawData
196     */
197    private function createUser(array $data, array $rawData): EngelsystemUser
198    {
199        // Ensure all user entries got created before saving
200        $this->dbConnection->beginTransaction();
201
202        $user = new EngelsystemUser([
203            'name'          => $data['username'],
204            'password'      => '',
205            'email'         => $data['email'],
206            'api_key'       => '',
207            'last_login_at' => null,
208        ]);
209        $user->save();
210
211        $contact = new Contact([
212            'dect'   => $data['dect'] ?? null,
213            'mobile' => $data['mobile'],
214        ]);
215        $contact->user()
216            ->associate($user)
217            ->save();
218
219        $isPlannedArrivalDateEnabled = $this->config->get('enable_planned_arrival');
220        $plannedArrivalDate = null;
221
222        if ($isPlannedArrivalDateEnabled) {
223            $plannedArrivalDate = Carbon::createFromFormat('Y-m-d', $data['planned_arrival_date']);
224        }
225
226        $personalData = new PersonalData([
227            'first_name'           => $data['firstname'] ?? null,
228            'last_name'            => $data['lastname'] ?? null,
229            'pronoun'              => $data['pronoun'] ?? null,
230            'shirt_size'           => $data['tshirt_size'] ?? null,
231            'planned_arrival_date' => $plannedArrivalDate,
232        ]);
233        $personalData->user()
234            ->associate($user)
235            ->save();
236
237        $isShowMobileEnabled = $this->config->get('enable_mobile_show');
238
239        $settings = new Settings([
240            'language'        => $this->session->get('locale') ?? 'en_US',
241            'theme'           => $this->config->get('theme'),
242            'email_human'     => $data['email_by_human_allowed'] ?? false,
243            'email_messages'  => $data['email_messages'] ?? false,
244            'email_goodie'     => $data['email_goodie'] ?? false,
245            'email_shiftinfo' => $data['email_shiftinfo'] ?? false,
246            'email_news'      => $data['email_news'] ?? false,
247            'mobile_show'     => $isShowMobileEnabled && $data['mobile_show'],
248        ]);
249        $settings->user()
250            ->associate($user)
251            ->save();
252
253        $state = new State([]);
254
255        if ($this->config->get('autoarrive')) {
256            $state->arrival_date = CarbonImmutable::now();
257        }
258
259        $state->user()
260            ->associate($user)
261            ->save();
262
263        // Handle OAuth registration
264        if ($this->session->has('oauth2_connect_provider') && $this->session->has('oauth2_user_id')) {
265            $oauth = new OAuth([
266                'provider'      => $this->session->get('oauth2_connect_provider'),
267                'identifier'    => $this->session->get('oauth2_user_id'),
268                'access_token'  => $this->session->get('oauth2_access_token'),
269                'refresh_token' => $this->session->get('oauth2_refresh_token'),
270                'expires_at'    => $this->session->get('oauth2_expires_at'),
271            ]);
272            $oauth->user()
273                ->associate($user)
274                ->save();
275
276            $this->session->remove('oauth2_connect_provider');
277            $this->session->remove('oauth2_user_id');
278            $this->session->remove('oauth2_access_token');
279            $this->session->remove('oauth2_refresh_token');
280            $this->session->remove('oauth2_expires_at');
281
282            $this->logger->info(
283                '{user} connected OAuth user {oauth_user} using {provider}',
284                [
285                    'provider' => $oauth->provider,
286                    'user' => sprintf('%s (%u)', $user->displayName, $user->id),
287                    'oauth_user' => $oauth->identifier,
288                ]
289            );
290        }
291
292        $defaultGroup = Group::find($this->authenticator->getDefaultRole());
293        $user->groups()->attach($defaultGroup);
294
295        auth()->resetApiKey($user);
296        if ($this->determineIsPasswordEnabled() && array_key_exists('password', $data)) {
297            auth()->setPassword($user, $data['password']);
298        }
299
300        $assignedAngelTypeNames = $this->assignAngelTypes($user, $rawData);
301
302        $this->logger->info(
303            'User {user} registered and signed up as: {angeltypes}',
304            [
305                'user' => sprintf('%s (%u)', $user->displayName, $user->id),
306                'angeltypes' => join(', ', $assignedAngelTypeNames),
307            ]
308        );
309
310        $this->dbConnection->commit();
311
312        return $user;
313    }
314
315    private function assignAngelTypes(EngelsystemUser $user, array $rawData): array
316    {
317        $possibleAngelTypes = AngelType::whereHideRegister(false)->get();
318        $assignedAngelTypeNames = [];
319
320        foreach ($possibleAngelTypes as $possibleAngelType) {
321            $angelTypeCheckboxId = 'angel_types_' . $possibleAngelType->id;
322
323            if (array_key_exists($angelTypeCheckboxId, $rawData)) {
324                $user->userAngelTypes()->attach($possibleAngelType);
325                $assignedAngelTypeNames[] = $possibleAngelType->name;
326            }
327        }
328
329        return $assignedAngelTypeNames;
330    }
331}