From 2c1935510d07e1414a927b46f6cbe7eb1dc211b6 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Jan 2026 09:13:13 +0800 Subject: [PATCH 1/5] feat: fix basic api paging + offset + page params + added meta/options columns for both user & company --- composer.json | 2 +- ...ions_columns_for_users_companies_table.php | 47 +++++++++++++++++++ src/Models/Company.php | 4 ++ src/Models/User.php | 12 +++-- src/Services/UserCacheService.php | 5 +- src/Traits/HasApiModelBehavior.php | 30 +++++++++--- src/Traits/HasApiModelCache.php | 30 +++++++++--- 7 files changed, 112 insertions(+), 18 deletions(-) create mode 100644 migrations/2026_01_08_041737_add_meta_options_columns_for_users_companies_table.php diff --git a/composer.json b/composer.json index f9cf117..d2ac12f 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.33", + "version": "1.6.34", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", diff --git a/migrations/2026_01_08_041737_add_meta_options_columns_for_users_companies_table.php b/migrations/2026_01_08_041737_add_meta_options_columns_for_users_companies_table.php new file mode 100644 index 0000000..8187e73 --- /dev/null +++ b/migrations/2026_01_08_041737_add_meta_options_columns_for_users_companies_table.php @@ -0,0 +1,47 @@ +json('options')->nullable()->after('meta'); + } + }); + + // Add meta column to companies table + Schema::table('companies', function (Blueprint $table) { + if (!Schema::hasColumn('companies', 'meta')) { + $table->json('meta')->nullable()->after('options'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove options column from users table + Schema::table('users', function (Blueprint $table) { + if (Schema::hasColumn('users', 'options')) { + $table->dropColumn('options'); + } + }); + + // Remove meta column from companies table + Schema::table('companies', function (Blueprint $table) { + if (Schema::hasColumn('companies', 'meta')) { + $table->dropColumn('meta'); + } + }); + } +}; diff --git a/src/Models/Company.php b/src/Models/Company.php index 1a3a1e3..bf8e8b1 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -5,6 +5,7 @@ use Fleetbase\Casts\Json; use Fleetbase\FleetOps\Models\Driver; use Fleetbase\Traits\HasApiModelBehavior; +use Fleetbase\Traits\HasMetaAttributes; use Fleetbase\Traits\HasOptionsAttributes; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasUuid; @@ -28,6 +29,7 @@ class Company extends Model use TracksApiCredential; use HasApiModelBehavior; use HasOptionsAttributes; + use HasMetaAttributes; use HasSlug; use Searchable; use SendsWebhooks; @@ -86,6 +88,7 @@ class Company extends Model 'website_url', 'description', 'options', + 'meta', 'type', 'currency', 'country', @@ -118,6 +121,7 @@ class Company extends Model */ protected $casts = [ 'options' => Json::class, + 'meta' => Json::class, 'trial_ends_at' => 'datetime', ]; diff --git a/src/Models/User.php b/src/Models/User.php index deeb1da..fe3a89a 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -14,6 +14,7 @@ use Fleetbase\Traits\HasApiModelBehavior; use Fleetbase\Traits\HasCacheableAttributes; use Fleetbase\Traits\HasMetaAttributes; +use Fleetbase\Traits\HasOptionsAttributes; use Fleetbase\Traits\HasPresence; use Fleetbase\Traits\HasPublicId; use Fleetbase\Traits\HasSessionAttributes; @@ -53,6 +54,7 @@ class User extends Authenticatable use HasApiModelBehavior; use HasCacheableAttributes; use HasMetaAttributes; + use HasOptionsAttributes; use HasTimestamps; use LogsActivity; use CausesActivity; @@ -149,6 +151,7 @@ class User extends Authenticatable 'date_of_birth', 'timezone', 'meta', + 'options', 'country', 'ip_address', 'last_login', @@ -192,10 +195,11 @@ class User extends Authenticatable * @var array */ protected $casts = [ - 'meta' => Json::class, - 'email_verified_at' => 'datetime', - 'phone_verified_at' => 'datetime', - 'last_login' => 'datetime', + 'meta' => Json::class, + 'options' => Json::class, + 'email_verified_at' => 'datetime', + 'phone_verified_at' => 'datetime', + 'last_login' => 'datetime', ]; /** diff --git a/src/Services/UserCacheService.php b/src/Services/UserCacheService.php index b9c3846..f2d9a56 100644 --- a/src/Services/UserCacheService.php +++ b/src/Services/UserCacheService.php @@ -197,7 +197,10 @@ public static function invalidateCompany(string $companyId): void */ public static function generateETag(User $user): string { - return '"user-' . $user->uuid . '-' . $user->updated_at->timestamp . '"'; + $userMeta = json_encode($user->meta); + $userOptions = json_encode($user->options); + + return '"user-' . $user->uuid . '-' . $user->updated_at->timestamp . '-' . strlen($userMeta) . '-' . strlen($userOptions) . '"'; } /** diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index 0ff49f4..4266491 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -162,21 +162,39 @@ public function queryFromRequest(Request $request, ?\Closure $queryCallback = nu return $this->queryFromRequestCached($request, $queryCallback); } - $limit = $request->integer('limit', 30); - $columns = $request->input('columns', ['*']); + $columns = $request->input('columns', ['*']); + $limit = $request->integer('limit', 30); + $offset = $request->integer('offset', 0); + $page = max(1, $request->integer('page', 1)); + $calculateOffset = $request->missing('offset') && $request->has('page'); + + // Clamp limit + if ($limit !== -1) { + $limit = max(1, min($limit, 100)); + } /** * @var \Illuminate\Database\Eloquent\Builder $builder */ $builder = $this->searchBuilder($request, $columns); - if (intval($limit) > 0) { - $builder->limit($limit); - } elseif ($limit === -1) { - $limit = 999999999; + // Auto-calculate offset from page + if ($calculateOffset) { + $offset = ($page - 1) * $limit; + } + + // Handle limit + if ($limit === -1) { + $builder->limit(PHP_INT_MAX); + } elseif ($limit > 0) { $builder->limit($limit); } + // Handle offset + if ($offset > 0) { + $builder->offset($offset); + } + // if queryCallback is supplied if (is_callable($queryCallback)) { $queryCallback($builder, $request); diff --git a/src/Traits/HasApiModelCache.php b/src/Traits/HasApiModelCache.php index 69f8abe..195b89c 100644 --- a/src/Traits/HasApiModelCache.php +++ b/src/Traits/HasApiModelCache.php @@ -77,21 +77,39 @@ public function queryFromRequestCached(Request $request, ?\Closure $queryCallbac */ protected function queryFromRequestWithoutCache(Request $request, ?\Closure $queryCallback = null) { - $limit = $request->integer('limit', 30); - $columns = $request->input('columns', ['*']); + $columns = $request->input('columns', ['*']); + $limit = $request->integer('limit', 30); + $offset = $request->integer('offset', 0); + $page = max(1, $request->integer('page', 1)); + $calculateOffset = $request->missing('offset') && $request->has('page'); + + // Clamp limit + if ($limit !== -1) { + $limit = max(1, min($limit, 100)); + } /** * @var \Illuminate\Database\Eloquent\Builder $builder */ $builder = $this->searchBuilder($request, $columns); - if (intval($limit) > 0) { - $builder->limit($limit); - } elseif ($limit === -1) { - $limit = 999999999; + // Auto-calculate offset from page + if ($calculateOffset) { + $offset = ($page - 1) * $limit; + } + + // Handle limit + if ($limit === -1) { + $builder->limit(PHP_INT_MAX); + } elseif ($limit > 0) { $builder->limit($limit); } + // Handle offset + if ($offset > 0) { + $builder->offset($offset); + } + // if queryCallback is supplied if (is_callable($queryCallback)) { $queryCallback($builder, $request); From dad58072b03ca7654245e3552ce256682ed88251 Mon Sep 17 00:00:00 2001 From: roncodes <816371+roncodes@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:45:31 -0500 Subject: [PATCH 2/5] Move onboarding completion tracking from user to company - Add onboarding_completed_at and onboarding_completed_by_uuid to companies table - Add fields to Company model fillable and casts - Add onboarding_completed boolean to Organization resource (for frontend) - Fixes issue where additional users couldn't access console after org onboarding complete - Company-level tracking ensures all users see same onboarding state --- ...d_onboarding_fields_to_companies_table.php | 30 +++++++++++++++++++ src/Http/Resources/Organization.php | 1 + src/Models/Company.php | 9 ++++-- 3 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php diff --git a/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php b/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php new file mode 100644 index 0000000..932088f --- /dev/null +++ b/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php @@ -0,0 +1,30 @@ +timestamp('onboarding_completed_at')->nullable()->after('updated_at'); + $table->string('onboarding_completed_by_uuid')->nullable()->after('onboarding_completed_at'); + $table->index('onboarding_completed_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('companies', function (Blueprint $table) { + $table->dropIndex(['onboarding_completed_at']); + $table->dropColumn(['onboarding_completed_at', 'onboarding_completed_by_uuid']); + }); + } +}; diff --git a/src/Http/Resources/Organization.php b/src/Http/Resources/Organization.php index 12b0395..00a9153 100644 --- a/src/Http/Resources/Organization.php +++ b/src/Http/Resources/Organization.php @@ -37,6 +37,7 @@ public function toArray($request) 'owner' => $this->owner ? new User($this->owner) : null, 'slug' => $this->slug, 'status' => $this->status, + 'onboarding_completed' => $this->when(Http::isInternalRequest(), $this->onboarding_completed_at !== null), 'joined_at' => $this->when(Http::isInternalRequest() && $request->hasSession() && $request->session()->has('user'), function () { if ($this->resource->joined_at) { return $this->resource->joined_at; diff --git a/src/Models/Company.php b/src/Models/Company.php index bf8e8b1..f9c12f8 100644 --- a/src/Models/Company.php +++ b/src/Models/Company.php @@ -98,6 +98,8 @@ class Company extends Model 'status', 'slug', 'trial_ends_at', + 'onboarding_completed_at', + 'onboarding_completed_by_uuid', ]; /** @@ -120,9 +122,10 @@ class Company extends Model * @var array */ protected $casts = [ - 'options' => Json::class, - 'meta' => Json::class, - 'trial_ends_at' => 'datetime', + 'options' => Json::class, + 'meta' => Json::class, + 'trial_ends_at' => 'datetime', + 'onboarding_completed_at' => 'datetime', ]; /** From eaf2bf1ed5b2b301231effc315a931dfe4f32ce2 Mon Sep 17 00:00:00 2001 From: roncodes <816371+roncodes@users.noreply.github.com> Date: Mon, 12 Jan 2026 21:50:53 -0500 Subject: [PATCH 3/5] Mark company onboarding complete after email verification - verifyEmail() now sets company onboarding fields after successful verification - Email verification is the final step of basic self-hosted onboarding - Only sets if onboarding_completed_at is null (prevents overwriting) - Ensures company onboarding status is consistent across all flows --- src/Http/Controllers/Internal/v1/OnboardController.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index 180fc00..74b8f36 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -224,6 +224,15 @@ public function verifyEmail(Request $request) $user->updateLastLogin(); $token = $user->createToken($user->uuid); + // Mark company onboarding as complete (email verification is final step) + $company = $user->company; + if ($company && $company->onboarding_completed_at === null) { + $company->update([ + 'onboarding_completed_at' => $verifiedAt, + 'onboarding_completed_by_uuid' => $user->uuid, + ]); + } + return response()->json([ 'status' => 'ok', 'verified_at' => $verifiedAt, From 9dd7a72dd028e9c3f0b6b725d9618adb912694ea Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 13 Jan 2026 12:00:35 +0800 Subject: [PATCH 4/5] ran linter, added `company_onboarding_completed` attribute to user --- ...d_onboarding_fields_to_companies_table.php | 2 +- src/Http/Resources/Organization.php | 40 +++++------ src/Http/Resources/User.php | 69 ++++++++++--------- src/Models/User.php | 9 +++ 4 files changed, 65 insertions(+), 55 deletions(-) diff --git a/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php b/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php index 932088f..f9a220b 100644 --- a/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php +++ b/migrations/2026_01_13_000001_add_onboarding_fields_to_companies_table.php @@ -4,7 +4,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class() extends Migration { +return new class extends Migration { /** * Run the migrations. */ diff --git a/src/Http/Resources/Organization.php b/src/Http/Resources/Organization.php index 00a9153..404faff 100644 --- a/src/Http/Resources/Organization.php +++ b/src/Http/Resources/Organization.php @@ -18,27 +18,27 @@ class Organization extends FleetbaseResource public function toArray($request) { return [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'owner_uuid' => $this->when(Http::isInternalRequest(), $this->owner_uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'name' => $this->name, - 'description' => $this->description, - 'phone' => $this->phone, - 'type' => $this->when(Http::isInternalRequest(), $this->type), - 'users_count' => $this->when(Http::isInternalRequest(), $this->companyUsers()->count()), - 'timezone' => $this->timezone, - 'country' => $this->country, - 'currency' => $this->currency, - 'logo_url' => $this->logo_url, - 'backdrop_url' => $this->backdrop_url, - 'branding' => Setting::getBranding(), - 'options' => $this->options ?? Utils::createObject([]), - 'owner' => $this->owner ? new User($this->owner) : null, - 'slug' => $this->slug, - 'status' => $this->status, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'owner_uuid' => $this->when(Http::isInternalRequest(), $this->owner_uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'name' => $this->name, + 'description' => $this->description, + 'phone' => $this->phone, + 'type' => $this->when(Http::isInternalRequest(), $this->type), + 'users_count' => $this->when(Http::isInternalRequest(), $this->companyUsers()->count()), + 'timezone' => $this->timezone, + 'country' => $this->country, + 'currency' => $this->currency, + 'logo_url' => $this->logo_url, + 'backdrop_url' => $this->backdrop_url, + 'branding' => Setting::getBranding(), + 'options' => $this->options ?? Utils::createObject([]), + 'owner' => $this->owner ? new User($this->owner) : null, + 'slug' => $this->slug, + 'status' => $this->status, 'onboarding_completed' => $this->when(Http::isInternalRequest(), $this->onboarding_completed_at !== null), - 'joined_at' => $this->when(Http::isInternalRequest() && $request->hasSession() && $request->session()->has('user'), function () { + 'joined_at' => $this->when(Http::isInternalRequest() && $request->hasSession() && $request->session()->has('user'), function () { if ($this->resource->joined_at) { return $this->resource->joined_at; } diff --git a/src/Http/Resources/User.php b/src/Http/Resources/User.php index 5b1214d..6cbcb13 100644 --- a/src/Http/Resources/User.php +++ b/src/Http/Resources/User.php @@ -18,40 +18,41 @@ class User extends FleetbaseResource public function toArray($request) { $data = [ - 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), - 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), - 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), - 'company' => $this->when(Http::isPublicRequest(), $this->company ? $this->company->public_id : null), - 'name' => $this->name, - 'username' => $this->username, - 'email' => $this->email, - 'phone' => $this->phone, - 'country' => $this->country, - '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), - '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), - 'type' => $this->type, - 'locale' => $this->getLocale(), - 'types' => $this->when(Http::isInternalRequest(), $this->types ?? []), - 'company_name' => $this->when(Http::isInternalRequest(), $this->company_name), - 'session_status' => $this->when(Http::isInternalRequest(), $this->session_status), - 'is_admin' => $this->when(Http::isInternalRequest(), $this->is_admin), - 'is_online' => $this->is_online, - 'ip_address' => $this->ip_address, - 'date_of_birth' => $this->date_of_birth, - 'email_verified_at' => $this->email_verified_at, - 'phone_verified_at' => $this->phone_verified_at, - 'last_seen_at' => $this->last_seen_at, - 'last_login' => $this->last_login, - 'status' => $this->status, - 'slug' => $this->slug, - 'updated_at' => $this->updated_at, - 'created_at' => $this->created_at, + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'company_uuid' => $this->when(Http::isInternalRequest(), $this->company_uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'company' => $this->when(Http::isPublicRequest(), $this->company ? $this->company->public_id : null), + 'name' => $this->name, + 'username' => $this->username, + 'email' => $this->email, + 'phone' => $this->phone, + 'country' => $this->country, + '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), + '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), + 'type' => $this->type, + 'locale' => $this->getLocale(), + 'types' => $this->when(Http::isInternalRequest(), $this->types ?? []), + 'company_name' => $this->when(Http::isInternalRequest(), $this->company_name), + 'company_onboarding_completed' => $this->when(Http::isInternalRequest(), $this->company_onboarding_completed), + 'session_status' => $this->when(Http::isInternalRequest(), $this->session_status), + 'is_admin' => $this->when(Http::isInternalRequest(), $this->is_admin), + 'is_online' => $this->is_online, + 'ip_address' => $this->ip_address, + 'date_of_birth' => $this->date_of_birth, + 'email_verified_at' => $this->email_verified_at, + 'phone_verified_at' => $this->phone_verified_at, + 'last_seen_at' => $this->last_seen_at, + 'last_login' => $this->last_login, + 'status' => $this->status, + 'slug' => $this->slug, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, ]; return ResourceTransformerRegistry::transform($this->resource, $data); diff --git a/src/Models/User.php b/src/Models/User.php index fe3a89a..7cc1ab6 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -184,6 +184,7 @@ class User extends Authenticatable 'avatar_url', 'session_status', 'company_name', + 'company_onboarding_completed', 'is_admin', 'is_online', 'last_seen_at', @@ -753,6 +754,14 @@ public function getCompanyNameAttribute(): ?string return data_get($this, 'company.name'); } + /** + * Get the users's company onboard completed. + */ + public function getCompanyOnboardingCompletedAttribute(): bool + { + return data_get($this, 'company.onboarding_completed_at') !== null; + } + /** * Get the users's company name. */ From 55b990b4721360af7bd9fed87bc4c68d4550c43b Mon Sep 17 00:00:00 2001 From: roncodes <816371+roncodes@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:46:48 -0500 Subject: [PATCH 5/5] Fix user resource tracking during onboarding Issue: First user created during onboarding not tracked in billing system Root cause: User created BEFORE company exists, so company_uuid is null Solution: Create company FIRST, then set company_uuid before user creation Flow before: 1. User::create() - no company_uuid 2. Company created 3. assignCompany() - sets company_uuid after creation Flow after: 1. Company::create() - company exists first 2. Set company_uuid in attributes 3. User::create() - with company_uuid set 4. assignCompany() - maintains relationship This ensures ResourceCreatedListener can track the first user properly since it requires company_uuid to log usage to billing_resource_usage table. Related to fleetops driver creation fix (same issue, same solution). --- src/Http/Controllers/Internal/v1/OnboardController.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Http/Controllers/Internal/v1/OnboardController.php b/src/Http/Controllers/Internal/v1/OnboardController.php index 74b8f36..1f64556 100644 --- a/src/Http/Controllers/Internal/v1/OnboardController.php +++ b/src/Http/Controllers/Internal/v1/OnboardController.php @@ -57,6 +57,12 @@ public function createAccount(OnboardRequest $request) 'last_login' => $isAdmin ? now() : null, ]); + // create company FIRST (required for billing resource tracking) + $company = Company::create(['name' => $request->input('organization_name')]); + + // set company_uuid before creating user (required for billing resource tracking) + $attributes['company_uuid'] = $company->uuid; + // create user account $user = User::create($attributes); @@ -66,8 +72,7 @@ public function createAccount(OnboardRequest $request) // set the user type $user->setUserType($isAdmin ? 'admin' : 'user'); - // create company - $company = new Company(['name' => $request->input('organization_name')]); + // set company owner $company->setOwner($user)->save(); // assign user to organization