diff --git a/composer.json b/composer.json index 27ff07bcfd4..6be4bb32f3a 100644 --- a/composer.json +++ b/composer.json @@ -184,6 +184,7 @@ "CraftCms\\Cms\\Providers\\CraftServiceProvider" ], "aliases": { + "Activities": "CraftCms\\Cms\\Support\\Facades\\Activities", "Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses", "AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer", "Assets": "CraftCms\\Cms\\Support\\Facades\\Assets", diff --git a/composer.lock b/composer.lock index 7f9b0c5894f..e6ab38b1d82 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7793bc2d37d0eee59cc3ffe9611c3086", + "content-hash": "08fee03f9a631fa7c8dbe521e2c435e0", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/docs/activity-logging.md b/docs/activity-logging.md new file mode 100644 index 00000000000..9b26613e4c3 --- /dev/null +++ b/docs/activity-logging.md @@ -0,0 +1,299 @@ +# Activity logging + +Craft records durable activity events for actions that users may need to inspect later, such as creating an entry, applying a draft, moving an element, or replacing an asset file. Each event records what happened, who caused it, what it affected, and when it occurred. + +Activity events are application data stored in the `activityevents` database table. They are not application log messages, so do not write them with Laravel's `Log` facade. + +## How an event is recorded + +Core and plugin code describe an action with an activity event type, then pass an instance to the `Activities` facade. Recording is synchronous. + +```mermaid +sequenceDiagram + participant Action as Business action + participant Type as Activity event type + participant Activities + participant Recorder as ActivityEventRecorder + participant DB as activityevents + + Action->>Type: Construct after the action succeeds + Action->>Activities: record($event) + Activities->>Recorder: record($event) + Recorder->>Recorder: Resolve actor, subject, site, and labels + Recorder->>DB: Insert event and snapshots + DB-->>Action: ActivityEvent model +``` + +The recorder performs these steps: + +1. Reads the event type's source, subject, actor, site, data, and changes. +2. Resolves the actor when the event type did not supply one. +3. Captures labels for the source, event, actor, subject, and site. +4. Inserts an `ActivityEvent` with the current time. + +The insert uses the caller's database transaction. If the action and activity event run in one transaction, rolling back the action also removes the event. Record an event only after the corresponding action has succeeded, but before committing its transaction. + +Craft records its built-in events at the shared write and lifecycle boundaries. For example, entry writes compare the saved entry with its previous state, omit no-op saves, and record only JSON-safe field values. Draft, element lifecycle, structural, and asset replacement operations record their events at their own successful completion points. + +## What an event stores + +An event separates identifiers that support queries from descriptive data that preserves history. + +```mermaid +flowchart LR + Event[ActivityEvent] + Event --> Identity[Queryable identity] + Identity --> EventType[eventType] + Identity --> Source[source] + Identity --> Actor[actorType + actorId] + Identity --> Subject[subjectType + subjectId] + Identity --> Site[siteId] + Event --> Payload[JSON payload] + Payload --> Snapshots[snapshots] + Payload --> Changes[changes] + Payload --> Data[event-specific data] + Event --> Time[occurredAt] +``` + +| Value | Purpose | +| -------------------------- | ----------------------------------------------------- | +| `eventType` | Fully qualified event type class name | +| `source` | Stable source ID, normally `craft` or a plugin handle | +| `actorType`, `actorId` | User, system, or anonymous actor identity | +| `subjectType`, `subjectId` | Stable identity of the affected object | +| `siteId` | Site context, or `null` for a site-neutral event | +| `payload.snapshots` | Labels captured when the event occurred | +| `payload.changes` | Structured old and new values | +| `payload.data` | Data defined by the event type | +| `occurredAt` | Time the action occurred | + +Snapshots keep an event readable after a user, subject, site, or plugin has been removed. If the event type class is no longer available, Craft returns the captured event label. + +### Actors + +When an event does not provide an actor, Craft resolves one from the current execution context: + +| Context | Actor | +| ------------------------------- | ---------------------- | +| Authenticated request | Current user | +| Unauthenticated HTTP request | Anonymous | +| Console command or queue worker | Craft CMS system actor | + +Pass an actor explicitly when the execution context does not identify the person responsible. A queued job started by a user is a common case. Event types may accept either a saved `User` element or an `ActivityActor`. + +### Subjects + +An element subject is normalized to its canonical element. Craft stores the element class and UID, not its numeric database ID. Draft activity therefore remains attached to the canonical element. + +Plugins can describe a non-element subject with a stable type, ID, and label: + +```php +use CraftCms\Cms\Activity\Data\ActivitySubject; + +$subject = new ActivitySubject( + type: Campaign::class, + id: (string) $campaign->id, + label: $campaign->name, +); +``` + +Do not use a translated label, mutable handle, or array index as the subject ID. The ID must continue to identify the same object after its label changes. + +### Data and changes + +`data()` returns event-specific values used to describe or inspect the action. It must return a JSON object represented by an associative PHP array. Event type constructors should use specific parameter types; validate untrusted values before constructing the event. + +Use `ActivityChange` when consumers need a consistent old-versus-new representation: + +```php +use CraftCms\Cms\Activity\Data\ActivityChange; + +new ActivityChange( + label: $field->name, + old: 'Draft', + new: 'Approved', +); +``` + +The label is captured for display. Old and new values must be JSON-encodable. Laravel throws while applying the payload cast if encoding fails. Avoid secrets, access tokens, full request bodies, and other data that should not remain in an audit history. + +## Logging activity from a plugin + +A plugin owns its activity event classes. The stored class name identifies the event type, while the plugin handle identifies its source. + +The following example comes from a campaign plugin that sends an entry through an email provider. It records the campaign entry, site, provider response, recipient count, and responsible user. + +### Define the plugin source once + +Create a base event type so each plugin event reports the same source and translation category: + +```php + $this->provider, + 'deliveryId' => $this->deliveryId, + 'recipientCount' => $this->recipientCount, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Sent with {provider} to {count} recipients.', + [ + 'provider' => $event->data['provider'], + 'count' => $event->data['recipientCount'], + ], + category: self::source()->translationCategory, + ); + } +} +``` + +`LABEL` is the short fallback description. Craft translates it using the source's translation category. `format()` may return a string, an `Htmlable`, or `null`. Returning `null` tells Craft to use the translated label. Craft sanitizes strings and HTML before returning them from `Activities::format()`. + +### Record the event at the action boundary + +Record the event where the plugin knows that the operation succeeded: + +```php +use Acme\Campaigns\Activity\CampaignSent; +use CraftCms\Cms\Support\Facades\Activities; +use CraftCms\Cms\Support\Facades\Sites; + +$delivery = $campaignClient->send($entry); + +Activities::record(new CampaignSent( + subject: $entry, + site: Sites::getSiteById($entry->siteId), + provider: $delivery->provider, + deliveryId: $delivery->id, + recipientCount: $delivery->recipientCount, +)); +``` + +An authenticated request supplies the actor automatically. A queued job should pass the user who requested the send when that attribution is available: + +```php +Activities::record(new CampaignSent( + subject: $entry, + site: Sites::getSiteById($entry->siteId), + provider: $delivery->provider, + deliveryId: $delivery->id, + recipientCount: $delivery->recipientCount, + actor: $requestedBy, +)); +``` + +Do not insert an `ActivityEvent` model directly. The facade supplies actor resolution, validation, snapshots, translation metadata, and a consistent occurrence time. + +## Querying activity + +`Activities::query()` returns an Eloquent builder ordered by `occurredAt` and then `id`, both newest first. The ID tie-breaker makes cursor pagination stable when events share a timestamp. + +```php +use Acme\Campaigns\Activity\CampaignSent; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Support\Facades\Activities; + +$events = Activities::query() + ->subject(ActivitySubject::fromElement($entry)) + ->site($site) + ->source('campaigns') + ->eventTypes(CampaignSent::class) + ->occurredFrom(now()->subMonth()) + ->cursorPaginate(50); + +foreach ($events as $event) { + $label = Activities::format($event); + $icon = Activities::icon($event); +} +``` + +Available query scopes are: + +| Scope | Matches | +| ---------------------------------------- | ----------------------------------------------- | +| `subject(ActivitySubject $subject)` | One subject type and ID | +| `site($site)` | One `Site` or site ID, plus site-neutral events | +| `eventTypes($eventTypes)` | One event type class name or an array of names | +| `actor(ActivityActor $actor)` | One actor type and ID | +| `source(string $source)` | One source ID | +| `occurredFrom(DateTimeInterface $date)` | Events on or after the date | +| `occurredUntil(DateTimeInterface $date)` | Events on or before the date | +| `newestFirst()` | Newest timestamp and ID first | + +Use the formatter instead of calling an event type's `format()` method yourself. `Activities::format()` handles translation, sanitization, missing event classes, and formatter failures. `Activities::icon()` returns the event type's icon. + +## Retention + +Craft keeps activity indefinitely by default. Set `activityRetentionDuration` to let garbage collection delete older events: + +```php +// config/general.php + +use CraftCms\Cms\Cms; + +return Cms::config() + ->activityRetentionDuration('P90D'); +``` + +The `CRAFT_ACTIVITY_RETENTION_DURATION` environment variable accepts the same duration values. Set the value to `0` for unlimited retention. Garbage collection deletes events older than the configured cutoff in chunks. + +Choose a retention period based on the history users need and the data included in plugin payloads. Changing the period affects future garbage collection; it does not archive events before deleting them. diff --git a/resources/js/modules/auth-method-setup/auth.scss b/resources/js/modules/auth-method-setup/auth.scss index 95d243fa415..d6b388f5cf4 100644 --- a/resources/js/modules/auth-method-setup/auth.scss +++ b/resources/js/modules/auth-method-setup/auth.scss @@ -84,7 +84,7 @@ ul.auth-method-recovery-codes-list { font-family: - SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace; + SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace; font-size: 0.9em !important; max-width: 20em; margin-inline: auto; diff --git a/src/Activity/Activities.php b/src/Activity/Activities.php new file mode 100644 index 00000000000..0f0ab38d50d --- /dev/null +++ b/src/Activity/Activities.php @@ -0,0 +1,82 @@ +events->record($event); + } + + /** @return Builder */ + public function query(): Builder + { + return ActivityEvent::query()->newestFirst(); + } + + public function format(ActivityEvent $event): string|Htmlable + { + $type = $event->eventType; + + if (! is_a($type, ActivityEventTypeInterface::class, true)) { + return $this->capturedLabel($event); + } + + try { + $formatted = $type::format($event); + + if ($formatted === null) { + return t( + $type::label(), + category: $type::source()->translationCategory, + ) ?: $this->capturedLabel($event); + } + + if (is_string($formatted)) { + return $this->htmlSanitizers->sanitize($formatted); + } + + return new HtmlString($this->htmlSanitizers->sanitize($formatted->toHtml())); + } catch (Throwable $exception) { + report($exception); + + return $this->capturedLabel($event); + } + } + + public function icon(ActivityEvent $event): ?string + { + $type = $event->eventType; + + if (! is_a($type, ActivityEventTypeInterface::class, true)) { + return null; + } + + return $type::icon(); + } + + private function capturedLabel(ActivityEvent $event): string + { + return $event->snapshots['event']['label'] ?? $event->eventType; + } +} diff --git a/src/Activity/ActivityEventRecorder.php b/src/Activity/ActivityEventRecorder.php new file mode 100644 index 00000000000..0416144db32 --- /dev/null +++ b/src/Activity/ActivityEventRecorder.php @@ -0,0 +1,85 @@ +data(); + + $subject = $event->subject(); + $actor = $this->resolveActor($event->actor()); + $site = $event->site(); + + $source = $event::source(); + + $snapshots = [ + 'actor' => ['label' => $actor->label], + 'source' => ['label' => $source->label], + 'event' => ['label' => t($event::label(), category: $source->translationCategory)], + ]; + + if ($subject !== null) { + $snapshots['subject'] = ['label' => $subject->label]; + } + + if ($site !== null) { + $snapshots['site'] = ['name' => $site->getName(false)]; + } + + if (($impersonator = $this->impersonation->getImpersonator()) !== null) { + $snapshots['impersonator'] = ['id' => $impersonator->id, 'label' => $impersonator->name]; + } + + return ActivityEvent::query()->create([ + 'eventType' => $event::class, + 'source' => $source->id, + 'actorType' => $actor->type, + 'actorId' => $actor->id, + 'subjectType' => $subject?->type, + 'subjectId' => $subject?->id, + 'siteId' => $site?->id, + 'payload' => [ + 'snapshots' => $snapshots, + 'changes' => collect($event->changes())->toArray(), + 'data' => $data === [] ? (object) [] : $data, + ], + 'occurredAt' => now(), + ]); + } + + private function resolveActor(?ActivityActor $actor): ActivityActor + { + if ($actor !== null) { + return $actor; + } + + if (($user = currentUserElement()) !== null) { + return ActivityActor::user($user); + } + + $isHttpRequest = ! app()->runningInConsole() + || (app()->bound('request') && request()->route() !== null); + + return $isHttpRequest + ? ActivityActor::anonymous() + : ActivityActor::system(); + } +} diff --git a/src/Activity/ActivityEventType.php b/src/Activity/ActivityEventType.php new file mode 100644 index 00000000000..c100120ffb9 --- /dev/null +++ b/src/Activity/ActivityEventType.php @@ -0,0 +1,86 @@ + $changes + */ + public function __construct( + private readonly ElementInterface|ActivitySubject|null $subject = null, + private readonly CraftUser|ActivityActor|null $actor = null, + private readonly ?Site $site = null, + private readonly array $changes = [], + ) {} + + public function subject(): ?ActivitySubject + { + return $this->subject instanceof ElementInterface + ? ActivitySubject::fromElement($this->subject) + : $this->subject; + } + + public function actor(): ?ActivityActor + { + return $this->actor instanceof CraftUser + ? ActivityActor::user($this->actor) + : $this->actor; + } + + public function site(): ?Site + { + return $this->site; + } + + public function data(): array + { + return []; + } + + public function changes(): array + { + return $this->changes; + } + + public static function source(): ActivitySource + { + return new ActivitySource( + id: 'craft', + label: 'Craft', + translationCategory: 'app', + ); + } + + public static function label(): string + { + return static::LABEL; + } + + public static function icon(): ?string + { + return static::ICON; + } + + public static function format(ActivityEvent $event): string|Htmlable|null + { + return null; + } +} diff --git a/src/Activity/AssetActivity.php b/src/Activity/AssetActivity.php new file mode 100644 index 00000000000..371fbb415b8 --- /dev/null +++ b/src/Activity/AssetActivity.php @@ -0,0 +1,48 @@ +propagating && + $asset->tempFilePath !== null && + $asset->ruleset->getScenario() === AssetRules::SCENARIO_REPLACE; + } + + public static function original(Asset $asset): Asset + { + if ($asset->id === null) { + throw new LogicException('Only existing asset files can be replaced.'); + } + + return Asset::find()->id($asset->id)->siteId($asset->siteId)->status(null)->one() + ?? throw new LogicException("Could not load asset $asset->id before replacing its file."); + } + + public static function recordReplaced(Asset $newAsset, Asset $originalAsset): void + { + Activities::record(new AssetFileReplaced( + subject: $newAsset, + site: Sites::getSiteById($newAsset->siteId), + oldFilename: $originalAsset->getFilename(), + newFilename: $newAsset->getFilename(), + oldMimeType: $originalAsset->getMimeType(), + newMimeType: $newAsset->getMimeType(), + oldSize: $originalAsset->size, + newSize: $newAsset->size, + )); + } +} diff --git a/src/Activity/Contracts/ActivityEventTypeInterface.php b/src/Activity/Contracts/ActivityEventTypeInterface.php new file mode 100644 index 00000000000..0b75aacc4ca --- /dev/null +++ b/src/Activity/Contracts/ActivityEventTypeInterface.php @@ -0,0 +1,36 @@ + */ + public function data(): array; + + /** @return list */ + public function changes(): array; + + public static function source(): ActivitySource; + + public static function label(): string; + + public static function icon(): ?string; + + public static function format(ActivityEvent $event): string|Htmlable|null; +} diff --git a/src/Activity/Data/ActivityActor.php b/src/Activity/Data/ActivityActor.php new file mode 100644 index 00000000000..b3933e62606 --- /dev/null +++ b/src/Activity/Data/ActivityActor.php @@ -0,0 +1,52 @@ +type === self::TYPE_USER && $this->id === null) { + throw new InvalidArgumentException('User activity actors require an ID.'); + } + + if ($this->label === '') { + throw new InvalidArgumentException('Activity actor labels cannot be empty.'); + } + } + + public static function user(CraftUser $user): self + { + $user = $user->asElement(); + + if ($user->id === null) { + throw new InvalidArgumentException('Activity actors must be saved users.'); + } + + return new self(self::TYPE_USER, $user->name ?: $user->username ?: $user->email ?: "User #{$user->id}", $user->id); + } + + public static function system(): self + { + return new self(self::TYPE_SYSTEM, 'Craft CMS'); + } + + public static function anonymous(): self + { + return new self(self::TYPE_ANONYMOUS, 'Anonymous'); + } +} diff --git a/src/Activity/Data/ActivityChange.php b/src/Activity/Data/ActivityChange.php new file mode 100644 index 00000000000..3567e6e4eda --- /dev/null +++ b/src/Activity/Data/ActivityChange.php @@ -0,0 +1,47 @@ + */ +readonly class ActivityChange implements Arrayable +{ + /** + * @param string $label The human-readable name captured when the change occurred. + * @param mixed $old The value before the change. + * @param mixed $new The value after the change. + */ + public function __construct( + public string $label, + public mixed $old, + public mixed $new, + ) { + if ($this->label === '') { + throw new InvalidArgumentException('Activity changes require a label.'); + } + } + + /** @return array{label: string, old: mixed, new: mixed} */ + public function toArray(): array + { + return [ + 'label' => $this->label, + 'old' => $this->old, + 'new' => $this->new, + ]; + } + + /** @param array{label: string, old: mixed, new: mixed} $change */ + public static function fromArray(array $change): self + { + return new self( + $change['label'], + $change['old'], + $change['new'], + ); + } +} diff --git a/src/Activity/Data/ActivitySource.php b/src/Activity/Data/ActivitySource.php new file mode 100644 index 00000000000..f3424445b84 --- /dev/null +++ b/src/Activity/Data/ActivitySource.php @@ -0,0 +1,30 @@ +id === '' || $this->label === '' || $this->translationCategory === '') { + throw new InvalidArgumentException('Activity sources require an ID, label, and translation category.'); + } + } + + public static function fromPlugin(PluginInterface $plugin): self + { + return new self( + $plugin->handle, + $plugin->name ?? $plugin->handle, + $plugin->t9nCategory ?? $plugin->handle, + ); + } +} diff --git a/src/Activity/Data/ActivitySubject.php b/src/Activity/Data/ActivitySubject.php new file mode 100644 index 00000000000..dbcbcd23094 --- /dev/null +++ b/src/Activity/Data/ActivitySubject.php @@ -0,0 +1,38 @@ +type === '' || $this->id === '' || $this->label === '') { + throw new InvalidArgumentException('Activity subjects require a type, ID, and label.'); + } + } + + public static function fromElement(ElementInterface $element): self + { + $canonical = $element->getCanonical(); + + if ($canonical->uid === null) { + throw new InvalidArgumentException('Activity subjects must be saved elements.'); + } + + $label = $canonical->getUiLabel(); + + return new self( + type: $canonical::class, + id: $canonical->uid, + label: $label !== '' ? $label : sprintf('%s %s', $canonical::displayName(), $canonical->id), + ); + } +} diff --git a/src/Activity/DraftActivity.php b/src/Activity/DraftActivity.php new file mode 100644 index 00000000000..a9c591dc2bd --- /dev/null +++ b/src/Activity/DraftActivity.php @@ -0,0 +1,142 @@ + */ + private WeakMap $writes; + + public function __construct(private Sites $sites) + { + $this->writes = new WeakMap; + } + + public function handleElementSaving(ElementSaving $event): void + { + $element = $event->element; + + if ( + ! $element->getIsDraft() || + ! $element->markDraftAsSaved || + $element->duplicateOf !== null || + $element->isProvisionalDraft || + $element->applyingDraft || + $element->propagating || + $element->resaving || + $element->mergingCanonicalChanges + ) { + unset($this->writes[$element]); + + return; + } + + $draft = DB::table(Table::DRAFTS) + ->where('id', $element->draftId) + ->first(['provisional', 'name', 'notes', 'saved']) + ?? throw new LogicException("Could not load draft $element->draftId before saving it."); + $wasDraft = $element->id && DB::table(Table::ELEMENTS) + ->where('id', $element->id) + ->whereNotNull('draftId') + ->exists(); + + $this->writes[$element] = [ + 'isNew' => ! $wasDraft || (bool) $draft->provisional || ! (bool) $draft->saved, + 'metadataChanged' => (bool) $draft->provisional !== $element->isProvisionalDraft || + $draft->name !== $element->draftName || + $draft->notes !== $element->draftNotes || + (bool) $draft->saved !== $element->markDraftAsSaved, + ]; + } + + public function handleElementSaved(ElementSaved $event): void + { + $write = $this->writes[$event->element] ?? null; + unset($this->writes[$event->element]); + + $contentChanged = $event->element->getDirtyAttributes() !== [] + || $event->element->getDirtyFields() !== []; + + if ( + $write === null || + (! $write['isNew'] && ! $write['metadataChanged'] && ! $contentChanged) + ) { + return; + } + + $activity = $write['isNew'] + ? new DraftCreatedActivityEvent(subject: $event->element, site: $this->sites->getSiteById($event->element->siteId)) + : new DraftSaved(subject: $event->element, site: $this->sites->getSiteById($event->element->siteId)); + + Activities::record($activity); + } + + public function handleDraftCreated(DraftCreated $event): void + { + if ($event->provisional) { + return; + } + + Activities::record(new DraftCreatedActivityEvent( + subject: $event->canonical, + site: $this->sites->getSiteById($event->canonical->siteId), + )); + } + + public function handleDraftApplied(DraftApplied $event): void + { + if ($event->provisional) { + return; + } + + Activities::record(new DraftAppliedActivityEvent( + subject: $event->canonical, + site: $this->sites->getSiteById($event->canonical->siteId), + )); + } + + /** @return array */ + public function subscribe(): array + { + return [ + ElementSaving::class => 'handleElementSaving', + ElementSaved::class => 'handleElementSaved', + DraftCreated::class => 'handleDraftCreated', + DraftApplied::class => 'handleDraftApplied', + ]; + } + + /** + * @param string[] $dirtyAttributes + * @param string[] $dirtyFields + */ + public function recordProvisionalApplied( + Entry $entry, + Entry $original, + array $dirtyAttributes, + array $dirtyFields, + ): void { + EntryActivity::recordUpdated($entry, $original, $dirtyAttributes, $dirtyFields); + } +} diff --git a/src/Activity/ElementActivity.php b/src/Activity/ElementActivity.php new file mode 100644 index 00000000000..7f6a751160c --- /dev/null +++ b/src/Activity/ElementActivity.php @@ -0,0 +1,30 @@ +getIsCanonical() && + ! $element->getIsDraft() && + ! $element->getIsRevision() && + ! $element->updatingFromDerivative && + (! $element instanceof NestedElementInterface || $element->getPrimaryOwnerId() === null); + } + + public static function shouldRecordWrite(ElementInterface $element, bool $recordActivity = true): bool + { + return $recordActivity && + self::shouldRecord($element) && + ! $element->propagating && + ! $element->resaving && + ! $element->mergingCanonicalChanges; + } +} diff --git a/src/Activity/ElementWriteActivity.php b/src/Activity/ElementWriteActivity.php new file mode 100644 index 00000000000..af00a055ebd --- /dev/null +++ b/src/Activity/ElementWriteActivity.php @@ -0,0 +1,143 @@ + */ + private WeakMap $writes; + + /** @var WeakMap */ + private WeakMap $originalEntries; + + /** @var WeakMap */ + private WeakMap $originalAssets; + + public function __construct( + private Sites $sites, + ) { + $this->writes = new WeakMap; + $this->originalEntries = new WeakMap; + $this->originalAssets = new WeakMap; + } + + public function handleElementSaving(ElementSaving $event): void + { + $element = $event->element; + $this->forgetWrite($element); + + if ($element->duplicateOf !== null) { + return; + } + + $recordEntry = $element instanceof Entry && EntryActivity::shouldRecord($element, true); + $recordAsset = $element instanceof Asset && AssetActivity::shouldRecord($element, true); + $recordElement = ElementActivity::shouldRecordWrite($element); + + if (! $recordEntry && ! $recordAsset && ! $recordElement) { + return; + } + + $this->writes[$element] = $event->isNew; + + if ($recordEntry && ! $event->isNew) { + $original = EntryActivity::original($element); + + if ($original !== null) { + $this->originalEntries[$element] = $original; + } + } + + if ($recordAsset) { + $this->originalAssets[$element] = AssetActivity::original($element); + } + } + + public function handleElementSaved(ElementSaved $event): void + { + $element = $event->element; + + if ($element->propagatingFrom !== null) { + $isNew = $this->writes[$element->propagatingFrom] ?? null; + + if ($isNew === true && $element instanceof Entry) { + EntryActivity::recordCreated($element); + } elseif ($isNew === false && $element->isNewForSite) { + $this->recordSiteAdded($element); + } + + return; + } + + $isNew = $this->writes[$element] ?? null; + $originalEntry = $this->originalEntries[$element] ?? null; + $originalAsset = $this->originalAssets[$element] ?? null; + $this->forgetWrite($element); + + if ($isNew === null) { + return; + } + + if ($element instanceof Entry) { + if ($isNew) { + EntryActivity::recordCreated($element); + } elseif ($originalEntry !== null) { + EntryActivity::recordUpdated( + $element, + $originalEntry, + $element->getDirtyAttributes(), + $element->getDirtyFields(), + ); + } + } + + if ($element instanceof Asset && $originalAsset !== null) { + AssetActivity::recordReplaced($element, $originalAsset); + } + + if (! $isNew && $element->isNewForSite) { + $this->recordSiteAdded($element); + } + } + + private function recordSiteAdded(ElementInterface $element): void + { + Activities::record(new ElementSiteAdded( + subject: $element, + site: $this->sites->getSiteById($element->siteId), + )); + } + + private function forgetWrite(ElementInterface $element): void + { + unset( + $this->writes[$element], + $this->originalEntries[$element], + $this->originalAssets[$element], + ); + } + + /** @return array */ + public function subscribe(): array + { + return [ + ElementSaving::class => 'handleElementSaving', + ElementSaved::class => 'handleElementSaved', + ]; + } +} diff --git a/src/Activity/EntryActivity.php b/src/Activity/EntryActivity.php new file mode 100644 index 00000000000..e5219aa91e7 --- /dev/null +++ b/src/Activity/EntryActivity.php @@ -0,0 +1,195 @@ + */ + private const array Attributes = [ + 'title' => 'Title', + 'slug' => 'Slug', + 'enabled' => 'Enabled', + 'enabledForSite' => 'Enabled for site', + 'postDate' => 'Post Date', + 'expiryDate' => 'Expiry Date', + 'authorIds' => 'Authors', + ]; + + public static function shouldRecord(Entry $entry, bool $recordActivity): bool + { + return ElementActivity::shouldRecordWrite($entry, $recordActivity); + } + + public static function original(Entry $entry): ?Entry + { + return Entry::find() + ->id($entry->id) + ->siteId($entry->siteId) + ->status(null) + ->one(); + } + + public static function recordCreated(Entry $entry): void + { + Activities::record(new ElementCreated( + subject: $entry, + site: Sites::getSiteById($entry->siteId), + )); + } + + /** + * @param string[] $dirtyAttributes + * @param string[] $dirtyFields + */ + public static function recordUpdated( + Entry $entry, + Entry $original, + array $dirtyAttributes, + array $dirtyFields, + ): void { + [$changes, $contentChanged] = self::changes($entry, $original, $dirtyAttributes, $dirtyFields); + $oldStatus = $original->getStatus(); + $newStatus = $entry->getStatus(); + + if ($oldStatus === $newStatus && ! $contentChanged) { + return; + } + + $site = Sites::getSiteById($entry->siteId); + $event = $oldStatus === $newStatus + ? new ElementUpdated(subject: $entry, site: $site, changes: $changes) + : new ElementStatusChanged( + subject: $entry, + site: $site, + oldStatus: $oldStatus, + newStatus: $newStatus, + changes: $changes, + ); + + Activities::record($event); + } + + /** + * @param string[] $dirtyAttributes + * @param string[] $dirtyFields + * @return array{list, bool} + */ + private static function changes( + Entry $entry, + Entry $original, + array $dirtyAttributes, + array $dirtyFields, + ): array { + $changes = []; + $contentChanged = false; + + foreach (self::Attributes as $attribute => $label) { + if (! in_array($attribute, $dirtyAttributes, true)) { + continue; + } + + $old = self::attributeValue($original, $attribute); + $new = self::attributeValue($entry, $attribute); + self::appendChange($changes, $contentChanged, t($label), $old, $new); + } + + foreach ($entry->getFieldLayout()?->getCustomFields() ?? [] as $field) { + if (! in_array($field->handle, $dirtyFields, true)) { + continue; + } + + $old = $field->serializeValue($original->getFieldValue($field->handle), $original); + $new = $field->serializeValue($entry->getFieldValue($field->handle), $entry); + self::appendChange( + $changes, + $contentChanged, + t($field->name, category: 'site'), + $old, + $new, + ); + } + + return [$changes, $contentChanged]; + } + + /** @param list $changes */ + private static function appendChange( + array &$changes, + bool &$contentChanged, + string $label, + mixed $old, + mixed $new, + ): void { + if ($old === $new) { + return; + } + + $oldSafe = self::normalizeSafeValue($old); + $newSafe = self::normalizeSafeValue($new); + + if ($oldSafe && $newSafe && $old === $new) { + return; + } + + $contentChanged = true; + + if (! $oldSafe || ! $newSafe) { + return; + } + + $changes[] = new ActivityChange($label, $old, $new); + } + + private static function attributeValue(Entry $entry, string $attribute): mixed + { + return match ($attribute) { + 'enabledForSite' => $entry->getEnabledForSite(), + 'authorIds' => $entry->getAuthorIds(), + default => $entry->{$attribute}, + }; + } + + private static function normalizeSafeValue(mixed &$value): bool + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if ($value instanceof DateTimeInterface) { + $value = $value->format(DateTimeInterface::ATOM); + } + + if (is_string($value)) { + return mb_check_encoding($value) && strip_tags($value) === $value; + } + + if (is_float($value)) { + return is_finite($value); + } + + if (is_int($value) || is_bool($value) || $value === null) { + return true; + } + + if (! is_array($value)) { + return false; + } + + return array_all($value, fn ($item) => self::normalizeSafeValue($item)); + } +} diff --git a/src/Activity/EventTypes/AssetFileReplaced.php b/src/Activity/EventTypes/AssetFileReplaced.php new file mode 100644 index 00000000000..35915a8ed7e --- /dev/null +++ b/src/Activity/EventTypes/AssetFileReplaced.php @@ -0,0 +1,55 @@ + $this->oldFilename, + 'newFilename' => $this->newFilename, + 'oldMimeType' => $this->oldMimeType, + 'newMimeType' => $this->newMimeType, + 'oldSize' => $this->oldSize, + 'newSize' => $this->newSize, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Replaced {oldFilename} with {newFilename}.', + [ + 'oldFilename' => $event->data['oldFilename'], + 'newFilename' => $event->data['newFilename'], + ], + ); + } +} diff --git a/src/Activity/EventTypes/DraftApplied.php b/src/Activity/EventTypes/DraftApplied.php new file mode 100644 index 00000000000..7e952aff0db --- /dev/null +++ b/src/Activity/EventTypes/DraftApplied.php @@ -0,0 +1,14 @@ +source = ActivitySubject::fromElement($source); + } + + public function data(): array + { + return ['source' => [ + 'type' => $this->source->type, + 'id' => $this->source->id, + 'label' => $this->source->label, + ]]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Duplicated from {source}.', + ['source' => $event->data['source']['label']], + ); + } +} diff --git a/src/Activity/EventTypes/ElementMerged.php b/src/Activity/EventTypes/ElementMerged.php new file mode 100644 index 00000000000..684c28981f4 --- /dev/null +++ b/src/Activity/EventTypes/ElementMerged.php @@ -0,0 +1,50 @@ + $this->role, + 'other' => [ + 'type' => $this->other->type, + 'id' => $this->other->id, + 'label' => $this->other->label, + ], + ]; + } + + public static function format(ActivityEvent $event): string + { + $other = $event->data['other']['label']; + + return match ($event->data['role']) { + 'merged' => t('Merged into {other}.', compact('other')), + 'prevailing' => t('Merged {other} into this element.', compact('other')), + default => throw new UnexpectedValueException('Unknown activity merge role.'), + }; + } +} diff --git a/src/Activity/EventTypes/ElementMoved.php b/src/Activity/EventTypes/ElementMoved.php new file mode 100644 index 00000000000..366816d1277 --- /dev/null +++ b/src/Activity/EventTypes/ElementMoved.php @@ -0,0 +1,78 @@ + $this->origin, + 'destination' => $this->destination, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Moved from {origin} to {destination}.', + [ + 'origin' => self::positionDescription($event->data['origin']), + 'destination' => self::positionDescription($event->data['destination']), + ], + ); + } + + private static function positionDescription(mixed $position): string + { + if (! is_array($position)) { + throw new UnexpectedValueException('Activity movement positions must be arrays.'); + } + + $parent = $position['parent']['label'] ?? null; + $previousSibling = $position['previousSibling']['label'] ?? null; + + return match (true) { + $parent !== null && $previousSibling !== null => t( + 'the position after {previousSibling} in {parent}', + compact('parent', 'previousSibling'), + ), + $parent !== null => t( + 'the first position in {parent}', + compact('parent'), + ), + $previousSibling !== null => t( + 'the position after {previousSibling} at the top level', + compact('previousSibling'), + ), + default => t('the first position at the top level'), + }; + } +} diff --git a/src/Activity/EventTypes/ElementRestored.php b/src/Activity/EventTypes/ElementRestored.php new file mode 100644 index 00000000000..d49e67a5b0b --- /dev/null +++ b/src/Activity/EventTypes/ElementRestored.php @@ -0,0 +1,14 @@ + $event->snapshots['site']['name']], + ); + } +} diff --git a/src/Activity/EventTypes/ElementSiteRemoved.php b/src/Activity/EventTypes/ElementSiteRemoved.php new file mode 100644 index 00000000000..b35d9486bd2 --- /dev/null +++ b/src/Activity/EventTypes/ElementSiteRemoved.php @@ -0,0 +1,25 @@ + $event->snapshots['site']['name']], + ); + } +} diff --git a/src/Activity/EventTypes/ElementStatusChanged.php b/src/Activity/EventTypes/ElementStatusChanged.php new file mode 100644 index 00000000000..63d1a5d3656 --- /dev/null +++ b/src/Activity/EventTypes/ElementStatusChanged.php @@ -0,0 +1,53 @@ + $changes + */ + public function __construct( + ElementInterface $subject, + ?Site $site, + private readonly string $oldStatus, + private readonly string $newStatus, + array $changes = [], + ) { + parent::__construct(subject: $subject, site: $site, changes: $changes); + } + + public function data(): array + { + return [ + 'oldStatus' => $this->oldStatus, + 'newStatus' => $this->newStatus, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Status changed from {oldStatus} to {newStatus}.', + [ + 'oldStatus' => t(Str::headline($event->data['oldStatus'])), + 'newStatus' => t(Str::headline($event->data['newStatus'])), + ], + ); + } +} diff --git a/src/Activity/EventTypes/ElementTrashed.php b/src/Activity/EventTypes/ElementTrashed.php new file mode 100644 index 00000000000..17572a6fe34 --- /dev/null +++ b/src/Activity/EventTypes/ElementTrashed.php @@ -0,0 +1,14 @@ + $this->revisionNum]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Restored revision {revision}.', + ['revision' => $event->data['revisionNum']], + ); + } +} diff --git a/src/Activity/Models/ActivityEvent.php b/src/Activity/Models/ActivityEvent.php new file mode 100644 index 00000000000..5194f9530e5 --- /dev/null +++ b/src/Activity/Models/ActivityEvent.php @@ -0,0 +1,180 @@ +>, changes: list>, data: array} $payload + * @property array> $snapshots + * @property list $changes + * @property array $data + * @property CarbonImmutable $occurredAt + */ +class ActivityEvent extends BaseModel +{ + #[\Override] + protected $table = Table::ACTIVITYEVENTS; + + #[\Override] + public $timestamps = false; + + #[\Override] + protected function casts(): array + { + return [ + 'id' => 'string', + 'actorId' => 'integer', + 'siteId' => 'integer', + 'payload' => 'array', + 'occurredAt' => 'immutable_datetime', + ]; + } + + /** @return Attribute>, never> */ + protected function snapshots(): Attribute + { + return Attribute::get(fn () => $this->payload['snapshots']); + } + + /** @return Attribute, never> */ + protected function changes(): Attribute + { + return Attribute::get(fn () => array_map( + ActivityChange::fromArray(...), + $this->payload['changes'], + )); + } + + /** @return Attribute, never> */ + protected function data(): Attribute + { + return Attribute::get(fn () => $this->payload['data']); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function subject(Builder $query, ActivitySubject $subject): Builder + { + return $query + ->where('subjectType', $subject->type) + ->where('subjectId', $subject->id); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function site(Builder $query, Site|int $site): Builder + { + $siteId = $site instanceof Site ? $site->id : $site; + + if ($siteId === null) { + throw new InvalidArgumentException('Activity site criteria require a saved site.'); + } + + return $query->where(fn (Builder $query) => $query + ->whereNull('siteId') + ->orWhere('siteId', $siteId)); + } + + /** + * @param Builder $query + * @param string|list $eventTypes + * @return Builder + */ + #[Scope] + protected function eventTypes(Builder $query, string|array $eventTypes): Builder + { + $eventTypes = (array) $eventTypes; + + if ($eventTypes === []) { + throw new InvalidArgumentException('Activity event type criteria cannot be empty.'); + } + + return $query->whereIn('eventType', $eventTypes); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function actor(Builder $query, ActivityActor $actor): Builder + { + return $query + ->where('actorType', $actor->type) + ->where('actorId', $actor->id); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function source(Builder $query, string $source): Builder + { + if ($source === '') { + throw new InvalidArgumentException('Activity source criteria cannot be empty.'); + } + + return $query->where('source', $source); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function occurredFrom(Builder $query, DateTimeInterface $date): Builder + { + return $query->where('occurredAt', '>=', $date); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function occurredUntil(Builder $query, DateTimeInterface $date): Builder + { + return $query->where('occurredAt', '<=', $date); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function newestFirst(Builder $query): Builder + { + return $query + ->latest('occurredAt') + ->orderByDesc('id'); + } +} diff --git a/src/Activity/StructuralElementActivity.php b/src/Activity/StructuralElementActivity.php new file mode 100644 index 00000000000..c766491c444 --- /dev/null +++ b/src/Activity/StructuralElementActivity.php @@ -0,0 +1,105 @@ +siteId ? Sites::getSiteById($duplicate->siteId) : null, + source: $source, + )); + } + + /** + * @param array $origin + * @param array $destination + */ + public static function recordMoved(ElementInterface $element, array $origin, array $destination): void + { + if (! self::shouldRecordMovement($element) || $origin === $destination) { + return; + } + + Activities::record(new ElementMoved( + subject: $element, + site: $element->siteId ? Sites::getSiteById($element->siteId) : null, + origin: $origin, + destination: $destination, + )); + } + + public static function recordMerged(ActivitySubject $merged, ActivitySubject $prevailing): void + { + Activities::record(new ElementMerged( + subject: $merged, + role: 'merged', + other: $prevailing, + )); + + Activities::record(new ElementMerged( + subject: $prevailing, + role: 'prevailing', + other: $merged, + )); + } + + /** @return array{structure: string, parent: array{type: string, id: string, label: string}|null, previousSibling: array{type: string, id: string, label: string}|null} */ + public static function position(string $structureUid, ElementInterface $element): array + { + return [ + 'structure' => $structureUid, + 'parent' => self::nullableReference($element->getParent()), + 'previousSibling' => self::nullableReference($element->getPrevSibling()), + ]; + } + + /** @return array{type: string, id: string, label: string} */ + private static function reference(ElementInterface $element): array + { + return self::subjectReference(ActivitySubject::fromElement($element)); + } + + /** @return array{type: string, id: string, label: string}|null */ + private static function nullableReference(?ElementInterface $element): ?array + { + return $element ? self::reference($element) : null; + } + + /** @return array{type: string, id: string, label: string} */ + private static function subjectReference(ActivitySubject $subject): array + { + return [ + 'type' => $subject->type, + 'id' => $subject->id, + 'label' => $subject->label, + ]; + } +} diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index 2b6c60a15dc..97885436a3a 100644 --- a/src/Config/GeneralConfig.php +++ b/src/Config/GeneralConfig.php @@ -87,6 +87,28 @@ class GeneralConfig extends BaseConfig */ public string $actionTrigger = 'actions'; + /** + * @var mixed The maximum age of activity events before garbage collection deletes them. + * + * Set to `0` to retain activity indefinitely. + * + * See {@see ConfigHelper::durationInSeconds()} for a list of supported value types. + * + * ::: code + * ```php Static Config + * ->activityRetentionDuration('P90D') + * ``` + * ```shell Environment Override + * CRAFT_ACTIVITY_RETENTION_DURATION=P90D + * ``` + * ::: + * + * @group Garbage Collection + * + * @defaultAlt Unlimited + */ + public mixed $activityRetentionDuration = 0; + /** * @var mixed The URI that users without access to the control panel should be redirected to after activating their account. * @@ -3057,6 +3079,7 @@ public function __construct() ->allowedFileExtensions($this->allowedFileExtensions) ->extraAllowedFileExtensions($this->extraAllowedFileExtensions) // durations + ->activityRetentionDuration($this->activityRetentionDuration) ->cacheDuration($this->cacheDuration) ->cooldownDuration($this->cooldownDuration) ->defaultTokenDuration($this->defaultTokenDuration) @@ -3123,6 +3146,36 @@ public function actionTrigger(string $value): self return $this; } + /** + * The maximum age of activity events before garbage collection deletes them. + * + * Set to `0` to retain activity indefinitely. + * + * See {@see ConfigHelper::durationInSeconds()} for a list of supported value types. + * + * ```php + * ->activityRetentionDuration('P90D') + * ``` + * + * @group Garbage Collection + * + * @defaultAlt Unlimited + * + * @see $activityRetentionDuration + */ + public function activityRetentionDuration(mixed $value): self + { + $duration = ConfigHelper::durationInSeconds($value); + + if ($duration < 0) { + throw new InvalidArgumentException('Activity retention duration must be zero or greater.'); + } + + $this->activityRetentionDuration = $duration; + + return $this; + } + /** * The URI that users without access to the control panel should be redirected to after activating their account. * diff --git a/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php b/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php new file mode 100644 index 00000000000..f6cc7f45a81 --- /dev/null +++ b/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('eventType'); + $table->string('source'); + $table->string('actorType'); + $table->unsignedBigInteger('actorId')->nullable(); + $table->string('subjectType')->nullable(); + $table->string('subjectId')->nullable(); + $table->unsignedBigInteger('siteId')->nullable(); + $table->jsonb('payload'); + $table->dateTime('occurredAt'); + }); + + Schema::createIndex(Table::ACTIVITYEVENTS, ['actorType', 'actorId']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['subjectType', 'subjectId', 'siteId', 'occurredAt', 'id']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['occurredAt', 'id']); + } + + public function down(): void + { + Schema::dropIfExists(Table::ACTIVITYEVENTS); + } +}; diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index 73dd5ab46e0..ef868bdb90b 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -209,6 +209,20 @@ public function createTables(?Logger $logger = null): void { $this->dropEmptyStarterTable(Table::USERS); + $logger?->subLabel('activityevents'); + Schema::create(Table::ACTIVITYEVENTS, function (Blueprint $table) { + $table->id(); + $table->string('eventType'); + $table->string('source'); + $table->string('actorType'); + $table->unsignedBigInteger('actorId')->nullable(); + $table->string('subjectType')->nullable(); + $table->string('subjectId')->nullable(); + $table->unsignedBigInteger('siteId')->nullable(); + $table->jsonb('payload'); + $table->dateTime('occurredAt'); + }); + $logger?->subLabel('addresses'); Schema::create('addresses', function (Blueprint $table) { $table->integer('id', true); @@ -1020,6 +1034,9 @@ private function dropEmptyStarterTable(string $table): void public function createIndexes(): void { + Schema::createIndex(Table::ACTIVITYEVENTS, ['actorType', 'actorId']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['subjectType', 'subjectId', 'siteId', 'occurredAt', 'id']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['occurredAt', 'id']); Schema::createIndex(Table::ASSETINDEXDATA, ['sessionId', 'volumeId']); Schema::createIndex(Table::ASSETINDEXDATA, ['sessionId', 'status', 'id']); Schema::createIndex(Table::ASSETINDEXDATA, ['volumeId']); diff --git a/src/Database/Table.php b/src/Database/Table.php index 2b4f1ed8ab3..e3fd7c75e2c 100644 --- a/src/Database/Table.php +++ b/src/Database/Table.php @@ -9,6 +9,8 @@ */ readonly class Table { + public const string ACTIVITYEVENTS = 'activityevents'; + public const string ADDRESSES = 'addresses'; public const string ASSETINDEXDATA = 'assetindexdata'; diff --git a/src/Element/Actions/Duplicate.php b/src/Element/Actions/Duplicate.php index f5e93374f25..fb5cdd8d78c 100644 --- a/src/Element/Actions/Duplicate.php +++ b/src/Element/Actions/Duplicate.php @@ -127,6 +127,7 @@ private function _duplicateElements(ElementQueryInterface $query, int &$successC $duplicate = Elements::duplicateElement( $element, $attributes, + placeInStructure: false, asUnpublishedDraft: $this->asDrafts, ); } catch (Throwable) { diff --git a/src/Element/Drafts.php b/src/Element/Drafts.php index 23e39331717..b07bab5b664 100644 --- a/src/Element/Drafts.php +++ b/src/Element/Drafts.php @@ -4,6 +4,8 @@ namespace CraftCms\Cms\Element; +use CraftCms\Cms\Activity\DraftActivity; +use CraftCms\Cms\Activity\EventTypes\DraftDiscarded as DraftDiscardedActivityEvent; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; @@ -15,7 +17,10 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Validation\ElementRules; +use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Activities; +use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Facades\Structures; use CraftCms\Cms\User\Elements\User; use Exception; @@ -39,6 +44,7 @@ public function __construct( private Elements $elements, + private DraftActivity $activity, ) {} /** @@ -252,12 +258,25 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): DB::beginTransaction(); try { + $entryActivity = null; + if ($canonical !== $draft) { // Merge in any attribute & field values that were updated in the canonical element, but not the draft if ($draft::trackChanges() && ElementHelper::isOutdated($draft)) { $this->elements->mergeCanonicalChanges($draft); } + $entryActivity = ( + $draft instanceof Entry && + $draft->isProvisionalDraft && + $draft->getPrimaryOwnerId() === null && + $canonical instanceof Entry + ) ? [ + $canonical, + $draft->getModifiedAttributes(), + $draft->getModifiedFields(), + ] : null; + // "Duplicate" the draft with the canonical element’s ID and UID $newCanonical = $this->elements->updateCanonicalElement($draft, array_merge($newAttributes, [ 'revisionNotes' => $draftNotes ?: t('Applied “{name}”', ['name' => $draft->draftName]), @@ -277,6 +296,10 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): $newCanonical = $draft; } + if ($entryActivity !== null && $newCanonical instanceof Entry) { + $this->activity->recordProvisionalApplied($newCanonical, ...$entryActivity); + } + DB::commit(); } catch (Throwable $e) { DB::rollBack(); @@ -292,6 +315,7 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): event(new DraftApplied( canonical: $newCanonical, creatorId: $draft->draftCreatorId, + provisional: $draft->isProvisionalDraft, draftName: $draft->draftName, draftNotes: $draft->draftNotes, draft: $draft, @@ -306,6 +330,24 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): return $newCanonical; } + public function discardDraft(ElementInterface $draft): bool + { + return DB::transaction(function () use ($draft) { + $canonical = $draft->getCanonical(); + + if (! $this->elements->deleteElement($draft, true)) { + return false; + } + + Activities::record(new DraftDiscardedActivityEvent( + subject: $canonical, + site: Sites::getSiteById($canonical->siteId), + )); + + return true; + }); + } + /** * Removes draft data from the given draft. * diff --git a/src/Element/ElementServiceProvider.php b/src/Element/ElementServiceProvider.php index a13889d0441..ec38219bc2b 100644 --- a/src/Element/ElementServiceProvider.php +++ b/src/Element/ElementServiceProvider.php @@ -4,6 +4,8 @@ namespace CraftCms\Cms\Element; +use CraftCms\Cms\Activity\DraftActivity; +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Element\BulkOp\BulkOpDeferrals; use CraftCms\Cms\Element\BulkOp\Events\BulkOpCompleted; use CraftCms\Cms\Element\Commands\DeleteAllOfTypeCommand; @@ -21,6 +23,8 @@ class ElementServiceProvider extends ServiceProvider { public function boot(): void { + Event::subscribe(DraftActivity::class); + Event::subscribe(ElementWriteActivity::class); Event::listen(BulkOpCompleted::class, fn (BulkOpCompleted $event) => app(BulkOpDeferrals::class)->replay($event->key)); app()->terminating(fn () => app(BulkOpDeferrals::class)->persistPending()); diff --git a/src/Element/Operations/ElementDeletions.php b/src/Element/Operations/ElementDeletions.php index c1a4de808c4..43a081cd14b 100644 --- a/src/Element/Operations/ElementDeletions.php +++ b/src/Element/Operations/ElementDeletions.php @@ -4,6 +4,13 @@ namespace CraftCms\Cms\Element\Operations; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\ElementActivity; +use CraftCms\Cms\Activity\EventTypes\ElementDeleted as ElementDeletedActivity; +use CraftCms\Cms\Activity\EventTypes\ElementRestored as ElementRestoredActivity; +use CraftCms\Cms\Activity\EventTypes\ElementSiteRemoved; +use CraftCms\Cms\Activity\EventTypes\ElementTrashed; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -26,6 +33,7 @@ use CraftCms\Cms\Structure\Models\StructureElement as StructureElementModel; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\DateTimeHelper; +use CraftCms\Cms\Support\Facades\Activities; use CraftCms\Cms\Support\Facades\BulkOps; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\Sites; @@ -61,7 +69,10 @@ public function mergeElementsByIds(int $mergedElementId, int $prevailingElementI public function mergeElements(ElementInterface $mergedElement, ElementInterface $prevailingElement): bool { - return DB::transaction(function () use ($mergedElement, $prevailingElement) { + $mergedSubject = ActivitySubject::fromElement($mergedElement); + $prevailingSubject = ActivitySubject::fromElement($prevailingElement); + + return DB::transaction(function () use ($mergedElement, $prevailingElement, $mergedSubject, $prevailingSubject) { $data = DB::table(Table::RELATIONS, 'r') ->select(['r.sourceId', 'r.sourceSiteId', 'e.type']) ->join(new Alias(Table::ELEMENTS, 'e'), 'e.id', 'r.sourceId') @@ -182,7 +193,13 @@ public function mergeElements(ElementInterface $mergedElement, ElementInterface event(new ElementsMerged($mergedElement->id, $prevailingElement->id)); - return $this->deleteElement($mergedElement); + if (! $this->deleteElement($mergedElement, recordActivity: false)) { + return false; + } + + StructuralElementActivity::recordMerged($mergedSubject, $prevailingSubject); + + return true; }); } @@ -220,8 +237,11 @@ public function deleteElementById( return $this->deleteElement($element, $hardDelete); } - public function deleteElement(ElementInterface $element, bool $hardDelete = false): bool - { + public function deleteElement( + ElementInterface $element, + bool $hardDelete = false, + bool $recordActivity = true, + ): bool { event($event = new ElementDeleting($element, $hardDelete)); $element->hardDelete = $hardDelete || $event->hardDelete; @@ -234,9 +254,32 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals return false; } - BulkOps::ensure(function () use ($element) { + $recordActivity = $recordActivity && + $this->shouldRecordLifecycleActivity($element); + + return BulkOps::ensure(function () use ($element, $recordActivity) { DB::beginTransaction(); + DateTimeHelper::pause(); + try { + $elementRecord = DB::table(Table::ELEMENTS) + ->select('dateDeleted') + ->where('id', $element->id) + ->lockForUpdate() + ->first(); + + if ($elementRecord === null) { + DB::rollBack(); + + return false; + } + + if (! $element->hardDelete && $elementRecord->dateDeleted !== null) { + DB::commit(); + + return true; + } + while (($record = StructureElementModel::where('elementId', $element->id)->first()) !== null) { while (($child = $record->children(1)->first()) !== null) { /** @var StructureElementModel $child */ @@ -248,8 +291,6 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals $this->elementCaches->invalidateForElement($element); - DateTimeHelper::pause(); - if ($element->hardDelete) { DB::table(Table::ELEMENTS)->delete($element->id); DB::table(Table::SEARCHINDEX) @@ -270,6 +311,20 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals $element->dateDeleted = now(); $element->afterDelete(); + if ($recordActivity) { + $event = $element->hardDelete + ? new ElementDeletedActivity( + subject: $element, + site: Sites::getSiteById($element->siteId), + ) + : new ElementTrashed( + subject: $element, + site: Sites::getSiteById($element->siteId), + ); + + Activities::record($event); + } + if (! $element->hardDelete) { BulkOps::trackElement($element); } @@ -282,11 +337,11 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals } finally { DateTimeHelper::resume(); } - }); - event(new ElementDeleted($element)); + event(new ElementDeleted($element)); - return true; + return true; + }); } public function deleteElementForSite(ElementInterface $element): void @@ -312,64 +367,89 @@ public function deleteElementsForSite(array $elements): void } } - $multiSiteElementIds = $firstElement::find() - ->id(Arr::pluck($elements, 'id')) - ->status(null) - ->drafts(null) - ->siteId(['not', $firstElement->siteId]) - ->unique() - ->pluck('elements.id') - ->all(); + DB::transaction(function () use ($elements, $firstElement) { + $siteElementIds = DB::table(Table::ELEMENTS_SITES) + ->whereIn('elementId', Arr::pluck($elements, 'id')) + ->where('siteId', $firstElement->siteId) + ->lockForUpdate() + ->pluck('elementId') + ->flip(); - $multiSiteElementIdsIdx = array_flip($multiSiteElementIds); - $multiSiteElements = []; - $singleSiteElements = []; + $elements = array_filter( + $elements, + fn (ElementInterface $element) => $siteElementIds->has($element->id), + ); - foreach ($elements as $element) { - if (isset($multiSiteElementIdsIdx[$element->id])) { - $multiSiteElements[] = $element; - } else { - $singleSiteElements[] = $element; + if ($elements === []) { + return; } - } - if (! empty($multiSiteElements)) { - foreach ($multiSiteElements as $element) { - event(new ElementDeletingForSite($element)); - } + $multiSiteElementIds = $firstElement::find() + ->id(Arr::pluck($elements, 'id')) + ->status(null) + ->drafts(null) + ->siteId(['not', $firstElement->siteId]) + ->unique() + ->pluck('elements.id') + ->all(); - foreach ($multiSiteElements as $element) { - $element->beforeDeleteForSite(); + $multiSiteElementIdsIdx = array_flip($multiSiteElementIds); + $multiSiteElements = []; + $singleSiteElements = []; + + foreach ($elements as $element) { + if (isset($multiSiteElementIdsIdx[$element->id])) { + $multiSiteElements[] = $element; + } else { + $singleSiteElements[] = $element; + } } - DB::table(Table::ELEMENTS_SITES) - ->whereIn('elementId', $multiSiteElementIds) - ->where('siteId', $firstElement->siteId) - ->delete(); - - $this->elementWrites->resaveElements( - query: $firstElement::find() - ->id($multiSiteElementIds) - ->status(null) - ->drafts(null) - ->site('*') - ->unique(), - continueOnError: true, - updateSearchIndex: false, - ); + if (! empty($multiSiteElements)) { + foreach ($multiSiteElements as $element) { + event(new ElementDeletingForSite($element)); + } - foreach ($multiSiteElements as $element) { - $element->afterDeleteForSite(); - } + foreach ($multiSiteElements as $element) { + $element->beforeDeleteForSite(); + } + + DB::table(Table::ELEMENTS_SITES) + ->whereIn('elementId', $multiSiteElementIds) + ->where('siteId', $firstElement->siteId) + ->delete(); - foreach ($multiSiteElements as $element) { - event(new ElementDeletedForSite($element)); + $this->elementWrites->resaveElements( + query: $firstElement::find() + ->id($multiSiteElementIds) + ->status(null) + ->drafts(null) + ->site('*') + ->unique(), + continueOnError: true, + updateSearchIndex: false, + ); + + foreach ($multiSiteElements as $element) { + $element->afterDeleteForSite(); + + if ($this->shouldRecordLifecycleActivity($element)) { + Activities::record(new ElementSiteRemoved( + subject: $element, + site: Sites::getSiteById($element->siteId), + )); + } + } + + foreach ($multiSiteElements as $element) { + event(new ElementDeletedForSite($element)); + } } - } - foreach ($singleSiteElements as $element) { - $this->deleteElement($element, true); - } + foreach ($singleSiteElements as $element) { + $this->deleteElement($element, true); + } + }); } public function restoreElement(ElementInterface $element): bool @@ -393,6 +473,23 @@ public function restoreElements(array $elements): bool DB::beginTransaction(); try { + $recordActivity = []; + $elementStates = DB::table(Table::ELEMENTS) + ->whereIn('id', Arr::pluck($elements, 'id')) + ->lockForUpdate() + ->pluck('dateDeleted', 'id'); + + foreach ($elements as $element) { + if (! $elementStates->has($element->id) && $element->uid !== null) { + DB::rollBack(); + + return false; + } + + $recordActivity[spl_object_id($element)] = $this->shouldRecordLifecycleActivity($element) && + $elementStates->get($element->id) !== null; + } + /** @var Element $element */ foreach ($elements as $element) { $supportedSites = Arr::keyBy(ElementHelper::supportedSitesForElement($element), 'siteId'); @@ -463,6 +560,13 @@ public function restoreElements(array $elements): bool $element->dateDeleted = null; $element->deletedWithOwner = null; + if ($recordActivity[spl_object_id($element)]) { + Activities::record(new ElementRestoredActivity( + subject: $element, + site: Sites::getSiteById($element->siteId), + )); + } + event(new ElementRestored($element)); } @@ -476,6 +580,12 @@ public function restoreElements(array $elements): bool return true; } + private function shouldRecordLifecycleActivity(ElementInterface $element): bool + { + return $element->uid !== null && + ElementActivity::shouldRecord($element); + } + private function setDraftAndRevisionDeletionState(int $canonicalId, bool $delete = true): void { foreach (['draftId' => Table::DRAFTS, 'revisionId' => Table::REVISIONS] as $foreignKey => $table) { diff --git a/src/Element/Operations/ElementDuplicates.php b/src/Element/Operations/ElementDuplicates.php index e337c7480d1..656f9331ba6 100644 --- a/src/Element/Operations/ElementDuplicates.php +++ b/src/Element/Operations/ElementDuplicates.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Element\Operations; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; @@ -172,6 +173,8 @@ public function duplicateElement( $this->copyModifiedFields($element, $mainClone); } + StructuralElementActivity::recordDuplicated($element, $mainClone); + if ( $placeInStructure && $mainClone->getIsCanonical() && @@ -261,6 +264,8 @@ public function duplicateElement( $this->copyModifiedFields($siteElement, $siteClone); } + StructuralElementActivity::recordDuplicated($siteElement, $siteClone); + $propagatedTo[$siteClone->siteId] = true; if ($siteClone->isNewForSite) { $mainClone->newSiteIds[] = $siteClone->siteId; @@ -280,6 +285,10 @@ public function duplicateElement( } $propagatedTo[$siteId] = true; $mainClone->newSiteIds[] = $siteId; + + if ($siteClone instanceof ElementInterface) { + StructuralElementActivity::recordDuplicated($element, $siteClone); + } } } } diff --git a/src/Element/Operations/ElementWrites.php b/src/Element/Operations/ElementWrites.php index 6e97e9dda00..a39f90f1c47 100644 --- a/src/Element/Operations/ElementWrites.php +++ b/src/Element/Operations/ElementWrites.php @@ -86,7 +86,7 @@ public function saveElement( $element->isNewForSite = false; try { - return $this->save( + return $this->saveInternal( $element, $runValidation, $propagate, @@ -321,7 +321,6 @@ protected function saveInternal( try { $isNewElement = ! $element->id; $trackChanges = ElementHelper::shouldTrackChanges($element); - $propagate = $propagate && $element::isLocalized() && $this->sites->isMultiSite(); $originalPropagateAll = $element->propagateAll; $originalFirstSave = $element->firstSave; diff --git a/src/Element/Revisions.php b/src/Element/Revisions.php index 4faaff10fc5..717e24c6e8d 100644 --- a/src/Element/Revisions.php +++ b/src/Element/Revisions.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Element; +use CraftCms\Cms\Activity\EventTypes\RevisionRestored; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; @@ -14,6 +15,8 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Jobs\PruneRevisions; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Activities; +use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Container\Attributes\Singleton; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Date; @@ -184,30 +187,38 @@ public function createRevision( */ public function revertToRevision(ElementInterface $revision, int $creatorId): ElementInterface { - $canonical = $revision->getCanonical(); - - event(new ElementRevertingToRevision( - canonical: $canonical, - revisionNum: $revision->revisionNum, - creatorId: $creatorId, - revisionNotes: $revision->revisionNotes, - revision: $revision, - )); - - // "Duplicate" the revision with the source element’s ID and UID - $newSource = $this->elements->updateCanonicalElement($revision, [ - 'revisionCreatorId' => $creatorId, - 'revisionNotes' => t('Reverted content from revision {num}.', ['num' => $revision->revisionNum]), - ]); - - event(new RevertedToRevision( - canonical: $canonical, - revisionNum: $revision->revisionNum, - creatorId: $creatorId, - revisionNotes: $revision->revisionNotes, - revision: $revision, - )); - - return $newSource; + return DB::transaction(function () use ($revision, $creatorId) { + $canonical = $revision->getCanonical(); + + event(new ElementRevertingToRevision( + canonical: $canonical, + revisionNum: $revision->revisionNum, + creatorId: $creatorId, + revisionNotes: $revision->revisionNotes, + revision: $revision, + )); + + // "Duplicate" the revision with the source element’s ID and UID + $newSource = $this->elements->updateCanonicalElement($revision, [ + 'revisionCreatorId' => $creatorId, + 'revisionNotes' => t('Reverted content from revision {num}.', ['num' => $revision->revisionNum]), + ]); + + Activities::record(new RevisionRestored( + subject: $newSource, + site: Sites::getSiteById($newSource->siteId), + revisionNum: $revision->revisionNum, + )); + + event(new RevertedToRevision( + canonical: $canonical, + revisionNum: $revision->revisionNum, + creatorId: $creatorId, + revisionNotes: $revision->revisionNotes, + revision: $revision, + )); + + return $newSource; + }); } } diff --git a/src/GarbageCollection/Actions/PurgeExpiredActivity.php b/src/GarbageCollection/Actions/PurgeExpiredActivity.php new file mode 100644 index 00000000000..b76d978bfc0 --- /dev/null +++ b/src/GarbageCollection/Actions/PurgeExpiredActivity.php @@ -0,0 +1,35 @@ +generalConfig->activityRetentionDuration === 0) { + return; + } + + $this->components->task( + 'purging expired activity', + function () { + DB::table(Table::ACTIVITYEVENTS) + ->select('id') + ->where('occurredAt', '<', now()->subSeconds($this->generalConfig->activityRetentionDuration)) + ->orderBy('id') + ->chunkById( + $this->garbageCollection::CHUNK_SIZE, + fn (Collection $events) => DB::table(Table::ACTIVITYEVENTS) + ->whereIn('id', $events->pluck('id')) + ->delete(), + ); + }, + ); + } +} diff --git a/src/GarbageCollection/GarbageCollection.php b/src/GarbageCollection/GarbageCollection.php index 058a0e016b1..a34a9e8dee2 100644 --- a/src/GarbageCollection/GarbageCollection.php +++ b/src/GarbageCollection/GarbageCollection.php @@ -30,6 +30,7 @@ use CraftCms\Cms\GarbageCollection\Actions\HardDeleteElements; use CraftCms\Cms\GarbageCollection\Actions\HardDeleteStructures; use CraftCms\Cms\GarbageCollection\Actions\HardDeleteVolumes; +use CraftCms\Cms\GarbageCollection\Actions\PurgeExpiredActivity; use CraftCms\Cms\GarbageCollection\Actions\PurgePendingUsers; use CraftCms\Cms\GarbageCollection\Actions\PurgeUnsavedDrafts; use CraftCms\Cms\GarbageCollection\Actions\RemoveEmptyTempFolders; @@ -93,6 +94,7 @@ public function run(bool $force = false): void PurgeUnsavedDrafts::class, PurgePendingUsers::class, DeleteStaleElementActivity::class, + PurgeExpiredActivity::class, DeleteStaleBulkOpData::class, DeleteStaleNotifications::class, diff --git a/src/Http/Controllers/Elements/ElementDraftsController.php b/src/Http/Controllers/Elements/ElementDraftsController.php index f1f292b753c..d66cd665717 100644 --- a/src/Http/Controllers/Elements/ElementDraftsController.php +++ b/src/Http/Controllers/Elements/ElementDraftsController.php @@ -415,7 +415,7 @@ public function destroy(): Response Gate::authorize('delete', $element); - if (! $this->elements->deleteElement($element, true)) { + if (! $this->drafts->discardDraft($element)) { return new ElementResponse()->failure($element, t('Couldn’t delete {type}.', [ 'type' => t('draft'), ])); diff --git a/src/Structure/Structures.php b/src/Structure/Structures.php index c0cbd43864d..f0a823ce7e7 100644 --- a/src/Structure/Structures.php +++ b/src/Structure/Structures.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Structure; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -418,6 +419,16 @@ private function doIt( $mode = Mode::Insert; } + $recordMove = $mode === Mode::Update && StructuralElementActivity::shouldRecordMovement($element); + $structureUid = null; + $origin = null; + + if ($recordMove) { + $structureUid = $this->getStructureById($structureId)->uid + ?? throw new Exception("Structure $structureId does not have a UID."); + $origin = StructuralElementActivity::position($structureUid, $element); + } + /** @var Mode::Insert|Mode::Update $mode */ [$beforeEvent, $afterEvent] = match ($mode) { Mode::Insert => [StructureElementInserted::class, ElementInserted::class], @@ -471,6 +482,21 @@ private function doIt( // Tell the element about it $element->afterMoveInStructure($structureId); + if ($recordMove) { + $movedElement = $element::find() + ->id($element->id) + ->siteId($element->siteId) + ->structureId($structureId) + ->status(null) + ->one() ?? throw new Exception('Unable to capture the moved element position.'); + + StructuralElementActivity::recordMoved( + $movedElement, + $origin, + StructuralElementActivity::position($structureUid, $movedElement), + ); + } + DB::commit(); $this->releaseLock($structureId, $ownsLock); } catch (Throwable $e) { diff --git a/src/Support/Facades/Activities.php b/src/Support/Facades/Activities.php new file mode 100644 index 00000000000..02d963a625b --- /dev/null +++ b/src/Support/Facades/Activities.php @@ -0,0 +1,28 @@ + query() + * @method static string|Htmlable format(ActivityEvent $event) + * @method static string icon(ActivityEvent $event) + * + * @see \CraftCms\Cms\Activity\Activities + */ +class Activities extends Facade +{ + #[\Override] + protected static function getFacadeAccessor(): string + { + return \CraftCms\Cms\Activity\Activities::class; + } +} diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php new file mode 100644 index 00000000000..c76098e82c6 --- /dev/null +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -0,0 +1,385 @@ +activities = app(Activities::class); +}); + +afterEach(function () { + Date::setTestNow(); +}); + +it('records durable actor subject site and payload snapshots', function () { + $actor = User::factory()->createElement(['fullName' => 'Ada Lovelace']); + $subject = Entry::factory()->createElement(['title' => 'Release notes']); + $site = Sites::getSiteById(Site::factory()->create()->id); + $draft = app(Drafts::class)->createDraft($subject, $actor->id); + + $this->actingAs($actor); + + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Published', + subject: $draft, + site: $site, + changes: [new ActivityChange('Summary', null, 'Ready')], + )); + + expect($event->id)->toBeString() + ->and($event->eventType)->toBe(TestPluginEntryUpdated::class) + ->and($event->source)->toBe('test-plugin') + ->and($event->actorType)->toBe(ActivityActor::TYPE_USER) + ->and($event->actorId)->toBe($actor->id) + ->and($event->subjectType)->toBe($subject::class) + ->and($event->subjectId)->toBe($subject->uid) + ->and($event->siteId)->toBe($site->id) + ->and($event->snapshots)->toMatchArray([ + 'actor' => ['label' => 'Ada Lovelace'], + 'subject' => ['label' => 'Release notes'], + 'site' => ['name' => $site->getName(false)], + 'source' => ['label' => 'Test Plugin'], + 'event' => ['label' => 'Entry updated'], + ]) + ->and($event->changes)->toEqual([ + new ActivityChange('Summary', null, 'Ready'), + ]) + ->and($event->data)->toBe(['reason' => 'Published']); +}); + +it('distinguishes system anonymous and known user actors and captures impersonation', function () { + $system = $this->activities->record(new TestPluginEntryUpdated(reason: 'System')); + $anonymous = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Public form', + actor: ActivityActor::anonymous(), + )); + + $operator = User::factory()->createElement(['fullName' => 'Operator', 'admin' => true]); + $actor = User::factory()->createElement(['fullName' => 'Editor']); + $this->actingAs($actor); + app(Impersonation::class)->setImpersonatorId($operator->id); + + $user = $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); + + expect($system->actorType)->toBe(ActivityActor::TYPE_SYSTEM) + ->and($anonymous->actorType)->toBe(ActivityActor::TYPE_ANONYMOUS) + ->and($user->actorType)->toBe(ActivityActor::TYPE_USER) + ->and($user->snapshots['impersonator'])->toBe([ + 'id' => $operator->id, + 'label' => $operator->name, + ]); +}); + +it('accepts plugin-defined actor types', function () { + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Automated', + actor: new ActivityActor('test-plugin:automation', 'Campaign automation'), + )); + + expect($event->actorType)->toBe('test-plugin:automation') + ->and($event->snapshots['actor']['label'])->toBe('Campaign automation'); +}); + +it('uses the username for unnamed user actors', function () { + $actor = User::factory()->createElement([ + 'username' => 'editor', + 'email' => 'editor@example.com', + ]); + $actor->setName(''); + $this->actingAs($actor); + + $event = $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); + + expect($event->snapshots['actor']['label'])->toBe('editor'); +}); + +it('accepts Eloquent user models as actors', function () { + $actor = User::factory()->create(['fullName' => 'Ada Lovelace']); + + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Edited', + actor: $actor, + )); + + expect($event->actorId)->toBe($actor->id) + ->and($event->snapshots['actor']['label'])->toBe('Ada Lovelace'); +}); + +it('attributes unauthenticated HTTP activity to an anonymous actor', function () { + Route::get('test/activity-actor', fn () => ActivitiesFacade::record( + new TestPluginEntryUpdated(reason: 'Public request'), + )->actorType); + + get('test/activity-actor') + ->assertOk() + ->assertSeeText(ActivityActor::TYPE_ANONYMOUS); +}); + +it('rejects invalid changes', function () { + expect(fn () => new ActivityChange('', null, 'Ready')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rolls records back with their semantic action', function () { + DB::beginTransaction(); + + $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); + + DB::rollBack(); + + expect($this->activities->query()->get())->toBeEmpty(); +}); + +it('queries fixed criteria and paginates equal timestamps without gaps', function () { + Date::setTestNow('2026-08-25 12:00:00'); + + $subject = new ActivitySubject('document', 'one', 'Document one'); + $otherSubject = new ActivitySubject('document', 'two', 'Document two'); + $craftSubject = Entry::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $craftEvent = $this->activities->record(new ElementStatusChanged( + subject: $craftSubject, + site: null, + oldStatus: 'pending', + newStatus: 'disabled', + )); + $first = $this->activities->record(new TestPluginEntryUpdated(reason: 'First', subject: $subject)); + $second = $this->activities->record(new TestPluginEntryUpdated(reason: 'Second', subject: $subject)); + $this->activities->record(new TestPluginEntryUpdated(reason: 'Other', subject: $otherSubject)); + $this->activities->record(new TestPluginEntryUpdated( + reason: 'Anonymous', + subject: $subject, + actor: ActivityActor::anonymous(), + )); + $this->activities->record(new TestPluginEntryFeatured(subject: $subject)); + + $page = $this->activities->query() + ->subject($subject) + ->eventTypes(TestPluginEntryUpdated::class) + ->actor(ActivityActor::system()) + ->source('test-plugin') + ->occurredFrom(Date::parse('2026-08-25 00:00:00')) + ->occurredUntil(Date::parse('2026-08-25 23:59:59')) + ->cursorPaginate(1); + $nextPage = $this->activities->query() + ->subject($subject) + ->eventTypes(TestPluginEntryUpdated::class) + ->actor(ActivityActor::system()) + ->source('test-plugin') + ->occurredFrom(Date::parse('2026-08-25 00:00:00')) + ->occurredUntil(Date::parse('2026-08-25 23:59:59')) + ->cursorPaginate(1, cursor: $page->nextCursor()); + + expect($page->items())->toHaveCount(1) + ->and($page->items()[0]->id)->toBe($second->id) + ->and($nextPage->items())->toHaveCount(1) + ->and($nextPage->items()[0]->id)->toBe($first->id) + ->and($nextPage->nextCursor())->toBeNull() + ->and($this->activities->query()->source('craft')->sole()->id)->toBe($craftEvent->id); +}); + +it('applies occurrence bounds', function () { + Date::setTestNow('2026-08-25 12:00:00'); + $early = $this->activities->record(new TestPluginEntryUpdated(reason: 'Early')); + + Date::setTestNow('2026-08-25 12:00:02'); + $late = $this->activities->record(new TestPluginEntryUpdated(reason: 'Late')); + + $bound = Date::parse('2026-08-25 12:00:01'); + $from = $this->activities->query()->occurredFrom($bound)->get(); + $until = $this->activities->query()->occurredUntil($bound)->get(); + + expect($from)->toHaveCount(1) + ->and($from[0]->id)->toBe($late->id) + ->and($until)->toHaveCount(1) + ->and($until[0]->id)->toBe($early->id); +}); + +it('keeps site-neutral events in site-scoped queries', function () { + $site = Sites::getSiteById(Site::factory()->create()->id); + $otherSite = Sites::getSiteById(Site::factory()->create()->id); + + $neutral = $this->activities->record(new TestPluginEntryUpdated(reason: 'Neutral')); + $matching = $this->activities->record(new TestPluginEntryUpdated(reason: 'Matching', site: $site)); + $this->activities->record(new TestPluginEntryUpdated(reason: 'Other', site: $otherSite)); + + expect($this->activities->query()->site($site)->pluck('id')->all()) + ->toEqualCanonicalizing([$matching->id, $neutral->id]); +}); + +it('does not bind retained events to mutable Craft records', function () { + $actor = User::factory()->createElement(); + $subject = Entry::factory()->createElement(); + $siteModel = Site::factory()->create(); + + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Edited', + subject: $subject, + actor: $actor, + site: Sites::getSiteById($siteModel->id), + )); + + DB::table(Table::USERS)->where('id', $actor->id)->delete(); + DB::table(Table::ELEMENTS)->where('id', $subject->id)->delete(); + DB::table(Table::SITES)->where('id', $siteModel->id)->delete(); + + $retained = $this->activities->query()->firstOrFail(); + + expect($retained->id)->toBe($event->id) + ->and($retained->snapshots)->toMatchArray([ + 'actor' => ['label' => $actor->name], + 'subject' => ['label' => $subject->getUiLabel()], + 'site' => ['name' => $siteModel->name], + ]); +}); + +it('formats plugin events in the application locale as text or safe HTML', function () { + app()->setLocale('nl'); + + $textEvent = ActivitiesFacade::record(new TestPluginEntryPublished( + reason: 'Klaar', + )); + $htmlEvent = ActivitiesFacade::record(new TestPluginEntryFeatured); + $translatedEvent = ActivitiesFacade::record(new TestPluginEntryTranslated); + + expect(ActivitiesFacade::format($textEvent))->toBe('Klaar') + ->and(ActivitiesFacade::icon($textEvent))->toBe('bullhorn') + ->and(ActivitiesFacade::format($htmlEvent))->toBeInstanceOf(Htmlable::class) + ->and(ActivitiesFacade::format($htmlEvent)->toHtml())->toBe('Entry featured') + ->and(ActivitiesFacade::format( + ActivitiesFacade::record(new TestPluginEntryUpdated(reason: 'Klaar')), + ))->toBe('Entry updated') + ->and(ActivitiesFacade::format($translatedEvent))->toBe('Bewaren') + ->and(ActivitiesFacade::icon($translatedEvent))->toBeNull(); +}); + +it('translates status labels for the application locale', function () { + app()->setLocale('nl'); + + $event = ActivitiesFacade::record(new ElementStatusChanged( + subject: Entry::factory()->createElement(), + site: null, + oldStatus: 'pending', + newStatus: 'disabled', + )); + + expect(ActivitiesFacade::format($event)) + ->toContain(t('Pending')) + ->toContain(t('Disabled')) + ->not->toContain('Pending') + ->not->toContain('Disabled'); +}); + +it('reports formatter failures and keeps retained events readable when their class is unavailable', function () { + Exceptions::fake(); + + $event = ActivitiesFacade::record(new TestPluginEntryFailed); + $retainedEvent = clone $event; + $retainedEvent->eventType = 'Missing\\ActivityEventType'; + + expect(ActivitiesFacade::format($event))->toBe('Entry formatting failed') + ->and(ActivitiesFacade::format($retainedEvent))->toBe('Entry formatting failed') + ->and(ActivitiesFacade::icon($retainedEvent))->toBeNull(); + Exceptions::assertReported(RuntimeException::class); +}); + +abstract class TestPluginActivityEventType extends ActivityEventType +{ + public static function source(): ActivitySource + { + return ActivitySource::fromPlugin(TestPlugin::getInstance()); + } +} + +class TestPluginEntryUpdated extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry updated'; + + public function __construct( + private readonly string $reason, + ElementInterface|ActivitySubject|null $subject = null, + CraftUser|ActivityActor|null $actor = null, + ?SiteData $site = null, + array $changes = [], + ) { + parent::__construct($subject, $actor, $site, $changes); + } + + public function data(): array + { + return ['reason' => $this->reason]; + } +} + +class TestPluginEntryPublished extends TestPluginEntryUpdated +{ + protected const string LABEL = 'Entry published'; + + protected const string ICON = 'bullhorn'; + + public static function format(ActivityEvent $event): string + { + return $event->data['reason']; + } +} + +class TestPluginEntryFeatured extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry featured'; + + public static function format(ActivityEvent $event): HtmlString + { + return new HtmlString('Entry featured'); + } +} + +class TestPluginEntryTranslated extends TestPluginActivityEventType +{ + protected const string LABEL = 'Save'; + + public static function source(): ActivitySource + { + return new ActivitySource('test-plugin', 'Test Plugin', 'app'); + } +} + +class TestPluginEntryFailed extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry formatting failed'; + + public static function format(ActivityEvent $event): never + { + throw new RuntimeException('Formatter failed.'); + } +} diff --git a/tests/Feature/Activity/AssetActivityTest.php b/tests/Feature/Activity/AssetActivityTest.php new file mode 100644 index 00000000000..4653fb978a2 --- /dev/null +++ b/tests/Feature/Activity/AssetActivityTest.php @@ -0,0 +1,68 @@ +set('filesystems.disks.activity-assets', ['driver' => 'local', 'root' => $root]); + + $volume = Volume::factory()->create([ + 'name' => 'Activity assets', + 'handle' => 'activityAssets', + 'fs' => 'disk:activity-assets', + ]); + $folder = app(Folders::class)->getRootFolderByVolumeId($volume->id); + $original = Path::temp('original.txt'); + File::put($original, 'old'); + + $asset = Elements::createElement([ + 'type' => Asset::class, + 'volumeId' => $volume->id, + 'newFolderId' => $folder->id, + 'tempFilePath' => $original, + 'newFilename' => 'original.txt', + ]); + $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); + expect(Elements::saveElement($asset))->toBeTrue(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $replacement = Path::temp('replacement.txt'); + File::put($replacement, 'replacement'); + app(Assets::class)->replaceAssetFile($asset, $replacement, 'replacement.txt', 'text/plain'); + + $event = app(Activities::class)->query()->subject(ActivitySubject::fromElement($asset))->firstOrFail(); + + expect($event->eventType)->toBe(AssetFileReplaced::class) + ->and($event->siteId)->toBe($asset->siteId) + ->and($event->data)->toEqual([ + 'oldFilename' => 'original.txt', + 'newFilename' => 'replacement.txt', + 'oldMimeType' => 'text/plain', + 'newMimeType' => 'text/plain', + 'oldSize' => 3, + 'newSize' => 11, + ]) + ->and(app(Activities::class)->format($event)) + ->toBe('Replaced original.txt with replacement.txt.'); +}); diff --git a/tests/Feature/Activity/ElementLifecycleActivityTest.php b/tests/Feature/Activity/ElementLifecycleActivityTest.php new file mode 100644 index 00000000000..13aba534ebe --- /dev/null +++ b/tests/Feature/Activity/ElementLifecycleActivityTest.php @@ -0,0 +1,291 @@ +activities = app(Activities::class); +}); + +it('records trash restore and permanent deletion with durable snapshots', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Release notes']); + $subject = ActivitySubject::fromElement($entry); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry))->toBeTrue(); + + $entry = Entry::find()->id($entry->id)->siteId($entry->siteId)->trashed()->one(); + + expect(Elements::restoreElement($entry))->toBeTrue() + ->and(Elements::deleteElement($entry, true))->toBeTrue(); + + $events = $this->activities->query()->subject($subject)->get()->reverse()->values(); + + expect($events->pluck('eventType')->all())->toBe([ + ElementTrashed::class, + ElementRestoredActivity::class, + ElementDeletedActivity::class, + ])->and($events->pluck('siteId')->unique()->all())->toBe([$entry->siteId]) + ->and($events->pluck('snapshots.subject.label')->unique()->all())->toBe(['Release notes']) + ->and(Entry::find()->id($entry->id)->status(null)->trashed(null)->exists())->toBeFalse(); +}); + +it('does not record cancelled no-op or rolled-back lifecycle actions', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + $cancelNextDelete = true; + + Event::listen(function (ElementDeleting $event) use ($entry, &$cancelNextDelete) { + if (! $cancelNextDelete || $event->element !== $entry) { + return; + } + + $event->isValid = false; + $cancelNextDelete = false; + }); + + expect(Elements::deleteElement($entry))->toBeFalse() + ->and($this->activities->query()->get())->toBeEmpty(); + + expect(Elements::restoreElement($entry))->toBeTrue() + ->and($this->activities->query()->get())->toBeEmpty(); + + DB::beginTransaction(); + expect(Elements::deleteElement($entry))->toBeTrue(); + DB::rollBack(); + + expect($this->activities->query()->get())->toBeEmpty(); + + $entry = Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one(); + expect(Elements::deleteElement($entry))->toBeTrue(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry))->toBeTrue() + ->and($this->activities->query()->get())->toBeEmpty(); + + expect(Elements::deleteElement($entry, true))->toBeTrue(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry, true))->toBeFalse() + ->and(Elements::restoreElement($entry))->toBeFalse() + ->and($this->activities->query()->get())->toBeEmpty(); +}); + +it('dispatches the post-delete event within a surrounding transaction', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementDeleted $event) use ($entry) { + if ($event->element === $entry) { + throw new RuntimeException('Deletion failed.'); + } + }); + + expect(fn () => DB::transaction(fn () => Elements::deleteElement($entry))) + ->toThrow(RuntimeException::class, 'Deletion failed.'); + + expect($this->activities->query()->eventTypes(ElementTrashed::class)->count())->toBe(0) + ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->exists())->toBeTrue(); +}); + +it('records one event per restored element', function () { + $entries = [ + EntryModel::factory()->createElement(), + EntryModel::factory()->createElement(), + ]; + + foreach ($entries as $entry) { + Elements::deleteElement($entry); + } + + $entries = Entry::find() + ->id(array_column($entries, 'id')) + ->siteId($entries[0]->siteId) + ->trashed() + ->all(); + + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::restoreElements($entries))->toBeTrue(); + + $events = $this->activities->query()->eventTypes(ElementRestoredActivity::class)->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('subjectId')->unique())->toHaveCount(2); +}); + +it('records site removal and addition without generic propagation events', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Elements::deleteElementForSite($secondaryEntry); + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get()->reverse()->values(); + + expect($events->pluck('eventType')->all())->toBe([ + ElementSiteRemoved::class, + ElementSiteAdded::class, + ])->and($events->pluck('siteId')->all())->toBe([ + $secondarySite->id, + $secondarySite->id, + ])->and($events->pluck('snapshots.site.name')->unique()->all())->toBe(['Secondary Site']) + ->and($this->activities->format($events[0]))->toBe('Removed from Secondary Site.') + ->and($this->activities->format($events[1]))->toBe('Added to Secondary Site.'); +}); + +it('keeps actor labels after the actor is permanently deleted', function () { + $admin = User::findOne(); + $actor = UserModel::factory()->createElement(['fullName' => 'Ada Lovelace']); + $entry = EntryModel::factory()->createElement(); + $subject = ActivitySubject::fromElement($entry); + actingAs($actor); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry))->toBeTrue(); + + actingAs($admin); + expect(Elements::deleteElement($actor, true))->toBeTrue() + ->and(User::find()->id($actor->id)->status(null)->exists())->toBeFalse(); + + $event = $this->activities->query()->subject($subject)->firstOrFail(); + + expect($event->snapshots['actor']['label'])->toBe('Ada Lovelace'); +}); + +it('rolls back site removal when its post-delete event fails', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementDeletedForSite $event) use ($secondaryEntry) { + if ($event->element === $secondaryEntry) { + throw new RuntimeException('Site deletion failed.'); + } + }); + + expect(fn () => Elements::deleteElementForSite($secondaryEntry)) + ->toThrow(RuntimeException::class, 'Site deletion failed.'); + + expect(Entry::find()->id($secondaryEntry->id)->siteId($secondaryEntry->siteId)->status(null)->exists())->toBeTrue() + ->and($this->activities->query()->eventTypes(ElementSiteRemoved::class)->count())->toBe(0); +}); + +it('rolls back single-site deletion when its post-delete event fails', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementDeleted $event) use ($entry) { + if ($event->element === $entry) { + throw new RuntimeException('Site deletion failed.'); + } + }); + + expect(fn () => Elements::deleteElementForSite($entry)) + ->toThrow(RuntimeException::class, 'Site deletion failed.'); + + expect(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->exists())->toBeTrue() + ->and($this->activities->query()->eventTypes(ElementDeletedActivity::class)->count())->toBe(0); +}); + +it('dispatches the post-save event within a surrounding transaction', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + Elements::deleteElementForSite($secondaryEntry); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementSaved $event) use ($entry) { + if ($event->element === $entry) { + throw new RuntimeException('Save failed.'); + } + }); + + expect(fn () => DB::transaction(fn () => Elements::saveElement($entry, updateSearchIndex: false))) + ->toThrow(RuntimeException::class, 'Save failed.'); + + expect($this->activities->query()->eventTypes(ElementSiteAdded::class)->count())->toBe(0) + ->and(Entry::find()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists())->toBeFalse(); +}); + +function createLifecycleMultiSiteEntry(): array +{ + $secondarySite = Site::factory()->create([ + 'handle' => 'secondary', + 'name' => 'Secondary Site', + ]); + + Sites::refreshSites(); + + $section = Section::factory()->withEntryTypes( + $entryType = EntryType::factory()->create(), + )->create([ + 'propagationMethod' => PropagationMethod::Custom, + ]); + + SectionSiteSettings::factory()->create([ + 'sectionId' => $section->id, + 'siteId' => $secondarySite->id, + 'hasUrls' => true, + 'dateCreated' => $section->dateCreated, + 'dateUpdated' => $section->dateUpdated, + ]); + + app(Fields::class)->invalidateCaches(); + app(Fields::class)->refreshFields(); + + $entry = EntryModel::factory() + ->forSection($section) + ->forEntryType($entryType) + ->createElement(['title' => 'Multi-site entry']); + + $entry->setEnabledForSite([ + $entry->siteId => true, + $secondarySite->id => true, + ]); + Elements::saveElement($entry); + + return [$entry, $secondarySite]; +} diff --git a/tests/Feature/Activity/EntryActivityTest.php b/tests/Feature/Activity/EntryActivityTest.php new file mode 100644 index 00000000000..dc9d143476b --- /dev/null +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -0,0 +1,352 @@ +activities = app(Activities::class); +}); + +it('records entry creation once for each supported site', function () { + $otherSite = Site::factory()->create(); + Sites::refreshSites(); + $entryType = EntryType::factory()->create(); + $section = Section::factory()->withEntryTypes($entryType)->withSites($otherSite)->create(); + + post(action(StoreEntryController::class), [ + 'sectionId' => $section->id, + 'typeId' => $entryType->id, + 'title' => 'New entry', + 'enabled' => true, + ])->assertRedirect()->assertSessionHasNoErrors(); + + $entry = Entry::find()->sectionId($section->id)->title('New entry')->status(null)->one(); + + $events = $this->activities->query() + ->subject(ActivitySubject::fromElement($entry)) + ->eventTypes(ElementCreated::class) + ->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('siteId')->all()) + ->toEqualCanonicalizing([Sites::getPrimarySite()->id, $otherSite->id]); +}); + +it('records normalized entry content changes', function () { + $result = EntryModel::factory() + ->withField('bodyField', PlainText::class, value: 'Old body') + ->createElementWithFields(['title' => 'Old title']); + $entry = $result->element; + $field = $entry->getFieldLayout()->getFieldByHandle('bodyField'); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $entry->title = 'New title'; + $entry->setFieldValue($field->handle, 'New body'); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $event = $this->activities->query() + ->subject(ActivitySubject::fromElement($entry)) + ->eventTypes(ElementUpdated::class) + ->firstOrFail(); + + expect($event->changes)->toEqualCanonicalizing([ + new ActivityChange('Title', 'Old title', 'New title'), + new ActivityChange($field->name, 'Old body', 'New body'), + ]); +}); + +it('records a status change instead of a generic update', function () { + $result = EntryModel::factory() + ->withField('bodyField', PlainText::class, value: 'Old body') + ->createElementWithFields(); + $entry = $result->element; + $field = $entry->getFieldLayout()->getFieldByHandle('bodyField'); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $entry->setEnabledForSite(false); + $entry->setFieldValue($field->handle, 'New body'); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe(ElementStatusChanged::class) + ->and($events->first()->data)->toEqual(['oldStatus' => 'live', 'newStatus' => 'disabled']) + ->and($this->activities->format($events->first()))->toBe('Status changed from Live to Disabled.') + ->and($events->first()->changes)->toContainEqual( + new ActivityChange($field->name, 'Old body', 'New body'), + ); +}); + +it('records an update while omitting unsafe field values', function () { + $result = EntryModel::factory() + ->withField('bodyField', PlainText::class, value: 'Old body') + ->createElementWithFields(['title' => 'Old title']); + $entry = $result->element; + $field = $entry->getFieldLayout()->getFieldByHandle('bodyField'); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $entry->setFieldValue($field->handle, 'Rendered HTML'); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->firstOrFail(); + + expect($event->eventType)->toBe(ElementUpdated::class) + ->and($event->changes)->toBeEmpty(); +}); + +it('does not record cancelled saves', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $entry->title = 'Cancelled title'; + + Event::listen(function (ElementSaving $event) use ($entry) { + if ($event->element === $entry) { + $event->isValid = false; + } + }); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeFalse() + ->and($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); +}); + +it('does not record no-op, draft, resave, or rolled-back work', function () { + $entry = EntryModel::factory()->createElement([ + 'title' => 'Original title', + 'expiryDate' => now()->addDay(), + ]); + $entry->expiryDate = Date::parse($entry->expiryDate->format(DATE_ATOM)); + $entry->setDirtyAttributes(['expiryDate']); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue() + ->and($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); + + app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); + + Elements::resaveElements( + Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null), + updateSearchIndex: false, + ); + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); + + $entry = Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one(); + $entry->title = 'Rolled back title'; + + DB::beginTransaction(); + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + DB::rollBack(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty() + ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one()->title) + ->toBe('Original title'); +}); + +it('records draft work against the canonical entry', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, name: 'Campaign draft'); + + $draft->title = 'Draft title'; + expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue(); + + app(Drafts::class)->applyDraft($draft); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events->pluck('eventType')->all())->toBe([ + DraftApplied::class, + DraftSaved::class, + DraftCreated::class, + ])->and($events->pluck('siteId')->unique()->all())->toBe([$entry->siteId]); +}); + +it('records applying a provisional draft as an entry update', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft->title = 'Updated title'; + expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue(); + + app(Drafts::class)->applyDraft($draft); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->sole(); + + expect($event->eventType)->toBe(ElementUpdated::class) + ->and($event->snapshots['subject']['label'])->toBe('Updated title') + ->and($event->changes)->toContainEqual( + new ActivityChange('Title', 'Original title', 'Updated title'), + ); +}); + +it('records draft creation and its initial save', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + postJson(action([ElementDraftsController::class, 'store']), [ + 'elementType' => Entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + 'title' => 'Draft title', + ])->assertOk(); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events->pluck('eventType')->all())->toBe([DraftSaved::class, DraftCreated::class]); +}); + +it('records named draft discard through the endpoint', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, name: 'Discard me'); + postJson(action([ElementDraftsController::class, 'destroy']), [ + 'elementType' => Entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + 'draftId' => $draft->draftId, + ])->assertOk(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->pluck('eventType')->all())->toBe([ + DraftDiscarded::class, + DraftCreated::class, + ]); +}); + +it('ignores provisional creation and autosave but records its endpoint discard', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $provisional = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + $provisional->title = 'Autosaved title'; + expect(Elements::saveElement($provisional, updateSearchIndex: false))->toBeTrue(); + postJson(action([ElementDraftsController::class, 'destroy']), [ + 'elementType' => Entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + 'draftId' => $provisional->draftId, + 'provisional' => 1, + ])->assertOk(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->pluck('eventType')->all())->toBe([ + DraftDiscarded::class, + ]); +}); + +it('records an explicitly saved unpublished draft as created', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(app(Drafts::class)->saveElementAsDraft($entry, User::findOne()->id))->toBeTrue(); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->firstOrFail(); + + expect($event->eventType)->toBe(DraftCreated::class) + ->and($event->siteId)->toBe($entry->siteId); +}); + +it('records provisional draft promotion as creation and ignores no-op saves', function () { + $entry = EntryModel::factory()->createElement(); + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $payload = [ + 'elementType' => Entry::class, + 'draftId' => $draft->draftId, + 'siteId' => $draft->siteId, + 'title' => 'Saved draft', + ]; + + postJson(action([ElementDraftsController::class, 'store']), [ + ...$payload, + 'dropProvisional' => true, + ])->assertOk(); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->sole(); + + expect($event->eventType)->toBe(DraftCreated::class); + + DB::table(Table::ACTIVITYEVENTS)->delete(); + postJson(action([ElementDraftsController::class, 'store']), $payload)->assertOk(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); +}); + +it('records revision restoration without a generic update', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $revisionId = app(Revisions::class)->createRevision($entry, User::findOne()->id, force: true); + $revision = Entry::find()->id($revisionId)->revisions()->status(null)->one(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + app(Revisions::class)->revertToRevision($revision, User::findOne()->id); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe(RevisionRestored::class) + ->and($events->first()->data)->toBe(['revisionNum' => $revision->revisionNum]) + ->and($events->first()->siteId)->toBe($entry->siteId) + ->and($this->activities->format($events->first()))->toBe("Restored revision {$revision->revisionNum}."); +}); + +it('rolls back revision restoration when a post-save event fails', function (string $eventType) { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $revisionId = app(Revisions::class)->createRevision($entry, User::findOne()->id, force: true); + $revision = Entry::find()->id($revisionId)->revisions()->status(null)->one(); + $entry->title = 'Current title'; + Elements::saveElement($entry, updateSearchIndex: false); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen($eventType, function (object $event) use ($entry) { + if (! $event instanceof ElementSaved || $event->element->id === $entry->id) { + throw new RuntimeException('Post-save event failed.'); + } + }); + + expect(fn () => app(Revisions::class)->revertToRevision($revision, User::findOne()->id)) + ->toThrow(RuntimeException::class, 'Post-save event failed.'); + + expect(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one()->title)->toBe('Current title') + ->and($this->activities->query()->eventTypes(RevisionRestored::class)->count())->toBe(0); +})->with([ElementSaved::class, RevertedToRevision::class]); diff --git a/tests/Feature/Activity/StructuralElementActivityTest.php b/tests/Feature/Activity/StructuralElementActivityTest.php new file mode 100644 index 00000000000..0ea385f8d52 --- /dev/null +++ b/tests/Feature/Activity/StructuralElementActivityTest.php @@ -0,0 +1,157 @@ +activities = app(Activities::class); +}); + +it('records duplication instead of nested creation', function () { + $source = EntryModel::factory()->createElement(['title' => 'Source entry']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $duplicate = app(ElementDuplicates::class)->duplicateElement($source); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($duplicate))->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe(ElementDuplicated::class) + ->and($events->first()->data['source'])->toEqual([ + 'type' => $source::class, + 'id' => $source->uid, + 'label' => $source->getUiLabel(), + ]) + ->and($this->activities->format($events->first()))->toBe('Duplicated from Source entry.'); +}); + +it('does not record draft creation for unpublished draft duplicates', function () { + $source = EntryModel::factory()->createElement(['title' => 'Source entry']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + app(ElementDuplicates::class)->duplicateElement($source, asUnpublishedDraft: true); + + expect($this->activities->query()->eventTypes(DraftCreated::class)->get())->toBeEmpty(); +}); + +it('records one duplication event for each affected site', function () { + $otherSite = Site::factory()->create(); + Sites::refreshSites(); + $entryType = EntryType::factory()->create(); + $section = Section::factory()->withEntryTypes($entryType)->withSites($otherSite)->create(); + $source = EntryModel::factory() + ->forSection($section) + ->forEntryType($entryType) + ->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $duplicate = app(ElementDuplicates::class)->duplicateElement($source); + $events = $this->activities->query() + ->subject(ActivitySubject::fromElement($duplicate)) + ->eventTypes(ElementDuplicated::class) + ->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('siteId')->all()) + ->toEqualCanonicalizing([Sites::getPrimarySite()->id, $otherSite->id]); +}); + +it('records one event per bulk duplication subject', function () { + $sources = EntryModel::factory()->count(2)->create(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $action = new Duplicate; + $query = Entry::find()->id($sources->pluck('id'))->status(null); + + expect($action->performAction($query))->toBeTrue(); + + $events = $this->activities->query()->eventTypes(ElementDuplicated::class)->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('subjectId')->unique())->toHaveCount(2); +}); + +it('records captured structure movement positions', function () { + [ + 'structure' => $structure, + 'root' => $root, + 'children' => [$parent, $moved], + ] = createStructureHierarchy(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(app(Structures::class)->append($structure->id, $moved, $parent))->toBeTrue(); + + $event = $this->activities->query() + ->subject(ActivitySubject::fromElement($moved)) + ->eventTypes(ElementMoved::class) + ->firstOrFail(); + + expect($event->data)->toMatchArray([ + 'origin' => [ + 'structure' => $structure->uid, + 'parent' => [ + 'type' => $root::class, + 'id' => $root->uid, + 'label' => $root->getUiLabel(), + ], + 'previousSibling' => [ + 'type' => $parent::class, + 'id' => $parent->uid, + 'label' => $parent->getUiLabel(), + ], + ], + 'destination' => [ + 'structure' => $structure->uid, + 'parent' => [ + 'type' => $parent::class, + 'id' => $parent->uid, + 'label' => $parent->getUiLabel(), + ], + 'previousSibling' => null, + ], + ])->and($this->activities->format($event))->toBe( + "Moved from the position after {$parent->getUiLabel()} in {$root->getUiLabel()} to the first position in {$parent->getUiLabel()}.", + ); +}); + +it('records both merge subjects without nested updates or deletion', function () { + $merged = EntryModel::factory()->createElement(['title' => 'Merged entry']); + $prevailing = EntryModel::factory()->createElement(['title' => 'Prevailing entry']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + Queue::fake(); + + expect(app(ElementDeletions::class)->mergeElements($merged, $prevailing))->toBeTrue(); + + $events = $this->activities->query()->get(); + $mergedEvent = $events->firstWhere('subjectId', $merged->uid); + $prevailingEvent = $events->firstWhere('subjectId', $prevailing->uid); + + expect($events)->toHaveCount(2) + ->and($events->pluck('eventType')->unique()->all())->toBe([ElementMerged::class]) + ->and($events->pluck('subjectId')->all())->toEqualCanonicalizing([$merged->uid, $prevailing->uid]) + ->and($this->activities->format($mergedEvent))->toBe('Merged into Prevailing entry.') + ->and($this->activities->format($prevailingEvent))->toBe('Merged Merged entry into this element.'); +}); diff --git a/tests/Feature/Element/ElementEagerLoaderTest.php b/tests/Feature/Element/ElementEagerLoaderTest.php index b552b2ec014..12942af8bcb 100644 --- a/tests/Feature/Element/ElementEagerLoaderTest.php +++ b/tests/Feature/Element/ElementEagerLoaderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\DraftActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; @@ -366,7 +367,10 @@ function invokeElementEagerLoaderMethod(ElementEagerLoader $loader, string $meth }); it('uses custom element factories and provisional drafts when requested', function () { - $loader = app(ElementEagerLoader::class, ['drafts' => new TestElementEagerLoaderDrafts(app(Elements::class))]); + $loader = app(ElementEagerLoader::class, ['drafts' => new TestElementEagerLoaderDrafts( + app(Elements::class), + app(DraftActivity::class), + )]); $source = new TestElementEagerLoaderSourceElement(['id' => 1]); TestElementEagerLoaderSourceElement::setTestEagerLoadingMap('drafty', [ diff --git a/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php new file mode 100644 index 00000000000..e7160daa847 --- /dev/null +++ b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php @@ -0,0 +1,56 @@ + Date::setTestNow()); + +it('leaves activity intact when retention is unlimited', function () { + Date::setTestNow('2025-08-26 12:00:00'); + $event = app(Activities::class)->record(new ElementCreated( + subject: new ActivitySubject('document', 'one', 'Document one'), + )); + + Date::setTestNow('2026-08-26 12:00:00'); + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->whereKey($event->id)->exists())->toBeTrue(); +}); + +it('purges activity older than the retention duration', function () { + Cms::config()->activityRetentionDuration(3600); + $activities = app(Activities::class); + $subject = new ActivitySubject('document', 'one', 'Document one'); + + Date::setTestNow('2026-08-26 10:00:00'); + $expired = $activities->record(new ElementCreated(subject: $subject)); + + Date::setTestNow('2026-08-26 12:00:00'); + $retained = $activities->record(new ElementUpdated(subject: $subject)); + + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->whereKey($retained->id)->exists())->toBeTrue() + ->and(ActivityEvent::query()->whereKey($expired->id)->exists())->toBeFalse(); +}); + +it('retains events until they cross the cutoff', function () { + Cms::config()->activityRetentionDuration(3600); + Date::setTestNow('2026-08-26 11:00:00'); + $event = app(Activities::class)->record(new ElementCreated( + subject: new ActivitySubject('document', 'one', 'Document one'), + )); + + Date::setTestNow('2026-08-26 12:00:00'); + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->whereKey($event->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php index bcd802d9b0f..9ca218c8e1e 100644 --- a/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\DraftActivity; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Elements; @@ -70,7 +71,7 @@ function createElementControllerPayload(object $section, object $entryType, arra }); it('returns a failure response when saving the draft fails', function () { - app()->instance(Drafts::class, new readonly class(app(Elements::class)) extends Drafts + app()->instance(Drafts::class, new readonly class(app(Elements::class), app(DraftActivity::class)) extends Drafts { public function saveElementAsDraft( ElementInterface $element, diff --git a/tests/Feature/Http/Controllers/MatrixControllerTest.php b/tests/Feature/Http/Controllers/MatrixControllerTest.php index da4e22687b8..beb0bd9b244 100644 --- a/tests/Feature/Http/Controllers/MatrixControllerTest.php +++ b/tests/Feature/Http/Controllers/MatrixControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\DraftActivity; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\ElementCaches; @@ -238,7 +239,7 @@ function refreshMatrixControllerFixture(array $fixture): array }); it('returns a failure response when saving a new matrix draft fails', function () { - app()->instance(Drafts::class, new readonly class(app(Elements::class)) extends Drafts + app()->instance(Drafts::class, new readonly class(app(Elements::class), app(DraftActivity::class)) extends Drafts { public function saveElementAsDraft(ElementInterface $element, ?int $creatorId = null, ?string $name = null, ?string $notes = null, bool $markAsSaved = true): bool { diff --git a/tests/Unit/Config/GeneralConfigTest.php b/tests/Unit/Config/GeneralConfigTest.php index 1f59f1b27d8..a04b522b9e3 100644 --- a/tests/Unit/Config/GeneralConfigTest.php +++ b/tests/Unit/Config/GeneralConfigTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; use Illuminate\Support\Facades\Config; +use InvalidArgumentException; it('can get from container', function () { expect(app(GeneralConfig::class))->toBe(Config::get('craft.general')); @@ -35,6 +36,14 @@ expect($config->compiledTemplatesPath)->toBe('@storage/custom-compiled-templates'); }); +it('normalizes activity retention durations and rejects negative values', function () { + $config = GeneralConfig::create(); + + expect($config->activityRetentionDuration)->toBe(0) + ->and($config->activityRetentionDuration('P1D')->activityRetentionDuration)->toBe(86400) + ->and(fn () => $config->activityRetentionDuration(-1))->toThrow(InvalidArgumentException::class); +}); + it('requires a default Asset Transformer', function () { $config = GeneralConfig::create(); diff --git a/tests/Unit/Element/ElementWrites/PropagateElementTest.php b/tests/Unit/Element/ElementWrites/PropagateElementTest.php index e8c141adc21..106c8c6d54f 100644 --- a/tests/Unit/Element/ElementWrites/PropagateElementTest.php +++ b/tests/Unit/Element/ElementWrites/PropagateElementTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -39,6 +40,7 @@ Mockery::mock(ElementCaches::class), Mockery::mock(Search::class), $this->sites, + Mockery::mock(ElementWriteActivity::class), ); $this->primarySite = new Site([ @@ -444,6 +446,7 @@ protected function saveInternal( bool $saveContent = false, ?ElementSiteSettings &$siteSettingsRecord = null, ?bool $inheritedUpdateSearchIndex = null, + bool $recordActivity = true, ): bool { $this->saveCalls[] = [ 'siteElement' => $element, diff --git a/tests/Unit/Element/ElementWrites/PropagateElementsTest.php b/tests/Unit/Element/ElementWrites/PropagateElementsTest.php index da5780a1dc5..486670cf58d 100644 --- a/tests/Unit/Element/ElementWrites/PropagateElementsTest.php +++ b/tests/Unit/Element/ElementWrites/PropagateElementsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Element\BulkOp\BulkOps; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -122,6 +123,7 @@ public function getCurrentSite(): Site $this->elementCaches, Mockery::mock(Search::class), app(SitesService::class), + Mockery::mock(ElementWriteActivity::class), ); $this->writes = $this->action; }); diff --git a/tests/Unit/Element/ElementWrites/ResaveElementsTest.php b/tests/Unit/Element/ElementWrites/ResaveElementsTest.php index 6f29b557b18..3eab5d33c7d 100644 --- a/tests/Unit/Element/ElementWrites/ResaveElementsTest.php +++ b/tests/Unit/Element/ElementWrites/ResaveElementsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Element\BulkOp\BulkOps as BulkOpsService; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Contracts\NestedElementInterface; @@ -41,6 +42,7 @@ Mockery::mock(ElementCaches::class), Mockery::mock(Search::class), Mockery::mock(Sites::class), + Mockery::mock(ElementWriteActivity::class), ); $this->saveElementAction = $this->action; });