> */ private array $identityMap = []; private int $identitySequence = 0; private readonly Generator $faker; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly LoggerInterface $logger, ) { $this->faker = Factory::create('de_DE'); } public function resetState(): void { $this->identityMap = []; $this->identitySequence = 0; } /** * @return array{users: int, newsletterConsents: int, newsletterOptInRequests: int, bookingEditDrafts: int, accommodationBookings: int} */ public function anonymizeAll(int $batchSize = self::DEFAULT_BATCH_SIZE): array { if ($batchSize < 1) { throw new \InvalidArgumentException('Batch size must be at least 1.'); } $this->resetState(); $report = [ 'users' => $this->anonymizeQuery( 'SELECT u FROM App\Entity\User u ORDER BY u.id ASC', fn (User $user) => $this->anonymizeUser($user), $batchSize, ), 'newsletterConsents' => $this->anonymizeQuery( 'SELECT c FROM App\Entity\NewsletterConsent c ORDER BY c.id ASC', fn (NewsletterConsent $consent) => $this->anonymizeNewsletterConsent($consent), $batchSize, ), 'newsletterOptInRequests' => $this->anonymizeQuery( 'SELECT r FROM App\Entity\NewsletterOptInRequest r ORDER BY r.id ASC', fn (NewsletterOptInRequest $request) => $this->anonymizeNewsletterOptInRequest($request), $batchSize, ), 'bookingEditDrafts' => $this->anonymizeQuery( 'SELECT d FROM App\Entity\BookingEditDraft d ORDER BY d.id ASC', fn (BookingEditDraft $draft) => $this->anonymizeBookingEditDraft($draft), $batchSize, ), // Last, so that a booking whose email matches a mirrored BusPro account resolves to // the synthetic identity the user pass has already minted for that person. 'accommodationBookings' => $this->anonymizeQuery( 'SELECT b FROM App\Entity\Groups\AccommodationBooking b ORDER BY b.id ASC', fn (AccommodationBooking $booking) => $this->anonymizeAccommodationBooking($booking), $batchSize, ), ]; $this->entityManager->flush(); $this->logger->info('Anonymized local database records', $report); return $report; } /** * Looked up by email alone, never by name: resolveIdentity() would register a name alias, * and two accounts of the same person — an intended state here — would then collapse onto * one synthetic identity and so onto one synthetic email, which User::$email is unique on. * * The names are only replaced where one is already set. Most accounts carry none until the * BusPro import fills them in, and minting a name for those would turn an incomplete profile * into one that looks complete while profileComplete still says otherwise. */ public function anonymizeUser(User $user): void { $identity = $this->resolveIdentity( email: $user->getEmail(), ); $user->setEmail($identity['email']); if ($this->hasReplaceableValue($user->getFirstName())) { $user->setFirstName($identity['firstName']); } if ($this->hasReplaceableValue($user->getLastName())) { $user->setLastName($identity['lastName']); } } public function anonymizeNewsletterConsent(NewsletterConsent $consent): void { $identity = $this->resolveIdentity( email: $consent->getEmail(), firstName: $consent->getFirstName(), lastName: $consent->getLastName(), ); $consent->setEmail($identity['email']); $consent->setNames( $this->hasReplaceableValue($consent->getFirstName()) ? $identity['firstName'] : $consent->getFirstName(), $this->hasReplaceableValue($consent->getLastName()) ? $identity['lastName'] : $consent->getLastName(), ); } public function anonymizeNewsletterOptInRequest(NewsletterOptInRequest $request): void { $identity = $this->resolveIdentity( email: $request->getEmail(), firstName: $request->getFirstName(), lastName: $request->getLastName(), ); $request->setEmail($identity['email']); $request->setNames( $this->hasReplaceableValue($request->getFirstName()) ? $identity['firstName'] : $request->getFirstName(), $this->hasReplaceableValue($request->getLastName()) ? $identity['lastName'] : $request->getLastName(), ); } public function anonymizeBookingEditDraft(BookingEditDraft $draft): void { $formData = $draft->getFormData(); $formData = $this->anonymizeDraftFormData($formData); $draft->replaceFormData($formData); } /** * The uuid is deliberately left alone: it is what the signed customer access link resolves * against, so regenerating it would silently invalidate every link in an anonymized dump. * The salutation stays too, being no more identifying than the gender it stands for. */ public function anonymizeAccommodationBooking(AccommodationBooking $booking): void { $identity = $this->resolveIdentity( email: $booking->getEmail(), firstName: $booking->getFirstName(), lastName: $booking->getLastName(), ); // Only fields that actually hold something are replaced, so a half-prepared draft stays // half-prepared instead of becoming a record the `edit` group would suddenly accept. if ($this->hasReplaceableValue($booking->getGroupName())) { $booking->setGroupName($identity['groupName']); } if ($this->hasReplaceableValue($booking->getFirstName())) { $booking->setFirstName($identity['firstName']); } if ($this->hasReplaceableValue($booking->getLastName())) { $booking->setLastName($identity['lastName']); } if ($this->hasReplaceableValue($booking->getEmail())) { $booking->setEmail($identity['email']); } if ($this->hasReplaceableValue($booking->getPhone())) { $booking->setPhone($identity['phone']); } if ($this->hasReplaceableValue($booking->getStreet())) { $booking->setStreet($identity['street']); } if ($this->hasReplaceableValue($booking->getZip())) { $booking->setZip($identity['postCode']); } if ($this->hasReplaceableValue($booking->getCity())) { $booking->setCity($identity['city']); } if ($this->hasReplaceableValue($booking->getRemarks())) { $booking->setRemarks($this->placeholderText($identity, 'remarks')); } } /** * @param callable(object):void $anonymize */ private function anonymizeQuery(string $dql, callable $anonymize, int $batchSize): int { $query = $this->entityManager->createQuery($dql); $processed = 0; $pendingFlush = 0; foreach ($query->toIterable() as $entity) { $anonymize($entity); ++$processed; ++$pendingFlush; if (0 === $pendingFlush % $batchSize) { $this->entityManager->flush(); $this->entityManager->clear(); $pendingFlush = 0; } } if ($pendingFlush > 0) { $this->entityManager->flush(); $this->entityManager->clear(); } return $processed; } /** * @param array $formData * * @return array */ private function anonymizeDraftFormData(array $formData): array { if (isset($formData['bankAccount']) && true === is_array($formData['bankAccount'])) { $formData['bankAccount'] = $this->anonymizeBankAccountData($formData['bankAccount']); } if (isset($formData['participants']) && true === is_array($formData['participants'])) { foreach ($formData['participants'] as $index => $participantData) { if (false === is_array($participantData)) { continue; } $formData['participants'][$index] = $this->anonymizeParticipantData($participantData); } } return $formData; } /** * @param array $participantData * * @return array */ private function anonymizeParticipantData(array $participantData): array { $identity = $this->resolveIdentity( email: $this->stringOrNull($participantData['personalData']['email'] ?? null), firstName: $this->stringOrNull($participantData['personalData']['firstName'] ?? null), lastName: $this->stringOrNull($participantData['personalData']['lastName'] ?? null), fullName: $this->stringOrNull($participantData['personalData']['name'] ?? null), fallback: $this->stringOrNull($participantData['licensePlate'] ?? null), ); if (isset($participantData['personalData']) && true === is_array($participantData['personalData'])) { $participantData['personalData'] = $this->anonymizePersonalData($participantData['personalData'], $identity); } if (isset($participantData['address']) && true === is_array($participantData['address'])) { $participantData['address'] = $this->anonymizeAddressData($participantData['address'], $identity); } if (isset($participantData['roomAssignment']) && true === is_array($participantData['roomAssignment'])) { if (array_key_exists('remarksRoom', $participantData['roomAssignment'])) { $participantData['roomAssignment']['remarksRoom'] = $this->placeholderText($identity, 'remarks'); } } if (array_key_exists('licensePlate', $participantData)) { $participantData['licensePlate'] = $this->placeholderText($identity, 'plate'); } if (isset($participantData['bankAccount']) && true === is_array($participantData['bankAccount'])) { $participantData['bankAccount'] = $this->anonymizeBankAccountData($participantData['bankAccount'], $identity); } return $participantData; } /** * @param array $personalData * @param array $identity * * @return array */ private function anonymizePersonalData(array $personalData, array $identity): array { if (array_key_exists('firstName', $personalData) && null !== $personalData['firstName']) { $personalData['firstName'] = $identity['firstName']; } if (array_key_exists('lastName', $personalData) && null !== $personalData['lastName']) { $personalData['lastName'] = $identity['lastName']; } if (array_key_exists('email', $personalData) && null !== $personalData['email']) { $personalData['email'] = $identity['email']; } if (array_key_exists('mobile', $personalData) && null !== $personalData['mobile']) { $personalData['mobile'] = $identity['mobile']; } if (array_key_exists('phone', $personalData) && null !== $personalData['phone']) { $personalData['phone'] = $identity['phone']; } if (array_key_exists('name', $personalData) && null !== $personalData['name']) { $personalData['name'] = $identity['lastName']; } return $personalData; } /** * @param array $addressData * @param array $identity * * @return array */ private function anonymizeAddressData(array $addressData, array $identity): array { if (array_key_exists('street', $addressData) && null !== $addressData['street']) { $addressData['street'] = $identity['street']; } if (array_key_exists('postCode', $addressData) && null !== $addressData['postCode']) { $addressData['postCode'] = $identity['postCode']; } if (array_key_exists('city', $addressData) && null !== $addressData['city']) { $addressData['city'] = $identity['city']; } if (array_key_exists('country', $addressData) && null !== $addressData['country']) { $addressData['country'] = $identity['country']; } if (array_key_exists('district', $addressData) && null !== $addressData['district']) { $addressData['district'] = $identity['district']; } return $addressData; } /** * @param array $bankAccountData * @param array|null $identity * * @return array */ private function anonymizeBankAccountData(array $bankAccountData, ?array $identity = null): array { $identity ??= $this->resolveIdentity( fullName: $this->stringOrNull($bankAccountData['accountHolder'] ?? null), ); if (array_key_exists('iban', $bankAccountData) && null !== $bankAccountData['iban']) { $bankAccountData['iban'] = $identity['iban']; } if (array_key_exists('accountHolder', $bankAccountData) && null !== $bankAccountData['accountHolder']) { $bankAccountData['accountHolder'] = $identity['accountHolder']; } if (array_key_exists('bankName', $bankAccountData) && null !== $bankAccountData['bankName']) { $bankAccountData['bankName'] = $identity['bankName']; } if (array_key_exists('bic', $bankAccountData) && null !== $bankAccountData['bic']) { $bankAccountData['bic'] = $identity['bic']; } return $bankAccountData; } /** * @return array */ private function resolveIdentity(?string $email = null, ?string $firstName = null, ?string $lastName = null, ?string $fullName = null, ?string $fallback = null): array { $aliases = []; foreach ([ $this->normalizeIdentityKey('email', $email), $this->normalizeIdentityKey('name', null !== $fullName ? $fullName : trim(($firstName ?? '').' '.($lastName ?? ''))), $this->normalizeIdentityKey('fallback', $fallback), ] as $alias) { if (null !== $alias) { $aliases[] = $alias; } } foreach ($aliases as $alias) { if (isset($this->identityMap[$alias])) { $identity = $this->identityMap[$alias]; foreach ($aliases as $candidateAlias) { $this->identityMap[$candidateAlias] = $identity; } return $identity; } } $identity = $this->createIdentity(); foreach ($aliases as $alias) { $this->identityMap[$alias] = $identity; } return $identity; } /** * @return array */ private function createIdentity(): array { ++$this->identitySequence; $sequence = $this->identitySequence; $firstName = $this->faker->firstName(); $lastName = $this->faker->lastName(); $emailLocal = sprintf('%s.%s.%04d', $this->slug($firstName), $this->slug($lastName), $sequence); return [ 'firstName' => $firstName, 'lastName' => $lastName, 'email' => sprintf('%s@%s', $emailLocal, self::SYNTHETIC_EMAIL_DOMAIN), 'mobile' => $this->faker->numerify('01#########'), 'phone' => $this->faker->numerify('0##########'), 'street' => sprintf('%s %s', $this->faker->streetName(), $this->faker->buildingNumber()), 'postCode' => $this->faker->postcode(), 'city' => $this->faker->city(), 'country' => self::SYNTHETIC_COUNTRY, 'district' => $this->faker->citySuffix(), 'groupName' => $this->faker->company(), 'accountHolder' => $firstName.' '.$lastName, 'bankName' => $this->faker->company(), 'iban' => self::SYNTHETIC_IBAN, 'bic' => 'GENODEF1SYN', 'licensePlate' => sprintf('SYN-%05d', $sequence), ]; } private function normalizeIdentityKey(string $type, ?string $value): ?string { $normalized = $this->stringOrNull($value); if (null === $normalized) { return null; } return $type.':'.mb_strtolower($normalized); } private function hasReplaceableValue(?string $value): bool { return null !== $this->stringOrNull($value); } /** * @param array $identity */ private function placeholderText(array $identity, string $suffix): string { return sprintf('%s-%s', $identity['lastName'], $suffix); } private function stringOrNull(mixed $value): ?string { if (null === $value) { return null; } if (false === is_string($value)) { return trim((string) $value); } $trimmed = trim($value); return '' === $trimmed ? null : $trimmed; } private function slug(string $value): string { $normalizedValue = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value); if (false === $normalizedValue) { $normalizedValue = $value; } $normalizedValue = strtolower($normalizedValue); $normalizedValue = preg_replace('/[^a-z0-9]+/', '.', $normalizedValue); $normalizedValue = trim((string) $normalizedValue, '.'); if ('' === $normalizedValue) { return 'user'; } return $normalizedValue; } }