Merged
Conversation
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.
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.
## 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.
…ting
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.
…switching
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.
…onnectionName() 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.
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.
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.
…ng to sandbox
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
…e config
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.
…ting 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.
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.
## 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.
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.
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')
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.
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.
…onnectionName() 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.
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.
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.
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.
fix: sync user/company to sandbox before creating test API key
feat: schedule sandbox:sync to run hourly
fix: restore cross-organisation invite flow for existing users
…es in middleware can happen after a controller is resolved
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.