From 94d7f390d698f8c0a0bacf691547cb02715e9d6a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 13 May 2026 14:56:53 +0800 Subject: [PATCH] Fix customer contact user type invariant --- ...001_repair_customer_contact_user_types.php | 86 +++++++++++ .../Internal/v1/ContactController.php | 41 ++++++ server/src/Models/Contact.php | 135 ++++++++++++++++-- server/src/Observers/ContactObserver.php | 8 ++ 4 files changed, 257 insertions(+), 13 deletions(-) create mode 100644 server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php diff --git a/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php b/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php new file mode 100644 index 000000000..dd237ab8d --- /dev/null +++ b/server/migrations/2026_05_13_000001_repair_customer_contact_user_types.php @@ -0,0 +1,86 @@ +where('type', 'customer') + ->whereNull('deleted_at') + ->chunkById(100, function ($contacts) { + foreach ($contacts as $contact) { + try { + $contact->repairCustomerTypeInvariant(true); + } catch (Throwable $e) { + // Skip ambiguous or unrecoverable records; runtime invariants handle new writes. + } + } + }); + + if (!$this->canUseCustomerRoleHint()) { + return; + } + + DB::table('contacts') + ->select('contacts.id') + ->join('company_users', function ($join) { + $join->on('company_users.user_uuid', '=', 'contacts.user_uuid'); + $join->on('company_users.company_uuid', '=', 'contacts.company_uuid'); + }) + ->join('model_has_roles', function ($join) { + $join->on('model_has_roles.model_uuid', '=', 'company_users.uuid'); + $join->where('model_has_roles.model_type', CompanyUser::class); + }) + ->join('roles', 'roles.id', '=', 'model_has_roles.role_id') + ->where('roles.name', 'Fleet-Ops Customer') + ->whereNull('contacts.deleted_at') + ->where(function ($query) { + $query->whereNull('contacts.type'); + $query->orWhere('contacts.type', '!=', 'customer'); + }) + ->distinct() + ->orderBy('contacts.id') + ->chunk(100, function ($rows) { + foreach ($rows as $row) { + $contact = Contact::withoutGlobalScopes()->find($row->id); + if (!$contact) { + continue; + } + + try { + $contact->repairCustomerTypeInvariant(true); + } catch (Throwable $e) { + // Skip ambiguous or unrecoverable records; role is only a recovery hint. + } + } + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // Data repair is intentionally not reversible. + } + + private function canUseCustomerRoleHint(): bool + { + return Schema::hasTable('contacts') + && Schema::hasTable('company_users') + && Schema::hasTable('model_has_roles') + && Schema::hasTable('roles'); + } +}; diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php index 3b6a1564f..426ec5b5e 100644 --- a/server/src/Http/Controllers/Internal/v1/ContactController.php +++ b/server/src/Http/Controllers/Internal/v1/ContactController.php @@ -8,6 +8,7 @@ use Fleetbase\FleetOps\Models\Contact; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Http\Requests\ImportRequest; +use Fleetbase\Models\User; use Illuminate\Http\Request; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -21,11 +22,35 @@ class ContactController extends FleetOpsController */ public $resource = 'contact'; + /** + * Handle pre-create transactions. + */ + public function onBeforeCreate(Request $request, array &$input) + { + $this->resolveUserInput($request, $input); + } + + /** + * Handle pre-update transactions. + */ + public function onBeforeUpdate(Request $request, Contact $contact, array &$input) + { + if ($contact->type === 'customer' && isset($input['type']) && $input['type'] !== 'customer') { + throw new \Exception('Customer contact type cannot be changed.'); + } + + $this->resolveUserInput($request, $input); + } + /** * Handle post save transactions. */ public function afterSave(Request $request, Contact $contact) { + if ($contact->type === 'customer') { + $contact->normalizeCustomerUser(); + } + $customFieldValues = $request->array('contact.custom_field_values'); if ($customFieldValues) { $contact->syncCustomFieldValues($customFieldValues); @@ -105,4 +130,20 @@ public function import(ImportRequest $request) return response()->json(['status' => 'ok', 'message' => 'Import completed', 'imported' => $importedCount]); } + + private function resolveUserInput(Request $request, array &$input): void + { + $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user'); + + if (is_array($user)) { + $user = data_get($user, 'uuid') ?? data_get($user, 'id'); + } + + if (!$user) { + return; + } + + $input['user_uuid'] = User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user; + unset($input['user']); + } } diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php index 9c845557d..302e4c702 100644 --- a/server/src/Models/Contact.php +++ b/server/src/Models/Contact.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException; use Fleetbase\FleetOps\Support\Utils; +use Fleetbase\Models\CompanyUser; use Fleetbase\Models\Invite; use Fleetbase\Models\Model; use Fleetbase\Models\User; @@ -261,6 +262,14 @@ public function getIsCustomerAttribute(): bool return $this->type === 'customer'; } + /** + * Determines if this contact is a Fleet-Ops customer. + */ + public function isCustomer(): bool + { + return $this->type === 'customer'; + } + /** * Creates a new contact from an import row. */ @@ -308,19 +317,33 @@ public static function createFromImport(array $row, bool $saveInstance = false): public static function createUserFromContact(Contact $contact, bool $sendInvite = false, bool $update = false): User { // Check if user already exist with email or phone number - $existingUser = User::where(function ($query) use ($contact) { - $query->where('company_uuid', $contact->company_uuid); - $query->where('email', $contact->email); - if ($contact->phone) { - $query->orWhere('phone', Utils::formatPhoneNumber($contact->phone)); - } - })->whereNull('deleted_at')->first(); + $existingUser = null; + if ($contact->email || $contact->phone) { + $existingUser = User::where('company_uuid', $contact->company_uuid) + ->where(function ($query) use ($contact) { + if ($contact->email) { + $query->where('email', $contact->email); + } + + if ($contact->phone) { + $method = $contact->email ? 'orWhere' : 'where'; + $query->{$method}('phone', Utils::formatPhoneNumber($contact->phone)); + } + }) + ->whereNull('deleted_at') + ->first(); + } + if ($existingUser) { // Check if existing user belongs to another contact $existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first(); if ($existingUserContact) { throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser); } else { + if ($contact->isCustomer()) { + $contact->normalizeCustomerUser($existingUser); + } + // Assign the user to this contact instead $contact->setAttribute('user_uuid', $existingUser->uuid); if ($update) { @@ -351,10 +374,10 @@ public static function createUserFromContact(Contact $contact, bool $sendInvite $user->setType($contact->type); // Assing to company - $user->assignCompany($contact->company, $user->type === 'customer' ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); + $user->assignCompany($contact->company, $contact->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); // Assign customer role - if ($user->type === 'customer') { + if ($contact->isCustomer()) { $user->assignSingleRole('Fleet-Ops Customer'); } @@ -386,6 +409,87 @@ public static function createUserFromContact(Contact $contact, bool $sendInvite return $user; } + /** + * Normalize the linked user for a customer contact. + */ + public function normalizeCustomerUser(?User $user = null, bool $quiet = false): ?User + { + if (!$this->isCustomer()) { + return $user; + } + + $user ??= $this->getUser(); + if (!$user) { + return null; + } + + if ($user->type !== 'customer') { + if ($quiet) { + $user->forceFill(['type' => 'customer'])->saveQuietly(); + } else { + $user->setType('customer'); + } + } + + $this->loadMissing('company'); + if ($this->company) { + $companyUser = CompanyUser::firstOrCreate( + [ + 'company_uuid' => $this->company->uuid, + 'user_uuid' => $user->uuid, + ], + [ + 'company_uuid' => $this->company->uuid, + 'user_uuid' => $user->uuid, + 'status' => $user->status ?? 'active', + ] + ); + $user->forceFill(['company_uuid' => $this->company->uuid])->saveQuietly(); + $user->setRelation('companyUser', $companyUser); + + if ($companyUser) { + $companyUser->assignSingleRole('Fleet-Ops Customer'); + } + } + + $this->setRelation('user', $user); + + return $user; + } + + /** + * Repair a historical customer contact and its linked user. + */ + public function repairCustomerTypeInvariant(bool $quiet = false): ?User + { + if (!$this->isCustomer()) { + $this->forceFill(['type' => 'customer'])->saveQuietly(); + } + + $user = $this->getUser(); + if (!$user && ($this->email || $this->phone)) { + if ($quiet) { + $this->loadMissing('company'); + $user = User::create([ + 'company_uuid' => $this->company_uuid, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone ? Utils::formatPhoneNumber($this->phone) : null, + 'username' => Str::slug($this->name . '_' . Str::random(4), '_'), + 'password' => Str::random(), + 'timezone' => $this->company->timezone ?? date_default_timezone_get(), + 'status' => 'pending', + ]); + $user->forceFill(['type' => 'customer'])->saveQuietly(); + $this->forceFill(['user_uuid' => $user->uuid])->saveQuietly(); + } else { + $user = $this->createUser(); + } + } + + return $this->normalizeCustomerUser($user, $quiet); + } + /** * Assigns a user to the company and optionally sends an invitation email. * @@ -406,16 +510,21 @@ public static function createUserFromContact(Contact $contact, bool $sendInvite public function assignUser(User $user, bool $sendInvite = false): self { // Load company - $this->loadMissing('copmany'); + $this->loadMissing('company'); + + if ($this->isCustomer() && $user->type !== 'customer') { + $user->setType('customer'); + } // Assing to company - $user->assignCompany($this->company, $this->type === 'customer' ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); + $user->assignCompany($this->company, $this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); // Get the company user instance $companyUser = $user->getCompanyUser($this->company); - // Assign customer role - $companyUser->assignSingleRole('Fleet-Ops Customer'); + if ($companyUser) { + $companyUser->assignSingleRole($this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact'); + } // Set user to contact $this->update(['user_uuid' => $user->uuid]); diff --git a/server/src/Observers/ContactObserver.php b/server/src/Observers/ContactObserver.php index 969376a23..2cbc692ab 100644 --- a/server/src/Observers/ContactObserver.php +++ b/server/src/Observers/ContactObserver.php @@ -27,11 +27,19 @@ public function creating(Contact $contact) */ public function saving(Contact $contact) { + if ($contact->exists && $contact->getOriginal('type') === 'customer' && $contact->isDirty('type') && $contact->type !== 'customer') { + throw new \Exception('Customer contact type cannot be changed.'); + } + // Get the contacts assosciated user if ($contact->doesntHaveUser()) { $contact->createUser(); } + if ($contact->isCustomer()) { + $contact->normalizeCustomerUser(); + } + // Validate email is available to user if (!empty($contact->email) && $contact->wasChanged('email') && $this->isEmailUnavailable($contact)) { throw new \Exception('Email attempting to update for ' . $contact->type . ' is not available.');