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", 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/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..6ce57d0f --- /dev/null +++ b/migrations/2026_04_21_000001_add_role_uuid_to_invites_table.php @@ -0,0 +1,36 @@ +json('meta')->nullable()->after('reason'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('invites', function (Blueprint $table) { + $table->dropColumn('meta'); + }); + } +}; 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) { diff --git a/src/Http/Controllers/Internal/v1/ApiCredentialController.php b/src/Http/Controllers/Internal/v1/ApiCredentialController.php index a3a994e8..a499e416 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,124 @@ 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. + */ + 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`. + */ + 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. * 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 ca060c1d..5f0e85f3 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -68,15 +68,74 @@ 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. * + * 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,61 +327,126 @@ 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(); - // 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 (!$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(); + + 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); + // 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')); + } + + 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)); + } + + 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 + */ + 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), '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), ]); - // 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, + ]); } /** @@ -345,6 +469,7 @@ public function resendInvitation(ResendUserInvite $request) 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', + 'expires_at' => now()->addHours(48), ]); // notify user @@ -384,11 +509,40 @@ 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) { + // Use Company::addUser() so that role assignment is handled in + // one place. The role stored in the invite meta takes precedence; + // if none was set the default 'Administrator' role is used. + $roleIdentifier = $invite->getMeta('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(); + $roleUuid = $invite->getMeta('role_uuid'); + if ($user->companyUser && $roleUuid) { + $user->companyUser->assignSingleRole($roleUuid); + } + } + + // 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 + // the correct company rather than the user's previous company. + $user->company_uuid = $company->uuid; + $user->save(); // activate user if ($isPending) { 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 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'); + }); } ); } diff --git a/src/Http/Requests/CreateUserRequest.php b/src/Http/Requests/CreateUserRequest.php index d7c06000..7cc861cb 100644 --- a/src/Http/Requests/CreateUserRequest.php +++ b/src/Http/Requests/CreateUserRequest.php @@ -30,14 +30,25 @@ 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 `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 */ 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(), 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'], ]; @@ -53,7 +64,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.', 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/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), diff --git a/src/Models/Invite.php b/src/Models/Invite.php index f921eb31..56fbbdcb 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. @@ -38,6 +40,23 @@ class Invite extends Model */ protected $connection = 'mysql'; + /** + * Invites must always be stored in the production database. + * + * 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. + */ + public function getConnectionName(): string + { + return env('DB_CONNECTION', 'mysql'); + } + /** * These attributes that can be queried. * @@ -62,6 +81,7 @@ class Invite extends Model 'protocol', 'recipients', 'reason', + 'meta', ]; /** @@ -85,6 +105,7 @@ class Invite extends Model */ protected $casts = [ 'recipients' => Json::class, + 'meta' => Json::class, ]; /** diff --git a/src/Models/Model.php b/src/Models/Model.php index c4bdd904..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.db.connection'); + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); } /** 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/Models/Setting.php b/src/Models/Setting.php index 91f1eec9..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.db.connection'); + return $this->connection ?: config('fleetbase.connection.db', 'mysql'); } /** 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 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(); 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 { 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) { 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'));