Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
179 / 179 |
|
100.00% |
12 / 12 |
CRAP | |
100.00% |
1 / 1 |
| User | |
100.00% |
179 / 179 |
|
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% |
81 / 81 |
|
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->arrival_date = CarbonImmutable::now(); |
| 279 | } |
| 280 | |
| 281 | $state->user() |
| 282 | ->associate($user) |
| 283 | ->save(); |
| 284 | |
| 285 | // Handle OAuth registration |
| 286 | if ($this->session->has('oauth2_connect_provider') && $this->session->has('oauth2_user_id')) { |
| 287 | $oauth = new OAuth([ |
| 288 | 'provider' => $this->session->get('oauth2_connect_provider'), |
| 289 | 'identifier' => $this->session->get('oauth2_user_id'), |
| 290 | 'access_token' => $this->session->get('oauth2_access_token'), |
| 291 | 'refresh_token' => $this->session->get('oauth2_refresh_token'), |
| 292 | 'expires_at' => $this->session->get('oauth2_expires_at'), |
| 293 | ]); |
| 294 | $oauth->user() |
| 295 | ->associate($user) |
| 296 | ->save(); |
| 297 | |
| 298 | $this->session->remove('oauth2_connect_provider'); |
| 299 | $this->session->remove('oauth2_user_id'); |
| 300 | $this->session->remove('oauth2_access_token'); |
| 301 | $this->session->remove('oauth2_refresh_token'); |
| 302 | $this->session->remove('oauth2_expires_at'); |
| 303 | } |
| 304 | |
| 305 | $defaultGroup = Group::find($this->authenticator->getDefaultRole()); |
| 306 | $user->groups()->attach($defaultGroup); |
| 307 | |
| 308 | auth()->resetApiKey($user); |
| 309 | if ($this->determineIsPasswordEnabled() && array_key_exists('password', $data)) { |
| 310 | auth()->setPassword($user, $data['password']); |
| 311 | } |
| 312 | |
| 313 | $assignedAngelTypeNames = $this->assignAngelTypes($user, $rawData); |
| 314 | |
| 315 | $this->logger->info( |
| 316 | 'User {user} registered and signed up as: {angeltypes}', |
| 317 | [ |
| 318 | 'user' => sprintf('%s (%u)', $user->displayName, $user->id), |
| 319 | 'angeltypes' => join(', ', $assignedAngelTypeNames), |
| 320 | ] |
| 321 | ); |
| 322 | |
| 323 | $this->dbConnection->commit(); |
| 324 | |
| 325 | return $user; |
| 326 | } |
| 327 | |
| 328 | private function assignAngelTypes(EngelsystemUser $user, array $rawData): array |
| 329 | { |
| 330 | $possibleAngelTypes = AngelType::whereHideRegister(false)->get(); |
| 331 | $assignedAngelTypeNames = []; |
| 332 | |
| 333 | foreach ($possibleAngelTypes as $possibleAngelType) { |
| 334 | $angelTypeCheckboxId = 'angel_types_' . $possibleAngelType->id; |
| 335 | |
| 336 | if (array_key_exists($angelTypeCheckboxId, $rawData)) { |
| 337 | $user->userAngelTypes()->attach($possibleAngelType); |
| 338 | $assignedAngelTypeNames[] = $possibleAngelType->name; |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | return $assignedAngelTypeNames; |
| 343 | } |
| 344 | |
| 345 | private function validate(array $rawData, array $rules): array |
| 346 | { |
| 347 | $isValid = $this->validator->validate($rawData, $rules); |
| 348 | |
| 349 | if (!$isValid) { |
| 350 | throw new ValidationException($this->validator); |
| 351 | } |
| 352 | |
| 353 | return $this->validator->getData(); |
| 354 | } |
| 355 | } |