Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
180 / 180 |
|
100.00% |
12 / 12 |
CRAP | |
100.00% |
1 / 1 |
User | |
100.00% |
180 / 180 |
|
100.00% |
12 / 12 |
37 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createFromData | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
determineIsPasswordEnabled | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
determineBuildUpStartDate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isRequired | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
validateUser | |
100.00% |
57 / 57 |
|
100.00% |
1 / 1 |
11 | |||
validatePasswordMatchesConfirmation | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
validateUniqueUsername | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
validateUniqueEmail | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
createUser | |
100.00% |
82 / 82 |
|
100.00% |
1 / 1 |
8 | |||
assignAngelTypes | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
validate | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | |
3 | declare(strict_types=1); |
4 | |
5 | namespace Engelsystem\Factories; |
6 | |
7 | use Carbon\CarbonImmutable; |
8 | use DateTimeInterface; |
9 | use Engelsystem\Config\Config; |
10 | use Engelsystem\Config\GoodieType; |
11 | use Engelsystem\Helpers\Authenticator; |
12 | use Engelsystem\Helpers\Carbon; |
13 | use Engelsystem\Http\Exceptions\ValidationException; |
14 | use Engelsystem\Http\Validation\Validator; |
15 | use Engelsystem\Models\AngelType; |
16 | use Engelsystem\Models\Group; |
17 | use Engelsystem\Models\OAuth; |
18 | use Engelsystem\Models\User\Contact; |
19 | use Engelsystem\Models\User\PersonalData; |
20 | use Engelsystem\Models\User\Settings; |
21 | use Engelsystem\Models\User\State; |
22 | use Engelsystem\Models\User\User as EngelsystemUser; |
23 | use Illuminate\Database\Connection; |
24 | use Psr\Log\LoggerInterface; |
25 | use Symfony\Component\HttpFoundation\Session\SessionInterface; |
26 | |
27 | class 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 | } |