Користувач не завжди реєструється до того, як починає взаємодіяти із системою.
Наприклад, гість може створити звернення через публічну форму, отримати відповідь, а обліковий запис створити лише пізніше.
Після registration виникає практичне питання: як знайти дані, які вже належать цій людині за змістом, але технічно ще не мають user_id, і безпечно прив’язати їх до нового користувача?
До реєстрації й після неї це два різні стани
До реєстрації conversation user_id = NULL guest_email = person@example.test Після реєстрації user id = 42 email = person@example.test conversation user_id = 42 guest_email = person@example.testДо реєстрації система ще не має стабільного user_id, тому потрібен інший ідентифікатор.
У моєму випадку таким містком був email: він уже існував у guest conversation і пізніше з’являвся у зареєстрованого користувача.
Але важливо не перетворити email на універсальну identity. Тут він використовується лише як правило для конкретного переходу між двома станами.
Email може бути містком, якщо його семантика достатньо сильна
Email підходить не в кожній системі.
Я використовую цей підхід лише тоді, коли для неавторизованого користувача email уже є контактним ідентифікатором, а система реєстрації не дозволяє двом активним обліковим записам мати однакову електронну адресу.
Якщо email можна довільно змінювати, повторно використовувати в різних облікових записах або він не дає достатніх підстав підтвердити право власності на дані, потрібен надійніший механізм їх прив’язки до користувача.
## Я чітко визначаю, які записи можна прив’язати до користувача
PendingConversation::query() ->whereNull('user_id') ->where( 'guest_email', $user->email, );Ключова умова тут - не лише збіг email.
whereNull('user_id') означає, що прив’язувати можна лише ті дані, які ще не належать жодному зареєстрованому користувачу.
Це захищає від небезпечної ситуації, коли новий обліковий запис випадково змінює власника розмови, яка вже була прив’язана до іншого користувача.
Подія реєстрації є природною точкою запуску
final readonly class ClaimPendingData{ public function __construct( private ClaimPendingDataService $service, ) { } public function handle( UserRegistered $event, ): void { $this->service->handle( $event->user, ); }}Реєстрація - це момент, коли вже існує постійний user_id і дані гостя можна прив’язати до зареєстрованого користувача.
Сам обробник події я залишаю простим. Він не повинен знати деталей SQL-запитів для прив’язки даних, правил повторного виконання або способів захисту від конфліктів під час одночасних операцій.
Реєстрацію і верифікацію емейлу не треба змішувати автоматично
Це пов’язано з статті про зміну lifecycle-rule після реєстрації.
Якщо поточне бізнес-правило передбачає прив’язку даних одразу після реєстрації, стара перевірка підтвердження електронної адреси не повинна непомітно повертати попередній порядок роботи системи.
Підтвердження електронної адреси може залишатися обов’язковою умовою лише тоді, коли це справді окрема й актуальна бізнес-вимога.
Базову прив’язку даних можна виконати одним оновленням із перевіркою умов
final class ClaimPendingDataService{ public function handle( User $user, ): int { return PendingConversation::query() ->whereNull('user_id') ->where( 'guest_email', $user->email, ) ->update([ 'user_id' => $user->id, ]); }}Після першого успішного виконання відповідні записи вже матимуть user_id, тому повторний запуск їх не змінить.
Це одна з причин, чому я віддаю перевагу прив’язці даних як зміні їхнього стану, а не як створенню ще одного зв’язку між записами.
Повторний запуск лістенера має бути безпечним
Цю частину я детальніше розбирала в статті про ідемпотентний лістенер і безпечні retries.
Повторна спроба виконання завдання в черзі, повторне отримання події або ручний запуск не повинні призводити до дублювання прив’язки даних.
Якщо потрібного стану вже досягнуто, повторна прив’язка має завершитися без внесення нових змін.
Один користувач може прив’язати кілька записів, створених до реєстрації
До реєстрації conversation #10 user_id = NULL guest_email = person@example.test conversation #11 user_id = NULL guest_email = person@example.test conversation #12 user_id = 7 guest_email = person@example.test Після реєстрації як користувач №42Це важливий тест правильності визначення власника даних.
Збіг електронних адрес не дає права змінювати власника запису, який уже належить іншому зареєстрованому користувачу.
Одночасна прив’язка даних потребує окремої уваги
Можливість безпечного повторного виконання захищає від помилок під час послідовних запусків, але два обробники черги можуть почати роботу одночасно.
Якщо операція прив’язки даних складніша за одне атомарне оновлення, я використовую транзакцію та lockForUpdate() для захисту критичної частини від одночасних змін.
$claimedIds = DB::transaction( function () use ($user): array { $ids = PendingConversation::query() ->whereNull('user_id') ->where( 'guest_email', $user->email, ) ->lockForUpdate() ->pluck('id'); if ($ids->isEmpty()) { return []; } PendingConversation::query()Додаткові дії запускаю лише після фактичної прив’язки даних
$claimedCount = $claimService->handle( $user, ); if ($claimedCount === 0) { return;} ClaimCompleted::dispatch( $user->id, $claimedCount,);Інакше стан бази даних може залишатися правильним після повторного виконання, але сповіщення, події аналітики або електронні листи все одно дублюватимуться.
Для мене «нічого не змінилося» означає, що пов’язані додаткові дії також не повинні запускатися повторно без окремої причини.
Подальші події краще відправляти після commit
DB::afterCommit( static function () use ( $user, $claimedIds, ): void { if ($claimedIds === []) { return; } ClaimCompleted::dispatch( $user->id, $claimedIds, ); });Для порівняння електронних адрес потрібно використовувати однакові правила нормалізації
Якщо форма для гостей зберігає Person@Example.test, а під час реєстрації записується person@example.test, прив’язка даних не повинна залежати від випадкових відмінностей у написанні адреси.
Тому правила нормалізації електронної адреси краще визначити до збереження даних і однаково застосовувати як у формах для гостей, так і під час реєстрації.
Важливо не застосовувати складні правила нормалізації, специфічні для окремих поштових сервісів, без чіткої потреби. Для більшості систем достатньо послідовного видалення зайвих пробілів і узгоджених із системою авторизації правил щодо регістру літер.
Прив’язувати дані краще під час реєстрації, а не після кожної зміни електронної адреси.
Якщо користувач пізніше змінює електронну адресу, я не хочу автоматично прив’язувати до нього всі старі записи гостей, пов’язані з новою адресою.
Це вже інша бізнес-операція, яка потребує окремих правил перевірки безпеки та права власності на дані.
Прив’язка даних під час реєстрації має чіткі часові та логічні межі: обліковий запис щойно створено, і ми приєднуємо до нього дані, збережені до реєстрації.
Відсутність даних, створених до реєстрації, не є помилкою
$claimedCount = $service->handle( $user, ); if ($claimedCount === 0) { return;}Більшість нових користувачів можуть взагалі не мати розмов, створених до реєстрації.
Тому відсутність записів для прив’язки - це нормальний результат роботи сервісу, а не помилка, яка потребує винятку.
Я тестую зміну власника даних, а не лише роботу обробника події
public function test_registration_claims_matching_guest_data(): void{ $user = User::factory()->create([ 'email' => 'person@example.test', ]); $conversation = PendingConversation::factory() ->create([ 'user_id' => null, 'guest_email' => 'person@example.test', ]); $this->service->handle( $user,Другий тест обов’язково перевіряє повторне виконання прив’язки даних
public function test_claim_is_idempotent(): void{ $user = User::factory()->create([ 'email' => 'person@example.test', ]); $conversation = PendingConversation::factory() ->create([ 'user_id' => null, 'guest_email' => 'person@example.test', ]); $this->service->handle( $user,Окремий test захищає від переприв’язки чужого запису
public function test_claim_does_not_reassign_owned_data(): void{ $existingUser = User::factory()->create(); $newUser = User::factory()->create([ 'email' => 'person@example.test', ]); $conversation = PendingConversation::factory() ->create([ 'user_id' => $existingUser->id, 'guest_email' =>Що я перевіряю під час прив’язки даних після реєстраці
Після реєстрації чітко визначено власника даних.
Електронна адреса використовується лише для зв’язування даних під час конкретного переходу.
Прив’язка охоплює лише записи з
user_id IS NULLабо іншою ознакою відсутності власника.Записи, які вже мають власника, ніколи не переприв’язуються лише через збіг електронних адрес.
Обробник події запускається на правильному етапі реєстрації.
Сервіс можна безпечно запускати повторно.
Для прив’язки, що складається з кількох операцій, визначено спосіб захисту від одночасних змін.
Додаткові дії запускаються лише після фактичної зміни власника даних.
Правила нормалізації електронних адрес однакові для гостьових форм і реєстрації.
Є тести на успішну прив’язку, відсутність змін, повторне виконання та захист записів, які вже мають власника.
Прив’язка даних після реєстрації - це контрольована зміна їхнього власника
Для мене головний висновок із цього кейсу такий: дані, створені до реєстрації, не потрібно будь-якою ціною шукати й прив’язувати до нового користувача.
Потрібно визначити чіткі правила зміни власника даних: який ідентифікатор гостя є достатнім, які записи ще можна прив’язати, на якому етапі реєстрації це відбувається і як система повинна поводитися під час повторного виконання.
Коли електронна адреса використовується лише для зв’язування даних, прив’язка обмежена записами без власника, а сервіс можна безпечно запускати повторно, процес реєстрації залишається простим і безпечним навіть за повторного виконання.
Після реєстрації я не створюю новий зв’язок із власником даних з нуля. Я безпечно переводжу вже наявні дані гостя у стан, коли вони належать зареєстрованому користувачу.



