Skip to content
Merged
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
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