diff --git a/config/activity-log.php b/config/activity-log.php index 5e6c337..f9591ef 100644 --- a/config/activity-log.php +++ b/config/activity-log.php @@ -3,8 +3,13 @@ declare(strict_types=1); return [ - 'activity_model' => \Spatie\Activitylog\Models\Activity::class, - + // Activity model for the read (timeline) path. Leave null to inherit + // Spatie's `activitylog.activity_model` — the same model used for writes — + // so a tenant-scoped Activity subclass applies to both. Set explicitly only + // to override that. + 'activity_model' => null, + + 'default_per_page' => 20, 'pagination_buffer' => 2, 'deduplicate_by_default' => true, diff --git a/resources/views/timeline.blade.php b/resources/views/timeline.blade.php index 7801d71..71091f8 100644 --- a/resources/views/timeline.blade.php +++ b/resources/views/timeline.blade.php @@ -2,7 +2,7 @@ @if ($entries->isEmpty())
- +

{{ $emptyState }}

diff --git a/src/Timeline/Sources/AbstractTimelineSource.php b/src/Timeline/Sources/AbstractTimelineSource.php index 4df8a4d..cf43e55 100644 --- a/src/Timeline/Sources/AbstractTimelineSource.php +++ b/src/Timeline/Sources/AbstractTimelineSource.php @@ -19,6 +19,25 @@ public function priority(): int return $this->priority; } + /** + * Resolve the Activity model used for the read path. + * + * Prefers the plugin's own key, then falls back to Spatie's canonical + * `activitylog.activity_model` — the same key that governs writes — so the + * rendered timeline reads through whatever (possibly tenant-scoped) Activity + * subclass the host configured, even when the hyphenated plugin config was + * never published. + * + * @return class-string<\Spatie\Activitylog\Contracts\Activity&\Illuminate\Database\Eloquent\Model> + */ + protected function getActivityModelClass(): string + { + /** @var class-string<\Spatie\Activitylog\Contracts\Activity&\Illuminate\Database\Eloquent\Model> */ + return config('activity-log.activity_model') + ?? config('activitylog.activity_model') + ?? \Spatie\Activitylog\Models\Activity::class; + } + protected function dedupKeyFor(string $class, int|string $id, CarbonImmutable $occurredAt): string { return sprintf( @@ -29,6 +48,16 @@ protected function dedupKeyFor(string $class, int|string $id, CarbonImmutable $o ); } + /** + * Dedup key for an activity-log row. Unlike the second-precision key above, + * this includes the activity id so several distinct activities written for + * the same subject in the same second (e.g. a multi-field save) stay separate. + */ + protected function dedupKeyForActivity(string $class, int|string $id, CarbonImmutable $occurredAt, int|string $activityId): string + { + return $this->dedupKeyFor($class, $id, $occurredAt).':'.$activityId; + } + /** * @return Relation */ diff --git a/src/Timeline/Sources/ActivityLogSource.php b/src/Timeline/Sources/ActivityLogSource.php index 4c7e0e5..a885a56 100644 --- a/src/Timeline/Sources/ActivityLogSource.php +++ b/src/Timeline/Sources/ActivityLogSource.php @@ -13,14 +13,6 @@ final class ActivityLogSource extends AbstractTimelineSource { - /** - * Get the activity model class from config or use default - */ - private function getActivityModelClass(): string - { - return config('activity-log.activity_model', ActivityModel::class); - } - public function resolve(Model $subject, Window $window): iterable { throw_if($subject->getKey() === null, DomainException::class, 'ActivityLogSource cannot resolve entries for an unsaved subject.'); @@ -56,7 +48,7 @@ private function makeEntry(Model $subject, ActivityModel $activity): TimelineEnt type: 'activity_log', event: $event, occurredAt: $occurredAt, - dedupKey: $this->dedupKeyFor($subject->getMorphClass(), (string) $subject->getKey(), $occurredAt), + dedupKey: $this->dedupKeyForActivity($subject->getMorphClass(), (string) $subject->getKey(), $occurredAt, (string) $activity->getKey()), sourcePriority: $this->priority, subject: $subject, causer: $activity->causer, diff --git a/src/Timeline/Sources/RelatedActivityLogSource.php b/src/Timeline/Sources/RelatedActivityLogSource.php index 7a01a38..061ca71 100644 --- a/src/Timeline/Sources/RelatedActivityLogSource.php +++ b/src/Timeline/Sources/RelatedActivityLogSource.php @@ -14,14 +14,6 @@ final class RelatedActivityLogSource extends AbstractTimelineSource { - /** - * Get the activity model class from config or use default - */ - private function getActivityModelClass(): string - { - return config('activity-log.activity_model', ActivityModel::class); - } - /** * @param array $relations */ @@ -54,7 +46,7 @@ public function resolve(Model $subject, Window $window): iterable } $modelClass = $this->getActivityModelClass(); - + $query = $modelClass::query() ->with(['causer', 'subject']) ->where(function (Builder $q) use ($subjectPairs): void { diff --git a/tests/Feature/ActivityModelResolutionTest.php b/tests/Feature/ActivityModelResolutionTest.php new file mode 100644 index 0000000..3277867 --- /dev/null +++ b/tests/Feature/ActivityModelResolutionTest.php @@ -0,0 +1,40 @@ +set('activity-log.activity_model', ScopedActivity::class); + + $person = Person::factory()->create(); + + $entries = collect((new ActivityLogSource(priority: 10))->resolve($person, new Window(cap: 10))); + + expect($entries)->toBeEmpty(); +}); + +it('falls back to Spatie activitylog.activity_model when the plugin key is null', function (): void { + config()->set('activity-log.activity_model', null); + config()->set('activitylog.activity_model', ScopedActivity::class); + + $person = Person::factory()->create(); + + $entries = collect((new ActivityLogSource(priority: 10))->resolve($person, new Window(cap: 10))); + + expect($entries)->toBeEmpty(); +}); + +it('uses the base Spatie Activity when nothing is configured', function (): void { + config()->set('activity-log.activity_model', null); + config()->set('activitylog.activity_model', null); + + $person = Person::factory()->create(); + + $entries = collect((new ActivityLogSource(priority: 10))->resolve($person, new Window(cap: 10))); + + expect($entries)->toHaveCount(1); +}); diff --git a/tests/Feature/BuilderDedupTest.php b/tests/Feature/BuilderDedupTest.php index 45eb5e8..2647a4c 100644 --- a/tests/Feature/BuilderDedupTest.php +++ b/tests/Feature/BuilderDedupTest.php @@ -65,6 +65,31 @@ expect($entries)->toHaveCount(1); }); +it('keeps distinct own-activity entries that land in the same second (multi-change save)', function (): void { + $person = Person::factory()->create(); + $person->update(['name' => 'First change']); + $person->update(['name' => 'Second change']); + + // Simulate one save producing several activity rows in the same second. + $stamp = CarbonImmutable::parse('2026-04-17T10:00:00Z'); + Activity::query() + ->where('subject_type', $person->getMorphClass()) + ->where('subject_id', $person->id) + ->update(['created_at' => $stamp]); + + $count = Activity::query() + ->where('subject_type', $person->getMorphClass()) + ->where('subject_id', $person->id) + ->count(); + + $entries = TimelineBuilder::make($person) + ->fromActivityLog() + ->deduplicate() + ->get(); + + expect($entries)->toHaveCount($count); +}); + it('deduplicate(false) skips dedup entirely', function (): void { $person = Person::factory()->create(); $email = Email::factory()->for($person)->create(); diff --git a/tests/Fixtures/Models/ScopedActivity.php b/tests/Fixtures/Models/ScopedActivity.php new file mode 100644 index 0000000..a228b6c --- /dev/null +++ b/tests/Fixtures/Models/ScopedActivity.php @@ -0,0 +1,22 @@ +whereRaw('1 = 0'); + }); + } +}