From b899cd9b6cc3cbfdedd4605203a57127be2a4642 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 22:08:21 -0400 Subject: [PATCH 01/26] fix: sync user/company to sandbox before creating test API key When a test/sandbox API key is created the request carries the Access-Console-Sandbox header, which causes SetupFleetbaseSession to switch the default database connection to 'sandbox'. The subsequent INSERT into api_credentials on the sandbox DB references user_uuid and company_uuid values that come from the production session. Because those rows do not necessarily exist in the sandbox database the foreign key constraint api_credentials_user_uuid_foreign (and the companion company_uuid constraint) fires and the insert fails with SQLSTATE[23000] 1452. Fix: override createRecord() in ApiCredentialController to detect the sandbox header and, before delegating to the generic create path, mirror the current user, company, and company_user pivot row from production into the sandbox DB using an on-demand upsert (the same pattern used by the sandbox:sync Artisan command). Foreign key checks are temporarily disabled during the upsert to avoid ordering issues, then re-enabled before the api_credentials insert proceeds. --- .../Internal/v1/ApiCredentialController.php | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/Http/Controllers/Internal/v1/ApiCredentialController.php b/src/Http/Controllers/Internal/v1/ApiCredentialController.php index a3a994e8..eb98af49 100644 --- a/src/Http/Controllers/Internal/v1/ApiCredentialController.php +++ b/src/Http/Controllers/Internal/v1/ApiCredentialController.php @@ -6,9 +6,14 @@ use Fleetbase\Http\Controllers\FleetbaseController; use Fleetbase\Http\Requests\ExportRequest; use Fleetbase\Models\ApiCredential; +use Fleetbase\Models\Company; +use Fleetbase\Models\CompanyUser; +use Fleetbase\Models\User; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\Str; use Maatwebsite\Excel\Facades\Excel; @@ -28,6 +33,132 @@ class ApiCredentialController extends FleetbaseController */ public $service = 'developers'; + /** + * Create a new API credential record. + * + * Overrides the generic createRecord to ensure that when a test/sandbox key + * is being created, the current user and company are mirrored into the sandbox + * database first. Without this, the foreign key constraint on `api_credentials.user_uuid` + * (which references `users.uuid` in the sandbox DB) will fail because the user + * and company rows only exist in the production database. + * + * @return \Illuminate\Http\Response + */ + public function createRecord(Request $request) + { + // Determine if this is a sandbox/test key creation request. + $isSandbox = \Fleetbase\Support\Utils::isTrue($request->header('Access-Console-Sandbox')); + + if ($isSandbox) { + // Ensure the current user and company exist in the sandbox DB before + // attempting the insert, so the FK constraints are satisfied. + $this->syncCurrentSessionToSandbox($request); + } + + return parent::createRecord($request); + } + + /** + * Mirrors the currently authenticated user, their company, and the company–user + * membership record into the sandbox database. + * + * This is a targeted, on-demand version of the `sandbox:sync` Artisan command, + * scoped only to the records needed to satisfy the foreign key constraints when + * inserting a new test-mode `api_credentials` row. + * + * @param Request $request + * + * @return void + */ + protected function syncCurrentSessionToSandbox(Request $request): void + { + $userUuid = session('user'); + $companyUuid = session('company'); + + if (!$userUuid || !$companyUuid) { + return; + } + + // Temporarily disable FK checks so we can upsert in any order. + Schema::connection('sandbox')->disableForeignKeyConstraints(); + + try { + // --- Sync User --- + $user = User::on('mysql')->withoutGlobalScopes()->where('uuid', $userUuid)->first(); + if ($user) { + $this->upsertModelToSandbox($user); + } + + // --- Sync Company --- + $company = Company::on('mysql')->withoutGlobalScopes()->where('uuid', $companyUuid)->first(); + if ($company) { + $this->upsertModelToSandbox($company); + } + + // --- Sync CompanyUser pivot --- + $companyUser = CompanyUser::on('mysql') + ->withoutGlobalScopes() + ->where('user_uuid', $userUuid) + ->where('company_uuid', $companyUuid) + ->first(); + if ($companyUser) { + $this->upsertModelToSandbox($companyUser); + } + } finally { + Schema::connection('sandbox')->enableForeignKeyConstraints(); + } + } + + /** + * Upserts a single Eloquent model record into the sandbox database. + * + * Mirrors the approach used in the `sandbox:sync` Artisan command: + * reduces the record to its fillable attributes, normalises datetime + * fields to strings, JSON-encodes any Json-cast columns, then performs + * an `updateOrInsert` keyed on `uuid`. + * + * @param \Illuminate\Database\Eloquent\Model $model + * + * @return void + */ + protected function upsertModelToSandbox(\Illuminate\Database\Eloquent\Model $model): void + { + $clone = collect($model->toArray()) + ->only($model->getFillable()) + ->toArray(); + + if (!isset($clone['uuid']) || !is_string($clone['uuid'])) { + return; + } + + // Normalise datetime columns to plain strings. + foreach ($clone as $key => $value) { + if (isset($clone[$key]) && Str::endsWith($key, '_at')) { + try { + $clone[$key] = Carbon::parse($clone[$key])->toDateTimeString(); + } catch (\Exception $e) { + $clone[$key] = null; + } + } + } + + // JSON-encode any Json-cast columns that are still arrays/objects. + $jsonColumns = collect($model->getCasts()) + ->filter(fn ($cast) => Str::contains($cast, 'Json')) + ->keys() + ->toArray(); + + foreach ($clone as $key => $value) { + if (in_array($key, $jsonColumns) && (is_object($value) || is_array($value))) { + $clone[$key] = json_encode($value); + } + } + + DB::connection('sandbox') + ->table($model->getTable()) + ->updateOrInsert(['uuid' => $clone['uuid']], $clone); + } + /** * Export the companies/users api credentials to excel or csv. * From 1208f2439fb14abcecd39439b4dca7711f122935 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 22:16:30 -0400 Subject: [PATCH 02/26] feat: schedule sandbox:sync to run hourly Registers the sandbox:sync Artisan command in the CoreServiceProvider scheduler so that production users and companies are periodically mirrored into the sandbox database without any manual intervention. Hourly cadence is chosen as a sensible default: - New users/companies created in production become available in sandbox within at most one hour, keeping the sandbox reasonably fresh. - The command is lightweight (upserts only; no heavy data transforms) so running it every hour does not impose meaningful DB load. - withoutOverlapping() ensures a long-running sync cannot queue up multiple concurrent instances if the scheduler fires while a previous run is still in progress. This complements the on-demand sync added to ApiCredentialController (which handles the immediate FK-constraint case at key-creation time) by ensuring the sandbox stays broadly consistent with production over time for all other sandbox operations. --- src/Providers/CoreServiceProvider.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index e3330a81..b04e2dd3 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -165,6 +165,13 @@ public function boot() $schedule->command('purge:scheduled-task-logs --force --no-interaction --days 1')->twiceDaily(1, 13); $schedule->command('telemetry:ping')->daily(); $schedule->job(new \Fleetbase\Jobs\MaterializeSchedulesJob())->dailyAt('01:00')->name('materialize-schedules')->withoutOverlapping(); + // Keep sandbox users/companies in sync with production so that + // test-mode API key creation (and other sandbox operations) never + // fail due to missing foreign-key referenced rows. Hourly cadence + // is a good balance: frequent enough that new users/companies are + // available in sandbox within an hour of being created in + // production, but infrequent enough to avoid unnecessary DB load. + $schedule->command('sandbox:sync')->hourly()->name('sandbox-sync')->withoutOverlapping(); }); $this->registerObservers(); $this->registerExpansionsFrom(); From 4c65e408b6721ef9c7c79e6c4e99813a6d721c81 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 22:33:52 -0400 Subject: [PATCH 03/26] fix: restore cross-organisation invite flow for existing users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Commit 7b80327 introduced CreateUserRequest with a hard Rule::unique('users', 'email') validation rule wired as $createRequest on UserController. This validation fires before any controller logic runs, meaning the historical cross-org invite branch — 'if the email already exists, invite the user instead of creating a duplicate' — was completely unreachable. Any attempt to add a user whose email was already in the system returned: 'An account with this email address already exists' A second issue: the inviteUser() controller method (which contained the correct invite logic) had no registered route in routes.php. Only resend-invite was wired; invite-user was never registered. ## Changes ### src/Http/Requests/CreateUserRequest.php - Remove Rule::unique from the email field. Uniqueness is a business concern handled by the controller, not a shape/format concern that belongs in a FormRequest. - Make phone 'sometimes' (optional). Invited users — both new and existing — may not supply a phone number at invite time; they complete their profile after accepting the invitation. ### src/Http/Controllers/Internal/v1/UserController.php - createRecord(): After validation, check whether the submitted email already exists. If it does, redirect to the cross-org invite flow instead of attempting a duplicate insert. Returns invited:true in the response so the frontend can show the appropriate success message. - inviteUser(): Refactored to use the new shared inviteExistingUser() helper for the existing-user path, and adds an already-member guard. - inviteExistingUser() [new private method]: Shared helper used by both createRecord() and inviteUser(). Checks for duplicate invitations via Invite::isAlreadySentToJoinCompany(), creates the Invite record, and dispatches the UserInvited notification. - acceptCompanyInvite(): Added idempotency guard — checks for an existing CompanyUser row before creating one, preventing a duplicate pivot row if the invite link is clicked more than once. ### src/routes.php - Register the missing POST users/invite-user route pointing to UserController@inviteUser. --- .../Internal/v1/UserController.php | 151 ++++++++++++++---- src/Http/Requests/CreateUserRequest.php | 17 +- src/routes.php | 1 + 3 files changed, 130 insertions(+), 39 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index ca060c1d..e39ffeed 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -71,12 +71,37 @@ class UserController extends FleetbaseController /** * Creates a record with request payload. * + * If the supplied email address already belongs to an existing user the + * request is treated as a cross-organisation invitation rather than a + * duplicate-creation attempt. The existing user is invited to join the + * current company and the response includes `invited: true` so the + * frontend can display the appropriate success message. + * * @return \Illuminate\Http\Response */ public function createRecord(Request $request) { $this->validateRequest($request); + // Detect whether the email already belongs to an existing user. + // If so, redirect to the cross-organisation invite flow instead of + // attempting to create a duplicate user record. + $email = strtolower((string) $request->input('user.email', '')); + $existingUser = $email ? User::where('email', $email)->whereNull('deleted_at')->first() : null; + + if ($existingUser) { + // Guard: the user is already a member of the current organisation. + $alreadyMember = $existingUser->companyUsers() + ->where('company_uuid', session('company')) + ->exists(); + + if ($alreadyMember) { + return response()->error('This user is already a member of your organisation.'); + } + + return $this->inviteExistingUser($existingUser, $request); + } + try { $record = $this->model->createRecordFromRequest($request, function (&$request, &$input) { // Get user properties @@ -268,49 +293,95 @@ public function saveTwoFactorSettings(Request $request) } /** - * Creates a user, adds the user to company and sends an email to user about being added. + * Invite a user (new or existing) to join the current organisation. + * + * - If the email belongs to an existing user in another organisation, a + * cross-organisation invitation is issued without creating a new user. + * - If the email is brand-new, a pending user record is created and the + * invitation email is sent so they can set a password on acceptance. * * @return \Illuminate\Http\Response */ #[SkipAuthorizationCheck] public function inviteUser(InviteUserRequest $request) { - // $data = $request->input(['name', 'email', 'phone', 'status', 'country', 'date_of_birth']); - $data = $request->input('user'); - $email = strtolower($data['email']); + $data = $request->input('user'); + $email = strtolower($data['email']); + $company = Auth::getCompany(); + + if (!$company) { + return response()->error('Unable to determine the current organisation.'); + } + + // Check if user already exists in the system. + $user = User::where('email', $email)->whereNull('deleted_at')->first(); - // set company - $data['company_uuid'] = session('company'); - $data['status'] = 'pending'; // pending acceptance - $data['type'] = 'user'; // set type as regular user - $data['created_at'] = Carbon::now(); // jic + if ($user) { + // Guard: already a member of this organisation. + $alreadyMember = $user->companyUsers() + ->where('company_uuid', $company->uuid) + ->exists(); - // make sure user isn't already invited - $isAlreadyInvited = Invite::where([ - 'company_uuid' => session('company'), - 'subject_uuid' => session('company'), - 'protocol' => 'email', - 'reason' => 'join_company', - ])->whereJsonContains('recipients', $email)->exists(); + if ($alreadyMember) { + return response()->error('This user is already a member of your organisation.'); + } - if ($isAlreadyInvited) { - return response()->error('This user has already been invited to join this organization.'); + // Existing user from another org — issue a cross-org invite. + return $this->inviteExistingUser($user, $request); } - // get the company inviting - $company = Company::where('uuid', session('company'))->first(); + // Brand-new user — create a pending record then invite. + $data['company_uuid'] = $company->uuid; + $data['status'] = 'pending'; + $data['type'] = 'user'; + $data['created_at'] = Carbon::now(); - // check if user exists already - $user = User::where('email', $email)->first(); + $user = User::create($data); - // if new user, create user - if (!$user) { - $user = User::create($data); + $invitation = Invite::create([ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'subject_uuid' => $company->uuid, + 'subject_type' => Utils::getMutationType($company), + 'protocol' => 'email', + 'recipients' => [$user->email], + 'reason' => 'join_company', + ]); + + $user->notify(new UserInvited($invitation)); + + return response()->json(['user' => new $this->resource($user)]); + } + + /** + * Issue a join-company invitation to a user who already exists in the + * system but belongs to a different organisation. + * + * This private helper is shared by both `createRecord()` (which detects + * an existing email during the standard "New User" flow) and `inviteUser()` + * (the dedicated invite endpoint). Keeping the logic in one place ensures + * both paths behave identically. + * + * @param User $user The existing user to invite. + * @param Request $request The originating HTTP request. + * + * @return \Illuminate\Http\JsonResponse + */ + private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse + { + $company = Auth::getCompany(); + + if (!$company) { + return response()->error('Unable to determine the current organisation.'); + } + + // Guard: prevent duplicate invitations using the model helper. + if (Invite::isAlreadySentToJoinCompany($user, $company)) { + return response()->error('This user has already been invited to join your organisation.'); } - // create invitation $invitation = Invite::create([ - 'company_uuid' => session('company'), + 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), 'subject_uuid' => $company->uuid, 'subject_type' => Utils::getMutationType($company), @@ -319,10 +390,14 @@ public function inviteUser(InviteUserRequest $request) 'reason' => 'join_company', ]); - // notify user $user->notify(new UserInvited($invitation)); - return response()->json(['user' => new $this->resource($user)]); + // Return `invited: true` so the frontend can distinguish between + // a newly created user and a cross-organisation invite. + return response()->json([ + 'user' => new $this->resource($user), + 'invited' => true, + ]); } /** @@ -384,11 +459,19 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) // determine if user needs to set password (when status pending) $isPending = $needsPassword = $user->status === 'pending'; - // add user to company - CompanyUser::create([ - 'user_uuid' => $user->uuid, - 'company_uuid' => $company->uuid, - ]); + // Add user to company only if they are not already a member. + // This guards against double-acceptance (e.g. clicking the invite + // link twice) creating a duplicate company_users row. + $alreadyMember = CompanyUser::where('user_uuid', $user->uuid) + ->where('company_uuid', $company->uuid) + ->exists(); + + if (!$alreadyMember) { + CompanyUser::create([ + 'user_uuid' => $user->uuid, + 'company_uuid' => $company->uuid, + ]); + } // activate user if ($isPending) { diff --git a/src/Http/Requests/CreateUserRequest.php b/src/Http/Requests/CreateUserRequest.php index d7c06000..cd7bd1ac 100644 --- a/src/Http/Requests/CreateUserRequest.php +++ b/src/Http/Requests/CreateUserRequest.php @@ -5,7 +5,6 @@ use Fleetbase\Rules\EmailDomainExcluded; use Fleetbase\Rules\ExcludeWords; use Fleetbase\Rules\ValidPhoneNumber; -use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; class CreateUserRequest extends FleetbaseRequest @@ -30,14 +29,24 @@ public function authorize() /** * Get the validation rules that apply to the request. * + * Note: email uniqueness is intentionally NOT enforced here. When an email + * already exists in the system the controller detects this and redirects to + * the cross-organisation invite flow rather than attempting to create a + * duplicate user. Enforcing uniqueness at the request layer would prevent + * that branch from ever being reached. + * + * Phone is marked `sometimes` because invited users (both new and existing) + * may not supply a phone number at invite time; they complete their profile + * after accepting the invitation. + * * @return array */ public function rules() { return [ 'name' => ['required', 'min:2', 'max:50', 'regex:/^(?!.*\b[a-z0-9]+(?:\.[a-z0-9]+){1,}\b)[a-zA-ZÀ-ÿ\'\-\s\.]+$/u', new ExcludeWords($this->excludedWords)], - 'email' => ['required', 'email', Rule::unique('users', 'email')->whereNull('deleted_at'), new EmailDomainExcluded()], - 'phone' => ['required', new ValidPhoneNumber(), Rule::unique('users', 'phone')->whereNull('deleted_at')], + 'email' => ['required', 'email', new EmailDomainExcluded()], + 'phone' => ['sometimes', 'nullable', new ValidPhoneNumber()], 'password' => ['sometimes', 'confirmed', 'string', Password::min(8)->mixedCase()->letters()->numbers()->symbols()->uncompromised()], 'password_confirmation' => ['sometimes', 'min:4', 'max:64'], ]; @@ -53,8 +62,6 @@ public function messages() return [ '*.required' => 'Your :attribute is required', 'email' => 'You must enter a valid :attribute', - 'email.unique' => 'An account with this email address already exists', - 'phone.unique' => 'An account with this phone number already exists', 'password.required' => 'You must enter a password.', 'password.mixed' => 'Password must contain both uppercase and lowercase letters.', 'password.letters' => 'Password must contain at least 1 letter.', diff --git a/src/routes.php b/src/routes.php index 50c1a695..2b86680e 100644 --- a/src/routes.php +++ b/src/routes.php @@ -244,6 +244,7 @@ function ($router, $controller) { $router->patch('verify/{id}', $controller('verify')); $router->delete('remove-from-company/{id}', $controller('removeFromCompany')); $router->delete('bulk-delete', $controller('bulkDelete')); + $router->post('invite-user', $controller('inviteUser')); $router->post('resend-invite', $controller('resendInvitation')); $router->post('set-password', $controller('setCurrentUserPassword')); $router->post('validate-password', $controller('validatePassword')); From 561d3cdbcc6c65b3b7b7b277064fd4455293e2d2 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 23:17:15 -0400 Subject: [PATCH 04/26] fix: correct fleetbase.db.connection config key typo in Model and Setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base Model constructor and Setting constructor both used the config key 'fleetbase.db.connection', which does not exist in config/fleetbase.php. The correct key is 'fleetbase.connection.db'. Because config() returns null for a missing key, $this->connection was being set to null in every model's constructor. Laravel then falls back to config('database.default') to resolve the connection. In sandbox mode, Auth::setSandboxSession() sets database.default to 'sandbox', so every model — including those that explicitly declare $connection = 'mysql' (e.g. Invite, Setting) — was silently writing to the sandbox database, because the parent constructor overwrote the child's property with null. Symptoms: - Invite records created during sandbox sessions were written to the sandbox database (or lost entirely if the sandbox invites table did not exist), while the email was still sent using the in-memory $invitation object (which had the code set by the 'creating' hook before the failed save). The user received an invite email with a code that could not be validated because no matching row existed in the production invites table. - Setting reads/writes in sandbox mode were hitting the sandbox DB instead of the production settings table. Fix: - Model::__construct(): only apply the connection override when the child class has not already declared an explicit $connection, and use the correct config key 'fleetbase.connection.db' with a safe 'mysql' fallback. - Setting::__construct(): same guard — preserve the explicit $connection = 'mysql' declared on the class and use the correct config key. --- src/Models/Model.php | 17 ++++++++++++++++- src/Models/Setting.php | 9 ++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/Models/Model.php b/src/Models/Model.php index c4bdd904..04949d6a 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -41,7 +41,22 @@ public function __construct(array $attributes = []) { parent::__construct($attributes); - $this->connection = config('fleetbase.db.connection'); + // Resolve the configured production DB connection name. + // The correct config key is `fleetbase.connection.db`; the previously + // used key `fleetbase.db.connection` was a typo that returned null, + // causing every model to silently fall back to `database.default`. + // When sandbox mode is active `database.default` is switched to + // `sandbox`, which meant models with an explicit `$connection = 'mysql'` + // (e.g. Invite) were still written to the sandbox database because the + // parent constructor overwrote the child's property with null. + // + // Only apply the override when the child class has NOT declared its own + // explicit $connection, so models that intentionally pin themselves to + // a specific connection (e.g. `protected $connection = 'mysql'`) are + // left untouched. + if (empty($this->connection)) { + $this->connection = config('fleetbase.connection.db', config('database.connections.mysql') ? 'mysql' : config('database.default')); + } } /** diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 91f1eec9..d5b387a5 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -31,7 +31,14 @@ public function __construct(array $attributes = []) { parent::__construct($attributes); - $this->connection = config('fleetbase.db.connection'); + // Settings must always read/write from the production database. + // Do not overwrite the explicit $connection = 'mysql' declared below; + // the previously used config key `fleetbase.db.connection` was a typo + // (correct key: `fleetbase.connection.db`) that returned null and caused + // this model to silently follow `database.default` into the sandbox DB. + if (empty($this->connection)) { + $this->connection = config('fleetbase.connection.db', 'mysql'); + } } /** From 3865ec74191406918bd74ce0c88f3e50bd536ba2 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 23:44:49 -0400 Subject: [PATCH 05/26] fix: remove Model/Setting constructors that broke sandbox connection switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructors introduced in both Model.php and Setting.php called: $this->connection = config('fleetbase.db.connection'); This config key does not exist (correct key: fleetbase.connection.db), so config() returned null, overwriting the child class's declared $connection property on every instantiation. A subsequent attempt to guard with empty($this->connection) overcorrected in the opposite direction: models without an explicit $connection declaration (the sandbox-aware ones) were always resolved to 'mysql', ignoring the database.default switch that sandbox mode sets to 'sandbox'. The correct fix is simply to remove both constructors entirely: - PHP initialises class property declarations before any constructor runs, so models that explicitly declare $connection = 'mysql' (Invite, Setting, User, Company, Role, Permission, etc.) will always use that value — no constructor logic needed. - Models that do NOT declare $connection (ApiCredential, CompanyUser, Activity, etc.) correctly inherit null from Eloquent's base class, which causes Laravel to resolve the connection from config('database.default'). In sandbox mode that is 'sandbox'; in normal mode it is 'mysql'. This is exactly the intended behaviour. No functional change for any model — this purely restores the correct connection resolution that existed before the constructors were added. --- src/Models/Model.php | 29 ----------------------------- src/Models/Setting.php | 21 --------------------- 2 files changed, 50 deletions(-) diff --git a/src/Models/Model.php b/src/Models/Model.php index 04949d6a..344d49ad 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -30,35 +30,6 @@ class Model extends EloquentModel public const UUID_COLUMN = 'uuid'; public const PUBLIC_ID_COLUMN = 'public_id'; - /** - * Create a new instance of the model. - * - * @param array $attributes the attributes to set on the model - * - * @return void - */ - public function __construct(array $attributes = []) - { - parent::__construct($attributes); - - // Resolve the configured production DB connection name. - // The correct config key is `fleetbase.connection.db`; the previously - // used key `fleetbase.db.connection` was a typo that returned null, - // causing every model to silently fall back to `database.default`. - // When sandbox mode is active `database.default` is switched to - // `sandbox`, which meant models with an explicit `$connection = 'mysql'` - // (e.g. Invite) were still written to the sandbox database because the - // parent constructor overwrote the child's property with null. - // - // Only apply the override when the child class has NOT declared its own - // explicit $connection, so models that intentionally pin themselves to - // a specific connection (e.g. `protected $connection = 'mysql'`) are - // left untouched. - if (empty($this->connection)) { - $this->connection = config('fleetbase.connection.db', config('database.connections.mysql') ? 'mysql' : config('database.default')); - } - } - /** * The primary key for the model. * diff --git a/src/Models/Setting.php b/src/Models/Setting.php index d5b387a5..23cfa789 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -20,27 +20,6 @@ class Setting extends EloquentModel use Searchable; use Filterable; - /** - * Create a new instance of the model. - * - * @param array $attributes the attributes to set on the model - * - * @return void - */ - public function __construct(array $attributes = []) - { - parent::__construct($attributes); - - // Settings must always read/write from the production database. - // Do not overwrite the explicit $connection = 'mysql' declared below; - // the previously used config key `fleetbase.db.connection` was a typo - // (correct key: `fleetbase.connection.db`) that returned null and caused - // this model to silently follow `database.default` into the sandbox DB. - if (empty($this->connection)) { - $this->connection = config('fleetbase.connection.db', 'mysql'); - } - } - /** * No timestamp columns. * From e1814fcbc20dbbe369943e2954814b8be74aac9e Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 23:49:52 -0400 Subject: [PATCH 06/26] fix: correct config key typo and pin Invite to production DB via getConnectionName() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the two overcorrecting commits (561d3cd, 3865ec7) and replaces them with the minimal, correct changes: ## 1. Config key typo fix (Model.php, Setting.php) The base Model constructor and Setting constructor both used: $this->connection = config('fleetbase.db.connection'); The correct key is 'fleetbase.connection.db'. The wrong key returned null, which caused $this->connection to be set to null on every instantiation, making all models fall back to config('database.default'). In sandbox mode that is 'sandbox', so models that explicitly declared $connection = 'mysql' (Invite, Setting, User, Company, etc.) were silently overwritten. Fixed to: config('fleetbase.connection.db', 'mysql') The constructor is intentionally kept — it is the correct place to resolve the configured production connection name for models that do not declare their own $connection. ## 2. Invite::getConnectionName() override (Invite.php) Invites must always be stored in and read from the production database, regardless of sandbox mode. The $connection property alone is insufficient because the base Model constructor overwrites it at instantiation time. Overriding getConnectionName() is the Laravel-idiomatic way to enforce a hard connection binding at the model level — it is called by Eloquent for every query and cannot be overridden by runtime config changes. --- src/Models/Invite.php | 19 +++++++++++++++++++ src/Models/Model.php | 14 ++++++++++++++ src/Models/Setting.php | 14 ++++++++++++++ 3 files changed, 47 insertions(+) diff --git a/src/Models/Invite.php b/src/Models/Invite.php index f921eb31..f3b1c5e1 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -38,6 +38,25 @@ class Invite extends Model */ protected $connection = 'mysql'; + /** + * Invites must always be stored in the production database. + * + * Sandbox mode switches `database.default` to `sandbox` and the base + * Model constructor sets `$this->connection` from config, which means + * any model without an explicit override would write to the sandbox DB. + * Overriding getConnectionName() here ensures that no matter what the + * runtime config state is, Invite records are always read from and + * written to the production connection. This is intentional: invite + * codes must be resolvable when the invited user accepts the link, + * which always happens outside of sandbox context. + * + * @return string + */ + public function getConnectionName(): string + { + return config('fleetbase.connection.db', 'mysql'); + } + /** * These attributes that can be queried. * diff --git a/src/Models/Model.php b/src/Models/Model.php index 344d49ad..9e0d7fb9 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -30,6 +30,20 @@ class Model extends EloquentModel public const UUID_COLUMN = 'uuid'; public const PUBLIC_ID_COLUMN = 'public_id'; + /** + * Create a new instance of the model. + * + * @param array $attributes the attributes to set on the model + * + * @return void + */ + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->connection = config('fleetbase.connection.db', 'mysql'); + } + /** * The primary key for the model. * diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 23cfa789..1f5509d7 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -20,6 +20,20 @@ class Setting extends EloquentModel use Searchable; use Filterable; + /** + * Create a new instance of the model. + * + * @param array $attributes the attributes to set on the model + * + * @return void + */ + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->connection = config('fleetbase.connection.db', 'mysql'); + } + /** * No timestamp columns. * From 676fabc8809156ec44a1eae0bfd49e593fe481fc Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Mon, 20 Apr 2026 23:59:38 -0400 Subject: [PATCH 07/26] fix: remove connection-overriding constructors from Model and Setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructors in Model.php and Setting.php both set: $this->connection = config('fleetbase.connection.db', 'mysql'); On main, the wrong config key ('fleetbase.db.connection') returned null, making the constructors effectively no-ops — models without an explicit $connection declaration correctly fell back to database.default, which sandbox mode switches to 'sandbox'. The typo fix made the constructors actively harmful by always forcing 'mysql', breaking sandbox switching for every model that does not declare its own $connection. The correct fix is to remove both constructors entirely: - Models that declare $connection = 'mysql' (User, Company, Invite, etc.) have that value set by PHP at class instantiation — no constructor needed. - Models without an explicit $connection (ApiCredential, CompanyUser, etc.) correctly inherit null from Eloquent and follow database.default, which is 'sandbox' in sandbox mode and 'mysql' otherwise. The Invite model is handled separately via getConnectionName() override. --- src/Models/Model.php | 14 -------------- src/Models/Setting.php | 14 -------------- 2 files changed, 28 deletions(-) diff --git a/src/Models/Model.php b/src/Models/Model.php index 9e0d7fb9..344d49ad 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -30,20 +30,6 @@ class Model extends EloquentModel public const UUID_COLUMN = 'uuid'; public const PUBLIC_ID_COLUMN = 'public_id'; - /** - * Create a new instance of the model. - * - * @param array $attributes the attributes to set on the model - * - * @return void - */ - public function __construct(array $attributes = []) - { - parent::__construct($attributes); - - $this->connection = config('fleetbase.connection.db', 'mysql'); - } - /** * The primary key for the model. * diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 1f5509d7..23cfa789 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -20,20 +20,6 @@ class Setting extends EloquentModel use Searchable; use Filterable; - /** - * Create a new instance of the model. - * - * @param array $attributes the attributes to set on the model - * - * @return void - */ - public function __construct(array $attributes = []) - { - parent::__construct($attributes); - - $this->connection = config('fleetbase.connection.db', 'mysql'); - } - /** * No timestamp columns. * From f39a144444d90b1982dc1b030f1a0a46f0eb1031 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:03:29 -0400 Subject: [PATCH 08/26] revert: restore Model.php and Setting.php to match main exactly These files should not have been modified. The constructor changes were careless and had wide blast radius across all Fleetbase models. Reverting to the exact state on main. --- src/Models/Model.php | 14 ++++++++++++++ src/Models/Setting.php | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Models/Model.php b/src/Models/Model.php index 344d49ad..c4bdd904 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -30,6 +30,20 @@ class Model extends EloquentModel public const UUID_COLUMN = 'uuid'; public const PUBLIC_ID_COLUMN = 'public_id'; + /** + * Create a new instance of the model. + * + * @param array $attributes the attributes to set on the model + * + * @return void + */ + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->connection = config('fleetbase.db.connection'); + } + /** * The primary key for the model. * diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 23cfa789..91f1eec9 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -20,6 +20,20 @@ class Setting extends EloquentModel use Searchable; use Filterable; + /** + * Create a new instance of the model. + * + * @param array $attributes the attributes to set on the model + * + * @return void + */ + public function __construct(array $attributes = []) + { + parent::__construct($attributes); + + $this->connection = config('fleetbase.db.connection'); + } + /** * No timestamp columns. * From 20f392c143d2480ff93a3b97e3352e19ac1958d1 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:04:17 -0400 Subject: [PATCH 09/26] fix: update fleetbase.connection.db in setSandboxSession when switching to sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base Model constructor reads config('fleetbase.connection.db') to resolve which database connection to use. Previously this config key was never updated when sandbox mode was activated — only database.default was switched to 'sandbox'. This meant that any model instantiated after the sandbox switch would still resolve 'mysql' (or null, depending on the config key typo) from the constructor, while models that relied solely on database.default would correctly use 'sandbox'. The fix updates both keys atomically in setSandboxSession() so that the two connection resolution paths stay in sync: - database.default → used by models with no explicit $connection - fleetbase.connection.db → used by the base Model constructor --- src/Support/Auth.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Support/Auth.php b/src/Support/Auth.php index 42cf75be..bed5531a 100644 --- a/src/Support/Auth.php +++ b/src/Support/Auth.php @@ -148,7 +148,15 @@ public static function setSandboxSession($request, $apiCredential = null) // if is sandbox environment switch to the sandbox database if ($isSandbox) { - config(['database.default' => 'sandbox']); + config([ + // Switch the default Laravel connection so models without an + // explicit $connection declaration use the sandbox database. + 'database.default' => 'sandbox', + // Also update the Fleetbase-specific connection key so that + // the base Model constructor (which reads this config value) + // resolves to the sandbox connection rather than production. + 'fleetbase.connection.db' => 'sandbox', + ]); $sandboxSession['is_sandbox'] = (bool) $isSandbox; if ($apiCredentialId) { From 5341ce94da02726ae666aa13c1e694f6fdb63e57 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:05:01 -0400 Subject: [PATCH 10/26] fix: pin Invite::getConnectionName() to env DB_CONNECTION, not runtime config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setSandboxSession() now updates both database.default and fleetbase.connection.db to 'sandbox' when sandbox mode is active. The previous getConnectionName() implementation used config('fleetbase.connection.db', 'mysql') — but since that config key is now set to 'sandbox' during sandbox sessions, the fallback 'mysql' would never fire and Invite records would still be written to sandbox. Using env('DB_CONNECTION', 'mysql') reads the value directly from the environment at boot time, which is never mutated by setSandboxSession(), guaranteeing that Invite always resolves to the production connection regardless of sandbox mode. --- src/Models/Invite.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Models/Invite.php b/src/Models/Invite.php index f3b1c5e1..3105349a 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -41,20 +41,20 @@ class Invite extends Model /** * Invites must always be stored in the production database. * - * Sandbox mode switches `database.default` to `sandbox` and the base - * Model constructor sets `$this->connection` from config, which means - * any model without an explicit override would write to the sandbox DB. - * Overriding getConnectionName() here ensures that no matter what the - * runtime config state is, Invite records are always read from and - * written to the production connection. This is intentional: invite - * codes must be resolvable when the invited user accepts the link, - * which always happens outside of sandbox context. + * setSandboxSession() switches both `database.default` and + * `fleetbase.connection.db` to 'sandbox' when sandbox mode is active. + * Overriding getConnectionName() here hard-pins this model to the + * production DB connection name sourced directly from the environment + * config (DB_CONNECTION), bypassing any runtime config changes made + * by sandbox session switching. Invite codes must be resolvable when + * the invited user accepts the link, which always happens outside of + * sandbox context. * * @return string */ public function getConnectionName(): string { - return config('fleetbase.connection.db', 'mysql'); + return env('DB_CONNECTION', 'mysql'); } /** From 6702f59a57d801c5ef44620150eba24a8306994e Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:09:27 -0400 Subject: [PATCH 11/26] fix: correct fleetbase.db.connection config key typo in Model and Setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constructors in Model.php and Setting.php both referenced the config key 'fleetbase.db.connection' which does not exist in config/fleetbase.php. The correct key is 'fleetbase.connection.db' (see config/fleetbase.php:29). Previously this typo caused config() to return null, making the constructors effectively no-ops and leaving connection resolution entirely to database.default. Now that setSandboxSession() also updates fleetbase.connection.db alongside database.default, the constructor will correctly resolve to 'sandbox' in sandbox mode and to the configured production connection otherwise — which is the intended behaviour. --- src/Models/Model.php | 2 +- src/Models/Setting.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Models/Model.php b/src/Models/Model.php index c4bdd904..9e0d7fb9 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -41,7 +41,7 @@ public function __construct(array $attributes = []) { parent::__construct($attributes); - $this->connection = config('fleetbase.db.connection'); + $this->connection = config('fleetbase.connection.db', 'mysql'); } /** diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 91f1eec9..1f5509d7 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -31,7 +31,7 @@ public function __construct(array $attributes = []) { parent::__construct($attributes); - $this->connection = config('fleetbase.db.connection'); + $this->connection = config('fleetbase.connection.db', 'mysql'); } /** From 7a520ad2b670f8876fa2d076ef4e73e73035eaac Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:19:14 -0400 Subject: [PATCH 12/26] fix: resolve 500 on /users/me after invite acceptance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues caused the 500 error: ## 1. Null role crash in User resource (User.php) new Role($this->role) was called unconditionally. When a newly invited user has no role assigned in the new company, $this->role returns null and Role::toArray() crashes on $this->id (line 17). Fixed with a null guard: $this->role ? new Role($this->role) : null ## 2. Wrong company context after invite acceptance (UserController.php) acceptCompanyInvite() created the CompanyUser row for the new company but never updated $user->company_uuid. The user's active company remained their previous company (or null for pending users). When the frontend immediately called /users/me with the new token, the companyUser() relationship resolved against the wrong company, found no CompanyUser record, and returned a null role — triggering issue #1. Fixed by setting $user->company_uuid = $company->uuid and saving before issuing the token, so the user's active company context is the one they just joined. --- src/Http/Controllers/Internal/v1/UserController.php | 7 +++++++ src/Http/Resources/User.php | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index e39ffeed..a4c31690 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -473,6 +473,13 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) ]); } + // Switch the user's active company to the one they just joined. + // This ensures that subsequent calls to /users/me resolve the + // companyUser relationship (and therefore role/policies) against + // the correct company rather than the user's previous company. + $user->company_uuid = $company->uuid; + $user->save(); + // activate user if ($isPending) { $user->update(['email_verified_at' => now()]); diff --git a/src/Http/Resources/User.php b/src/Http/Resources/User.php index 6cbcb132..c8af56cb 100644 --- a/src/Http/Resources/User.php +++ b/src/Http/Resources/User.php @@ -31,7 +31,7 @@ public function toArray($request) 'timezone' => $this->timezone, 'avatar_url' => $this->avatar_url, 'meta' => data_get($this, 'meta', Utils::createObject()), - 'role' => $this->when(Http::isInternalRequest(), new Role($this->role), null), + 'role' => $this->when(Http::isInternalRequest(), $this->role ? new Role($this->role) : null, null), 'policies' => $this->when(Http::isInternalRequest(), Policy::collection($this->policies), []), 'permissions' => $this->when(Http::isInternalRequest(), $this->serializePermissions($this->permissions), []), 'role_name' => $this->when(Http::isInternalRequest(), $this->role ? $this->role->name : null), From 04fb3fe4b67d4f78a4dd21e000beda495beb7eef Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:34:40 -0400 Subject: [PATCH 13/26] fix: persist and apply role_uuid through the full invite flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The selected role was never being set on invited users. Both invite paths (createRecord cross-org and inviteUser) created the Invite record without storing the intended role, and acceptCompanyInvite used CompanyUser::create() directly which bypasses all role assignment logic. ## Changes ### migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php Adds a nullable role_uuid column to the invites table so the intended role can be persisted on the invite record and applied at acceptance time. ### src/Models/Invite.php Added role_uuid to the fillable array. ### src/Http/Controllers/Internal/v1/UserController.php inviteUser() (brand-new user path): Passes request's user.role_uuid (or user.role fallback) into Invite::create() so the role is stored on the invite record. inviteExistingUser() (cross-org path): Same — role_uuid from the request is now stored on the invite. acceptCompanyInvite(): Replaced bare CompanyUser::create() with Company::addUser() which calls assignSingleRole() internally. The role identifier is read from invite->role_uuid; if none was set, 'Administrator' is the default. For the already-member branch, if the invite carries a role_uuid it is applied to the existing CompanyUser record. --- ..._000001_add_role_uuid_to_invites_table.php | 35 +++++++++++++++++++ .../Internal/v1/UserController.php | 20 ++++++++--- src/Models/Invite.php | 1 + 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php diff --git a/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php b/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php new file mode 100644 index 00000000..3101f5f9 --- /dev/null +++ b/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php @@ -0,0 +1,35 @@ +char('role_uuid', 36)->nullable()->after('reason'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('invites', function (Blueprint $table) { + $table->dropColumn('role_uuid'); + }); + } +}; diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index a4c31690..c06d6837 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -346,6 +346,7 @@ public function inviteUser(InviteUserRequest $request) 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', + 'role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role'), ]); $user->notify(new UserInvited($invitation)); @@ -388,6 +389,7 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', + 'role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role'), ]); $user->notify(new UserInvited($invitation)); @@ -467,10 +469,20 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) ->exists(); if (!$alreadyMember) { - CompanyUser::create([ - 'user_uuid' => $user->uuid, - 'company_uuid' => $company->uuid, - ]); + // Use Company::addUser() so that role assignment is handled in + // one place. The role stored on the invite takes precedence; + // if none was set the default 'Administrator' role is used. + $roleIdentifier = $invite->role_uuid ?? 'Administrator'; + $companyUser = $company->addUser($user, $roleIdentifier); + $user->setRelation('companyUser', $companyUser); + } else { + // User is already a member — ensure the companyUser relation is + // loaded so that role assignment below can still be applied if + // the invite carries a role (e.g. re-sent invite with a new role). + $user->loadCompanyUser(); + if ($user->companyUser && $invite->role_uuid) { + $user->companyUser->assignSingleRole($invite->role_uuid); + } } // Switch the user's active company to the one they just joined. diff --git a/src/Models/Invite.php b/src/Models/Invite.php index 3105349a..3c3825ce 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -81,6 +81,7 @@ public function getConnectionName(): string 'protocol', 'recipients', 'reason', + 'role_uuid', ]; /** From c0e1df6c81ac3cea1d3b9db0ffdaf68721f17efc Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:39:01 -0400 Subject: [PATCH 14/26] fix: restore phone uniqueness rule in CreateUserRequest The Rule::unique('users', 'phone')->whereNull('deleted_at') constraint was incorrectly removed when phone was made optional. The rule is preserved but now only fires when a phone value is actually present (the 'sometimes' modifier ensures the rule is skipped entirely when the field is absent), so invited users without a phone number still pass validation while duplicate phone numbers are still rejected. --- src/Http/Requests/CreateUserRequest.php | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Http/Requests/CreateUserRequest.php b/src/Http/Requests/CreateUserRequest.php index cd7bd1ac..7cc861cb 100644 --- a/src/Http/Requests/CreateUserRequest.php +++ b/src/Http/Requests/CreateUserRequest.php @@ -5,6 +5,7 @@ use Fleetbase\Rules\EmailDomainExcluded; use Fleetbase\Rules\ExcludeWords; use Fleetbase\Rules\ValidPhoneNumber; +use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\Password; class CreateUserRequest extends FleetbaseRequest @@ -35,9 +36,10 @@ public function authorize() * duplicate user. Enforcing uniqueness at the request layer would prevent * that branch from ever being reached. * - * Phone is marked `sometimes` because invited users (both new and existing) - * may not supply a phone number at invite time; they complete their profile - * after accepting the invitation. + * Phone is `sometimes|nullable` because invited users may not supply a phone + * number at invite time — they complete their profile after accepting. When a + * phone number IS provided the uniqueness constraint is still enforced so that + * two active users cannot share the same number. * * @return array */ @@ -46,7 +48,7 @@ public function rules() return [ 'name' => ['required', 'min:2', 'max:50', 'regex:/^(?!.*\b[a-z0-9]+(?:\.[a-z0-9]+){1,}\b)[a-zA-ZÀ-ÿ\'\-\s\.]+$/u', new ExcludeWords($this->excludedWords)], 'email' => ['required', 'email', new EmailDomainExcluded()], - 'phone' => ['sometimes', 'nullable', new ValidPhoneNumber()], + 'phone' => ['sometimes', 'nullable', new ValidPhoneNumber(), Rule::unique('users', 'phone')->whereNull('deleted_at')], 'password' => ['sometimes', 'confirmed', 'string', Password::min(8)->mixedCase()->letters()->numbers()->symbols()->uncompromised()], 'password_confirmation' => ['sometimes', 'min:4', 'max:64'], ]; @@ -62,6 +64,7 @@ public function messages() return [ '*.required' => 'Your :attribute is required', 'email' => 'You must enter a valid :attribute', + 'phone.unique' => 'An account with this phone number already exists', 'password.required' => 'You must enter a password.', 'password.mixed' => 'Password must contain both uppercase and lowercase letters.', 'password.letters' => 'Password must contain at least 1 letter.', From 1363cd409c2006605b93f8ff8e946e7d3120a907 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 00:48:44 -0400 Subject: [PATCH 15/26] refactor: store invite role_uuid in meta JSON via HasMetaAttributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of a dedicated role_uuid column, use a generic meta JSON column on the invites table. This keeps the schema clean and extensible — any future invite-time data (e.g. custom permissions, onboarding flags) can be stored in meta without additional migrations. Changes: - Migration: adds nullable json 'meta' column instead of char 'role_uuid' - Invite model: uses HasMetaAttributes trait; 'meta' added to fillable and cast via Json::class - UserController: Invite::create() now passes meta => ['role_uuid' => ...] (array_filter drops it when no role is selected); acceptCompanyInvite() reads the role via $invite->getMeta('role_uuid', 'Administrator') --- ..._04_21_000001_add_role_uuid_to_invites_table.php | 11 ++++++----- src/Http/Controllers/Internal/v1/UserController.php | 13 +++++++------ src/Models/Invite.php | 5 ++++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php b/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php index 3101f5f9..6ce57d0f 100644 --- a/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php +++ b/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php @@ -8,16 +8,17 @@ /** * Run the migrations. * - * Adds a nullable `role_uuid` column to the `invites` table so that the - * intended role for an invited user can be stored on the invite record and - * applied when the user accepts the invitation. + * Adds a nullable JSON `meta` column to the `invites` table so that + * arbitrary key-value data (e.g. role_uuid for user invitations) can be + * stored on an invite record without requiring dedicated columns for each + * use-case. The HasMetaAttributes trait is used to read and write values. * * @return void */ public function up() { Schema::table('invites', function (Blueprint $table) { - $table->char('role_uuid', 36)->nullable()->after('reason'); + $table->json('meta')->nullable()->after('reason'); }); } @@ -29,7 +30,7 @@ public function up() public function down() { Schema::table('invites', function (Blueprint $table) { - $table->dropColumn('role_uuid'); + $table->dropColumn('meta'); }); } }; diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index c06d6837..51125a27 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -346,7 +346,7 @@ public function inviteUser(InviteUserRequest $request) 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role'), + 'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]), ]); $user->notify(new UserInvited($invitation)); @@ -389,7 +389,7 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', - 'role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role'), + 'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]), ]); $user->notify(new UserInvited($invitation)); @@ -470,9 +470,9 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) if (!$alreadyMember) { // Use Company::addUser() so that role assignment is handled in - // one place. The role stored on the invite takes precedence; + // one place. The role stored in the invite meta takes precedence; // if none was set the default 'Administrator' role is used. - $roleIdentifier = $invite->role_uuid ?? 'Administrator'; + $roleIdentifier = $invite->getMeta('role_uuid', 'Administrator'); $companyUser = $company->addUser($user, $roleIdentifier); $user->setRelation('companyUser', $companyUser); } else { @@ -480,8 +480,9 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) // loaded so that role assignment below can still be applied if // the invite carries a role (e.g. re-sent invite with a new role). $user->loadCompanyUser(); - if ($user->companyUser && $invite->role_uuid) { - $user->companyUser->assignSingleRole($invite->role_uuid); + $roleUuid = $invite->getMeta('role_uuid'); + if ($user->companyUser && $roleUuid) { + $user->companyUser->assignSingleRole($roleUuid); } } diff --git a/src/Models/Invite.php b/src/Models/Invite.php index 3c3825ce..53a86ef1 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\Traits\Expirable; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; use Illuminate\Support\Carbon; @@ -16,6 +17,7 @@ class Invite extends Model use HasPublicId; use Expirable; use HasApiModelBehavior; + use HasMetaAttributes; /** * The database table used by the model. @@ -81,7 +83,7 @@ public function getConnectionName(): string 'protocol', 'recipients', 'reason', - 'role_uuid', + 'meta', ]; /** @@ -105,6 +107,7 @@ public function getConnectionName(): string */ protected $casts = [ 'recipients' => Json::class, + 'meta' => Json::class, ]; /** From 0d4320ce17ab8bc65e77cd5aeaaab4437dd63238 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 01:17:53 -0400 Subject: [PATCH 16/26] fix: include pending-invite users in IAM user list The UserFilter::queryForInternal() was scoped exclusively to users with a CompanyUser row for the current company. Newly invited users have no CompanyUser record until they accept their invite, so they were invisible in the IAM list. The fix adds an orWhereExists() subquery that also matches users whose email address appears in the recipients JSON column of a non-deleted join_company invite for the current company. This covers both the brand-new pending user path and the cross-org invite path. JSON_CONTAINS(invites.recipients, JSON_QUOTE(users.email)) is used instead of whereHas() because the invites table has no user_uuid column; the user is identified solely by their email in the recipients array. --- src/Http/Filter/UserFilter.php | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 40fcedb6..6f9857c1 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -6,15 +6,29 @@ class UserFilter extends Filter { public function queryForInternal() { + $companyUuid = $this->session->get('company'); + $this->builder->where( - function ($query) { - $query - ->whereHas( - 'companyUsers', - function ($query) { - $query->where('company_uuid', $this->session->get('company')); - } - ); + function ($query) use ($companyUuid) { + // Include users who are already members of the company. + $query->whereHas( + 'companyUsers', + function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid); + } + ) + // Also include users who have a pending invite to join the company + // but have not yet accepted (no CompanyUser row exists yet). + // The invites table has no user_uuid; the link is via the user's + // email stored in the JSON recipients column. + ->orWhereExists(function ($q) use ($companyUuid) { + $q->selectRaw(1) + ->from('invites') + ->whereRaw('JSON_CONTAINS(invites.recipients, JSON_QUOTE(users.email))') + ->where('invites.company_uuid', $companyUuid) + ->where('invites.reason', 'join_company') + ->whereNull('invites.deleted_at'); + }); } ); } From 7c5d1ca9c1d5e9faa90ace4f29f5390fe9862f9a Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 01:26:50 -0400 Subject: [PATCH 17/26] fix: sync company_users to sandbox so invited users appear in IAM list The IAM user list is queried against the sandbox database when the console is in sandbox mode. CompanyUser rows (created by acceptCompanyInvite) were only written to production, so invited users who had accepted were invisible to the UserFilter::queryForInternal() whereHas('companyUsers') check. Add CompanyUser to the SyncSandbox syncable models list so that membership rows are mirrored to sandbox on every hourly sync. Also add company_users to the --truncate block for consistency. --- src/Console/Commands/SyncSandbox.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/SyncSandbox.php b/src/Console/Commands/SyncSandbox.php index 7073a9fd..c8ceebed 100644 --- a/src/Console/Commands/SyncSandbox.php +++ b/src/Console/Commands/SyncSandbox.php @@ -56,10 +56,13 @@ public function handle() DB::connection('sandbox') ->table('api_credentials') ->truncate(); + DB::connection('sandbox') + ->table('company_users') + ->truncate(); } // Models that need to be synced from Production to Sandbox - $syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\ApiCredential::class]; + $syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\CompanyUser::class, \Fleetbase\Models\ApiCredential::class]; // Sync each syncable data model foreach ($syncable as $model) { From a7294adf323d5d18b211dbd8c27bc254683e1965 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 01:36:19 -0400 Subject: [PATCH 18/26] fix: pin User, Company, CompanyUser to production connection via getConnectionName() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These models are authoritative in production. Sandbox contains only a read-only mirror synced by sandbox:sync. Using getConnectionName() ensures Eloquent always routes queries for these models to the production DB, regardless of what setSandboxSession() does to database.default or fleetbase.connection.db at runtime. Also reverts the previous SyncSandbox change that added CompanyUser to the syncable list — that was a workaround for the wrong root cause. --- src/Console/Commands/SyncSandbox.php | 5 +---- src/Models/Company.php | 10 ++++++++++ src/Models/CompanyUser.php | 10 ++++++++++ src/Models/User.php | 14 ++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Console/Commands/SyncSandbox.php b/src/Console/Commands/SyncSandbox.php index c8ceebed..7073a9fd 100644 --- a/src/Console/Commands/SyncSandbox.php +++ b/src/Console/Commands/SyncSandbox.php @@ -56,13 +56,10 @@ public function handle() DB::connection('sandbox') ->table('api_credentials') ->truncate(); - DB::connection('sandbox') - ->table('company_users') - ->truncate(); } // Models that need to be synced from Production to Sandbox - $syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\CompanyUser::class, \Fleetbase\Models\ApiCredential::class]; + $syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\ApiCredential::class]; // Sync each syncable data model foreach ($syncable as $model) { diff --git a/src/Models/Company.php b/src/Models/Company.php index 5b072fb9..88283b2e 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -42,6 +42,16 @@ class Company extends Model */ protected $connection = 'mysql'; + /** + * Always use the production database connection. + * + * @return string + */ + public function getConnectionName(): string + { + return env('DB_CONNECTION', 'mysql'); + } + /** * The database table used by the model. * diff --git a/src/Models/CompanyUser.php b/src/Models/CompanyUser.php index 41d85a40..62e5c71e 100644 --- a/src/Models/CompanyUser.php +++ b/src/Models/CompanyUser.php @@ -23,6 +23,16 @@ class CompanyUser extends Model */ protected $table = 'company_users'; + /** + * Always use the production database connection. + * + * @return string + */ + public function getConnectionName(): string + { + return env('DB_CONNECTION', 'mysql'); + } + /** * The attributes that are mass assignable. * diff --git a/src/Models/User.php b/src/Models/User.php index 881c7344..c07b5adb 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -74,6 +74,20 @@ class User extends Authenticatable */ protected $connection = 'mysql'; + /** + * Always use the production database connection. + * + * User records are authoritative in production and are only mirrored + * (read-only) to sandbox via sandbox:sync. Reads and writes must always + * target production regardless of whether sandbox mode is active. + * + * @return string + */ + public function getConnectionName(): string + { + return env('DB_CONNECTION', 'mysql'); + } + /** * Override the default primary key. * From 9f92a0110dbfb44d50ad6d2c26dcb4fdfc08b6d1 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 01:36:58 -0400 Subject: [PATCH 19/26] refactor: remove redundant invite subquery from UserFilter Now that User and CompanyUser are pinned to the production connection via getConnectionName(), the whereHas('companyUsers') check correctly finds all members regardless of sandbox mode. The orWhereExists invite subquery was a workaround for the wrong root cause and is no longer needed. --- src/Http/Filter/UserFilter.php | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 6f9857c1..1eb788b6 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -8,27 +8,10 @@ public function queryForInternal() { $companyUuid = $this->session->get('company'); - $this->builder->where( - function ($query) use ($companyUuid) { - // Include users who are already members of the company. - $query->whereHas( - 'companyUsers', - function ($q) use ($companyUuid) { - $q->where('company_uuid', $companyUuid); - } - ) - // Also include users who have a pending invite to join the company - // but have not yet accepted (no CompanyUser row exists yet). - // The invites table has no user_uuid; the link is via the user's - // email stored in the JSON recipients column. - ->orWhereExists(function ($q) use ($companyUuid) { - $q->selectRaw(1) - ->from('invites') - ->whereRaw('JSON_CONTAINS(invites.recipients, JSON_QUOTE(users.email))') - ->where('invites.company_uuid', $companyUuid) - ->where('invites.reason', 'join_company') - ->whereNull('invites.deleted_at'); - }); + $this->builder->whereHas( + 'companyUsers', + function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid); } ); } From 684d01ce3c3960cdf06e1ec497980ffaaffe2317 Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 01:37:30 -0400 Subject: [PATCH 20/26] feat: include CompanyUser in sandbox:sync for structural consistency Even though CompanyUser is pinned to production via getConnectionName(), keeping company_users mirrored in sandbox ensures the sandbox DB stays structurally consistent for raw SQL queries, reporting tools, and any future code that may bypass the model layer. --- src/Console/Commands/SyncSandbox.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Console/Commands/SyncSandbox.php b/src/Console/Commands/SyncSandbox.php index 7073a9fd..c8ceebed 100644 --- a/src/Console/Commands/SyncSandbox.php +++ b/src/Console/Commands/SyncSandbox.php @@ -56,10 +56,13 @@ public function handle() DB::connection('sandbox') ->table('api_credentials') ->truncate(); + DB::connection('sandbox') + ->table('company_users') + ->truncate(); } // Models that need to be synced from Production to Sandbox - $syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\ApiCredential::class]; + $syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\CompanyUser::class, \Fleetbase\Models\ApiCredential::class]; // Sync each syncable data model foreach ($syncable as $model) { From d5ffc7ad455caf6aa1de15b8e28350141e93e9ce Mon Sep 17 00:00:00 2001 From: Ronald A Richardson Date: Tue, 21 Apr 2026 01:40:44 -0400 Subject: [PATCH 21/26] fix: query users from production connection in UserController only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the broad getConnectionName() overrides from User, Company, and CompanyUser models — those would break sandbox relations system-wide. Instead, override queryRecord() in UserController to temporarily restore the production connection for the duration of the user list query only, then restore the sandbox connection immediately after. This is surgical: only the IAM /users list hits production; all other sandbox queries (orders, drivers, etc.) continue to use the sandbox connection normally. --- .../Internal/v1/UserController.php | 34 +++++++++++++++++++ src/Models/Company.php | 10 ------ src/Models/CompanyUser.php | 10 ------ src/Models/User.php | 14 -------- 4 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 51125a27..97280987 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -68,6 +68,40 @@ class UserController extends FleetbaseController */ public $updateRequest = UpdateUserRequest::class; + /** + * Query users always against the production database. + * + * Users are authoritative in production. The sandbox database contains + * only a mirrored copy. Temporarily restoring the production connection + * for this query ensures the IAM list is correct regardless of whether + * the console is in sandbox mode, without affecting any other sandbox + * queries in the same request lifecycle. + * + * @return \Illuminate\Http\Response + */ + public function queryRecord(Request $request) + { + $isSandbox = config('fleetbase.connection.db') === 'sandbox'; + + if ($isSandbox) { + config([ + 'database.default' => env('DB_CONNECTION', 'mysql'), + 'fleetbase.connection.db' => env('DB_CONNECTION', 'mysql'), + ]); + } + + $response = parent::queryRecord($request); + + if ($isSandbox) { + config([ + 'database.default' => 'sandbox', + 'fleetbase.connection.db' => 'sandbox', + ]); + } + + return $response; + } + /** * Creates a record with request payload. * diff --git a/src/Models/Company.php b/src/Models/Company.php index 88283b2e..5b072fb9 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -42,16 +42,6 @@ class Company extends Model */ protected $connection = 'mysql'; - /** - * Always use the production database connection. - * - * @return string - */ - public function getConnectionName(): string - { - return env('DB_CONNECTION', 'mysql'); - } - /** * The database table used by the model. * diff --git a/src/Models/CompanyUser.php b/src/Models/CompanyUser.php index 62e5c71e..41d85a40 100644 --- a/src/Models/CompanyUser.php +++ b/src/Models/CompanyUser.php @@ -23,16 +23,6 @@ class CompanyUser extends Model */ protected $table = 'company_users'; - /** - * Always use the production database connection. - * - * @return string - */ - public function getConnectionName(): string - { - return env('DB_CONNECTION', 'mysql'); - } - /** * The attributes that are mass assignable. * diff --git a/src/Models/User.php b/src/Models/User.php index c07b5adb..881c7344 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -74,20 +74,6 @@ class User extends Authenticatable */ protected $connection = 'mysql'; - /** - * Always use the production database connection. - * - * User records are authoritative in production and are only mirrored - * (read-only) to sandbox via sandbox:sync. Reads and writes must always - * target production regardless of whether sandbox mode is active. - * - * @return string - */ - public function getConnectionName(): string - { - return env('DB_CONNECTION', 'mysql'); - } - /** * Override the default primary key. * From bb1d8a42f58b28c19aeaeede8ffc14b0e414671c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 21 Apr 2026 14:57:50 +0800 Subject: [PATCH 22/26] Ran linter and fixed invite flow --- ...heduled_status_to_schedule_items_table.php | 3 +- ...d_company_uuid_to_schedule_items_table.php | 6 +- .../v1/ScheduleExceptionController.php | 2 - .../v1/ScheduleTemplateController.php | 2 - .../Internal/v1/UserController.php | 23 +++++-- src/Http/Filter/ScheduleExceptionFilter.php | 1 + src/Http/Filter/ScheduleFilter.php | 1 + src/Http/Filter/ScheduleItemFilter.php | 1 + src/Http/Filter/ScheduleTemplateFilter.php | 1 + src/Http/Filter/UserFilter.php | 25 ++++++-- src/Http/Resources/ScheduleException.php | 2 - src/Http/Resources/ScheduleTemplate.php | 2 - src/Models/Invite.php | 2 - src/Models/Schedule.php | 40 +++++------- src/Models/ScheduleException.php | 39 ++++------- src/Models/ScheduleItem.php | 32 ++++------ src/Models/ScheduleTemplate.php | 64 ++++++++----------- src/Services/Scheduling/ScheduleService.php | 27 ++------ 18 files changed, 124 insertions(+), 149 deletions(-) diff --git a/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php b/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php index 9ebac7f6..edac3f7c 100644 --- a/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php +++ b/migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php @@ -13,8 +13,7 @@ * 'pending' is retained for backwards compatibility (e.g. manually-created items * that have not yet been confirmed). */ -return new class extends Migration -{ +return new class extends Migration { public function up(): void { DB::statement(" diff --git a/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php b/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php index 0e1aa29e..5693360e 100644 --- a/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php +++ b/migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php @@ -2,8 +2,8 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Schema; return new class extends Migration { public function up(): void @@ -13,13 +13,13 @@ public function up(): void }); // Backfill from the parent schedule - DB::statement(" + DB::statement(' UPDATE schedule_items si JOIN schedules s ON s.uuid = si.schedule_uuid SET si.company_uuid = s.company_uuid WHERE si.company_uuid IS NULL AND si.schedule_uuid IS NOT NULL - "); + '); } public function down(): void diff --git a/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php b/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php index 9aba1ff8..44679a15 100644 --- a/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php +++ b/src/Http/Controllers/Internal/v1/ScheduleExceptionController.php @@ -19,8 +19,6 @@ class ScheduleExceptionController extends FleetbaseController /** * The ScheduleService instance. - * - * @var ScheduleService */ protected ScheduleService $scheduleService; diff --git a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php index cb47d17d..6c21e463 100644 --- a/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php +++ b/src/Http/Controllers/Internal/v1/ScheduleTemplateController.php @@ -20,8 +20,6 @@ class ScheduleTemplateController extends FleetbaseController /** * The ScheduleService instance. - * - * @var ScheduleService */ protected ScheduleService $scheduleService; diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 97280987..ca00807e 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -372,6 +372,17 @@ public function inviteUser(InviteUserRequest $request) $user = User::create($data); + // Set user type + $user->setUserType('user'); + + // Assign to user + $user->assignCompany($company, $request->input('user.role_uuid')); + + // Assign role if set + if ($request->filled('user.role_uuid')) { + $user->assignSingleRole($request->input('user.role_uuid')); + } + $invitation = Invite::create([ 'company_uuid' => $company->uuid, 'created_by_uuid' => session('user'), @@ -381,6 +392,7 @@ public function inviteUser(InviteUserRequest $request) 'recipients' => [$user->email], 'reason' => 'join_company', 'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]), + 'expires_at' => now()->addHours(48), ]); $user->notify(new UserInvited($invitation)); @@ -397,10 +409,8 @@ public function inviteUser(InviteUserRequest $request) * (the dedicated invite endpoint). Keeping the logic in one place ensures * both paths behave identically. * - * @param User $user The existing user to invite. - * @param Request $request The originating HTTP request. - * - * @return \Illuminate\Http\JsonResponse + * @param User $user the existing user to invite + * @param Request $request the originating HTTP request */ private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse { @@ -424,6 +434,7 @@ private function inviteExistingUser(User $user, Request $request): \Illuminate\H 'recipients' => [$user->email], 'reason' => 'join_company', 'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]), + 'expires_at' => now()->addHours(48), ]); $user->notify(new UserInvited($invitation)); @@ -456,6 +467,7 @@ public function resendInvitation(ResendUserInvite $request) 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', + 'expires_at' => now()->addHours(48), ]); // notify user @@ -520,6 +532,9 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request) } } + // Delete the invite + $invite->delete(); + // Switch the user's active company to the one they just joined. // This ensures that subsequent calls to /users/me resolve the // companyUser relationship (and therefore role/policies) against diff --git a/src/Http/Filter/ScheduleExceptionFilter.php b/src/Http/Filter/ScheduleExceptionFilter.php index 4088b893..17393087 100644 --- a/src/Http/Filter/ScheduleExceptionFilter.php +++ b/src/Http/Filter/ScheduleExceptionFilter.php @@ -60,6 +60,7 @@ public function subjectType(?string $type) if (Str::contains($type, '\\')) { $this->builder->where('subject_type', $type); + return; } diff --git a/src/Http/Filter/ScheduleFilter.php b/src/Http/Filter/ScheduleFilter.php index 42d506a4..2c82fdd6 100644 --- a/src/Http/Filter/ScheduleFilter.php +++ b/src/Http/Filter/ScheduleFilter.php @@ -36,6 +36,7 @@ public function subjectType(?string $type) // If it already looks like a fully-qualified class name, use as-is if (Str::contains($type, '\\')) { $this->builder->where('subject_type', $type); + return; } diff --git a/src/Http/Filter/ScheduleItemFilter.php b/src/Http/Filter/ScheduleItemFilter.php index 4a9af2eb..d59f5fff 100644 --- a/src/Http/Filter/ScheduleItemFilter.php +++ b/src/Http/Filter/ScheduleItemFilter.php @@ -67,6 +67,7 @@ public function assigneeType(?string $type) if (Str::contains($type, '\\')) { $this->builder->where('assignee_type', $type); + return; } diff --git a/src/Http/Filter/ScheduleTemplateFilter.php b/src/Http/Filter/ScheduleTemplateFilter.php index 635f7717..c999068e 100644 --- a/src/Http/Filter/ScheduleTemplateFilter.php +++ b/src/Http/Filter/ScheduleTemplateFilter.php @@ -33,6 +33,7 @@ public function subjectType(?string $type) if (Str::contains($type, '\\')) { $this->builder->where('subject_type', $type); + return; } diff --git a/src/Http/Filter/UserFilter.php b/src/Http/Filter/UserFilter.php index 1eb788b6..6f9857c1 100644 --- a/src/Http/Filter/UserFilter.php +++ b/src/Http/Filter/UserFilter.php @@ -8,10 +8,27 @@ public function queryForInternal() { $companyUuid = $this->session->get('company'); - $this->builder->whereHas( - 'companyUsers', - function ($q) use ($companyUuid) { - $q->where('company_uuid', $companyUuid); + $this->builder->where( + function ($query) use ($companyUuid) { + // Include users who are already members of the company. + $query->whereHas( + 'companyUsers', + function ($q) use ($companyUuid) { + $q->where('company_uuid', $companyUuid); + } + ) + // Also include users who have a pending invite to join the company + // but have not yet accepted (no CompanyUser row exists yet). + // The invites table has no user_uuid; the link is via the user's + // email stored in the JSON recipients column. + ->orWhereExists(function ($q) use ($companyUuid) { + $q->selectRaw(1) + ->from('invites') + ->whereRaw('JSON_CONTAINS(invites.recipients, JSON_QUOTE(users.email))') + ->where('invites.company_uuid', $companyUuid) + ->where('invites.reason', 'join_company') + ->whereNull('invites.deleted_at'); + }); } ); } diff --git a/src/Http/Resources/ScheduleException.php b/src/Http/Resources/ScheduleException.php index bd97610a..9dba1b95 100644 --- a/src/Http/Resources/ScheduleException.php +++ b/src/Http/Resources/ScheduleException.php @@ -2,8 +2,6 @@ namespace Fleetbase\Http\Resources; -use Fleetbase\Http\Resources\FleetbaseResource; - class ScheduleException extends FleetbaseResource { /** diff --git a/src/Http/Resources/ScheduleTemplate.php b/src/Http/Resources/ScheduleTemplate.php index a865564f..e1721f1c 100644 --- a/src/Http/Resources/ScheduleTemplate.php +++ b/src/Http/Resources/ScheduleTemplate.php @@ -2,8 +2,6 @@ namespace Fleetbase\Http\Resources; -use Fleetbase\Http\Resources\FleetbaseResource; - class ScheduleTemplate extends FleetbaseResource { /** diff --git a/src/Models/Invite.php b/src/Models/Invite.php index 53a86ef1..56fbbdcb 100644 --- a/src/Models/Invite.php +++ b/src/Models/Invite.php @@ -51,8 +51,6 @@ class Invite extends Model * by sandbox session switching. Invite codes must be resolvable when * the invited user accepts the link, which always happens outside of * sandbox context. - * - * @return string */ public function getConnectionName(): string { diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index 47a4aa2b..32d91f41 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -22,19 +22,19 @@ * expands their RRULEs using rlanvin/php-rrule, and writes ScheduleItem rows for a rolling * window. The last_materialized_at and materialization_horizon columns track the engine's progress. * - * @property string $uuid - * @property string $public_id - * @property string $company_uuid - * @property string $subject_uuid - * @property string $subject_type - * @property string|null $name - * @property string|null $description + * @property string $uuid + * @property string $public_id + * @property string $company_uuid + * @property string $subject_uuid + * @property string $subject_type + * @property string|null $name + * @property string|null $description * @property \Carbon\Carbon|null $start_date * @property \Carbon\Carbon|null $end_date - * @property string|null $timezone - * @property string $status + * @property string|null $timezone + * @property string $status * @property \Carbon\Carbon|null $last_materialized_at - * @property string|null $materialization_horizon + * @property string|null $materialization_horizon */ class Schedule extends Model { @@ -96,14 +96,14 @@ class Schedule extends Model * @var array */ protected $casts = [ - 'start_date' => 'date', - 'end_date' => 'date', - 'last_materialized_at' => 'datetime', + 'start_date' => 'date', + 'end_date' => 'date', + 'last_materialized_at' => 'datetime', 'materialization_horizon' => 'date', - 'meta' => Json::class, - 'subject_type' => PolymorphicType::class, - 'hos_daily_limit' => 'integer', - 'hos_weekly_limit' => 'integer', + 'meta' => Json::class, + 'subject_type' => PolymorphicType::class, + 'hos_daily_limit' => 'integer', + 'hos_weekly_limit' => 'integer', ]; /** @@ -238,10 +238,6 @@ public function scopeNeedsMaterialization($query, $date) /** * Determine whether this schedule needs materialization up to the given date. - * - * @param \Carbon\Carbon $upTo - * - * @return bool */ public function needsMaterializationUpTo(\Carbon\Carbon $upTo): bool { @@ -251,8 +247,6 @@ public function needsMaterializationUpTo(\Carbon\Carbon $upTo): bool /** * Get the effective timezone for this schedule, falling back to UTC. - * - * @return string */ public function getEffectiveTimezone(): string { diff --git a/src/Models/ScheduleException.php b/src/Models/ScheduleException.php index 27df3da3..41a666db 100644 --- a/src/Models/ScheduleException.php +++ b/src/Models/ScheduleException.php @@ -17,19 +17,19 @@ * When the materialization engine generates ScheduleItem records from a ScheduleTemplate's RRULE, * it checks this table and skips (or cancels) any occurrence that falls within an approved exception window. * - * @property string $uuid - * @property string $public_id - * @property string $company_uuid - * @property string $subject_uuid - * @property string $subject_type - * @property string|null $schedule_uuid + * @property string $uuid + * @property string $public_id + * @property string $company_uuid + * @property string $subject_uuid + * @property string $subject_type + * @property string|null $schedule_uuid * @property \Carbon\Carbon|null $start_at * @property \Carbon\Carbon|null $end_at - * @property string|null $type - * @property string $status - * @property string|null $reason - * @property string|null $notes - * @property string|null $reviewed_by_uuid + * @property string|null $type + * @property string $status + * @property string|null $reason + * @property string|null $notes + * @property string|null $reviewed_by_uuid * @property \Carbon\Carbon|null $reviewed_at */ class ScheduleException extends Model @@ -121,12 +121,12 @@ class ScheduleException extends Model /** * Valid exception types. */ - const TYPES = ['time_off', 'sick', 'holiday', 'swap', 'training', 'other']; + public const TYPES = ['time_off', 'sick', 'holiday', 'swap', 'training', 'other']; /** * Valid workflow statuses. */ - const STATUSES = ['pending', 'approved', 'rejected', 'cancelled']; + public const STATUSES = ['pending', 'approved', 'rejected', 'cancelled']; // ─── Relationships ──────────────────────────────────────────────────────── @@ -190,7 +190,6 @@ public function scopePending($query) * Scope: filter by exception type. * * @param \Illuminate\Database\Eloquent\Builder $query - * @param string $type * * @return \Illuminate\Database\Eloquent\Builder */ @@ -203,8 +202,6 @@ public function scopeOfType($query, string $type) * Scope: filter by subject (polymorphic). * * @param \Illuminate\Database\Eloquent\Builder $query - * @param string $type - * @param string $uuid * * @return \Illuminate\Database\Eloquent\Builder */ @@ -248,8 +245,6 @@ public function scopeCoveringDate($query, $date) /** * Get a human-readable label for the exception type. - * - * @return string */ public function getTypeLabelAttribute(): string { @@ -267,8 +262,6 @@ public function getTypeLabelAttribute(): string /** * Determine whether this exception is in pending status. - * - * @return bool */ public function getIsPendingAttribute(): bool { @@ -280,8 +273,6 @@ public function getIsPendingAttribute(): bool /** * Approve this exception. * - * @param string|null $reviewerUuid - * * @return $this */ public function approve(?string $reviewerUuid = null): self @@ -298,8 +289,6 @@ public function approve(?string $reviewerUuid = null): self /** * Reject this exception. * - * @param string|null $reviewerUuid - * * @return $this */ public function reject(?string $reviewerUuid = null): self @@ -315,8 +304,6 @@ public function reject(?string $reviewerUuid = null): self /** * Determine whether this exception is currently active (approved and covering now). - * - * @return bool */ public function isActive(): bool { diff --git a/src/Models/ScheduleItem.php b/src/Models/ScheduleItem.php index 829250da..d9a965b7 100644 --- a/src/Models/ScheduleItem.php +++ b/src/Models/ScheduleItem.php @@ -24,22 +24,22 @@ * and exception_for_date records the original RRULE occurrence date. The materialization * engine will never overwrite items where is_exception = true. * - * @property string $uuid - * @property string $public_id - * @property string|null $schedule_uuid - * @property string|null $template_uuid - * @property string|null $assignee_uuid - * @property string|null $assignee_type - * @property string|null $resource_uuid - * @property string|null $resource_type + * @property string $uuid + * @property string $public_id + * @property string|null $schedule_uuid + * @property string|null $template_uuid + * @property string|null $assignee_uuid + * @property string|null $assignee_type + * @property string|null $resource_uuid + * @property string|null $resource_type * @property \Carbon\Carbon|null $start_at * @property \Carbon\Carbon|null $end_at - * @property int|null $duration + * @property int|null $duration * @property \Carbon\Carbon|null $break_start_at * @property \Carbon\Carbon|null $break_end_at - * @property string $status - * @property bool $is_exception - * @property string|null $exception_for_date + * @property string $status + * @property bool $is_exception + * @property string|null $exception_for_date */ class ScheduleItem extends Model { @@ -293,8 +293,6 @@ public function scopeByStatus($query, $status) /** * Calculate the duration in minutes from start_at and end_at. - * - * @return int */ public function calculateDuration(): int { @@ -325,8 +323,6 @@ public function markAsException(): self /** * Determine whether this item is currently active (in progress). - * - * @return bool */ public function isActive(): bool { @@ -345,7 +341,7 @@ protected static function boot() static::creating(function ($item) { if (empty($item->company_uuid)) { if ($item->schedule_uuid) { - $schedule = \Fleetbase\Models\Schedule::where('uuid', $item->schedule_uuid)->first(); + $schedule = Schedule::where('uuid', $item->schedule_uuid)->first(); if ($schedule) { $item->company_uuid = $schedule->company_uuid; } @@ -366,7 +362,7 @@ protected static function boot() // When a materialized item is manually updated, flag it as an exception static::updating(function ($item) { if ($item->template_uuid && !$item->is_exception) { - $dirty = $item->getDirty(); + $dirty = $item->getDirty(); $scheduleFields = ['start_at', 'end_at', 'break_start_at', 'break_end_at', 'status']; if (count(array_intersect(array_keys($dirty), $scheduleFields)) > 0) { $item->is_exception = true; diff --git a/src/Models/ScheduleTemplate.php b/src/Models/ScheduleTemplate.php index 8838d7a6..dac4103b 100644 --- a/src/Models/ScheduleTemplate.php +++ b/src/Models/ScheduleTemplate.php @@ -27,20 +27,20 @@ * to the driver's Schedule. This ensures that editing a driver's applied template does not * affect the original library template or other drivers using it. * - * @property string $uuid - * @property string $public_id - * @property string $company_uuid + * @property string $uuid + * @property string $public_id + * @property string $company_uuid * @property string|null $schedule_uuid * @property string|null $subject_uuid * @property string|null $subject_type - * @property string $name + * @property string $name * @property string|null $description - * @property string|null $start_time e.g. "08:00" - * @property string|null $end_time e.g. "16:00" - * @property int|null $duration shift duration in minutes - * @property int|null $break_duration break duration in minutes - * @property string|null $rrule RFC 5545 RRULE string e.g. "FREQ=WEEKLY;BYDAY=MO,TU,TH" - * @property string|null $color Hex colour for calendar rendering e.g. "#6366f1" + * @property string|null $start_time e.g. "08:00" + * @property string|null $end_time e.g. "16:00" + * @property int|null $duration shift duration in minutes + * @property int|null $break_duration break duration in minutes + * @property string|null $rrule RFC 5545 RRULE string e.g. "FREQ=WEEKLY;BYDAY=MO,TU,TH" + * @property string|null $color Hex colour for calendar rendering e.g. "#6366f1" */ class ScheduleTemplate extends Model { @@ -213,8 +213,6 @@ public function scopeForSubject($query, $type, $uuid) /** * Determine whether this template has a valid RRULE string. - * - * @return bool */ public function hasRrule(): bool { @@ -225,10 +223,8 @@ public function hasRrule(): bool * Parse the RRULE string and return an RRule instance. * The DTSTART is synthesized from the template's start_time and the given reference date. * - * @param \Carbon\Carbon|null $referenceDate The date from which to start the rule (defaults to today) - * @param string|null $timezone Timezone to use (defaults to UTC) - * - * @return \RRule\RRule|null + * @param \Carbon\Carbon|null $referenceDate The date from which to start the rule (defaults to today) + * @param string|null $timezone Timezone to use (defaults to UTC) */ public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string $timezone = null): ?RRule { @@ -267,9 +263,7 @@ public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string // Throw a clear RuntimeException so the API returns a 500 with a // meaningful message instead of silently materialising 0 items. if (!class_exists('RRule\\RRule')) { - throw new \RuntimeException( - 'php-rrule is not installed. Run: composer require rlanvin/php-rrule inside the API container.' - ); + throw new \RuntimeException('php-rrule is not installed. Run: composer require rlanvin/php-rrule inside the API container.'); } try { @@ -300,10 +294,6 @@ public function getRruleInstance(?\Carbon\Carbon $referenceDate = null, ?string /** * Get all occurrence dates between two Carbon dates. * - * @param \Carbon\Carbon $from - * @param \Carbon\Carbon $to - * @param string|null $timezone - * * @return \Carbon\Carbon[] */ public function getOccurrencesBetween(\Carbon\Carbon $from, \Carbon\Carbon $to, ?string $timezone = null): array @@ -331,28 +321,24 @@ public function getOccurrencesBetween(\Carbon\Carbon $from, \Carbon\Carbon $to, /** * Apply this library template to a given Schedule, creating a driver-specific copy. * - * @param Schedule $schedule - * @param string|null $subjectType - * @param string|null $subjectUuid - * * @return static */ public function applyToSchedule(Schedule $schedule, ?string $subjectType = null, ?string $subjectUuid = null): self { return static::create([ - 'company_uuid' => $this->company_uuid, - 'schedule_uuid' => $schedule->uuid, - 'subject_type' => $subjectType ?? $schedule->subject_type, - 'subject_uuid' => $subjectUuid ?? $schedule->subject_uuid, - 'name' => $this->name, - 'description' => $this->description, - 'start_time' => $this->start_time, - 'end_time' => $this->end_time, - 'duration' => $this->duration, + 'company_uuid' => $this->company_uuid, + 'schedule_uuid' => $schedule->uuid, + 'subject_type' => $subjectType ?? $schedule->subject_type, + 'subject_uuid' => $subjectUuid ?? $schedule->subject_uuid, + 'name' => $this->name, + 'description' => $this->description, + 'start_time' => $this->start_time, + 'end_time' => $this->end_time, + 'duration' => $this->duration, 'break_duration' => $this->break_duration, - 'rrule' => $this->rrule, - 'color' => $this->color, - 'meta' => $this->meta, + 'rrule' => $this->rrule, + 'color' => $this->color, + 'meta' => $this->meta, ]); } } diff --git a/src/Services/Scheduling/ScheduleService.php b/src/Services/Scheduling/ScheduleService.php index b5c7271a..d4dcf198 100644 --- a/src/Services/Scheduling/ScheduleService.php +++ b/src/Services/Scheduling/ScheduleService.php @@ -17,7 +17,7 @@ class ScheduleService * The number of days ahead to materialize shifts for. * The rolling window will always extend at least this many days from today. */ - const MATERIALIZATION_WINDOW_DAYS = 60; + public const MATERIALIZATION_WINDOW_DAYS = 60; // ─── Schedule CRUD ──────────────────────────────────────────────────────── @@ -256,10 +256,7 @@ public function rejectException(ScheduleException $exception, ?string $reviewerU * This creates a driver-specific copy of the template linked to the schedule, * then immediately materializes it for the rolling window. * - * @param ScheduleTemplate $template - * @param Schedule $schedule - * - * @return array{template: ScheduleTemplate, items_created: int} The applied template copy and the number of ScheduleItems created + * @return array{template: ScheduleTemplate, items_created: int} The applied template copy and the number of ScheduleItems created */ public function applyTemplateToSchedule(ScheduleTemplate $template, Schedule $schedule): array { @@ -328,10 +325,9 @@ public function materializeAll(): array /** * Materialize a single Schedule up to the given horizon date. * - * @param Schedule $schedule - * @param Carbon|null $horizon Defaults to today + MATERIALIZATION_WINDOW_DAYS + * @param Carbon|null $horizon Defaults to today + MATERIALIZATION_WINDOW_DAYS * - * @return int Number of ScheduleItem records created + * @return int Number of ScheduleItem records created */ public function materializeSchedule(Schedule $schedule, ?Carbon $horizon = null): int { @@ -365,16 +361,13 @@ public function materializeSchedule(Schedule $schedule, ?Carbon $horizon = null) * - A ScheduleItem already exists for that date (idempotency) * 5. Creates a new ScheduleItem for each remaining occurrence * - * @param ScheduleTemplate $template - * @param Schedule $schedule - * @param Carbon|null $horizon - * - * @return int Number of ScheduleItem records created + * @return int Number of ScheduleItem records created */ public function materializeTemplate(ScheduleTemplate $template, Schedule $schedule, ?Carbon $horizon = null): int { if (!$template->hasRrule()) { Log::debug('[materializeTemplate] no rrule on template', ['template_uuid' => $template->uuid]); + return 0; } @@ -400,7 +393,7 @@ public function materializeTemplate(ScheduleTemplate $template, Schedule $schedu Log::debug('[materializeTemplate] occurrences', [ 'template_uuid' => $template->uuid, 'count' => count($occurrences), - 'first_3' => array_map(fn($c) => $c->toDateTimeString(), array_slice($occurrences, 0, 3)), + 'first_3' => array_map(fn ($c) => $c->toDateTimeString(), array_slice($occurrences, 0, 3)), ]); if (empty($occurrences)) { @@ -531,12 +524,6 @@ public function getScheduleItemsForAssignee(string $assigneeType, string $assign /** * Get the active shift for a specific assignee on a given date. * Returns null if the assignee has no shift on that date or has an approved exception. - * - * @param string $assigneeType - * @param string $assigneeUuid - * @param Carbon $date - * - * @return ScheduleItem|null */ public function getActiveShiftFor(string $assigneeType, string $assigneeUuid, Carbon $date): ?ScheduleItem { From f38d47ca9ec86718cf9eed1565d1da6fbd0495ca Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 21 Apr 2026 14:58:45 +0800 Subject: [PATCH 23/26] v1.6.41 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 8e2ca05d..a82b8108 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.40", + "version": "1.6.41", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", From c42838850feb13db781b79d4aec478e33e8b258a Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 21 Apr 2026 15:01:22 +0800 Subject: [PATCH 24/26] ran linter --- .../Controllers/Internal/v1/ApiCredentialController.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/ApiCredentialController.php b/src/Http/Controllers/Internal/v1/ApiCredentialController.php index eb98af49..a499e416 100644 --- a/src/Http/Controllers/Internal/v1/ApiCredentialController.php +++ b/src/Http/Controllers/Internal/v1/ApiCredentialController.php @@ -65,10 +65,6 @@ public function createRecord(Request $request) * This is a targeted, on-demand version of the `sandbox:sync` Artisan command, * scoped only to the records needed to satisfy the foreign key constraints when * inserting a new test-mode `api_credentials` row. - * - * @param Request $request - * - * @return void */ protected function syncCurrentSessionToSandbox(Request $request): void { @@ -116,10 +112,6 @@ protected function syncCurrentSessionToSandbox(Request $request): void * reduces the record to its fillable attributes, normalises datetime * fields to strings, JSON-encodes any Json-cast columns, then performs * an `updateOrInsert` keyed on `uuid`. - * - * @param \Illuminate\Database\Eloquent\Model $model - * - * @return void */ protected function upsertModelToSandbox(\Illuminate\Database\Eloquent\Model $model): void { From b28bede01c5f4d2409b6d07bf41a7cbdf7957ded Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 21 Apr 2026 15:41:22 +0800 Subject: [PATCH 25/26] Fixed to use the correct connection at runtime as setting config values in middleware can happen after a controller is resolved --- src/Models/Model.php | 12 +++--------- src/Models/Setting.php | 14 +++++--------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/Models/Model.php b/src/Models/Model.php index 9e0d7fb9..ac2e58ec 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -31,17 +31,11 @@ class Model extends EloquentModel public const PUBLIC_ID_COLUMN = 'public_id'; /** - * Create a new instance of the model. - * - * @param array $attributes the attributes to set on the model - * - * @return void + * Get the correct current connection to use. */ - public function __construct(array $attributes = []) + public function getConnectionName() { - parent::__construct($attributes); - - $this->connection = config('fleetbase.connection.db', 'mysql'); + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); } /** diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 1f5509d7..df1a299f 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -21,17 +21,13 @@ class Setting extends EloquentModel use Filterable; /** - * Create a new instance of the model. - * - * @param array $attributes the attributes to set on the model - * - * @return void + * Get the correct current connection to use. + * NOTE FOR LATER: Perhaps system level settings should always write to main connection, + * but allow organization level settings to write to sandbox connection. */ - public function __construct(array $attributes = []) + public function getConnectionName() { - parent::__construct($attributes); - - $this->connection = config('fleetbase.connection.db', 'mysql'); + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); } /** From 383b9c5efcf47aa5dc1f8f77e97776a486058e27 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 21 Apr 2026 16:12:07 +0800 Subject: [PATCH 26/26] Fix invitation flow --- .../Internal/v1/UserController.php | 26 ++++++++++--------- src/Models/User.php | 1 + 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index ca00807e..5f0e85f3 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -383,19 +383,21 @@ public function inviteUser(InviteUserRequest $request) $user->assignSingleRole($request->input('user.role_uuid')); } - $invitation = Invite::create([ - 'company_uuid' => $company->uuid, - 'created_by_uuid' => session('user'), - 'subject_uuid' => $company->uuid, - 'subject_type' => Utils::getMutationType($company), - 'protocol' => 'email', - 'recipients' => [$user->email], - 'reason' => 'join_company', - 'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]), - 'expires_at' => now()->addHours(48), - ]); + if (!Invite::isAlreadySentToJoinCompany($user, $company)) { + $invitation = Invite::create([ + 'company_uuid' => $company->uuid, + 'created_by_uuid' => session('user'), + 'subject_uuid' => $company->uuid, + 'subject_type' => Utils::getMutationType($company), + 'protocol' => 'email', + 'recipients' => [$user->email], + 'reason' => 'join_company', + 'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]), + 'expires_at' => now()->addHours(48), + ]); - $user->notify(new UserInvited($invitation)); + $user->notify(new UserInvited($invitation)); + } return response()->json(['user' => new $this->resource($user)]); } diff --git a/src/Models/User.php b/src/Models/User.php index 881c7344..ee26c651 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -1149,6 +1149,7 @@ public function sendInviteFromCompany(?Company $company = null): bool 'protocol' => 'email', 'recipients' => [$this->email], 'reason' => 'join_company', + 'expires_at' => now()->addHours(48), ]); // notify user