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/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..ca00807e 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,63 +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(); + + if (!$company) { + return response()->error('Unable to determine the current organisation.'); + } - // 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 + // Check if user already exists in the system. + $user = User::where('email', $email)->whereNull('deleted_at')->first(); - // 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 ($user) { + // Guard: already a member of this organisation. + $alreadyMember = $user->companyUsers() + ->where('company_uuid', $company->uuid) + ->exists(); - if ($isAlreadyInvited) { - return response()->error('This user has already been invited to join this organization.'); + if ($alreadyMember) { + return response()->error('This user is already a member of your organisation.'); + } + + // 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')); } - // 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)]); } + /** + * 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.'); + } + + $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 `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, + ]); + } + /** * Resend invitation to pending user. * @@ -345,6 +467,7 @@ public function resendInvitation(ResendUserInvite $request) 'protocol' => 'email', 'recipients' => [$user->email], 'reason' => 'join_company', + 'expires_at' => now()->addHours(48), ]); // notify user @@ -384,11 +507,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..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/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..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'); } /** 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'));