Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
# v1.6.61Faster IAM user authorization loading
# v1.6.62Custom fields get a public id

## Improvements

- Reduce repeated database queries when listing IAM users by loading roles, policies, and permissions in batches and reading each user's primary role once.
- Match authorization to each user's company membership, including users belonging to multiple companies and system administrators viewing users across companies. User response fields remain unchanged.
- Give every custom field a public id, so an API that hands one out names it the way the rest of the platform names a resource rather than exposing an internal uuid. `CustomField` takes `HasPublicId` with the `custom_field` prefix, and `public_id` becomes fillable.
- Mint an id on the one path that would otherwise miss it: `HasCustomFields::setCustomField()` saves a field it creates on the fly with `saveQuietly()`, which skips the hook that assigns the id.

## Fixes

- Let an observer's refusal reach the caller on the update and bulk-delete paths. An observer that refused a write by throwing `FleetbaseRequestValidationException` had its explanation discarded: `HasApiModelBehavior::updateRecordFromRequest()` rewrapped every exception from the save as a plain `\Exception`, and `HasApiControllerBehavior::bulkDelete()` caught `\Exception` ahead of its dedicated handler, so callers saw `Invalid request` or a generic update error instead of the message the observer wrote. The exception now passes through untouched on both paths and is rendered with `getErrors()`, as it already was on create and single delete. Every other exception is wrapped exactly as before. Reported in [#256](https://github.com/fleetbase/core-api/issues/256).

## Reliability

- Add database-backed coverage for company isolation, missing and deleted memberships, recovery after a membership was initially absent, and matching responses between lazy and eager loading.
- Enable PHP CI and Postman checks for `release/v*` branches and support release tagging from both `release/v*` and `dev-v*` branches.
- Backfill existing rows in the migration, and add the column as nullable and indexed rather than unique-and-required, so it is safe on an already-populated `custom_fields` table.
- Cover id generation for `CustomField`, and add the column to the in-memory schemas whose saves now probe it for uniqueness.

This is platform-wide: every custom field gains a public id, not only those used by inspections. Nothing reads the new column yet — `withCustomFields()`'s public projection emits field names and is unchanged — so the change is additive for existing consumers.

No database migration or configuration change is required.
A database migration is required. No configuration change is needed.

