Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

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

/**
* One logical membership per (fleet, vehicle) and (fleet, driver) pair, enforced
* by the database rather than by a read-then-write in the controller.
*
* The public membership endpoints are idempotent by checking for an existing
* pivot and creating one only if there is none. That holds for sequential calls
* and fails for concurrent ones: two requests can both find nothing and both
* insert, leaving a fleet with the same vehicle twice. An importer that retries
* a timed-out request is exactly the caller that produces this.
*
* The index deliberately covers soft-deleted rows too. Unlike the SKU and
* provider-transaction keys — where a tombstone must free the key, and does so
* through a generated column that is NULL when deleted — a removed membership
* here must keep occupying its key, because re-assigning restores that row
* rather than inserting a second one. Letting a tombstone free the key would
* reintroduce the duplicate it is meant to prevent.
*
* Rows with a NULL fleet_uuid or subject_uuid are left alone: MySQL permits any
* number of NULLs in a unique index, and such a row is orphaned data rather than
* a membership.
*/
return new class extends Migration {
/**
* The pivots, and the column naming the member on each.
*
* @var array<string, array{0: string, 1: string}>
*/
private array $pivots = [
'fleet_vehicles' => ['vehicle_uuid', 'fleet_vehicles_fleet_vehicle_unique'],
'fleet_drivers' => ['driver_uuid', 'fleet_drivers_fleet_driver_unique'],
];

/**
* Run the migrations.
*
* @return void
*/
public function up()
{
foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
if (!Schema::hasTable($table)) {
continue;
}

$this->removeDuplicateMemberships($table, $memberColumn);

if (!$this->indexExists($table, $indexName)) {
Schema::table($table, function (Blueprint $blueprint) use ($memberColumn, $indexName) {
$blueprint->unique(['fleet_uuid', $memberColumn], $indexName);
});
}
}
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
if (Schema::hasTable($table) && $this->indexExists($table, $indexName)) {
Schema::table($table, function (Blueprint $blueprint) use ($indexName) {
$blueprint->dropUnique($indexName);
});
}
}
}

/**
* Collapse every duplicated pair down to the one row worth keeping.
*
* An active row wins over a tombstone, because that is the membership the
* fleet actually has today. Among equals the lowest id wins, so the outcome
* is deterministic and a re-run is a no-op. When every row for a pair is
* soft-deleted, one is still kept — removing them all would turn a later
* re-assignment into a new row and lose the original membership's history.
*
* Only redundant pivot rows are removed. No fleet, vehicle or driver is
* touched, and no surviving membership changes state.
*/
private function removeDuplicateMemberships(string $table, string $memberColumn): void
{
$duplicatePairs = DB::table($table)
->select('fleet_uuid', $memberColumn)
->whereNotNull('fleet_uuid')
->whereNotNull($memberColumn)
->groupBy('fleet_uuid', $memberColumn)
->havingRaw('COUNT(*) > 1')
->get();

foreach ($duplicatePairs as $pair) {
$pair = (array) $pair;

$rows = DB::table($table)
->where('fleet_uuid', $pair['fleet_uuid'])
->where($memberColumn, $pair[$memberColumn])
// Active rows first, then oldest first.
->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END')
->orderBy('id')
->pluck('id');

$redundant = $rows->slice(1)->values();

if ($redundant->isNotEmpty()) {
DB::table($table)->whereIn('id', $redundant->all())->delete();
}
}
}

private function indexExists(string $table, string $index): bool
{
$database = DB::connection()->getDatabaseName();

return DB::table('information_schema.statistics')
->where('table_schema', $database)
->where('table_name', $table)
->where('index_name', $index)
->exists();
}
};
47 changes: 47 additions & 0 deletions server/src/Exceptions/PublicRelationNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

namespace Fleetbase\FleetOps\Exceptions;

