Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\Models\CompanyUser;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Contact::withoutGlobalScopes()
->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');
}
};
41 changes: 41 additions & 0 deletions server/src/Http/Controllers/Internal/v1/ContactController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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']);
}
}
135 changes: 122 additions & 13 deletions server/src/Models/Contact.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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.
*
Expand All @@ -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]);
Expand Down
8 changes: 8 additions & 0 deletions server/src/Observers/ContactObserver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
Loading