Changes: [#251](https://github.com/fleetbase/core-api/pull/251), [#250](https://github.com/fleetbase/core-api/pull/250), and release-branch CI updates in [#252](https://github.com/fleetbase/core-api/pull/252).
Changes: [#254](https://github.com/fleetbase/core-api/pull/254), [#259](https://github.com/fleetbase/core-api/pull/259).
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.61",
"version": "1.6.62",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

use Fleetbase\Models\CustomField;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* Custom fields were addressed by uuid alone, which left every API that
* hands one out exposing an internal identifier where the rest of the
* platform shows a public id. Existing rows are backfilled so nothing has
* to cope with a field that has no id.
*/
public function up(): void
{
if (!Schema::hasTable('custom_fields') || Schema::hasColumn('custom_fields', 'public_id')) {
return;
}

Schema::table('custom_fields', function (Blueprint $table) {
$table->string('public_id', 191)->nullable()->after('uuid')->index();
});

CustomField::withTrashed()->whereNull('public_id')->get()->each(function (CustomField $field) {
$field->update(['public_id' => CustomField::generatePublicId('custom_field')]);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
if (!Schema::hasTable('custom_fields') || !Schema::hasColumn('custom_fields', 'public_id')) {
return;
}

Schema::table('custom_fields', function (Blueprint $table) {
$table->dropIndex(['public_id']);
$table->dropColumn(['public_id']);
});
}
};
11 changes: 10 additions & 1 deletion src/Models/CustomField.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
use Fleetbase\Casts\Json;
use Fleetbase\Casts\PolymorphicType;
use Fleetbase\Traits\HasApiModelBehavior;
use Fleetbase\Traits\HasPublicId;
use Fleetbase\Traits\HasUuid;

class CustomField extends Model
{
use HasUuid;
use HasPublicId;
use HasApiModelBehavior;

/**
Expand All @@ -19,12 +21,19 @@ class CustomField extends Model
*/
protected $table = 'custom_fields';

/**
* The type of public Id to generate.
*
* @var string
*/
protected $publicIdType = 'custom_field';

/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = ['company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'];
protected $fillable = ['public_id', 'company_uuid', 'category_uuid', 'subject_uuid', 'subject_type', 'name', 'label', 'type', 'for', 'component', 'options', 'required', 'editable', 'default_value', 'validation_rules', 'meta', 'description', 'help_text', 'order'];

/**
* The attributes that are guarded.
Expand Down
12 changes: 4 additions & 8 deletions src/Traits/HasApiControllerBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -627,17 +627,13 @@ public function bulkDelete(BulkDeleteRequest $request)

try {
$count = $this->model->bulkRemove($ids);
} catch (\Exception $e) {
return response()->error($e->getMessage());
// QueryException and FleetbaseRequestValidationException are covered by
// the preceding Exception catch in PHP's current catch order.
// @codeCoverageIgnoreStart
} catch (QueryException $e) {
return response()->error($e->getMessage());
} catch (FleetbaseRequestValidationException $e) {
return response()->error($e->getErrors());
} catch (QueryException $e) {
return response()->error($e->getMessage());
} catch (\Exception $e) {
return response()->error($e->getMessage());
}
// @codeCoverageIgnoreEnd

return response()->json(
[
Expand Down
5 changes: 5 additions & 0 deletions src/Traits/HasApiModelBehavior.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\Traits;

use Fleetbase\Exceptions\FleetbaseRequestValidationException;
use Fleetbase\Support\ApiModelCache;
use Fleetbase\Support\Auth;
use Fleetbase\Support\Http;
Expand Down Expand Up @@ -416,6 +417,10 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo
$input = Arr::except($input, ['uuid', 'public_id', 'deleted_at', 'updated_at', 'created_at']);
try {
$record->update($input);
} catch (FleetbaseRequestValidationException $e) {
// An observer refusing the write is user-facing feedback, not an internal failure.
// Let it through untouched so the controller and global handler can render getErrors().
throw $e;
} catch (\Exception $e) {
throw new \Exception(app()->hasDebugModeEnabled() ? $e->getMessage() : 'Failed to update ' . $this->getApiHumanReadableName());
}
Expand Down
7 changes: 6 additions & 1 deletion src/Traits/HasCustomFields.php
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,12 @@ public function setCustomFieldValue(string|CustomField $fieldOrKey, mixed $value
'subject_uuid' => $this->getAttribute('uuid'),
'company_uuid' => $this->getAttribute('company_uuid') ?? session('company'),
]);
$field->forceFill(['uuid' => CustomField::generateUuid()]);
// A quiet save skips the `creating` hook that mints a public id,
// so a field created on the fly would be the only one without one.
$field->forceFill([
'uuid' => CustomField::generateUuid(),
'public_id' => CustomField::generatePublicId('custom_field'),
]);
method_exists($field, 'saveQuietly') ? $field->saveQuietly() : $field->save();
// bust definition cache for subsequent lookups
$this->customFieldCache = [];
Expand Down
39 changes: 39 additions & 0 deletions tests/Unit/Models/RecordModelsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,30 @@ public function clear(): bool
$table->timestamps();
$table->softDeletes();
});
$schema->create('custom_fields', function ($table) {
$table->string('uuid')->primary();
$table->string('public_id')->nullable()->unique();
$table->string('company_uuid')->nullable();
$table->string('category_uuid')->nullable();
$table->string('subject_uuid')->nullable();
$table->string('subject_type')->nullable();
$table->string('name')->nullable();
$table->string('label')->nullable();
$table->string('type')->nullable();
$table->string('for')->nullable();
$table->string('component')->nullable();
$table->text('options')->nullable();
$table->boolean('required')->default(false);
$table->boolean('editable')->default(true);
$table->text('default_value')->nullable();
$table->text('validation_rules')->nullable();
$table->text('meta')->nullable();
$table->text('description')->nullable();
$table->text('help_text')->nullable();
$table->integer('order')->default(0);
$table->timestamps();
$table->softDeletes();
});
$schema->create('user_devices', function ($table) {
$table->string('uuid')->primary();
$table->string('public_id')->nullable()->unique();
Expand Down Expand Up @@ -249,6 +273,21 @@ public function clear(): bool
]);
});

it('generates custom field public ids', function () {
record_models_database();

$field = CustomField::query()->create([
'company_uuid' => 'company-1',
'name' => 'brakes',
'label' => 'Brakes',
'type' => 'pass-fail',
]);

expect($field->public_id)->toStartWith('custom_field_')
->and($field->public_id)->toHaveLength(strlen('custom_field_') + 10)
->and($field->uuid)->not->toBeNull();
});

it('casts custom field configuration values and keeps relationship keys stable', function () {
record_models_database();

Expand Down
32 changes: 32 additions & 0 deletions tests/Unit/Traits/HasApiControllerBehaviorTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Fleetbase\Exceptions\FleetbaseRequestValidationException;
use Fleetbase\Http\Requests\Internal\BulkDeleteRequest;
use Fleetbase\Http\Resources\FleetbaseResource;
use Fleetbase\Traits\HasApiControllerBehavior;
Expand Down Expand Up @@ -506,6 +507,37 @@ public function updateRecordFromRequest(Request $request, $id, ?callable $onBefo
->and($updateQueryFailure->getData(true))->toBe(['errors' => ['Error occurred while trying to update a Widget']]);
});

test('api controller behavior surfaces observer refusals on update and bulk delete', function () {
$refusingModel = new class extends HasApiControllerBehaviorModel {
public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null): self
{
throw new FleetbaseRequestValidationException(['Widget is locked and cannot be changed.']);
}

public function bulkRemove(array $ids): int
{
throw new FleetbaseRequestValidationException(['Widget is locked and cannot be deleted.']);
}
};
$queryFailingModel = new class extends HasApiControllerBehaviorModel {
public function bulkRemove(array $ids): int
{
throw new QueryException('mysql', 'delete from widgets', [], new RuntimeException('database unavailable'));
}
};

$refusingController = new HasApiControllerBehaviorController($refusingModel);
$updateRefusal = $refusingController->updateRecord(has_api_controller_behavior_request('/v1/widgets/widget-1', 'PATCH', ['name' => 'Nope']), 'widget-1');
$bulkDeleteRefusal = $refusingController->bulkDelete(BulkDeleteRequest::create('/v1/widgets/bulk-delete', 'DELETE', ['ids' => ['widget-1']]));
$bulkDeleteQuery = (new HasApiControllerBehaviorController($queryFailingModel))->bulkDelete(
BulkDeleteRequest::create('/v1/widgets/bulk-delete', 'DELETE', ['ids' => ['widget-1']])
);

expect($updateRefusal->getData(true))->toBe(['errors' => ['Widget is locked and cannot be changed.']])
->and($bulkDeleteRefusal->getData(true))->toBe(['errors' => ['Widget is locked and cannot be deleted.']])
->and($bulkDeleteQuery->getData(true)['errors'][0])->toContain('database unavailable');
});

test('api controller behavior validates fallback rule contracts before writing', function () {
$controller = new HasApiControllerBehaviorController();
$controller->rules = ['name' => ['required']];
Expand Down
38 changes: 38 additions & 0 deletions tests/Unit/Traits/HasApiModelBehaviorTest.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php

use Fleetbase\Exceptions\FleetbaseRequestValidationException;
use Fleetbase\Http\Filter\Filter;
use Fleetbase\Models\Model;
use Fleetbase\Traits\HasApiModelBehavior;
Expand Down Expand Up @@ -299,6 +300,16 @@ public function update(array $attributes = [], array $options = [])
}
}

class HasApiModelBehaviorRefusingUpdateRecord extends HasApiModelBehaviorRecord
{
protected static function booted(): void
{
static::updating(function () {
throw new FleetbaseRequestValidationException(['Record is locked and cannot be updated.']);
});
}
}

class HasApiModelBehaviorFailingBulkDeleteRecord extends HasApiModelBehaviorRecord
{
public function where($column, $operator = null, $value = null, $boolean = 'and')
Expand Down Expand Up @@ -883,6 +894,33 @@ public function total(): int
->and(fn () => (new HasApiModelBehaviorRecord())->remove('record_alpha'))->toThrow(Exception::class);
});

test('api model behavior propagates observer update refusals without rewrapping them', function () {
$capsule = has_api_model_behavior_database();
has_api_model_behavior_seed_records($capsule);
session(['company' => 'company-a']);

$refusals = [];
foreach ([true, false] as $debug) {
config(['app.debug' => $debug]);

try {
(new HasApiModelBehaviorRefusingUpdateRecord())->updateRecordFromRequest(
has_api_model_behavior_request(['name' => 'Refused update'], method: 'PATCH'),
'record_alpha'
);
} catch (Exception $exception) {
$refusals[] = $exception;
}
}

expect($refusals)->toHaveCount(2)
->and($refusals[0])->toBeInstanceOf(FleetbaseRequestValidationException::class)
->and($refusals[0]->getErrors())->toBe(['Record is locked and cannot be updated.'])
->and($refusals[1])->toBeInstanceOf(FleetbaseRequestValidationException::class)
->and($refusals[1]->getErrors())->toBe(['Record is locked and cannot be updated.'])
->and($capsule->getConnection('mysql')->table('api_model_behavior_records')->where('public_id', 'record_alpha')->value('name'))->toBe('Alpha Dispatch');
});

test('api model behavior exposes default searchable fields options and no-op query branches', function () {
$capsule = has_api_model_behavior_database();
has_api_model_behavior_seed_records($capsule);
Expand Down
1 change: 1 addition & 0 deletions tests/Unit/Traits/HasCustomFieldsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function has_custom_fields_database(string $routeUri = 'int/v1/subjects'): HasCu
});
$schema->create('custom_fields', function ($table) {
$table->string('uuid')->primary();
$table->string('public_id')->nullable();
$table->string('company_uuid')->nullable();
$table->string('category_uuid')->nullable();
$table->string('subject_uuid')->nullable();
Expand Down
Loading