/**
* Thrown when a public relationship input — `vendor`, `parent_fleet`, `zone`,
* and friends — names a resource that does not exist inside the authenticated
* company.
*
* A cross-company identifier is deliberately indistinguishable from a missing
* one: both raise this, so the response cannot be used to probe whether some
* other organization holds a given public id.
*/
class PublicRelationNotFoundException extends \Exception
{
/**
* The request key that failed to resolve, e.g. `parent_fleet`.
*/
private string $relation;

/**
* The public identifier that was supplied for that key.
*/
private ?string $identifier;

public function __construct(string $relation, ?string $identifier = null, ?\Throwable $previous = null)
{
$this->relation = $relation;
$this->identifier = $identifier;

parent::__construct(
sprintf('No %s resource found for the identifier provided.', str_replace('_', ' ', $relation)),
0,
$previous
);
}

public function getRelation(): string
{
return $this->relation;
}

public function getIdentifier(): ?string
{
return $this->identifier;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;

use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\FleetOps\Models\Device;
use Fleetbase\FleetOps\Models\Driver;
Expand All @@ -23,16 +24,20 @@

trait ResolvesFleetOpsApiResources
{
protected function resolveUuid(string $modelClass, ?string $id): ?string
protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
{
if (empty($id)) {
return null;
}

return $this->resolveModel($modelClass, $id)->uuid;
return $this->resolveModel($modelClass, $id, $companyUuid)->uuid;
}

protected function resolveModel(string $modelClass, string $id): Model
/**
* @param string|null $companyUuid the company to scope the lookup to; defaults
* to the session company
*/
protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model
{
$instance = new $modelClass();
$query = $modelClass::query()->where(function ($query) use ($id, $instance) {
Expand All @@ -47,8 +52,10 @@ protected function resolveModel(string $modelClass, string $id): Model
}
});

if (session('company') && $this->modelHasColumn($instance, 'company_uuid')) {
$query->where($instance->qualifyColumn('company_uuid'), session('company'));
$companyUuid = $companyUuid ?? session('company');

if ($companyUuid && $this->modelHasColumn($instance, 'company_uuid')) {
$query->where($instance->qualifyColumn('company_uuid'), $companyUuid);
}

$model = $query->first();
Expand Down Expand Up @@ -114,17 +121,45 @@ protected function isUuidIdentifierKey(string $key): bool
return preg_match('/(^uuid$|_uuid$|Uuid$|UUID$)/', $key) === 1;
}

protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request): void
protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request, ?string $companyUuid = null): void
{
if (!$request->exists($requestKey)) {
return;
}

$input[$column] = filled($request->input($requestKey))
? $this->resolveUuid($modelClass, $request->input($requestKey))
? $this->resolveUuid($modelClass, $request->input($requestKey), $companyUuid)
: null;
}

/**
* Apply a set of public-ID relationship inputs in one pass.
*
* `$map` is keyed by the public request key and holds `[column, modelClass]`,
* e.g. `['parent_fleet' => ['parent_fleet_uuid', Fleet::class]]`. A key that is
* absent from the request is left untouched; a key sent empty clears the column.
*
* Resolution failures are rethrown as a PublicRelationNotFoundException so the
* caller can say which input was at fault rather than answering with a bare
* "not found" that names no field.
*
* @param array<string, array{0: string, 1: class-string}> $map
*
* @throws PublicRelationNotFoundException
*/
protected function applyPublicIdRelations(array &$input, array $map, $request, ?string $companyUuid = null): void
{
foreach ($map as $requestKey => [$column, $modelClass]) {
try {
$this->applyPublicIdRelation($input, $requestKey, $column, $modelClass, $request, $companyUuid);
} catch (ModelNotFoundException $exception) {
$identifier = $request->input($requestKey);

throw new PublicRelationNotFoundException($requestKey, is_scalar($identifier) ? (string) $identifier : null, $exception);
}
}
}

protected function allowedMorphTypes(): array
{
return [
Expand Down
Loading
Loading