From d73e0449d9582c50f41e1ca815d5d16aa0f0adb0 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 14 Sep 2026 14:55:41 +0800 Subject: [PATCH 1/6] Give custom fields a public id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other resource in the platform is addressed by a prefixed public id; a custom field was addressed by its uuid, so any API handing one out exposed an internal identifier. FleetOps' inspection API is the case in point — a submission names the field each answer belongs to, and had nothing but a uuid to name it with. Existing rows are backfilled, and the column is nullable and indexed rather than unique-and-required so the migration is safe on a table that is already populated. A field created on the fly through setCustomField() is saved quietly, which skips the hook that mints the id, so that path mints its own. --- ...0_add_public_id_to_custom_fields_table.php | 46 +++++++++++++++++++ src/Models/CustomField.php | 11 ++++- src/Traits/HasCustomFields.php | 7 ++- tests/Unit/Models/RecordModelsTest.php | 39 ++++++++++++++++ tests/Unit/Traits/HasCustomFieldsTest.php | 1 + 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php diff --git a/migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php b/migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php new file mode 100644 index 00000000..78f861e1 --- /dev/null +++ b/migrations/2026_09_14_000000_add_public_id_to_custom_fields_table.php @@ -0,0 +1,46 @@ +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']); + }); + } +}; diff --git a/src/Models/CustomField.php b/src/Models/CustomField.php index 1f0d72e1..3796f864 100644 --- a/src/Models/CustomField.php +++ b/src/Models/CustomField.php @@ -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; /** @@ -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. diff --git a/src/Traits/HasCustomFields.php b/src/Traits/HasCustomFields.php index 7852c84a..e770a48f 100644 --- a/src/Traits/HasCustomFields.php +++ b/src/Traits/HasCustomFields.php @@ -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 = []; diff --git a/tests/Unit/Models/RecordModelsTest.php b/tests/Unit/Models/RecordModelsTest.php index dce43c1d..e0351db8 100644 --- a/tests/Unit/Models/RecordModelsTest.php +++ b/tests/Unit/Models/RecordModelsTest.php @@ -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(); @@ -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(); diff --git a/tests/Unit/Traits/HasCustomFieldsTest.php b/tests/Unit/Traits/HasCustomFieldsTest.php index ac1fc872..de8b5d70 100644 --- a/tests/Unit/Traits/HasCustomFieldsTest.php +++ b/tests/Unit/Traits/HasCustomFieldsTest.php @@ -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(); From 546d962fa8ab077fe1b36a0f672a6832be35851b Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 14 Sep 2026 17:15:43 +0800 Subject: [PATCH 2/6] Prepare the v1.6.62 release metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release branch carried the feature and nothing else: composer.json still read 1.6.61 and RELEASE.md still described v1.6.61's IAM authorization work. release.yml refuses the tag unless both agree with the branch name, and its RELEASE.md check exists precisely to catch the previous release's notes left in place — which is what these were. composer.json is this repository's single source of the version, and RELEASE.md now opens with the version it describes. --- RELEASE.md | 16 +++++++++------- composer.json | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index c398fce9..03da73fc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,15 +1,17 @@ -# v1.6.61 — Faster IAM user authorization loading +# v1.6.62 — Custom 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. ## 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. -No database migration or configuration change is required. +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. -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). +A database migration is required. No configuration change is needed. + +Changes: [#254](https://github.com/fleetbase/core-api/pull/254). diff --git a/composer.json b/composer.json index 9d9aa28d..ecfbb64a 100644 --- a/composer.json +++ b/composer.json @@ -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", From fb783ce2d90ce633189264b3bb81c2694b3a6a23 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 15 Sep 2026 17:10:06 +0800 Subject: [PATCH 3/6] Give alerts a real snooze, an owner and a planned time A triage queue needs to exclude snoozed alerts in the query and to say who owns one. `snooze()` kept its wake time inside the `meta` JSON, so a list of open-but-not-snoozed alerts had to load every row and ask each one. Move it to an indexed `snoozed_until` column (reading the old meta value for rows written before it existed), record who snoozed it, add `assigned_to_uuid` for an owner distinct from the acknowledger, and `planned_at` for a time the owner chose to handle it. `acknowledge()` now also moves an open alert to `acknowledged`, which the status column always documented but nothing wrote. New `snoozed()` and `active()` scopes, plus `unsnooze()` and `assignTo()`. --- ..._snooze_and_assignment_to_alerts_table.php | 70 +++++++++ src/Models/Alert.php | 134 ++++++++++++++++-- tests/Unit/Models/OperationalModelsTest.php | 79 ++++++++++- 3 files changed, 273 insertions(+), 10 deletions(-) create mode 100644 migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php diff --git a/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php b/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php new file mode 100644 index 00000000..635dfd2e --- /dev/null +++ b/migrations/2026_09_15_000000_add_snooze_and_assignment_to_alerts_table.php @@ -0,0 +1,70 @@ +timestamp('snoozed_until')->nullable()->index()->after('acknowledged_at'); + } + + if (!Schema::hasColumn('alerts', 'snoozed_by_uuid')) { + $table->foreignUuid('snoozed_by_uuid')->nullable()->after('resolved_by_uuid')->constrained('users', 'uuid')->nullOnDelete(); + } + + if (!Schema::hasColumn('alerts', 'assigned_to_uuid')) { + $table->foreignUuid('assigned_to_uuid')->nullable()->after('snoozed_by_uuid')->constrained('users', 'uuid')->nullOnDelete(); + } + + if (!Schema::hasColumn('alerts', 'planned_at')) { + $table->timestamp('planned_at')->nullable()->index()->after('snoozed_until'); + } + }); + + Schema::table('alerts', function (Blueprint $table) { + $table->index(['company_uuid', 'status', 'snoozed_until'], 'alerts_company_status_snoozed_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('alerts', function (Blueprint $table) { + $table->dropIndex('alerts_company_status_snoozed_index'); + }); + + Schema::table('alerts', function (Blueprint $table) { + foreach (['snoozed_by_uuid', 'assigned_to_uuid'] as $column) { + if (Schema::hasColumn('alerts', $column)) { + $table->dropConstrainedForeignId($column); + } + } + + foreach (['planned_at', 'snoozed_until'] as $column) { + if (Schema::hasColumn('alerts', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/src/Models/Alert.php b/src/Models/Alert.php index b84a5f3a..6c49044f 100644 --- a/src/Models/Alert.php +++ b/src/Models/Alert.php @@ -81,6 +81,10 @@ class Alert extends Model 'resolved_at', 'acknowledged_by_uuid', 'resolved_by_uuid', + 'snoozed_until', + 'snoozed_by_uuid', + 'assigned_to_uuid', + 'planned_at', 'meta', ]; @@ -93,8 +97,10 @@ class Alert extends Model 'subject_name', 'acknowledged_by_name', 'resolved_by_name', + 'assigned_to_name', 'is_acknowledged', 'is_resolved', + 'is_snoozed', 'duration_minutes', 'age_minutes', ]; @@ -104,7 +110,7 @@ class Alert extends Model * * @var array */ - protected $hidden = ['subject', 'acknowledgedBy', 'resolvedBy']; + protected $hidden = ['subject', 'acknowledgedBy', 'resolvedBy', 'snoozedBy', 'assignedTo']; /** * The attributes that should be cast to native types. @@ -117,6 +123,8 @@ class Alert extends Model 'triggered_at' => 'datetime', 'acknowledged_at' => 'datetime', 'resolved_at' => 'datetime', + 'snoozed_until' => 'datetime', + 'planned_at' => 'datetime', 'meta' => Json::class, ]; @@ -159,6 +167,16 @@ public function resolvedBy(): BelongsTo return $this->belongsTo(User::class, 'resolved_by_uuid', 'uuid'); } + public function snoozedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'snoozed_by_uuid', 'uuid'); + } + + public function assignedTo(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_to_uuid', 'uuid'); + } + public function subject(): MorphTo { return $this->morphTo(); @@ -192,6 +210,22 @@ public function getResolvedByNameAttribute(): ?string return $this->resolvedBy?->name; } + /** + * Get the name of the user the alert is assigned to. + */ + public function getAssignedToNameAttribute(): ?string + { + return $this->assignedTo?->name; + } + + /** + * Whether the alert is snoozed right now. + */ + public function getIsSnoozedAttribute(): bool + { + return $this->isSnoozed(); + } + /** * Check if the alert has been acknowledged. */ @@ -302,6 +336,32 @@ public function scopeUnacknowledged($query) return $query->whereNull('acknowledged_at'); } + /** + * Scope to alerts whose snooze has not ended yet. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeSnoozed($query) + { + return $query->whereNotNull('snoozed_until')->where('snoozed_until', '>', now()); + } + + /** + * Scope to alerts that still need someone: not resolved and not snoozed. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeActive($query) + { + return $query->where('status', '!=', 'resolved')->where(function ($query) { + $query->whereNull('snoozed_until')->orWhere('snoozed_until', '<=', now()); + }); + } + /** * Scope to get critical alerts. * @@ -337,10 +397,16 @@ public function acknowledge(?User $user = null): bool $user = $user ?? auth()->user(); - $updated = $this->update([ + $updateData = [ 'acknowledged_at' => now(), 'acknowledged_by_uuid' => $user?->uuid, - ]); + ]; + + if ($this->status === 'open') { + $updateData['status'] = 'acknowledged'; + } + + $updated = $this->update($updateData); if ($updated) { activity('alert_acknowledged') @@ -438,16 +504,25 @@ public function escalate(string $newSeverity, ?string $reason = null): bool /** * Snooze the alert for a specified duration. + * + * The wake time lives in `snoozed_until` so a queue can exclude snoozed + * alerts in the query; the reason stays in `meta` as before. */ - public function snooze(int $minutes, ?string $reason = null): bool + public function snooze(int $minutes, ?string $reason = null, ?User $user = null): bool { $snoozeUntil = now()->addMinutes($minutes); + $actor = auth()->user(); + $user = $user ?? ($actor instanceof User ? $actor : null); $meta = $this->meta ?? []; - $meta['snoozed_until'] = $snoozeUntil; $meta['snooze_reason'] = $reason; + unset($meta['snoozed_until']); - $updated = $this->update(['meta' => $meta]); + $updated = $this->update([ + 'snoozed_until' => $snoozeUntil, + 'snoozed_by_uuid' => $user?->uuid, + 'meta' => $meta, + ]); if ($updated) { activity('alert_snoozed') @@ -456,6 +531,7 @@ public function snooze(int $minutes, ?string $reason = null): bool 'snoozed_for_minutes' => $minutes, 'snoozed_until' => $snoozeUntil, 'reason' => $reason, + 'snoozed_by' => $user?->name, ]) ->log('Alert snoozed'); } @@ -463,13 +539,55 @@ public function snooze(int $minutes, ?string $reason = null): bool return $updated; } + /** + * End a snooze early so the alert is active again. + */ + public function unsnooze(): bool + { + if (!$this->snoozed_until) { + return false; + } + + $updated = $this->update([ + 'snoozed_until' => null, + 'snoozed_by_uuid' => null, + ]); + + if ($updated) { + activity('alert_unsnoozed') + ->performedOn($this) + ->log('Alert snooze ended'); + } + + return $updated; + } + + /** + * Give the alert an owner (or clear it with null). + */ + public function assignTo(?User $user): bool + { + $updated = $this->update(['assigned_to_uuid' => $user?->uuid]); + + if ($updated) { + activity('alert_assigned') + ->performedOn($this) + ->withProperties(['assigned_to' => $user?->name]) + ->log($user ? 'Alert assigned' : 'Alert unassigned'); + } + + return $updated; + } + /** * Check if the alert is currently snoozed. + * + * Reads the column, falling back to the `meta.snoozed_until` value older + * rows were written with before the column existed. */ public function isSnoozed(): bool { - $meta = $this->meta ?? []; - $snoozeUntil = $meta['snoozed_until'] ?? null; + $snoozeUntil = $this->snoozed_until ?? ($this->meta['snoozed_until'] ?? null); if (!$snoozeUntil) { return false; diff --git a/tests/Unit/Models/OperationalModelsTest.php b/tests/Unit/Models/OperationalModelsTest.php index 1c72b4fd..06e6910f 100644 --- a/tests/Unit/Models/OperationalModelsTest.php +++ b/tests/Unit/Models/OperationalModelsTest.php @@ -201,6 +201,10 @@ public function clear(): bool $table->timestamp('resolved_at')->nullable(); $table->string('acknowledged_by_uuid')->nullable(); $table->string('resolved_by_uuid')->nullable(); + $table->string('snoozed_by_uuid')->nullable(); + $table->string('assigned_to_uuid')->nullable(); + $table->timestamp('snoozed_until')->nullable(); + $table->timestamp('planned_at')->nullable(); $table->text('meta')->nullable(); $table->timestamps(); $table->softDeletes(); @@ -326,6 +330,7 @@ public function clear(): bool expect($alert->acknowledge($user))->toBeTrue() ->and($alert->updates[0]['acknowledged_at']->toISOString())->toBe('2026-07-17T12:00:00.000000Z') ->and($alert->updates[0]['acknowledged_by_uuid'])->toBe('user-1') + ->and($alert->updates[0]['status'])->toBe('acknowledged') ->and($alert->acknowledge($user))->toBeFalse() ->and($alert->resolve($user, 'Sensor recalibrated'))->toBeTrue() ->and($alert->updates[1]['status'])->toBe('resolved') @@ -350,9 +355,79 @@ public function clear(): bool ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['from'])->toBe('low') ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['to'])->toBe('medium') ->and($escalatingAlert->updates[0]['meta']['escalation_history'][0]['escalated_by'])->toBe('session-user') - ->and($escalatingAlert->snooze(30, 'Awaiting technician'))->toBeTrue() + ->and($escalatingAlert->snooze(30, 'Awaiting technician', $user))->toBeTrue() ->and($escalatingAlert->updates[1]['meta']['snooze_reason'])->toBe('Awaiting technician') - ->and($escalatingAlert->isSnoozed())->toBeTrue(); + ->and($escalatingAlert->updates[1]['snoozed_until']->toISOString())->toBe('2026-07-17T12:30:00.000000Z') + ->and($escalatingAlert->updates[1]['snoozed_by_uuid'])->toBe('user-1') + ->and($escalatingAlert->isSnoozed())->toBeTrue() + ->and($escalatingAlert->is_snoozed)->toBeTrue() + ->and($escalatingAlert->unsnooze())->toBeTrue() + ->and($escalatingAlert->updates[2])->toBe(['snoozed_until' => null, 'snoozed_by_uuid' => null]) + ->and($escalatingAlert->isSnoozed())->toBeFalse() + ->and($escalatingAlert->unsnooze())->toBeFalse() + ->and($escalatingAlert->assignTo($user))->toBeTrue() + ->and($escalatingAlert->updates[3])->toBe(['assigned_to_uuid' => 'user-1']) + ->and($escalatingAlert->assignTo(null))->toBeTrue() + ->and($escalatingAlert->updates[4])->toBe(['assigned_to_uuid' => null]); + + // Rows written before the column existed kept the wake time in meta. + $legacyAlert = new Alert(); + $legacyAlert->setRawAttributes([ + 'uuid' => 'alert-legacy', + 'status' => 'open', + 'meta' => ['snoozed_until' => '2026-07-17 13:00:00'], + ], true); + + expect($legacyAlert->isSnoozed())->toBeTrue(); + + Carbon::setTestNow(); +}); + +it('filters alerts through the snoozed and active scopes', function () { + $capsule = operational_models_database(); + Carbon::setTestNow(Carbon::parse('2026-07-17 12:00:00', 'UTC')); + + $capsule->getConnection('mysql')->table('alerts')->insert([ + [ + 'uuid' => 'alert-open', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'open', + 'snoozed_until' => null, + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-snoozed', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'acknowledged', + 'snoozed_until' => '2026-07-17 14:00:00', + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-woken', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'open', + 'snoozed_until' => '2026-07-17 11:00:00', + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + [ + 'uuid' => 'alert-resolved', + 'type' => 'temperature', + 'severity' => 'high', + 'status' => 'resolved', + 'snoozed_until' => null, + 'created_at' => '2026-07-17 10:00:00', + 'updated_at' => '2026-07-17 10:00:00', + ], + ]); + + expect(Alert::query()->snoozed()->pluck('uuid')->all())->toBe(['alert-snoozed']) + ->and(Alert::query()->active()->pluck('uuid')->all())->toBe(['alert-open', 'alert-woken']); Carbon::setTestNow(); }); From 6c6adacd34ca4fcc3d9bf2808edf699c42f1ac17 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 11:41:17 +0800 Subject: [PATCH 4/6] Cover the alert's new relations and owner name The coverage gate wanted the three one-line members the alert tests never reached: the snoozedBy and assignedTo relations and the assigned_to_name accessor. Assert their foreign keys and the null fallback alongside the existing relationship contracts. --- tests/Unit/Models/OperationalModelsTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Unit/Models/OperationalModelsTest.php b/tests/Unit/Models/OperationalModelsTest.php index 06e6910f..4978d81d 100644 --- a/tests/Unit/Models/OperationalModelsTest.php +++ b/tests/Unit/Models/OperationalModelsTest.php @@ -296,8 +296,12 @@ public function clear(): bool expect($alert->getActivitylogOptions()->logAttributes)->toBe(['*']) ->and($alert->acknowledgedBy()->getForeignKeyName())->toBe('acknowledged_by_uuid') ->and($alert->resolvedBy()->getForeignKeyName())->toBe('resolved_by_uuid') + ->and($alert->snoozedBy()->getForeignKeyName())->toBe('snoozed_by_uuid') + ->and($alert->assignedTo()->getForeignKeyName())->toBe('assigned_to_uuid') ->and($alert->subject()->getMorphType())->toBe('subject_type') ->and($alert->subject_name)->toBeNull() + ->and($alert->assigned_to_name)->toBeNull() + ->and($alert->is_snoozed)->toBeFalse() ->and($alert->duration_minutes)->toBeNull() ->and($alert->age_minutes)->toBe(15) ->and($alert->isSnoozed())->toBeFalse() From 96d1847fd2d7a20b581e8627ac0795486d723984 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 12:07:39 +0800 Subject: [PATCH 5/6] Let observer refusals reach the caller on update and bulk delete An observer that refuses a write by throwing FleetbaseRequestValidationException had its explanation discarded on two paths. The write was still refused, but the caller saw "Invalid request" or "Error occurred while trying to update a X" instead of the message the observer wrote. Both sites already had a handler for the exception; each was made unreachable by a catch block one frame deeper. updateRecordFromRequest() wrapped every exception from $record->update() in a plain \Exception, so the controller's dedicated catch and the global handler's getErrors() rendering never saw the original. It now rethrows FleetbaseRequestValidationException untouched and keeps wrapping everything else, so database and internal errors still do not leak. bulkDelete() listed catch (\Exception) before its QueryException and FleetbaseRequestValidationException catches, leaving both dead behind a @codeCoverageIgnore block that acknowledged as much. The catches are now ordered most specific first and the ignore block is gone because the branches are reachable and covered. Create and single delete were already correct and are unchanged. Fixes #256 --- src/Traits/HasApiControllerBehavior.php | 12 ++---- src/Traits/HasApiModelBehavior.php | 5 +++ .../Traits/HasApiControllerBehaviorTest.php | 32 ++++++++++++++++ tests/Unit/Traits/HasApiModelBehaviorTest.php | 38 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/Traits/HasApiControllerBehavior.php b/src/Traits/HasApiControllerBehavior.php index a6167cbd..79f80331 100644 --- a/src/Traits/HasApiControllerBehavior.php +++ b/src/Traits/HasApiControllerBehavior.php @@ -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( [ diff --git a/src/Traits/HasApiModelBehavior.php b/src/Traits/HasApiModelBehavior.php index e1a8bc89..2161f7cf 100644 --- a/src/Traits/HasApiModelBehavior.php +++ b/src/Traits/HasApiModelBehavior.php @@ -2,6 +2,7 @@ namespace Fleetbase\Traits; +use Fleetbase\Exceptions\FleetbaseRequestValidationException; use Fleetbase\Support\ApiModelCache; use Fleetbase\Support\Auth; use Fleetbase\Support\Http; @@ -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()); } diff --git a/tests/Unit/Traits/HasApiControllerBehaviorTest.php b/tests/Unit/Traits/HasApiControllerBehaviorTest.php index 31837c30..8ef2feef 100644 --- a/tests/Unit/Traits/HasApiControllerBehaviorTest.php +++ b/tests/Unit/Traits/HasApiControllerBehaviorTest.php @@ -1,5 +1,6 @@ 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']]; diff --git a/tests/Unit/Traits/HasApiModelBehaviorTest.php b/tests/Unit/Traits/HasApiModelBehaviorTest.php index d034aaff..f3b4f071 100644 --- a/tests/Unit/Traits/HasApiModelBehaviorTest.php +++ b/tests/Unit/Traits/HasApiModelBehaviorTest.php @@ -1,5 +1,6 @@ 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); From 09af8646d7c7b5cc36aee104d79a9e73d7bb5232 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Wed, 16 Sep 2026 12:07:59 +0800 Subject: [PATCH 6/6] Note the observer refusal fix in the v1.6.62 release notes --- RELEASE.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/RELEASE.md b/RELEASE.md index 03da73fc..254b05d3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,6 +5,10 @@ - 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 - 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. @@ -14,4 +18,4 @@ This is platform-wide: every custom field gains a public id, not only those used A database migration is required. No configuration change is needed. -Changes: [#254](https://github.com/fleetbase/core-api/pull/254). +Changes: [#254](https://github.com/fleetbase/core-api/pull/254), [#259](https://github.com/fleetbase/core-api/pull/259).