diff --git a/app/Models/Audit.php b/app/Models/Audit.php index 312ff0db..ff6a0fb4 100644 --- a/app/Models/Audit.php +++ b/app/Models/Audit.php @@ -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 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 + */ + 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 $builder + */ + public function scopeWhereMissingOwner(Builder $builder): void + { + $builder->whereNull('owner_organization_id') + ->whereNull('owner_user_id') + ->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner()); + } } diff --git a/app/Models/Concerns/AuditableWithoutOwner.php b/app/Models/Concerns/AuditableWithoutOwner.php new file mode 100644 index 00000000..a32b0ae0 --- /dev/null +++ b/app/Models/Concerns/AuditableWithoutOwner.php @@ -0,0 +1,14 @@ +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); + } + } }