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
9 changes: 7 additions & 2 deletions config/activity-log.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion resources/views/timeline.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
@if ($entries->isEmpty())
<div class="flex flex-col items-center justify-center gap-3 py-10 text-center">
<div class="flex h-11 w-11 items-center justify-center rounded-full bg-gray-100 text-gray-400 dark:bg-white/5 dark:text-gray-500">
<x-filament::icon icon="ri-history-line" class="h-5 w-5" />
<x-filament::icon icon="heroicon-o-clock" class="h-5 w-5" />
</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ $emptyState }}</p>
</div>
Expand Down
29 changes: 29 additions & 0 deletions src/Timeline/Sources/AbstractTimelineSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<Model, Model, mixed>
*/
Expand Down
10 changes: 1 addition & 9 deletions src/Timeline/Sources/ActivityLogSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 1 addition & 9 deletions src/Timeline/Sources/RelatedActivityLogSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int, string> $relations
*/
Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions tests/Feature/ActivityModelResolutionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

use Relaticle\ActivityLog\Tests\Fixtures\Models\Person;
use Relaticle\ActivityLog\Tests\Fixtures\Models\ScopedActivity;
use Relaticle\ActivityLog\Timeline\Sources\ActivityLogSource;
use Relaticle\ActivityLog\Timeline\Window;

it('reads through the plugin activity_model when set', function (): void {
config()->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);
});
25 changes: 25 additions & 0 deletions tests/Feature/BuilderDedupTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions tests/Fixtures/Models/ScopedActivity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Relaticle\ActivityLog\Tests\Fixtures\Models;

use Illuminate\Database\Eloquent\Builder;
use Spatie\Activitylog\Models\Activity as SpatieActivity;

/**
* Stand-in for a host's tenant-scoped Activity subclass: a global scope that
* hides every row, so a read path honoring it returns nothing.
*/
final class ScopedActivity extends SpatieActivity
{
protected static function booted(): void
{
static::addGlobalScope('scoped', function (Builder $query): void {
$query->whereRaw('1 = 0');
});
}
}
Loading