Skip to content
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']);
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Give alerts the columns a triage queue needs: when a snooze ends, who
* set it, who owns the alert, and when the owner planned to handle it.
*
* `Alert::snooze()` used to keep `snoozed_until` inside the `meta` JSON,
* which meant a list of open-but-not-snoozed alerts had to load every row
* and ask `isSnoozed()` one at a time. A real column can be indexed and
* queried, and `assigned_to_uuid` gives an alert an owner distinct from
* whoever acknowledged or resolved it. `planned_at` is a time the owner
* chose to deal with it — a scheduling hint, not a due date.
*
* Every column is guarded so the migration is safe to run against a
* table that already has some of them.
*/
public function up(): void
{
Schema::table('alerts', function (Blueprint $table) {
if (!Schema::hasColumn('alerts', 'snoozed_until')) {
$table->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);
}
}
});
}
};
134 changes: 126 additions & 8 deletions src/Models/Alert.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
];

Expand All @@ -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',
];
Expand All @@ -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.
Expand All @@ -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,
];

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -456,20 +531,63 @@ 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');
}

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;
Expand Down
Loading
Loading