From 34273a4863a8e0a436467417bb279f5ea784b581 Mon Sep 17 00:00:00 2001 From: Constantin Graf Date: Wed, 7 Oct 2026 16:05:45 +0200 Subject: [PATCH] Add audit owner through parent model Models that belong to an organization via a parent model (for example project members via their project) implement AuditableThroughParent and return the parent relation. The owner organization of their audits is taken from the loaded parent if it matches the foreign key, otherwise it is queried. The declaration is also used to backfill the owner of existing audits. --- .../Concerns/AuditableThroughParent.php | 22 ++++++++++++ app/Models/Concerns/CustomAuditable.php | 31 ++++++++++++++++ app/Models/ProjectMember.php | 13 +++---- tests/Unit/Model/AuditModelTest.php | 36 +++++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 app/Models/Concerns/AuditableThroughParent.php diff --git a/app/Models/Concerns/AuditableThroughParent.php b/app/Models/Concerns/AuditableThroughParent.php new file mode 100644 index 00000000..47856c7c --- /dev/null +++ b/app/Models/Concerns/AuditableThroughParent.php @@ -0,0 +1,22 @@ + + */ + public function getAuditParentRelation(): BelongsTo; +} diff --git a/app/Models/Concerns/CustomAuditable.php b/app/Models/Concerns/CustomAuditable.php index 150bfb21..1557c0c0 100644 --- a/app/Models/Concerns/CustomAuditable.php +++ b/app/Models/Concerns/CustomAuditable.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models\Concerns; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Config; use OwenIt\Auditing\Auditable; @@ -27,6 +28,36 @@ trait CustomAuditable */ public function getAuditOwnerOrganizationId(): ?string { + if ($this instanceof AuditableThroughParent) { + $relation = $this->getAuditParentRelation(); + $parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null; + if ($parentId === null) { + return null; + } + + // Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization + $relationName = $relation->getRelationName(); + if ($this->relationLoaded($relationName)) { + $parent = $this->getRelation($relationName); + if ($parent instanceof Model + && $parent->getAttribute($relation->getOwnerKeyName()) === $parentId + && array_key_exists('organization_id', $parent->getAttributes())) { + /** @var string|null $organizationId */ + $organizationId = $parent->getAttributes()['organization_id']; + + return $organizationId; + } + } + + /** @var string|null $organizationId */ + $organizationId = $relation->getRelated()->newQuery() + ->toBase() + ->where($relation->getOwnerKeyName(), $parentId) + ->value('organization_id'); + + return $organizationId; + } + return $this->getAttributes()['organization_id'] ?? null; } diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index 5d45095d..1bff0042 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Models\Concerns\AuditableThroughParent; use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\HasUuids; use Database\Factories\ProjectMemberFactory; @@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract; * @method static Builder whereBelongsToOrganization(Organization $organization) * @method static ProjectMemberFactory factory() */ -class ProjectMember extends Model implements AuditableContract +class ProjectMember extends Model implements AuditableContract, AuditableThroughParent { use CustomAuditable; @@ -83,11 +84,11 @@ class ProjectMember extends Model implements AuditableContract }); } - public function getAuditOwnerOrganizationId(): ?string + /** + * @return BelongsTo + */ + public function getAuditParentRelation(): BelongsTo { - /** @var string|null $organizationId */ - $organizationId = Project::query()->whereKey($this->project_id)->value('organization_id'); - - return $organizationId; + return $this->project(); } } diff --git a/tests/Unit/Model/AuditModelTest.php b/tests/Unit/Model/AuditModelTest.php index 6e0950a9..f08daa85 100644 --- a/tests/Unit/Model/AuditModelTest.php +++ b/tests/Unit/Model/AuditModelTest.php @@ -14,6 +14,7 @@ use App\Models\TimeEntry; use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; +use Illuminate\Support\Facades\DB; use PHPUnit\Framework\Attributes\CoversClass; #[CoversClass(Audit::class)] @@ -84,6 +85,41 @@ class AuditModelTest extends ModelTestAbstract $this->assertNull($audit->owner_user_id); } + public function test_owner_organization_through_parent_uses_the_loaded_parent_without_query(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create(); + $projectMember->load('project'); + DB::enableQueryLog(); + + // Act + $ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId(); + + // Assert + $this->assertSame($organization->getKey(), $ownerOrganizationId); + $this->assertCount(0, DB::getQueryLog()); + } + + public function test_owner_organization_through_parent_ignores_a_loaded_parent_that_does_not_match_the_foreign_key(): void + { + // Arrange + $organization = Organization::factory()->create(); + $otherOrganization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $otherProject = Project::factory()->forOrganization($otherOrganization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create(); + $projectMember->load('project'); + $projectMember->project_id = $otherProject->getKey(); + + // Act + $ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId(); + + // Assert + $this->assertSame($otherOrganization->getKey(), $ownerOrganizationId); + } + public function test_audits_of_an_organization_have_the_organization_itself_as_owner(): void { // Act