Add marker for auditable models without owner

Models implementing AuditableWithoutOwner intentionally have no audit
owner, either because they belong to neither an organization nor a user,
or because they and their audits have to be kept when the owner is
deleted (for example billing records). Adds helpers and a scope on the
audit model to find audits that are missing an owner although they
should have one.
This commit is contained in:
Constantin Graf
2026-10-07 14:39:47 +02:00
committed by Constantin Graf
parent 30a90f80e0
commit f921452419
3 changed files with 106 additions and 0 deletions

View File

@@ -4,9 +4,12 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
@@ -31,6 +34,7 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property-read Organization|null $ownerOrganization
*
* @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/
class Audit extends PackageAuditModel
{
@@ -52,4 +56,41 @@ class Audit extends PackageAuditModel
{
return $this->belongsTo(Organization::class, 'owner_organization_id');
}
/**
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
*/
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
{
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
}
/**
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
*
* @return array<int, string>
*/
public static function getAuditableTypesWithoutOwner(): array
{
return collect(Relation::morphMap())
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
->keys()
->values()
->all();
}
/**
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
* These are audits whose owner no longer exists or could not be determined (yet).
*
* @param Builder<Audit> $builder
*/
public function scopeWhereMissingOwner(Builder $builder): void
{
$builder->whereNull('owner_organization_id')
->whereNull('owner_user_id')
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
/**
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
* either because the model belongs to neither of them, or because the model and its audits have to be kept
* when its organization or user is deleted (for example billing records).
* Audits of these models are not deleted together with an organization or user,
* and are not considered as missing an owner (for example by the audit backfill command).
*/
interface AuditableWithoutOwner {}

View File

@@ -5,12 +5,15 @@ declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Audit;
use App\Models\Concerns\AuditableWithoutOwner;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Audit::class)]
@@ -119,4 +122,52 @@ class AuditModelTest extends ModelTestAbstract
$this->assertSame(0, Audit::query()->where('auditable_id', $organization->getKey())->count());
$this->assertSame(1, Audit::query()->where('auditable_id', $otherUser->getKey())->count());
}
public function test_auditable_types_without_owner_are_determined_by_the_marker_interface(): void
{
// Arrange
$originalMorphMap = Relation::morphMap();
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
try {
// Act
$typesWithoutOwner = Audit::getAuditableTypesWithoutOwner();
$isWithoutOwner = Audit::isAuditableTypeWithoutOwner('model-without-owner');
$isTimeEntryWithoutOwner = Audit::isAuditableTypeWithoutOwner((new TimeEntry)->getMorphClass());
// Assert
$this->assertContains('model-without-owner', $typesWithoutOwner);
$this->assertNotContains((new TimeEntry)->getMorphClass(), $typesWithoutOwner);
$this->assertTrue($isWithoutOwner);
$this->assertFalse($isTimeEntryWithoutOwner);
} finally {
Relation::morphMap($originalMorphMap, false);
}
}
public function test_scope_where_missing_owner_only_returns_audits_without_owner_whose_type_should_have_one(): void
{
// Arrange
$originalMorphMap = Relation::morphMap();
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
$organization = Organization::factory()->create();
$user = User::factory()->create();
Audit::query()->delete();
$missingOwnerAudit = Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass()]);
Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass(), 'owner_organization_id' => $organization->getKey()]);
Audit::factory()->create(['owner_user_id' => $user->getKey()]);
Audit::factory()->create(['auditable_type' => 'model-without-owner']);
try {
// Act
$auditIds = Audit::query()->whereMissingOwner()->pluck('id')->all();
// Assert
$this->assertSame([$missingOwnerAudit->getKey()], $auditIds);
} finally {
Relation::morphMap($originalMorphMap, false);
}
}
}