From f3463305ae4563fb78e6729240f7433074fb5a6d Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 09:02:40 +0200 Subject: [PATCH 01/10] Add activity log backend --- composer.json | 1 + composer.lock | 2 +- src/Activity/Activities.php | 117 +++++++ src/Activity/ActivityEventRecorder.php | 140 ++++++++ src/Activity/ActivityEventTypes.php | 263 +++++++++++++++ src/Activity/AssetActivity.php | 50 +++ src/Activity/Data/ActivityActor.php | 49 +++ src/Activity/Data/ActivitySource.php | 30 ++ src/Activity/Data/ActivitySubject.php | 32 ++ .../Data/ElementWriteActivityState.php | 24 ++ src/Activity/ElementActivity.php | 30 ++ src/Activity/ElementWriteActivity.php | 144 ++++++++ src/Activity/EntryActivity.php | 201 ++++++++++++ src/Activity/Enums/ActivityActorType.php | 12 + src/Activity/Models/ActivityEvent.php | 194 +++++++++++ src/Activity/StructuralElementActivity.php | 109 ++++++ src/Cms.php | 2 +- src/Config/GeneralConfig.php | 53 +++ ..._25_000000_create_activityevents_table.php | 39 +++ src/Database/Migrations/Install.php | 16 + src/Database/Table.php | 2 + src/Element/Actions/Duplicate.php | 1 + src/Element/Drafts.php | 54 +++ src/Element/Operations/ElementDeletions.php | 216 ++++++++---- src/Element/Operations/ElementDuplicates.php | 9 + src/Element/Operations/ElementWrites.php | 19 +- src/Element/Revisions.php | 61 ++-- .../Actions/PurgeExpiredActivity.php | 35 ++ src/GarbageCollection/GarbageCollection.php | 2 + src/Structure/Structures.php | 26 ++ src/Support/Facades/Activities.php | 35 ++ tests/Feature/Activity/ActivitiesTest.php | 310 ++++++++++++++++++ tests/Feature/Activity/AssetActivityTest.php | 71 ++++ .../Activity/ElementLifecycleActivityTest.php | 280 ++++++++++++++++ tests/Feature/Activity/EntryActivityTest.php | 308 +++++++++++++++++ .../StructuralElementActivityTest.php | 155 +++++++++ .../Actions/PurgeExpiredActivityTest.php | 56 ++++ tests/Unit/Config/GeneralConfigTest.php | 9 + 38 files changed, 3072 insertions(+), 85 deletions(-) create mode 100644 src/Activity/Activities.php create mode 100644 src/Activity/ActivityEventRecorder.php create mode 100644 src/Activity/ActivityEventTypes.php create mode 100644 src/Activity/AssetActivity.php create mode 100644 src/Activity/Data/ActivityActor.php create mode 100644 src/Activity/Data/ActivitySource.php create mode 100644 src/Activity/Data/ActivitySubject.php create mode 100644 src/Activity/Data/ElementWriteActivityState.php create mode 100644 src/Activity/ElementActivity.php create mode 100644 src/Activity/ElementWriteActivity.php create mode 100644 src/Activity/EntryActivity.php create mode 100644 src/Activity/Enums/ActivityActorType.php create mode 100644 src/Activity/Models/ActivityEvent.php create mode 100644 src/Activity/StructuralElementActivity.php create mode 100644 src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php create mode 100644 src/GarbageCollection/Actions/PurgeExpiredActivity.php create mode 100644 src/Support/Facades/Activities.php create mode 100644 tests/Feature/Activity/ActivitiesTest.php create mode 100644 tests/Feature/Activity/AssetActivityTest.php create mode 100644 tests/Feature/Activity/ElementLifecycleActivityTest.php create mode 100644 tests/Feature/Activity/EntryActivityTest.php create mode 100644 tests/Feature/Activity/StructuralElementActivityTest.php create mode 100644 tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php diff --git a/composer.json b/composer.json index 4f53a62ed81..e4d3d0861a0 100644 --- a/composer.json +++ b/composer.json @@ -184,6 +184,7 @@ ], "aliases": { "Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses", + "Activities": "CraftCms\\Cms\\Support\\Facades\\Activities", "Announcements": "CraftCms\\Cms\\Support\\Facades\\Announcements", "AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer", "Assets": "CraftCms\\Cms\\Support\\Facades\\Assets", diff --git a/composer.lock b/composer.lock index 0b93082b4de..6afbae66163 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": "19b037e2250902259f0ef6f00c199866", + "content-hash": "49c8dc777f00efdf0aaa5c9dee15d86c", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/src/Activity/Activities.php b/src/Activity/Activities.php new file mode 100644 index 00000000000..38f37a0a5a8 --- /dev/null +++ b/src/Activity/Activities.php @@ -0,0 +1,117 @@ + $rules + * @param (Closure(ActivityEvent, string): (string|Htmlable))|null $formatter + */ + public function extend( + string $eventType, + ActivitySource $source, + string $label, + string $icon = 'wave-pulse', + array $rules = [], + ?Closure $formatter = null, + ): void { + $this->eventTypes->register( + eventType: $eventType, + source: $source, + label: $label, + icon: $icon, + rules: $rules, + formatter: $formatter, + ); + } + + /** + * @param array $data + * @param list> $changes + */ + public function record( + string $eventType, + ElementInterface|ActivitySubject|null $subject = null, + User|ActivityActor|null $actor = null, + ?Site $site = null, + array $data = [], + array $changes = [], + ): ActivityEvent { + return $this->events->record($eventType, $subject, $actor, $site, $data, $changes); + } + + /** @return Builder */ + public function query(): Builder + { + return ActivityEvent::query()->newestFirst(); + } + + public function format(ActivityEvent $event, ?string $locale = null): string|Htmlable + { + $registration = $this->eventTypes->find($event->eventType); + + if ($registration === null) { + return $this->capturedLabel($event); + } + + $locale ??= app()->getLocale(); + + try { + if ($registration['formatter'] === null) { + return $this->eventTypes->label($event->eventType, $locale); + } + + $formatted = ($registration['formatter'])($event, $locale); + + if (is_string($formatted)) { + return $formatted; + } + + if (! $formatted instanceof Htmlable) { + throw new UnexpectedValueException('Activity event formatters must return plain text or safe HTML.'); + } + + return new HtmlString($this->htmlSanitizers->sanitize($formatted->toHtml())); + } catch (Throwable $exception) { + report($exception); + + return $this->capturedLabel($event); + } + } + + public function icon(ActivityEvent $event): string + { + return $this->eventTypes->icon($event->eventType); + } + + 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..dc2f8d85e8d --- /dev/null +++ b/src/Activity/ActivityEventRecorder.php @@ -0,0 +1,140 @@ + $data + * @param list> $changes + */ + public function record( + string $eventType, + ElementInterface|ActivitySubject|null $subject = null, + User|ActivityActor|null $actor = null, + ?Site $site = null, + array $data = [], + array $changes = [], + ): ActivityEvent { + $registration = $this->eventTypes->get($eventType); + + $this->validatePayload($data, $changes, $registration['rules']); + + $subject = $subject instanceof ElementInterface ? ActivitySubject::fromElement($subject) : $subject; + $actor = $this->resolveActor($actor); + + if ($site !== null && $site->id === null) { + throw new InvalidArgumentException('Activity sites must be saved.'); + } + + $snapshots = [ + 'actor' => ['label' => $actor->label], + 'source' => ['label' => $registration['source']->label], + 'event' => ['label' => $this->eventTypes->label($eventType, app()->getLocale())], + ]; + + 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' => $eventType, + 'source' => $registration['source']->id, + 'actorType' => $actor->type->value, + 'actorId' => $actor->id, + 'subjectType' => $subject?->type, + 'subjectId' => $subject?->id, + 'siteId' => $site?->id, + 'payload' => [ + 'snapshots' => $snapshots, + 'changes' => $changes, + 'data' => $data === [] ? (object) [] : $data, + ], + 'occurredAt' => now(), + ]); + } + + private function resolveActor(User|ActivityActor|null $actor): ActivityActor + { + if ($actor instanceof User) { + return ActivityActor::user($actor); + } + + if ($actor !== null) { + return $actor; + } + + if (($user = currentUserElement()) !== null) { + return ActivityActor::user($user); + } + + return ActivityActor::system(); + } + + /** + * @param array $data + * @param list> $changes + * @param array $rules + */ + private function validatePayload(array $data, array $changes, array $rules): void + { + $validJson = static function (string $attribute, mixed $value, Closure $fail): void { + try { + Json::encode($value, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $fail("The $attribute must be valid JSON."); + } + }; + + Validator::make(['data' => $data, 'changes' => $changes], [ + 'data' => [ + 'array', + static function (string $attribute, mixed $value, Closure $fail): void { + if ($value !== [] && array_is_list($value)) { + $fail('The activity event data must be a JSON object.'); + } + }, + $validJson, + ], + 'changes' => ['list', $validJson], + 'changes.*' => ['array:type,id,label,old,new'], + 'changes.*.type' => ['required', 'string'], + 'changes.*.id' => ['required', 'string'], + 'changes.*.label' => ['required', 'string'], + ...Arr::prependKeysWith($rules, 'data.'), + ])->validate(); + } +} diff --git a/src/Activity/ActivityEventTypes.php b/src/Activity/ActivityEventTypes.php new file mode 100644 index 00000000000..8bdb40c36a7 --- /dev/null +++ b/src/Activity/ActivityEventTypes.php @@ -0,0 +1,263 @@ +, formatter: (Closure(ActivityEvent, string): (string|Htmlable))|null}> */ + private array $eventTypes = []; + + public function __construct() + { + $source = new ActivitySource('craft', 'Craft', 'app'); + + $this->register('craft.element.created', $source, 'Created', icon: 'plus'); + $this->register('craft.element.updated', $source, 'Updated', icon: 'pencil'); + $this->register('craft.element.status-changed', $source, 'Status changed', 'circle-half-stroke', [ + 'oldStatus' => ['required', 'string'], + 'newStatus' => ['required', 'string'], + ], formatter: fn (ActivityEvent $event, string $locale): string => t( + 'Status changed from {oldStatus} to {newStatus}.', + [ + 'oldStatus' => Str::headline($event->data['oldStatus']), + 'newStatus' => Str::headline($event->data['newStatus']), + ], + locale: $locale, + )); + $referenceRules = [ + 'type' => ['required', 'string'], + 'id' => ['required', 'string'], + 'label' => ['required', 'string'], + ]; + $this->register('craft.element.duplicated', $source, 'Duplicated', 'copy', [ + 'source' => ['required', 'array:type,id,label'], + ...Arr::prependKeysWith($referenceRules, 'source.'), + ], formatter: fn (ActivityEvent $event, string $locale): string => t( + 'Duplicated from {source}.', + ['source' => $event->data['source']['label']], + locale: $locale, + )); + $this->register('craft.element.moved', $source, 'Moved', 'arrows-up-down-left-right', [ + 'origin' => ['required', 'array:structure,parent,previousSibling'], + 'destination' => ['required', 'array:structure,parent,previousSibling'], + 'origin.structure' => ['required', 'uuid'], + 'destination.structure' => ['required', 'uuid'], + 'origin.parent' => ['nullable', 'array:type,id,label'], + 'destination.parent' => ['nullable', 'array:type,id,label'], + 'origin.previousSibling' => ['nullable', 'array:type,id,label'], + 'destination.previousSibling' => ['nullable', 'array:type,id,label'], + 'origin.parent.*' => ['string'], + 'destination.parent.*' => ['string'], + 'origin.previousSibling.*' => ['string'], + 'destination.previousSibling.*' => ['string'], + ], formatter: self::formatMovement(...)); + $this->register('craft.element.merged', $source, 'Merged', 'code-merge', [ + 'role' => ['required', 'in:merged,prevailing'], + 'other' => ['required', 'array:type,id,label'], + ...Arr::prependKeysWith($referenceRules, 'other.'), + ], formatter: self::formatMerge(...)); + $this->register('craft.draft.created', $source, 'Draft created', icon: 'scribble'); + $this->register('craft.draft.saved', $source, 'Draft saved', icon: 'floppy-disk'); + $this->register('craft.draft.applied', $source, 'Draft applied', icon: 'check'); + $this->register('craft.draft.discarded', $source, 'Draft discarded', icon: 'trash'); + $this->register('craft.revision.restored', $source, 'Revision restored', 'rotate-left', [ + 'revisionNum' => ['required', 'integer'], + ], formatter: fn (ActivityEvent $event, string $locale): string => t( + 'Restored revision {revision}.', + ['revision' => $event->data['revisionNum']], + locale: $locale, + )); + $this->register('craft.asset.file-replaced', $source, 'File replaced', 'file-arrow-up', [ + 'oldFilename' => ['required', 'string'], + 'newFilename' => ['required', 'string'], + 'oldMimeType' => ['nullable', 'string'], + 'newMimeType' => ['nullable', 'string'], + 'oldSize' => ['nullable', 'integer'], + 'newSize' => ['nullable', 'integer'], + ], formatter: self::formatFileReplacement(...)); + $this->register('craft.element.trashed', $source, 'Trashed', icon: 'trash'); + $this->register('craft.element.restored', $source, 'Restored', icon: 'rotate-left'); + $this->register('craft.element.deleted', $source, 'Deleted', icon: 'trash'); + $this->register( + 'craft.element.site-added', + $source, + 'Added to site', + 'circle-plus', + formatter: fn (ActivityEvent $event, string $locale): string => t( + 'Added to {site}.', + ['site' => $event->snapshots['site']['name']], + locale: $locale, + ), + ); + $this->register( + 'craft.element.site-removed', + $source, + 'Removed from site', + 'circle-minus', + formatter: fn (ActivityEvent $event, string $locale): string => t( + 'Removed from {site}.', + ['site' => $event->snapshots['site']['name']], + locale: $locale, + ), + ); + } + + /** + * @param array $rules + * @param (Closure(ActivityEvent, string): (string|Htmlable))|null $formatter + */ + public function register( + string $eventType, + ActivitySource $source, + string $label, + string $icon = 'wave-pulse', + array $rules = [], + ?Closure $formatter = null, + ): void { + if ($label === '') { + throw new InvalidArgumentException('Activity event types require a label.'); + } + + if ($icon === '') { + throw new InvalidArgumentException('Activity event types require an icon.'); + } + + if (isset($this->eventTypes[$eventType])) { + throw new LogicException("The [$eventType] activity event type is already registered."); + } + + $this->eventTypes[$eventType] = [ + 'source' => $source, + 'label' => $label, + 'icon' => $icon, + 'rules' => $rules, + 'formatter' => $formatter, + ]; + } + + /** @return array{source: ActivitySource, label: string, icon: string, rules: array, formatter: (Closure(ActivityEvent, string): (string|Htmlable))|null} */ + public function get(string $eventType): array + { + return $this->eventTypes[$eventType] ?? throw new InvalidArgumentException( + "The [$eventType] activity event type is not registered.", + ); + } + + /** @return array{source: ActivitySource, label: string, icon: string, rules: array, formatter: (Closure(ActivityEvent, string): (string|Htmlable))|null}|null */ + public function find(string $eventType): ?array + { + return $this->eventTypes[$eventType] ?? null; + } + + public function icon(string $eventType): string + { + return $this->find($eventType)['icon'] ?? 'wave-pulse'; + } + + public function label(string $eventType, string $locale): string + { + $registration = $this->get($eventType); + $label = t( + $registration['label'], + category: $registration['source']->translationCategory, + locale: $locale, + ); + + if ($label === '') { + throw new UnexpectedValueException('Activity event labels cannot be empty.'); + } + + return $label; + } + + private static function formatMovement(ActivityEvent $event, string $locale): string + { + return t( + 'Moved from {origin} to {destination}.', + [ + 'origin' => self::positionDescription($event->data['origin'], $locale), + 'destination' => self::positionDescription($event->data['destination'], $locale), + ], + locale: $locale, + ); + } + + private static function positionDescription(mixed $position, string $locale): 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'), + locale: $locale, + ), + $parent !== null => t( + 'the first position in {parent}', + compact('parent'), + locale: $locale, + ), + $previousSibling !== null => t( + 'the position after {previousSibling} at the top level', + compact('previousSibling'), + locale: $locale, + ), + default => t('the first position at the top level', locale: $locale), + }; + } + + private static function formatMerge(ActivityEvent $event, string $locale): string + { + $other = $event->data['other']['label']; + + return match ($event->data['role']) { + 'merged' => t('Merged into {other}.', compact('other'), locale: $locale), + 'prevailing' => t('Merged {other} into this element.', compact('other'), locale: $locale), + default => throw new UnexpectedValueException('Unknown activity merge role.'), + }; + } + + private static function formatFileReplacement(ActivityEvent $event, string $locale): string + { + $oldFile = self::fileDescription($event, 'old'); + $newFile = self::fileDescription($event, 'new'); + + return t( + 'Replaced {oldFile} with {newFile}.', + compact('oldFile', 'newFile'), + locale: $locale, + ); + } + + private static function fileDescription(ActivityEvent $event, string $version): string + { + $details = array_filter([ + $event->data["{$version}MimeType"], + isset($event->data["{$version}Size"]) ? "{$event->data["{$version}Size"]} B" : null, + ]); + $filename = $event->data["{$version}Filename"]; + + return $details === [] ? $filename : sprintf('%s (%s)', $filename, implode(', ', $details)); + } +} diff --git a/src/Activity/AssetActivity.php b/src/Activity/AssetActivity.php new file mode 100644 index 00000000000..0bcaa90cbc2 --- /dev/null +++ b/src/Activity/AssetActivity.php @@ -0,0 +1,50 @@ +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 $asset, Asset $original): void + { + Activities::record( + 'craft.asset.file-replaced', + subject: $asset, + site: Sites::getSiteById($asset->siteId), + data: [ + 'oldFilename' => $original->getFilename(), + 'newFilename' => $asset->getFilename(), + 'oldMimeType' => $original->getMimeType(), + 'newMimeType' => $asset->getMimeType(), + 'oldSize' => $original->size, + 'newSize' => $asset->size, + ], + ); + } +} diff --git a/src/Activity/Data/ActivityActor.php b/src/Activity/Data/ActivityActor.php new file mode 100644 index 00000000000..a2db89015f1 --- /dev/null +++ b/src/Activity/Data/ActivityActor.php @@ -0,0 +1,49 @@ +type === ActivityActorType::User && $this->id === null) { + throw new InvalidArgumentException('User activity actors require an ID.'); + } + + if ($this->type !== ActivityActorType::User && $this->id !== null) { + throw new InvalidArgumentException('Only user activity actors may have an ID.'); + } + + if ($this->label === '') { + throw new InvalidArgumentException('Activity actor labels cannot be empty.'); + } + } + + public static function user(User $user): self + { + if ($user->id === null) { + throw new InvalidArgumentException('Activity actors must be saved users.'); + } + + return new self(ActivityActorType::User, $user->id, $user->name); + } + + public static function system(): self + { + return new self(ActivityActorType::System, null, 'Craft CMS'); + } + + public static function anonymous(): self + { + return new self(ActivityActorType::Anonymous, null, 'Anonymous'); + } +} 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..a65271422ea --- /dev/null +++ b/src/Activity/Data/ActivitySubject.php @@ -0,0 +1,32 @@ +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.'); + } + + return new self($canonical::class, $canonical->uid, $canonical->getUiLabel()); + } +} diff --git a/src/Activity/Data/ElementWriteActivityState.php b/src/Activity/Data/ElementWriteActivityState.php new file mode 100644 index 00000000000..b08c4e1f024 --- /dev/null +++ b/src/Activity/Data/ElementWriteActivityState.php @@ -0,0 +1,24 @@ +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..dbbddb02191 --- /dev/null +++ b/src/Activity/ElementWriteActivity.php @@ -0,0 +1,144 @@ +getIsDraft() && + $element->markDraftAsSaved && + ! $element->isProvisionalDraft && + ! $element->applyingDraft && + ! $element->propagating && + ! $element->resaving && + ! $element->mergingCanonicalChanges; + $isNewDraft = false; + $draftMetadataChanged = false; + + if ($recordDraft) { + $draftState = 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(); + $isNewDraft = ! $wasDraft || (bool) $draftState->provisional || ! (bool) $draftState->saved; + $draftMetadataChanged = + (bool) $draftState->provisional !== $element->isProvisionalDraft || + $draftState->name !== $element->draftName || + $draftState->notes !== $element->draftNotes || + (bool) $draftState->saved !== $element->markDraftAsSaved; + } + + return new ElementWriteActivityState( + $recordActivity, + $recordEntry, + $originalEntry, + $originalAsset, + $recordDraft, + $isNewDraft, + $draftMetadataChanged, + ); + } + + /** @param string[] $dirtyFields */ + public function captureContentChanges( + ElementWriteActivityState $state, + ElementInterface $element, + array $dirtyFields, + ): void { + $state->draftContentChanged = $state->recordDraft && + ($element->getDirtyAttributes() !== [] || $dirtyFields !== []); + } + + /** + * @param string[] $dirtyAttributes + * @param string[] $dirtyFields + * @param array $siteElements + */ + public function record( + ElementWriteActivityState $state, + ElementInterface $element, + bool $isNewElement, + array $dirtyAttributes, + array $dirtyFields, + array $siteElements, + ): void { + if ($state->recordEntry && $element instanceof Entry) { + if ($isNewElement) { + EntryActivity::recordCreated($element); + + foreach ($siteElements as $siteElement) { + if ($siteElement instanceof Entry) { + EntryActivity::recordCreated($siteElement); + } + } + } elseif ($state->originalEntry !== null) { + EntryActivity::recordUpdated($element, $state->originalEntry, $dirtyAttributes, $dirtyFields); + } + } + + if ($element instanceof Asset && $state->originalAsset !== null) { + AssetActivity::recordReplaced($element, $state->originalAsset); + } + + if ( + $state->recordActivity && + ! $isNewElement && + ElementActivity::shouldRecordWrite($element) + ) { + $addedSiteElements = $element->isNewForSite ? [$element] : []; + + foreach ($siteElements as $siteElement) { + if (in_array($siteElement->siteId, $element->newSiteIds, true)) { + $addedSiteElements[] = $siteElement; + } + } + + foreach ($addedSiteElements as $siteElement) { + Activities::record( + 'craft.element.site-added', + subject: $siteElement, + site: $this->sites->getSiteById($siteElement->siteId), + ); + } + } + + if ($state->recordDraft && ($state->isNewDraft || $state->draftMetadataChanged || $state->draftContentChanged)) { + Activities::record( + $state->isNewDraft ? 'craft.draft.created' : 'craft.draft.saved', + subject: $element, + site: $this->sites->getSiteById($element->siteId), + ); + } + } +} diff --git a/src/Activity/EntryActivity.php b/src/Activity/EntryActivity.php new file mode 100644 index 00000000000..e89f4051117 --- /dev/null +++ b/src/Activity/EntryActivity.php @@ -0,0 +1,201 @@ + */ + 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( + 'craft.element.created', + 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; + } + + Activities::record( + $oldStatus === $newStatus ? 'craft.element.updated' : 'craft.element.status-changed', + subject: $entry, + site: Sites::getSiteById($entry->siteId), + data: $oldStatus === $newStatus ? [] : compact('oldStatus', 'newStatus'), + changes: $changes, + ); + } + + /** + * @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); + + if ($old === $new) { + continue; + } + + $oldSafe = self::normalizeSafeValue($old); + $newSafe = self::normalizeSafeValue($new); + + if ($oldSafe && $newSafe && $old === $new) { + continue; + } + + $contentChanged = true; + + if (! $oldSafe || ! $newSafe) { + continue; + } + + $changes[] = [ + 'type' => 'attribute', + 'id' => $attribute, + 'label' => t($label), + 'old' => $old, + 'new' => $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); + + if ($old === $new) { + continue; + } + + $oldSafe = self::normalizeSafeValue($old); + $newSafe = self::normalizeSafeValue($new); + + if ($oldSafe && $newSafe && $old === $new) { + continue; + } + + $contentChanged = true; + + if (! $oldSafe || ! $newSafe) { + continue; + } + + $changes[] = [ + 'type' => 'field', + 'id' => $field->layoutElement->uid, + 'label' => t($field->name, category: 'site'), + 'old' => $old, + 'new' => $new, + ]; + } + + return [$changes, $contentChanged]; + } + + 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/Enums/ActivityActorType.php b/src/Activity/Enums/ActivityActorType.php new file mode 100644 index 00000000000..848d07e8a13 --- /dev/null +++ b/src/Activity/Enums/ActivityActorType.php @@ -0,0 +1,12 @@ +>, 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 static function booted(): void + { + static::updating(fn () => throw new LogicException('Activity events cannot be updated.')); + static::deleting(fn () => throw new LogicException('Activity events cannot be deleted.')); + } + + #[\Override] + protected function casts(): array + { + return [ + 'id' => 'string', + 'actorType' => ActivityActorType::class, + '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 () => $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, ElementInterface|ActivitySubject $subject): Builder + { + $subject = $subject instanceof ElementInterface + ? ActivitySubject::fromElement($subject) + : $subject; + + 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, User|ActivityActor $actor): Builder + { + $actor = $actor instanceof User ? ActivityActor::user($actor) : $actor; + + 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 + ->orderByDesc('occurredAt') + ->orderByDesc('id'); + } +} diff --git a/src/Activity/StructuralElementActivity.php b/src/Activity/StructuralElementActivity.php new file mode 100644 index 00000000000..4a111c7c2cb --- /dev/null +++ b/src/Activity/StructuralElementActivity.php @@ -0,0 +1,109 @@ +siteId ? Sites::getSiteById($duplicate->siteId) : null, + data: ['source' => self::reference($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( + 'craft.element.moved', + subject: $element, + site: $element->siteId ? Sites::getSiteById($element->siteId) : null, + data: compact('origin', 'destination'), + ); + } + + public static function recordMerged(ActivitySubject $merged, ActivitySubject $prevailing): void + { + Activities::record( + 'craft.element.merged', + subject: $merged, + data: [ + 'role' => 'merged', + 'other' => self::subjectReference($prevailing), + ], + ); + + Activities::record( + 'craft.element.merged', + subject: $prevailing, + data: [ + 'role' => 'prevailing', + 'other' => self::subjectReference($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/Cms.php b/src/Cms.php index 1ed42dfa5e0..92c757345f5 100644 --- a/src/Cms.php +++ b/src/Cms.php @@ -30,7 +30,7 @@ public const string VERSION = '6.0.0-alpha.17'; - public const string SCHEMA_VERSION = '6.0.0.5'; + public const string SCHEMA_VERSION = '6.0.0.6'; public const string MIN_VERSION_REQUIRED = '5.9.0'; diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index 0cb5e7f02b7..3867bfe478b 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. * @@ -3106,6 +3128,7 @@ public function __construct() ->allowedFileExtensions($this->allowedFileExtensions) ->extraAllowedFileExtensions($this->extraAllowedFileExtensions) // durations + ->activityRetentionDuration($this->activityRetentionDuration) ->cacheDuration($this->cacheDuration) ->cooldownDuration($this->cooldownDuration) ->defaultTokenDuration($this->defaultTokenDuration) @@ -3172,6 +3195,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..e0538b7d0aa --- /dev/null +++ b/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php @@ -0,0 +1,39 @@ +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, ['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 0b4f0497dd1..6b1b4f1d3aa 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -206,6 +206,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); @@ -1030,6 +1044,8 @@ private function dropEmptyStarterTable(string $table): void public function createIndexes(): void { + Schema::createIndex(Table::ACTIVITYEVENTS, ['subjectType', 'subjectId', 'siteId', 'occurredAt', 'id']); + Schema::createIndex(Table::ACTIVITYEVENTS, ['occurredAt', 'id']); Schema::createIndex(Table::ANNOUNCEMENTS, ['userId', 'unread', 'dateRead', 'dateCreated']); Schema::createIndex(Table::ANNOUNCEMENTS, ['dateRead']); Schema::createIndex(Table::ASSETINDEXDATA, ['sessionId', 'volumeId']); diff --git a/src/Database/Table.php b/src/Database/Table.php index f89d037ca53..65af24b48f8 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 ANNOUNCEMENTS = 'announcements'; 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..4e7bb432110 100644 --- a/src/Element/Drafts.php +++ b/src/Element/Drafts.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Element; +use CraftCms\Cms\Activity\EntryActivity; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; @@ -15,7 +16,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; @@ -144,6 +148,14 @@ public function createDraft( }), ); + if (! $provisional) { + Activities::record( + 'craft.draft.created', + subject: $canonical, + site: Sites::getSiteById($canonical->siteId), + ); + } + DB::commit(); } catch (Throwable $e) { DB::rollBack(); @@ -252,12 +264,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 +302,16 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): $newCanonical = $draft; } + if ($entryActivity !== null && $newCanonical instanceof Entry) { + EntryActivity::recordUpdated($newCanonical, ...$entryActivity); + } elseif (! $draft->isProvisionalDraft) { + Activities::record( + 'craft.draft.applied', + subject: $newCanonical, + site: Sites::getSiteById($newCanonical->siteId), + ); + } + DB::commit(); } catch (Throwable $e) { DB::rollBack(); @@ -306,6 +341,25 @@ 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( + 'craft.draft.discarded', + subject: $canonical, + site: Sites::getSiteById($canonical->siteId), + ); + + return true; + }); + } + /** * Removes draft data from the given draft. * diff --git a/src/Element/Operations/ElementDeletions.php b/src/Element/Operations/ElementDeletions.php index c1a4de808c4..b8f292d6b32 100644 --- a/src/Element/Operations/ElementDeletions.php +++ b/src/Element/Operations/ElementDeletions.php @@ -4,6 +4,9 @@ namespace CraftCms\Cms\Element\Operations; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\ElementActivity; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -26,6 +29,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 +65,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 +189,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 +233,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 +250,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 +287,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 +307,14 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals $element->dateDeleted = now(); $element->afterDelete(); + if ($recordActivity) { + Activities::record( + $element->hardDelete ? 'craft.element.deleted' : 'craft.element.trashed', + subject: $element, + site: Sites::getSiteById($element->siteId), + ); + } + if (! $element->hardDelete) { BulkOps::trackElement($element); } @@ -282,11 +327,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 +357,90 @@ 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( + 'craft.element.site-removed', + 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 +464,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 +551,14 @@ public function restoreElements(array $elements): bool $element->dateDeleted = null; $element->deletedWithOwner = null; + if ($recordActivity[spl_object_id($element)]) { + Activities::record( + 'craft.element.restored', + subject: $element, + site: Sites::getSiteById($element->siteId), + ); + } + event(new ElementRestored($element)); } @@ -476,6 +572,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 4aca8dcf666..cb5639a4b45 100644 --- a/src/Element/Operations/ElementWrites.php +++ b/src/Element/Operations/ElementWrites.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Element\Operations; +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Contracts\NestedElementInterface; @@ -66,6 +67,7 @@ public function __construct( private ElementCaches $elementCaches, private Search $search, private Sites $sites, + private ElementWriteActivity $activity, ) {} public function saveElement( @@ -86,7 +88,7 @@ public function saveElement( $element->isNewForSite = false; try { - return $this->save( + return $this->saveInternal( $element, $runValidation, $propagate, @@ -94,6 +96,7 @@ public function saveElement( forceTouch: $forceTouch, crossSiteValidate: $crossSiteValidate ?? false, saveContent: $saveContent, + recordActivity: $duplicateOf === null, ); } finally { $element->duplicateOf = $duplicateOf; @@ -125,6 +128,7 @@ public function save( $crossSiteValidate, $saveContent, $siteSettingsRecord, + recordActivity: $element->duplicateOf === null, ); } @@ -316,11 +320,13 @@ protected function saveInternal( bool $saveContent = false, ?ElementSiteSettings &$siteSettingsRecord = null, ?bool $inheritedUpdateSearchIndex = null, + bool $recordActivity = true, ): bool { $originalScenario = $element->ruleset->getScenario(); try { $isNewElement = ! $element->id; $trackChanges = ElementHelper::shouldTrackChanges($element); + $activityState = $this->activity->capture($element, $recordActivity, $isNewElement); $propagate = $propagate && $element::isLocalized() && $this->sites->isMultiSite(); $originalPropagateAll = $element->propagateAll; @@ -377,6 +383,7 @@ protected function saveInternal( $fieldLayout = $element->getFieldLayout(); $dirtyFields = $element->getDirtyFields(); + $this->activity->captureContentChanges($activityState, $element, $dirtyFields); if (! $isNewElement && ! $element->isNewForSite) { $siteSettingsRecord = ElementSiteSettings::query() @@ -428,6 +435,7 @@ protected function saveInternal( $originalPropagateAll, $originalDateUpdated, $inheritedUpdateSearchIndex, + $activityState, &$dirtyAttributes, &$siteSettingsRecord, ) { @@ -626,6 +634,15 @@ protected function saveInternal( BulkOps::trackElement($element); } + $this->activity->record( + $activityState, + $element, + $isNewElement, + $dirtyAttributes, + $dirtyFields, + $siteElements, + ); + DB::commit(); } catch (Throwable $throwable) { DB::rollBack(); diff --git a/src/Element/Revisions.php b/src/Element/Revisions.php index a6462f90b57..81dc7f1d657 100644 --- a/src/Element/Revisions.php +++ b/src/Element/Revisions.php @@ -14,6 +14,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; @@ -189,30 +191,39 @@ 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( + 'craft.revision.restored', + subject: $newSource, + site: Sites::getSiteById($newSource->siteId), + data: ['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..80710296d97 --- /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::transaction(fn () => DB::table(Table::ACTIVITYEVENTS) + ->whereIn('id', $events->pluck('id')) + ->delete()), + ); + }, + ); + } +} diff --git a/src/GarbageCollection/GarbageCollection.php b/src/GarbageCollection/GarbageCollection.php index 7fa5cfcbae4..6eb501054fd 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; @@ -94,6 +95,7 @@ public function run(bool $force = false): void PurgePendingUsers::class, DeleteStaleAnnouncements::class, DeleteStaleElementActivity::class, + PurgeExpiredActivity::class, DeleteStaleBulkOpData::class, // elements should always go first 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..4d5aa84435b --- /dev/null +++ b/src/Support/Facades/Activities.php @@ -0,0 +1,35 @@ + query() + * @method static string|Htmlable format(ActivityEvent $event, ?string $locale = null) + * @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..b6be28e5c2b --- /dev/null +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -0,0 +1,310 @@ +source = ActivitySource::fromPlugin(TestPlugin::getInstance()); + $this->activities = app(Activities::class); + ActivitiesFacade::extend( + eventType: 'test-plugin.entry.updated', + source: $this->source, + label: 'Entry updated', + rules: ['reason' => ['required', 'string']], + ); +}); + +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( + eventType: 'test-plugin.entry.updated', + subject: $draft, + site: $site, + data: ['reason' => 'Published'], + changes: [[ + 'type' => 'field', + 'id' => 'summary', + 'label' => 'Summary', + 'old' => null, + 'new' => 'Ready', + ]], + ); + + expect($event->id)->toBeString() + ->and($event->eventType)->toBe('test-plugin.entry.updated') + ->and($event->source)->toBe('test-plugin') + ->and($event->actorType)->toBe(ActivityActorType::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)->toBe([[ + 'type' => 'field', + 'id' => 'summary', + 'label' => 'Summary', + 'old' => null, + 'new' => 'Ready', + ]]) + ->and($event->data)->toBe(['reason' => 'Published']); +}); + +it('distinguishes system anonymous and known user actors and captures impersonation', function () { + $system = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'System']); + $anonymous = $this->activities->record( + 'test-plugin.entry.updated', + actor: ActivityActor::anonymous(), + data: ['reason' => 'Public form'], + ); + + $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('test-plugin.entry.updated', data: ['reason' => 'Edited']); + + expect($system->actorType)->toBe(ActivityActorType::System) + ->and($anonymous->actorType)->toBe(ActivityActorType::Anonymous) + ->and($user->actorType)->toBe(ActivityActorType::User) + ->and($user->snapshots['impersonator'])->toBe([ + 'id' => $operator->id, + 'label' => $operator->name, + ]); +}); + +it('rejects unregistered types and invalid payload sections', function () { + expect(fn () => $this->activities->record('test.unknown.happened')) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => $this->activities->record('test-plugin.entry.updated', data: [])) + ->toThrow(ValidationException::class) + ->and(fn () => $this->activities->record( + 'test-plugin.entry.updated', + data: ['reason' => 'Edited'], + changes: [['type' => 'field', 'id' => 'summary']], + ))->toThrow(ValidationException::class); +}); + +it('rolls records back with their semantic action', function () { + DB::beginTransaction(); + + $this->activities->record('test-plugin.entry.updated', data: ['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'); + + $first = $this->activities->record('test-plugin.entry.updated', subject: $subject, data: ['reason' => 'First']); + $second = $this->activities->record('test-plugin.entry.updated', subject: $subject, data: ['reason' => 'Second']); + $this->activities->record('test-plugin.entry.updated', subject: $otherSubject, data: ['reason' => 'Other']); + + $page = $this->activities->query() + ->subject($subject) + ->eventTypes('test-plugin.entry.updated') + ->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) + ->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(); +}); + +it('blocks updates and deletions through model instances', function () { + $event = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'Recorded']); + + expect(fn () => $event->update(['source' => 'changed'])) + ->toThrow(LogicException::class) + ->and(fn () => $event->delete()) + ->toThrow(LogicException::class); +}); + +it('applies occurrence bounds', function () { + Date::setTestNow('2026-08-25 12:00:00'); + $early = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'Early']); + + Date::setTestNow('2026-08-25 12:00:02'); + $late = $this->activities->record('test-plugin.entry.updated', data: ['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('test-plugin.entry.updated', data: ['reason' => 'Neutral']); + $matching = $this->activities->record('test-plugin.entry.updated', site: $site, data: ['reason' => 'Matching']); + $this->activities->record('test-plugin.entry.updated', site: $otherSite, data: ['reason' => 'Other']); + + expect($this->activities->query()->site($site)->get()) + ->sequence( + fn ($event) => $event->id->toBe($matching->id), + fn ($event) => $event->id->toBe($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( + 'test-plugin.entry.updated', + subject: $subject, + actor: $actor, + site: Sites::getSiteById($siteModel->id), + data: ['reason' => 'Edited'], + ); + + 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(); + + expect($this->activities->query()->first()->id)->toBe($event->id); +}); + +it('rejects duplicate plugin event registrations', function () { + expect(fn () => ActivitiesFacade::extend( + eventType: 'test-plugin.entry.updated', + source: $this->source, + label: 'Entry updated', + ))->toThrow(LogicException::class); +}); + +it('formats plugin events for the requested locale as text or safe HTML', function () { + ActivitiesFacade::extend( + eventType: 'test-plugin.entry.published', + source: $this->source, + label: 'Entry published', + formatter: fn (ActivityEvent $event, string $locale): string => "$locale: {$event->data['reason']}", + rules: ['reason' => ['required', 'string']], + icon: 'bullhorn', + ); + ActivitiesFacade::extend( + eventType: 'test-plugin.entry.featured', + source: $this->source, + label: 'Entry featured', + formatter: fn (): HtmlString => new HtmlString('Entry featured'), + ); + ActivitiesFacade::extend( + eventType: 'test-plugin.entry.translated', + source: new ActivitySource('test-plugin', 'Test Plugin', 'app'), + label: 'Save', + ); + + $textEvent = ActivitiesFacade::record('test-plugin.entry.published', data: ['reason' => 'Klaar']); + $htmlEvent = ActivitiesFacade::record('test-plugin.entry.featured'); + $translatedEvent = ActivitiesFacade::record('test-plugin.entry.translated'); + + expect(ActivitiesFacade::format($textEvent, 'nl'))->toBe('nl: Klaar') + ->and(ActivitiesFacade::icon($textEvent))->toBe('bullhorn') + ->and(ActivitiesFacade::format($htmlEvent, 'en'))->toBeInstanceOf(HtmlString::class) + ->and(ActivitiesFacade::format($htmlEvent, 'en')->toHtml())->toBe('Entry featured') + ->and(ActivitiesFacade::format( + ActivitiesFacade::record('test-plugin.entry.updated', data: ['reason' => 'Klaar']), + 'nl', + ))->toBe('Entry updated') + ->and(ActivitiesFacade::format($translatedEvent, 'nl'))->toBe('Bewaren'); +}); + +it('reports formatter failures and keeps retained plugin events readable without registration', function () { + Exceptions::fake(); + ActivitiesFacade::extend( + eventType: 'test-plugin.entry.failed', + source: $this->source, + label: 'Entry formatting failed', + formatter: fn () => throw new RuntimeException('Formatter failed.'), + ); + + $event = ActivitiesFacade::record('test-plugin.entry.failed'); + $eventTypes = new ActivityEventTypes; + $htmlSanitizers = app(HtmlSanitizerManager::class); + $events = new ActivityEventRecorder($eventTypes, app(Impersonation::class)); + $retainedActivities = new Activities( + $eventTypes, + $htmlSanitizers, + $events, + ); + + expect(ActivitiesFacade::format($event))->toBe('Entry formatting failed') + ->and($retainedActivities->format($event))->toBe('Entry formatting failed') + ->and($retainedActivities->icon($event))->toBe('wave-pulse'); + Exceptions::assertReported(RuntimeException::class); +}); + +it('reports invalid formatter output and uses the captured fallback', function () { + Exceptions::fake(); + ActivitiesFacade::extend( + eventType: 'test-plugin.entry.invalid', + source: $this->source, + label: 'Entry formatter invalid', + formatter: fn (): int => 42, + ); + + $event = ActivitiesFacade::record('test-plugin.entry.invalid'); + + expect(ActivitiesFacade::format($event))->toBe('Entry formatter invalid'); + Exceptions::assertReported(UnexpectedValueException::class); +}); diff --git a/tests/Feature/Activity/AssetActivityTest.php b/tests/Feature/Activity/AssetActivityTest.php new file mode 100644 index 00000000000..795be6e46dc --- /dev/null +++ b/tests/Feature/Activity/AssetActivityTest.php @@ -0,0 +1,71 @@ +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($asset)->firstOrFail(); + + expect($event->eventType)->toBe('craft.asset.file-replaced') + ->and($event->siteId)->toBe($asset->siteId) + ->and($event->data)->toBe([ + '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 (text/plain, 3 B) with replacement.txt (text/plain, 11 B).'); + + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::saveElement($asset))->toBeFalse() + ->and(app(Activities::class)->query()->subject($asset)->get())->toBeEmpty(); +}); diff --git a/tests/Feature/Activity/ElementLifecycleActivityTest.php b/tests/Feature/Activity/ElementLifecycleActivityTest.php new file mode 100644 index 00000000000..e4e86bd4244 --- /dev/null +++ b/tests/Feature/Activity/ElementLifecycleActivityTest.php @@ -0,0 +1,280 @@ +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([ + 'craft.element.trashed', + 'craft.element.restored', + 'craft.element.deleted', + ])->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 after committing deletion activity', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + $transactionLevel = DB::transactionLevel(); + $eventTransactionLevel = null; + + Event::listen(function (ElementDeleted $event) use ($entry, &$eventTransactionLevel) { + if ($event->element === $entry) { + $eventTransactionLevel = DB::transactionLevel(); + + throw new RuntimeException('Deletion failed.'); + } + }); + + expect(fn () => Elements::deleteElement($entry)) + ->toThrow(RuntimeException::class, 'Deletion failed.'); + + expect($eventTransactionLevel)->toBe($transactionLevel) + ->and($this->activities->query()->eventTypes('craft.element.trashed')->count())->toBe(1) + ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->trashed()->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('craft.element.restored')->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($entry)->get()->reverse()->values(); + + expect($events->pluck('eventType')->all())->toBe([ + 'craft.element.site-removed', + 'craft.element.site-added', + ])->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 activity when the action 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 removal failed.'); + } + }); + + expect(fn () => Elements::deleteElementForSite($secondaryEntry)) + ->toThrow(RuntimeException::class, 'Site removal failed.'); + + expect($this->activities->query()->get())->toBeEmpty() + ->and(Entry::find()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists()) + ->toBeTrue(); +}); + +it('dispatches the post-save event after committing site addition activity', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + Elements::deleteElementForSite($secondaryEntry); + DB::table(Table::ACTIVITYEVENTS)->delete(); + $transactionLevel = DB::transactionLevel(); + $eventTransactionLevel = null; + + Event::listen(function (ElementSaved $event) use ($entry, &$eventTransactionLevel) { + if ($event->element === $entry) { + $eventTransactionLevel = DB::transactionLevel(); + + throw new RuntimeException('Save failed.'); + } + }); + + expect(fn () => Elements::saveElement($entry, updateSearchIndex: false)) + ->toThrow(RuntimeException::class, 'Save failed.'); + + expect($eventTransactionLevel)->toBe($transactionLevel) + ->and($this->activities->query()->eventTypes('craft.element.site-added')->count())->toBe(1) + ->and(Entry::find()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists())->toBeTrue(); +}); + +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..db22cd99aac --- /dev/null +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -0,0 +1,308 @@ +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($entry) + ->eventTypes('craft.element.created') + ->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($entry) + ->eventTypes('craft.element.updated') + ->firstOrFail(); + + expect($event->changes)->toBe([ + [ + 'type' => 'attribute', + 'id' => 'title', + 'label' => 'Title', + 'old' => 'Old title', + 'new' => 'New title', + ], + [ + 'type' => 'field', + 'id' => $field->layoutElement->uid, + 'label' => $field->name, + 'old' => 'Old body', + 'new' => '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($entry)->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe('craft.element.status-changed') + ->and($events->first()->data)->toBe(['oldStatus' => 'live', 'newStatus' => 'disabled']) + ->and($this->activities->format($events->first()))->toBe('Status changed from Live to Disabled.') + ->and($events->first()->changes)->toContain([ + 'type' => 'field', + 'id' => $field->layoutElement->uid, + 'label' => $field->name, + 'old' => 'Old body', + 'new' => '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($entry)->firstOrFail(); + + expect($event->eventType)->toBe('craft.element.updated') + ->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($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($entry)->get())->toBeEmpty(); + + app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + + $entry->resaving = true; + $entry->title = 'Resaved title'; + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue() + ->and($this->activities->query()->subject($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($entry)->get())->toBeEmpty() + ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one()->title) + ->toBe('Resaved 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($entry)->get(); + + expect($events->pluck('eventType')->all())->toBe([ + 'craft.draft.applied', + 'craft.draft.saved', + 'craft.draft.created', + ])->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($entry)->sole(); + + expect($event->eventType)->toBe('craft.element.updated') + ->and($event->snapshots['subject']['label'])->toBe('Updated title') + ->and($event->changes)->toContain([ + 'type' => 'attribute', + 'id' => 'title', + 'label' => 'Title', + 'old' => 'Original title', + 'new' => '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($entry)->get(); + + expect($events->pluck('eventType')->all())->toBe(['craft.draft.saved', 'craft.draft.created']); +}); + +it('ignores provisional creation and autosave but records its discard', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, name: 'Discard me'); + expect(app(Drafts::class)->discardDraft($draft))->toBeTrue(); + + $provisional = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + $provisional->title = 'Autosaved title'; + expect(Elements::saveElement($provisional, updateSearchIndex: false))->toBeTrue(); + expect(app(Drafts::class)->discardDraft($provisional))->toBeTrue(); + + expect($this->activities->query()->subject($entry)->pluck('eventType')->all())->toBe([ + 'craft.draft.discarded', + 'craft.draft.discarded', + 'craft.draft.created', + ]); +}); + +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($entry)->firstOrFail(); + + expect($event->eventType)->toBe('craft.draft.created') + ->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(); + + $draft->isProvisionalDraft = false; + expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue(); + + $event = $this->activities->query()->subject($entry)->sole(); + + expect($event->eventType)->toBe('craft.draft.created'); + + DB::table(Table::ACTIVITYEVENTS)->delete(); + expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue() + ->and($this->activities->query()->subject($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($entry)->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe('craft.revision.restored') + ->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}."); +}); diff --git a/tests/Feature/Activity/StructuralElementActivityTest.php b/tests/Feature/Activity/StructuralElementActivityTest.php new file mode 100644 index 00000000000..07b53ad4390 --- /dev/null +++ b/tests/Feature/Activity/StructuralElementActivityTest.php @@ -0,0 +1,155 @@ +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($duplicate)->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe('craft.element.duplicated') + ->and($events->first()->data['source'])->toBe([ + 'type' => $source::class, + 'id' => $source->uid, + 'label' => $source->getUiLabel(), + ]) + ->and($this->activities->format($events->first()))->toBe('Duplicated from Source entry.'); +}); + +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($duplicate) + ->eventTypes('craft.element.duplicated') + ->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('craft.element.duplicated')->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($moved) + ->eventTypes('craft.element.moved') + ->firstOrFail(); + + expect($event->data)->toBe([ + '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('does not record technical structure movement', function () { + [ + 'structure' => $structure, + 'children' => [$parent, $moved], + ] = createStructureHierarchy(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + $moved->resaving = true; + + expect(app(Structures::class)->append($structure->id, $moved, $parent))->toBeTrue() + ->and($this->activities->query()->get())->toBeEmpty(); +}); + +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(['craft.element.merged']) + ->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/GarbageCollection/Actions/PurgeExpiredActivityTest.php b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php new file mode 100644 index 00000000000..dcf6e1a0864 --- /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( + 'craft.element.created', + subject: new ActivitySubject('document', 'one', 'Document one'), + ); + + Date::setTestNow('2026-08-26 12:00:00'); + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->pluck('id')->all())->toBe([$event->id]); +}); + +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('craft.element.created', subject: $subject); + + Date::setTestNow('2026-08-26 12:00:00'); + $retained = $activities->record('craft.element.updated', subject: $subject); + + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->pluck('id')->all())->toBe([$retained->id]) + ->and(ActivityEvent::query()->find($expired->id))->toBeNull(); +}); + +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( + 'craft.element.created', + subject: new ActivitySubject('document', 'one', 'Document one'), + ); + + Date::setTestNow('2026-08-26 12:00:00'); + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->pluck('id')->all())->toBe([$event->id]); +}); diff --git a/tests/Unit/Config/GeneralConfigTest.php b/tests/Unit/Config/GeneralConfigTest.php index 1d36ffc545d..d8073dccde6 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('normalizes pageTrigger on the main config class', function () { $config = GeneralConfig::create(); From dbf697eb6264097de01e6a6fd88f62f91329799a Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 10:45:16 +0200 Subject: [PATCH 02/10] Refine activity logging architecture --- composer.json | 2 +- composer.lock | 2 +- src/Activity/Activities.php | 83 ++-- src/Activity/ActivityEventRecorder.php | 63 ++-- src/Activity/ActivityEventType.php | 90 +++++ src/Activity/ActivityEventTypes.php | 263 ------------- src/Activity/AssetActivity.php | 20 +- .../Contracts/ActivityEventTypeInterface.php | 38 ++ src/Activity/Data/ActivityActor.php | 8 +- src/Activity/Data/ActivitySubject.php | 8 +- src/Activity/Data/DraftWriteActivityState.php | 16 + .../Data/ElementWriteActivityState.php | 6 +- src/Activity/DraftActivity.php | 91 +++++ src/Activity/ElementWriteActivity.php | 56 +-- src/Activity/EntryActivity.php | 107 +++--- src/Activity/EventTypes/AssetFileReplaced.php | 67 ++++ src/Activity/EventTypes/DraftApplied.php | 14 + src/Activity/EventTypes/DraftCreated.php | 14 + src/Activity/EventTypes/DraftDiscarded.php | 14 + src/Activity/EventTypes/DraftSaved.php | 14 + src/Activity/EventTypes/ElementCreated.php | 14 + src/Activity/EventTypes/ElementDeleted.php | 14 + src/Activity/EventTypes/ElementDuplicated.php | 59 +++ src/Activity/EventTypes/ElementMerged.php | 61 +++ src/Activity/EventTypes/ElementMoved.php | 96 +++++ src/Activity/EventTypes/ElementRestored.php | 14 + src/Activity/EventTypes/ElementSiteAdded.php | 25 ++ .../EventTypes/ElementSiteRemoved.php | 25 ++ .../EventTypes/ElementStatusChanged.php | 60 +++ src/Activity/EventTypes/ElementTrashed.php | 14 + src/Activity/EventTypes/ElementUpdated.php | 14 + src/Activity/EventTypes/RevisionRestored.php | 45 +++ src/Activity/Models/ActivityEvent.php | 20 +- src/Activity/StructuralElementActivity.php | 40 +- src/Element/Drafts.php | 23 +- src/Element/Operations/ElementDeletions.php | 30 +- src/Element/Revisions.php | 8 +- .../Actions/PurgeExpiredActivity.php | 4 +- .../Elements/ElementDraftsController.php | 2 +- src/Support/Facades/Activities.php | 13 +- tests/Feature/Activity/ActivitiesTest.php | 353 +++++++++++------- tests/Feature/Activity/AssetActivityTest.php | 13 +- .../Activity/ElementLifecycleActivityTest.php | 77 ++-- tests/Feature/Activity/EntryActivityTest.php | 147 +++++--- .../StructuralElementActivityTest.php | 34 +- .../Element/ElementEagerLoaderTest.php | 6 +- .../Actions/PurgeExpiredActivityTest.php | 24 +- .../Elements/CreateElementControllerTest.php | 3 +- .../Http/Controllers/MatrixControllerTest.php | 3 +- .../ElementWrites/PropagateElementTest.php | 3 + .../ElementWrites/PropagateElementsTest.php | 2 + .../ElementWrites/ResaveElementsTest.php | 2 + 52 files changed, 1410 insertions(+), 814 deletions(-) create mode 100644 src/Activity/ActivityEventType.php delete mode 100644 src/Activity/ActivityEventTypes.php create mode 100644 src/Activity/Contracts/ActivityEventTypeInterface.php create mode 100644 src/Activity/Data/DraftWriteActivityState.php create mode 100644 src/Activity/DraftActivity.php create mode 100644 src/Activity/EventTypes/AssetFileReplaced.php create mode 100644 src/Activity/EventTypes/DraftApplied.php create mode 100644 src/Activity/EventTypes/DraftCreated.php create mode 100644 src/Activity/EventTypes/DraftDiscarded.php create mode 100644 src/Activity/EventTypes/DraftSaved.php create mode 100644 src/Activity/EventTypes/ElementCreated.php create mode 100644 src/Activity/EventTypes/ElementDeleted.php create mode 100644 src/Activity/EventTypes/ElementDuplicated.php create mode 100644 src/Activity/EventTypes/ElementMerged.php create mode 100644 src/Activity/EventTypes/ElementMoved.php create mode 100644 src/Activity/EventTypes/ElementRestored.php create mode 100644 src/Activity/EventTypes/ElementSiteAdded.php create mode 100644 src/Activity/EventTypes/ElementSiteRemoved.php create mode 100644 src/Activity/EventTypes/ElementStatusChanged.php create mode 100644 src/Activity/EventTypes/ElementTrashed.php create mode 100644 src/Activity/EventTypes/ElementUpdated.php create mode 100644 src/Activity/EventTypes/RevisionRestored.php diff --git a/composer.json b/composer.json index e4d3d0861a0..e26ea48032f 100644 --- a/composer.json +++ b/composer.json @@ -183,8 +183,8 @@ "CraftCms\\Cms\\Providers\\CraftServiceProvider" ], "aliases": { - "Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses", "Activities": "CraftCms\\Cms\\Support\\Facades\\Activities", + "Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses", "Announcements": "CraftCms\\Cms\\Support\\Facades\\Announcements", "AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer", "Assets": "CraftCms\\Cms\\Support\\Facades\\Assets", diff --git a/composer.lock b/composer.lock index 6afbae66163..89e77301d75 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": "49c8dc777f00efdf0aaa5c9dee15d86c", + "content-hash": "4a6d0b2a1583a3b73c3ef0e248ae8d93", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/src/Activity/Activities.php b/src/Activity/Activities.php index 38f37a0a5a8..e79e096bb6a 100644 --- a/src/Activity/Activities.php +++ b/src/Activity/Activities.php @@ -4,66 +4,28 @@ namespace CraftCms\Cms\Activity; -use Closure; -use CraftCms\Cms\Activity\Data\ActivityActor; -use CraftCms\Cms\Activity\Data\ActivitySource; -use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\Contracts\ActivityEventTypeInterface; use CraftCms\Cms\Activity\Models\ActivityEvent; -use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; -use CraftCms\Cms\User\Elements\User; use Illuminate\Container\Attributes\Scoped; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\HtmlString; use Throwable; -use UnexpectedValueException; + +use function CraftCms\Cms\t; #[Scoped] class Activities { public function __construct( - private readonly ActivityEventTypes $eventTypes, private readonly HtmlSanitizerManager $htmlSanitizers, private readonly ActivityEventRecorder $events, ) {} - /** - * @param array $rules - * @param (Closure(ActivityEvent, string): (string|Htmlable))|null $formatter - */ - public function extend( - string $eventType, - ActivitySource $source, - string $label, - string $icon = 'wave-pulse', - array $rules = [], - ?Closure $formatter = null, - ): void { - $this->eventTypes->register( - eventType: $eventType, - source: $source, - label: $label, - icon: $icon, - rules: $rules, - formatter: $formatter, - ); - } - - /** - * @param array $data - * @param list> $changes - */ - public function record( - string $eventType, - ElementInterface|ActivitySubject|null $subject = null, - User|ActivityActor|null $actor = null, - ?Site $site = null, - array $data = [], - array $changes = [], - ): ActivityEvent { - return $this->events->record($eventType, $subject, $actor, $site, $data, $changes); + public function record(ActivityEventTypeInterface $event): ActivityEvent + { + return $this->events->record($event); } /** @return Builder */ @@ -72,29 +34,26 @@ public function query(): Builder return ActivityEvent::query()->newestFirst(); } - public function format(ActivityEvent $event, ?string $locale = null): string|Htmlable + public function format(ActivityEvent $event): string|Htmlable { - $registration = $this->eventTypes->find($event->eventType); + $type = $event->eventType; - if ($registration === null) { + if (! is_a($type, ActivityEventTypeInterface::class, true)) { return $this->capturedLabel($event); } - $locale ??= app()->getLocale(); - try { - if ($registration['formatter'] === null) { - return $this->eventTypes->label($event->eventType, $locale); - } + $formatted = $type::format($event); - $formatted = ($registration['formatter'])($event, $locale); - - if (is_string($formatted)) { - return $formatted; + if ($formatted === null) { + return t( + $type::label(), + category: $type::source()->translationCategory, + ) ?: $this->capturedLabel($event); } - if (! $formatted instanceof Htmlable) { - throw new UnexpectedValueException('Activity event formatters must return plain text or safe HTML.'); + if (is_string($formatted)) { + return $this->htmlSanitizers->sanitize($formatted); } return new HtmlString($this->htmlSanitizers->sanitize($formatted->toHtml())); @@ -107,7 +66,13 @@ public function format(ActivityEvent $event, ?string $locale = null): string|Htm public function icon(ActivityEvent $event): string { - return $this->eventTypes->icon($event->eventType); + $type = $event->eventType; + + if (! is_a($type, ActivityEventTypeInterface::class, true)) { + return 'wave-pulse'; + } + + return $type::icon() ?: 'wave-pulse'; } private function capturedLabel(ActivityEvent $event): string diff --git a/src/Activity/ActivityEventRecorder.php b/src/Activity/ActivityEventRecorder.php index dc2f8d85e8d..ea24ef08890 100644 --- a/src/Activity/ActivityEventRecorder.php +++ b/src/Activity/ActivityEventRecorder.php @@ -5,57 +5,43 @@ namespace CraftCms\Cms\Activity; use Closure; +use CraftCms\Cms\Activity\Contracts\ActivityEventTypeInterface; use CraftCms\Cms\Activity\Data\ActivityActor; -use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Auth\Impersonation; -use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Site\Data\Site; use CraftCms\Cms\Support\Json; -use CraftCms\Cms\User\Elements\User; use Illuminate\Container\Attributes\Scoped; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; -use InvalidArgumentException; use JsonException; use function CraftCms\Cms\currentUserElement; +use function CraftCms\Cms\t; #[Scoped] class ActivityEventRecorder { public function __construct( - private readonly ActivityEventTypes $eventTypes, private readonly Impersonation $impersonation, ) {} - /** - * @param array $data - * @param list> $changes - */ - public function record( - string $eventType, - ElementInterface|ActivitySubject|null $subject = null, - User|ActivityActor|null $actor = null, - ?Site $site = null, - array $data = [], - array $changes = [], - ): ActivityEvent { - $registration = $this->eventTypes->get($eventType); - - $this->validatePayload($data, $changes, $registration['rules']); - - $subject = $subject instanceof ElementInterface ? ActivitySubject::fromElement($subject) : $subject; - $actor = $this->resolveActor($actor); - - if ($site !== null && $site->id === null) { - throw new InvalidArgumentException('Activity sites must be saved.'); - } + public function record(ActivityEventTypeInterface $event): ActivityEvent + { + $data = $event->data(); + $changes = $event->changes(); + + $this->validatePayload($data, $changes, $event::rules()); + + $subject = $event->subject(); + $actor = $this->resolveActor($event->actor()); + $site = $event->site(); + + $source = $event::source(); $snapshots = [ 'actor' => ['label' => $actor->label], - 'source' => ['label' => $registration['source']->label], - 'event' => ['label' => $this->eventTypes->label($eventType, app()->getLocale())], + 'source' => ['label' => $source->label], + 'event' => ['label' => t($event::label(), category: $source->translationCategory)], ]; if ($subject !== null) { @@ -71,8 +57,8 @@ public function record( } return ActivityEvent::query()->create([ - 'eventType' => $eventType, - 'source' => $registration['source']->id, + 'eventType' => $event::class, + 'source' => $source->id, 'actorType' => $actor->type->value, 'actorId' => $actor->id, 'subjectType' => $subject?->type, @@ -87,12 +73,8 @@ public function record( ]); } - private function resolveActor(User|ActivityActor|null $actor): ActivityActor + private function resolveActor(?ActivityActor $actor): ActivityActor { - if ($actor instanceof User) { - return ActivityActor::user($actor); - } - if ($actor !== null) { return $actor; } @@ -101,7 +83,12 @@ private function resolveActor(User|ActivityActor|null $actor): ActivityActor return ActivityActor::user($user); } - return ActivityActor::system(); + $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..fa9e91c95d1 --- /dev/null +++ b/src/Activity/ActivityEventType.php @@ -0,0 +1,90 @@ +> $changes + */ + public function __construct( + private readonly ElementInterface|ActivitySubject|null $subject = null, + private readonly User|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 User + ? 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 rules(): array + { + return []; + } + + public static function format(ActivityEvent $event): string|Htmlable|null + { + return null; + } +} diff --git a/src/Activity/ActivityEventTypes.php b/src/Activity/ActivityEventTypes.php deleted file mode 100644 index 8bdb40c36a7..00000000000 --- a/src/Activity/ActivityEventTypes.php +++ /dev/null @@ -1,263 +0,0 @@ -, formatter: (Closure(ActivityEvent, string): (string|Htmlable))|null}> */ - private array $eventTypes = []; - - public function __construct() - { - $source = new ActivitySource('craft', 'Craft', 'app'); - - $this->register('craft.element.created', $source, 'Created', icon: 'plus'); - $this->register('craft.element.updated', $source, 'Updated', icon: 'pencil'); - $this->register('craft.element.status-changed', $source, 'Status changed', 'circle-half-stroke', [ - 'oldStatus' => ['required', 'string'], - 'newStatus' => ['required', 'string'], - ], formatter: fn (ActivityEvent $event, string $locale): string => t( - 'Status changed from {oldStatus} to {newStatus}.', - [ - 'oldStatus' => Str::headline($event->data['oldStatus']), - 'newStatus' => Str::headline($event->data['newStatus']), - ], - locale: $locale, - )); - $referenceRules = [ - 'type' => ['required', 'string'], - 'id' => ['required', 'string'], - 'label' => ['required', 'string'], - ]; - $this->register('craft.element.duplicated', $source, 'Duplicated', 'copy', [ - 'source' => ['required', 'array:type,id,label'], - ...Arr::prependKeysWith($referenceRules, 'source.'), - ], formatter: fn (ActivityEvent $event, string $locale): string => t( - 'Duplicated from {source}.', - ['source' => $event->data['source']['label']], - locale: $locale, - )); - $this->register('craft.element.moved', $source, 'Moved', 'arrows-up-down-left-right', [ - 'origin' => ['required', 'array:structure,parent,previousSibling'], - 'destination' => ['required', 'array:structure,parent,previousSibling'], - 'origin.structure' => ['required', 'uuid'], - 'destination.structure' => ['required', 'uuid'], - 'origin.parent' => ['nullable', 'array:type,id,label'], - 'destination.parent' => ['nullable', 'array:type,id,label'], - 'origin.previousSibling' => ['nullable', 'array:type,id,label'], - 'destination.previousSibling' => ['nullable', 'array:type,id,label'], - 'origin.parent.*' => ['string'], - 'destination.parent.*' => ['string'], - 'origin.previousSibling.*' => ['string'], - 'destination.previousSibling.*' => ['string'], - ], formatter: self::formatMovement(...)); - $this->register('craft.element.merged', $source, 'Merged', 'code-merge', [ - 'role' => ['required', 'in:merged,prevailing'], - 'other' => ['required', 'array:type,id,label'], - ...Arr::prependKeysWith($referenceRules, 'other.'), - ], formatter: self::formatMerge(...)); - $this->register('craft.draft.created', $source, 'Draft created', icon: 'scribble'); - $this->register('craft.draft.saved', $source, 'Draft saved', icon: 'floppy-disk'); - $this->register('craft.draft.applied', $source, 'Draft applied', icon: 'check'); - $this->register('craft.draft.discarded', $source, 'Draft discarded', icon: 'trash'); - $this->register('craft.revision.restored', $source, 'Revision restored', 'rotate-left', [ - 'revisionNum' => ['required', 'integer'], - ], formatter: fn (ActivityEvent $event, string $locale): string => t( - 'Restored revision {revision}.', - ['revision' => $event->data['revisionNum']], - locale: $locale, - )); - $this->register('craft.asset.file-replaced', $source, 'File replaced', 'file-arrow-up', [ - 'oldFilename' => ['required', 'string'], - 'newFilename' => ['required', 'string'], - 'oldMimeType' => ['nullable', 'string'], - 'newMimeType' => ['nullable', 'string'], - 'oldSize' => ['nullable', 'integer'], - 'newSize' => ['nullable', 'integer'], - ], formatter: self::formatFileReplacement(...)); - $this->register('craft.element.trashed', $source, 'Trashed', icon: 'trash'); - $this->register('craft.element.restored', $source, 'Restored', icon: 'rotate-left'); - $this->register('craft.element.deleted', $source, 'Deleted', icon: 'trash'); - $this->register( - 'craft.element.site-added', - $source, - 'Added to site', - 'circle-plus', - formatter: fn (ActivityEvent $event, string $locale): string => t( - 'Added to {site}.', - ['site' => $event->snapshots['site']['name']], - locale: $locale, - ), - ); - $this->register( - 'craft.element.site-removed', - $source, - 'Removed from site', - 'circle-minus', - formatter: fn (ActivityEvent $event, string $locale): string => t( - 'Removed from {site}.', - ['site' => $event->snapshots['site']['name']], - locale: $locale, - ), - ); - } - - /** - * @param array $rules - * @param (Closure(ActivityEvent, string): (string|Htmlable))|null $formatter - */ - public function register( - string $eventType, - ActivitySource $source, - string $label, - string $icon = 'wave-pulse', - array $rules = [], - ?Closure $formatter = null, - ): void { - if ($label === '') { - throw new InvalidArgumentException('Activity event types require a label.'); - } - - if ($icon === '') { - throw new InvalidArgumentException('Activity event types require an icon.'); - } - - if (isset($this->eventTypes[$eventType])) { - throw new LogicException("The [$eventType] activity event type is already registered."); - } - - $this->eventTypes[$eventType] = [ - 'source' => $source, - 'label' => $label, - 'icon' => $icon, - 'rules' => $rules, - 'formatter' => $formatter, - ]; - } - - /** @return array{source: ActivitySource, label: string, icon: string, rules: array, formatter: (Closure(ActivityEvent, string): (string|Htmlable))|null} */ - public function get(string $eventType): array - { - return $this->eventTypes[$eventType] ?? throw new InvalidArgumentException( - "The [$eventType] activity event type is not registered.", - ); - } - - /** @return array{source: ActivitySource, label: string, icon: string, rules: array, formatter: (Closure(ActivityEvent, string): (string|Htmlable))|null}|null */ - public function find(string $eventType): ?array - { - return $this->eventTypes[$eventType] ?? null; - } - - public function icon(string $eventType): string - { - return $this->find($eventType)['icon'] ?? 'wave-pulse'; - } - - public function label(string $eventType, string $locale): string - { - $registration = $this->get($eventType); - $label = t( - $registration['label'], - category: $registration['source']->translationCategory, - locale: $locale, - ); - - if ($label === '') { - throw new UnexpectedValueException('Activity event labels cannot be empty.'); - } - - return $label; - } - - private static function formatMovement(ActivityEvent $event, string $locale): string - { - return t( - 'Moved from {origin} to {destination}.', - [ - 'origin' => self::positionDescription($event->data['origin'], $locale), - 'destination' => self::positionDescription($event->data['destination'], $locale), - ], - locale: $locale, - ); - } - - private static function positionDescription(mixed $position, string $locale): 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'), - locale: $locale, - ), - $parent !== null => t( - 'the first position in {parent}', - compact('parent'), - locale: $locale, - ), - $previousSibling !== null => t( - 'the position after {previousSibling} at the top level', - compact('previousSibling'), - locale: $locale, - ), - default => t('the first position at the top level', locale: $locale), - }; - } - - private static function formatMerge(ActivityEvent $event, string $locale): string - { - $other = $event->data['other']['label']; - - return match ($event->data['role']) { - 'merged' => t('Merged into {other}.', compact('other'), locale: $locale), - 'prevailing' => t('Merged {other} into this element.', compact('other'), locale: $locale), - default => throw new UnexpectedValueException('Unknown activity merge role.'), - }; - } - - private static function formatFileReplacement(ActivityEvent $event, string $locale): string - { - $oldFile = self::fileDescription($event, 'old'); - $newFile = self::fileDescription($event, 'new'); - - return t( - 'Replaced {oldFile} with {newFile}.', - compact('oldFile', 'newFile'), - locale: $locale, - ); - } - - private static function fileDescription(ActivityEvent $event, string $version): string - { - $details = array_filter([ - $event->data["{$version}MimeType"], - isset($event->data["{$version}Size"]) ? "{$event->data["{$version}Size"]} B" : null, - ]); - $filename = $event->data["{$version}Filename"]; - - return $details === [] ? $filename : sprintf('%s (%s)', $filename, implode(', ', $details)); - } -} diff --git a/src/Activity/AssetActivity.php b/src/Activity/AssetActivity.php index 0bcaa90cbc2..7adceff528e 100644 --- a/src/Activity/AssetActivity.php +++ b/src/Activity/AssetActivity.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Activity; +use CraftCms\Cms\Activity\EventTypes\AssetFileReplaced; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Validation\AssetRules; use CraftCms\Cms\Support\Facades\Activities; @@ -33,18 +34,15 @@ public static function original(Asset $asset): Asset public static function recordReplaced(Asset $asset, Asset $original): void { - Activities::record( - 'craft.asset.file-replaced', + Activities::record(new AssetFileReplaced( subject: $asset, site: Sites::getSiteById($asset->siteId), - data: [ - 'oldFilename' => $original->getFilename(), - 'newFilename' => $asset->getFilename(), - 'oldMimeType' => $original->getMimeType(), - 'newMimeType' => $asset->getMimeType(), - 'oldSize' => $original->size, - 'newSize' => $asset->size, - ], - ); + oldFilename: $original->getFilename(), + newFilename: $asset->getFilename(), + oldMimeType: $original->getMimeType(), + newMimeType: $asset->getMimeType(), + oldSize: $original->size, + newSize: $asset->size, + )); } } diff --git a/src/Activity/Contracts/ActivityEventTypeInterface.php b/src/Activity/Contracts/ActivityEventTypeInterface.php new file mode 100644 index 00000000000..4cbf5ea2951 --- /dev/null +++ b/src/Activity/Contracts/ActivityEventTypeInterface.php @@ -0,0 +1,38 @@ + */ + 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; + + /** @return array */ + public static function rules(): array; + + public static function format(ActivityEvent $event): string|Htmlable|null; +} diff --git a/src/Activity/Data/ActivityActor.php b/src/Activity/Data/ActivityActor.php index a2db89015f1..2100d48bd8a 100644 --- a/src/Activity/Data/ActivityActor.php +++ b/src/Activity/Data/ActivityActor.php @@ -12,8 +12,8 @@ { public function __construct( public ActivityActorType $type, - public ?int $id, public string $label, + public ?int $id = null, ) { if ($this->type === ActivityActorType::User && $this->id === null) { throw new InvalidArgumentException('User activity actors require an ID.'); @@ -34,16 +34,16 @@ public static function user(User $user): self throw new InvalidArgumentException('Activity actors must be saved users.'); } - return new self(ActivityActorType::User, $user->id, $user->name); + return new self(ActivityActorType::User, $user->name, $user->id); } public static function system(): self { - return new self(ActivityActorType::System, null, 'Craft CMS'); + return new self(ActivityActorType::System, 'Craft CMS'); } public static function anonymous(): self { - return new self(ActivityActorType::Anonymous, null, 'Anonymous'); + return new self(ActivityActorType::Anonymous, 'Anonymous'); } } diff --git a/src/Activity/Data/ActivitySubject.php b/src/Activity/Data/ActivitySubject.php index a65271422ea..4c5264bcd82 100644 --- a/src/Activity/Data/ActivitySubject.php +++ b/src/Activity/Data/ActivitySubject.php @@ -27,6 +27,12 @@ public static function fromElement(ElementInterface $element): self throw new InvalidArgumentException('Activity subjects must be saved elements.'); } - return new self($canonical::class, $canonical->uid, $canonical->getUiLabel()); + $label = $canonical->getUiLabel(); + + return new self( + $canonical::class, + $canonical->uid, + $label !== '' ? $label : sprintf('%s %s', $canonical::displayName(), $canonical->id), + ); } } diff --git a/src/Activity/Data/DraftWriteActivityState.php b/src/Activity/Data/DraftWriteActivityState.php new file mode 100644 index 00000000000..bb3ed851693 --- /dev/null +++ b/src/Activity/Data/DraftWriteActivityState.php @@ -0,0 +1,16 @@ +getIsDraft() || + ! $element->markDraftAsSaved || + $element->isProvisionalDraft || + $element->applyingDraft || + $element->propagating || + $element->resaving || + $element->mergingCanonicalChanges + ) { + return null; + } + + $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(); + + return new DraftWriteActivityState( + 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, + ); + } + + /** @param string[] $dirtyFields */ + public function captureContentChanges( + ?DraftWriteActivityState $state, + ElementInterface $element, + array $dirtyFields, + ): void { + if ($state !== null) { + $state->contentChanged = $element->getDirtyAttributes() !== [] || $dirtyFields !== []; + } + } + + public function recordWrite(?DraftWriteActivityState $state, ElementInterface $element): void + { + if ($state !== null && ($state->isNew || $state->metadataChanged || $state->contentChanged)) { + $event = $state->isNew + ? new DraftCreated(subject: $element, site: $this->sites->getSiteById($element->siteId)) + : new DraftSaved(subject: $element, site: $this->sites->getSiteById($element->siteId)); + + Activities::record($event); + } + } + + /** + * @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/ElementWriteActivity.php b/src/Activity/ElementWriteActivity.php index dbbddb02191..94ceb04a21e 100644 --- a/src/Activity/ElementWriteActivity.php +++ b/src/Activity/ElementWriteActivity.php @@ -5,21 +5,22 @@ namespace CraftCms\Cms\Activity; use CraftCms\Cms\Activity\Data\ElementWriteActivityState; +use CraftCms\Cms\Activity\EventTypes\ElementSiteAdded; use CraftCms\Cms\Asset\Elements\Asset; -use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Site\Sites; use CraftCms\Cms\Support\Facades\Activities; use Illuminate\Container\Attributes\Singleton; -use Illuminate\Support\Facades\DB; -use LogicException; /** @internal */ #[Singleton] readonly class ElementWriteActivity { - public function __construct(private Sites $sites) {} + public function __construct( + private Sites $sites, + private DraftActivity $drafts, + ) {} public function capture( ElementInterface $element, @@ -31,42 +32,13 @@ public function capture( $originalAsset = $element instanceof Asset && AssetActivity::shouldRecord($element, $recordActivity) ? AssetActivity::original($element) : null; - $recordDraft = $recordActivity && - $element->getIsDraft() && - $element->markDraftAsSaved && - ! $element->isProvisionalDraft && - ! $element->applyingDraft && - ! $element->propagating && - ! $element->resaving && - ! $element->mergingCanonicalChanges; - $isNewDraft = false; - $draftMetadataChanged = false; - - if ($recordDraft) { - $draftState = 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(); - $isNewDraft = ! $wasDraft || (bool) $draftState->provisional || ! (bool) $draftState->saved; - $draftMetadataChanged = - (bool) $draftState->provisional !== $element->isProvisionalDraft || - $draftState->name !== $element->draftName || - $draftState->notes !== $element->draftNotes || - (bool) $draftState->saved !== $element->markDraftAsSaved; - } return new ElementWriteActivityState( $recordActivity, $recordEntry, $originalEntry, $originalAsset, - $recordDraft, - $isNewDraft, - $draftMetadataChanged, + $recordActivity ? $this->drafts->capture($element) : null, ); } @@ -76,8 +48,7 @@ public function captureContentChanges( ElementInterface $element, array $dirtyFields, ): void { - $state->draftContentChanged = $state->recordDraft && - ($element->getDirtyAttributes() !== [] || $dirtyFields !== []); + $this->drafts->captureContentChanges($state->draft, $element, $dirtyFields); } /** @@ -125,20 +96,13 @@ public function record( } foreach ($addedSiteElements as $siteElement) { - Activities::record( - 'craft.element.site-added', + Activities::record(new ElementSiteAdded( subject: $siteElement, site: $this->sites->getSiteById($siteElement->siteId), - ); + )); } } - if ($state->recordDraft && ($state->isNewDraft || $state->draftMetadataChanged || $state->draftContentChanged)) { - Activities::record( - $state->isNewDraft ? 'craft.draft.created' : 'craft.draft.saved', - subject: $element, - site: $this->sites->getSiteById($element->siteId), - ); - } + $this->drafts->recordWrite($state->draft, $element); } } diff --git a/src/Activity/EntryActivity.php b/src/Activity/EntryActivity.php index e89f4051117..57f953c9af1 100644 --- a/src/Activity/EntryActivity.php +++ b/src/Activity/EntryActivity.php @@ -5,6 +5,9 @@ namespace CraftCms\Cms\Activity; use BackedEnum; +use CraftCms\Cms\Activity\EventTypes\ElementCreated; +use CraftCms\Cms\Activity\EventTypes\ElementStatusChanged; +use CraftCms\Cms\Activity\EventTypes\ElementUpdated; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Support\Facades\Activities; use CraftCms\Cms\Support\Facades\Sites; @@ -42,11 +45,10 @@ public static function original(Entry $entry): ?Entry public static function recordCreated(Entry $entry): void { - Activities::record( - 'craft.element.created', + Activities::record(new ElementCreated( subject: $entry, site: Sites::getSiteById($entry->siteId), - ); + )); } /** @@ -67,13 +69,18 @@ public static function recordUpdated( return; } - Activities::record( - $oldStatus === $newStatus ? 'craft.element.updated' : 'craft.element.status-changed', - subject: $entry, - site: Sites::getSiteById($entry->siteId), - data: $oldStatus === $newStatus ? [] : compact('oldStatus', 'newStatus'), - changes: $changes, - ); + $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); } /** @@ -97,31 +104,7 @@ private static function changes( $old = self::attributeValue($original, $attribute); $new = self::attributeValue($entry, $attribute); - - if ($old === $new) { - continue; - } - - $oldSafe = self::normalizeSafeValue($old); - $newSafe = self::normalizeSafeValue($new); - - if ($oldSafe && $newSafe && $old === $new) { - continue; - } - - $contentChanged = true; - - if (! $oldSafe || ! $newSafe) { - continue; - } - - $changes[] = [ - 'type' => 'attribute', - 'id' => $attribute, - 'label' => t($label), - 'old' => $old, - 'new' => $new, - ]; + self::appendChange($changes, $contentChanged, 'attribute', $attribute, t($label), $old, $new); } foreach ($entry->getFieldLayout()?->getCustomFields() ?? [] as $field) { @@ -131,34 +114,48 @@ private static function changes( $old = $field->serializeValue($original->getFieldValue($field->handle), $original); $new = $field->serializeValue($entry->getFieldValue($field->handle), $entry); + self::appendChange( + $changes, + $contentChanged, + 'field', + $field->layoutElement->uid, + t($field->name, category: 'site'), + $old, + $new, + ); + } - if ($old === $new) { - continue; - } + return [$changes, $contentChanged]; + } - $oldSafe = self::normalizeSafeValue($old); - $newSafe = self::normalizeSafeValue($new); + /** @param list $changes */ + private static function appendChange( + array &$changes, + bool &$contentChanged, + string $type, + string $id, + string $label, + mixed $old, + mixed $new, + ): void { + if ($old === $new) { + return; + } - if ($oldSafe && $newSafe && $old === $new) { - continue; - } + $oldSafe = self::normalizeSafeValue($old); + $newSafe = self::normalizeSafeValue($new); - $contentChanged = true; + if ($oldSafe && $newSafe && $old === $new) { + return; + } - if (! $oldSafe || ! $newSafe) { - continue; - } + $contentChanged = true; - $changes[] = [ - 'type' => 'field', - 'id' => $field->layoutElement->uid, - 'label' => t($field->name, category: 'site'), - 'old' => $old, - 'new' => $new, - ]; + if (! $oldSafe || ! $newSafe) { + return; } - return [$changes, $contentChanged]; + $changes[] = compact('type', 'id', 'label', 'old', 'new'); } private static function attributeValue(Entry $entry, string $attribute): mixed diff --git a/src/Activity/EventTypes/AssetFileReplaced.php b/src/Activity/EventTypes/AssetFileReplaced.php new file mode 100644 index 00000000000..c0c9470c66f --- /dev/null +++ b/src/Activity/EventTypes/AssetFileReplaced.php @@ -0,0 +1,67 @@ + $this->oldFilename, + 'newFilename' => $this->newFilename, + 'oldMimeType' => $this->oldMimeType, + 'newMimeType' => $this->newMimeType, + 'oldSize' => $this->oldSize, + 'newSize' => $this->newSize, + ]; + } + + public static function rules(): array + { + return [ + 'oldFilename' => ['required', 'string'], + 'newFilename' => ['required', 'string'], + 'oldMimeType' => ['nullable', 'string'], + 'newMimeType' => ['nullable', 'string'], + 'oldSize' => ['nullable', 'integer'], + 'newSize' => ['nullable', 'integer'], + ]; + } + + 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 rules(): array + { + return [ + 'source' => ['required', 'array:type,id,label'], + 'source.type' => ['required', 'string'], + 'source.id' => ['required', 'string'], + 'source.label' => ['required', 'string'], + ]; + } + + 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..c54bce7a045 --- /dev/null +++ b/src/Activity/EventTypes/ElementMerged.php @@ -0,0 +1,61 @@ + $this->role, + 'other' => [ + 'type' => $this->other->type, + 'id' => $this->other->id, + 'label' => $this->other->label, + ], + ]; + } + + public static function rules(): array + { + return [ + 'role' => ['required', 'in:merged,prevailing'], + 'other' => ['required', 'array:type,id,label'], + 'other.type' => ['required', 'string'], + 'other.id' => ['required', 'string'], + 'other.label' => ['required', 'string'], + ]; + } + + 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..4e705d1b579 --- /dev/null +++ b/src/Activity/EventTypes/ElementMoved.php @@ -0,0 +1,96 @@ + $this->origin, + 'destination' => $this->destination, + ]; + } + + public static function rules(): array + { + return [ + 'origin' => ['required', 'array:structure,parent,previousSibling'], + 'origin.structure' => ['required', 'uuid'], + 'origin.parent' => ['nullable', 'array:type,id,label'], + 'origin.previousSibling' => ['nullable', 'array:type,id,label'], + 'origin.parent.*' => ['string'], + 'origin.previousSibling.*' => ['string'], + 'destination' => ['required', 'array:structure,parent,previousSibling'], + 'destination.structure' => ['required', 'uuid'], + 'destination.parent' => ['nullable', 'array:type,id,label'], + 'destination.previousSibling' => ['nullable', 'array:type,id,label'], + 'destination.parent.*' => ['string'], + 'destination.previousSibling.*' => ['string'], + ]; + } + + 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..7dc4ce896fd --- /dev/null +++ b/src/Activity/EventTypes/ElementStatusChanged.php @@ -0,0 +1,60 @@ +> $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 rules(): array + { + return [ + 'oldStatus' => ['required', 'string'], + 'newStatus' => ['required', 'string'], + ]; + } + + 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 rules(): array + { + return ['revisionNum' => ['required', 'integer']]; + } + + 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 index af391b7c262..032a27cbe70 100644 --- a/src/Activity/Models/ActivityEvent.php +++ b/src/Activity/Models/ActivityEvent.php @@ -9,16 +9,13 @@ use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Enums\ActivityActorType; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Shared\BaseModel; use CraftCms\Cms\Site\Data\Site; -use CraftCms\Cms\User\Elements\User; use DateTimeInterface; use Illuminate\Database\Eloquent\Attributes\Scope; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use InvalidArgumentException; -use LogicException; /** * @property string $id @@ -43,13 +40,6 @@ class ActivityEvent extends BaseModel #[\Override] public $timestamps = false; - #[\Override] - protected static function booted(): void - { - static::updating(fn () => throw new LogicException('Activity events cannot be updated.')); - static::deleting(fn () => throw new LogicException('Activity events cannot be deleted.')); - } - #[\Override] protected function casts(): array { @@ -86,12 +76,8 @@ protected function data(): Attribute * @return Builder */ #[Scope] - protected function subject(Builder $query, ElementInterface|ActivitySubject $subject): Builder + protected function subject(Builder $query, ActivitySubject $subject): Builder { - $subject = $subject instanceof ElementInterface - ? ActivitySubject::fromElement($subject) - : $subject; - return $query ->where('subjectType', $subject->type) ->where('subjectId', $subject->id); @@ -137,10 +123,8 @@ protected function eventTypes(Builder $query, string|array $eventTypes): Builder * @return Builder */ #[Scope] - protected function actor(Builder $query, User|ActivityActor $actor): Builder + protected function actor(Builder $query, ActivityActor $actor): Builder { - $actor = $actor instanceof User ? ActivityActor::user($actor) : $actor; - return $query ->where('actorType', $actor->type) ->where('actorId', $actor->id); diff --git a/src/Activity/StructuralElementActivity.php b/src/Activity/StructuralElementActivity.php index 4a111c7c2cb..c766491c444 100644 --- a/src/Activity/StructuralElementActivity.php +++ b/src/Activity/StructuralElementActivity.php @@ -5,6 +5,9 @@ namespace CraftCms\Cms\Activity; use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\EventTypes\ElementDuplicated; +use CraftCms\Cms\Activity\EventTypes\ElementMerged; +use CraftCms\Cms\Activity\EventTypes\ElementMoved; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Support\Facades\Activities; use CraftCms\Cms\Support\Facades\Sites; @@ -28,12 +31,11 @@ public static function recordDuplicated(ElementInterface $source, ElementInterfa return; } - Activities::record( - 'craft.element.duplicated', + Activities::record(new ElementDuplicated( subject: $duplicate, site: $duplicate->siteId ? Sites::getSiteById($duplicate->siteId) : null, - data: ['source' => self::reference($source)], - ); + source: $source, + )); } /** @@ -46,33 +48,27 @@ public static function recordMoved(ElementInterface $element, array $origin, arr return; } - Activities::record( - 'craft.element.moved', + Activities::record(new ElementMoved( subject: $element, site: $element->siteId ? Sites::getSiteById($element->siteId) : null, - data: compact('origin', 'destination'), - ); + origin: $origin, + destination: $destination, + )); } public static function recordMerged(ActivitySubject $merged, ActivitySubject $prevailing): void { - Activities::record( - 'craft.element.merged', + Activities::record(new ElementMerged( subject: $merged, - data: [ - 'role' => 'merged', - 'other' => self::subjectReference($prevailing), - ], - ); + role: 'merged', + other: $prevailing, + )); - Activities::record( - 'craft.element.merged', + Activities::record(new ElementMerged( subject: $prevailing, - data: [ - 'role' => 'prevailing', - 'other' => self::subjectReference($merged), - ], - ); + 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} */ diff --git a/src/Element/Drafts.php b/src/Element/Drafts.php index 4e7bb432110..7662e7bbee8 100644 --- a/src/Element/Drafts.php +++ b/src/Element/Drafts.php @@ -4,7 +4,10 @@ namespace CraftCms\Cms\Element; -use CraftCms\Cms\Activity\EntryActivity; +use CraftCms\Cms\Activity\DraftActivity; +use CraftCms\Cms\Activity\EventTypes\DraftApplied as DraftAppliedActivityEvent; +use CraftCms\Cms\Activity\EventTypes\DraftCreated as DraftCreatedActivityEvent; +use CraftCms\Cms\Activity\EventTypes\DraftDiscarded as DraftDiscardedActivityEvent; use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; @@ -43,6 +46,7 @@ public function __construct( private Elements $elements, + private DraftActivity $activity, ) {} /** @@ -149,11 +153,10 @@ public function createDraft( ); if (! $provisional) { - Activities::record( - 'craft.draft.created', + Activities::record(new DraftCreatedActivityEvent( subject: $canonical, site: Sites::getSiteById($canonical->siteId), - ); + )); } DB::commit(); @@ -303,13 +306,12 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): } if ($entryActivity !== null && $newCanonical instanceof Entry) { - EntryActivity::recordUpdated($newCanonical, ...$entryActivity); + $this->activity->recordProvisionalApplied($newCanonical, ...$entryActivity); } elseif (! $draft->isProvisionalDraft) { - Activities::record( - 'craft.draft.applied', + Activities::record(new DraftAppliedActivityEvent( subject: $newCanonical, site: Sites::getSiteById($newCanonical->siteId), - ); + )); } DB::commit(); @@ -350,11 +352,10 @@ public function discardDraft(ElementInterface $draft): bool return false; } - Activities::record( - 'craft.draft.discarded', + Activities::record(new DraftDiscardedActivityEvent( subject: $canonical, site: Sites::getSiteById($canonical->siteId), - ); + )); return true; }); diff --git a/src/Element/Operations/ElementDeletions.php b/src/Element/Operations/ElementDeletions.php index b8f292d6b32..43a081cd14b 100644 --- a/src/Element/Operations/ElementDeletions.php +++ b/src/Element/Operations/ElementDeletions.php @@ -6,6 +6,10 @@ 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; @@ -308,11 +312,17 @@ public function deleteElement( $element->afterDelete(); if ($recordActivity) { - Activities::record( - $element->hardDelete ? 'craft.element.deleted' : 'craft.element.trashed', - subject: $element, - site: Sites::getSiteById($element->siteId), - ); + $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) { @@ -424,11 +434,10 @@ public function deleteElementsForSite(array $elements): void $element->afterDeleteForSite(); if ($this->shouldRecordLifecycleActivity($element)) { - Activities::record( - 'craft.element.site-removed', + Activities::record(new ElementSiteRemoved( subject: $element, site: Sites::getSiteById($element->siteId), - ); + )); } } @@ -552,11 +561,10 @@ public function restoreElements(array $elements): bool $element->deletedWithOwner = null; if ($recordActivity[spl_object_id($element)]) { - Activities::record( - 'craft.element.restored', + Activities::record(new ElementRestoredActivity( subject: $element, site: Sites::getSiteById($element->siteId), - ); + )); } event(new ElementRestored($element)); diff --git a/src/Element/Revisions.php b/src/Element/Revisions.php index 81dc7f1d657..c2c22be29c9 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; @@ -208,12 +209,11 @@ public function revertToRevision(ElementInterface $revision, int $creatorId): El 'revisionNotes' => t('Reverted content from revision {num}.', ['num' => $revision->revisionNum]), ]); - Activities::record( - 'craft.revision.restored', + Activities::record(new RevisionRestored( subject: $newSource, site: Sites::getSiteById($newSource->siteId), - data: ['revisionNum' => $revision->revisionNum], - ); + revisionNum: $revision->revisionNum, + )); event(new RevertedToRevision( canonical: $canonical, diff --git a/src/GarbageCollection/Actions/PurgeExpiredActivity.php b/src/GarbageCollection/Actions/PurgeExpiredActivity.php index 80710296d97..b76d978bfc0 100644 --- a/src/GarbageCollection/Actions/PurgeExpiredActivity.php +++ b/src/GarbageCollection/Actions/PurgeExpiredActivity.php @@ -25,9 +25,9 @@ function () { ->orderBy('id') ->chunkById( $this->garbageCollection::CHUNK_SIZE, - fn (Collection $events) => DB::transaction(fn () => DB::table(Table::ACTIVITYEVENTS) + fn (Collection $events) => DB::table(Table::ACTIVITYEVENTS) ->whereIn('id', $events->pluck('id')) - ->delete()), + ->delete(), ); }, ); 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/Support/Facades/Activities.php b/src/Support/Facades/Activities.php index 4d5aa84435b..02d963a625b 100644 --- a/src/Support/Facades/Activities.php +++ b/src/Support/Facades/Activities.php @@ -4,23 +4,16 @@ namespace CraftCms\Cms\Support\Facades; -use Closure; -use CraftCms\Cms\Activity\Data\ActivityActor; -use CraftCms\Cms\Activity\Data\ActivitySource; -use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\Contracts\ActivityEventTypeInterface; use CraftCms\Cms\Activity\Models\ActivityEvent; -use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Site\Data\Site; -use CraftCms\Cms\User\Elements\User; use Illuminate\Contracts\Support\Htmlable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Facade; /** - * @method static void extend(string $eventType, ActivitySource $source, string $label, string $icon = 'wave-pulse', array $rules = [], (Closure(ActivityEvent, string): (string|Htmlable))|null $formatter = null) - * @method static ActivityEvent record(string $eventType, ElementInterface|ActivitySubject|null $subject = null, User|ActivityActor|null $actor = null, ?Site $site = null, array $data = [], array $changes = []) + * @method static ActivityEvent record(ActivityEventTypeInterface $event) * @method static Builder query() - * @method static string|Htmlable format(ActivityEvent $event, ?string $locale = null) + * @method static string|Htmlable format(ActivityEvent $event) * @method static string icon(ActivityEvent $event) * * @see \CraftCms\Cms\Activity\Activities diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index b6be28e5c2b..63978615956 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -3,39 +3,39 @@ declare(strict_types=1); use CraftCms\Cms\Activity\Activities; -use CraftCms\Cms\Activity\ActivityEventRecorder; -use CraftCms\Cms\Activity\ActivityEventTypes; +use CraftCms\Cms\Activity\ActivityEventType; use CraftCms\Cms\Activity\Data\ActivityActor; use CraftCms\Cms\Activity\Data\ActivitySource; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Enums\ActivityActorType; +use CraftCms\Cms\Activity\EventTypes\ElementStatusChanged; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Database\Table; +use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Entry\Models\Entry; +use CraftCms\Cms\Site\Data\Site as SiteData; use CraftCms\Cms\Site\Models\Site; use CraftCms\Cms\Support\Facades\Activities as ActivitiesFacade; use CraftCms\Cms\Support\Facades\Sites; -use CraftCms\Cms\Support\HtmlSanitizer\HtmlSanitizerManager; use CraftCms\Cms\Tests\TestClasses\TestPlugin\src\TestPlugin; +use CraftCms\Cms\User\Elements\User as UserElement; use CraftCms\Cms\User\Models\User; +use Illuminate\Contracts\Support\Htmlable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Exceptions; +use Illuminate\Support\Facades\Route; use Illuminate\Support\HtmlString; use Illuminate\Validation\ValidationException; +use function CraftCms\Cms\t; +use function Pest\Laravel\get; + beforeEach(function () { loadTestPlugin(); - $this->source = ActivitySource::fromPlugin(TestPlugin::getInstance()); $this->activities = app(Activities::class); - ActivitiesFacade::extend( - eventType: 'test-plugin.entry.updated', - source: $this->source, - label: 'Entry updated', - rules: ['reason' => ['required', 'string']], - ); }); afterEach(function () { @@ -50,11 +50,10 @@ $this->actingAs($actor); - $event = $this->activities->record( - eventType: 'test-plugin.entry.updated', + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Published', subject: $draft, site: $site, - data: ['reason' => 'Published'], changes: [[ 'type' => 'field', 'id' => 'summary', @@ -62,10 +61,10 @@ 'old' => null, 'new' => 'Ready', ]], - ); + )); expect($event->id)->toBeString() - ->and($event->eventType)->toBe('test-plugin.entry.updated') + ->and($event->eventType)->toBe(TestPluginEntryUpdated::class) ->and($event->source)->toBe('test-plugin') ->and($event->actorType)->toBe(ActivityActorType::User) ->and($event->actorId)->toBe($actor->id) @@ -90,19 +89,18 @@ }); it('distinguishes system anonymous and known user actors and captures impersonation', function () { - $system = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'System']); - $anonymous = $this->activities->record( - 'test-plugin.entry.updated', + $system = $this->activities->record(new TestPluginEntryUpdated(reason: 'System')); + $anonymous = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Public form', actor: ActivityActor::anonymous(), - data: ['reason' => 'Public form'], - ); + )); $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('test-plugin.entry.updated', data: ['reason' => 'Edited']); + $user = $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); expect($system->actorType)->toBe(ActivityActorType::System) ->and($anonymous->actorType)->toBe(ActivityActorType::Anonymous) @@ -113,22 +111,41 @@ ]); }); -it('rejects unregistered types and invalid payload sections', function () { - expect(fn () => $this->activities->record('test.unknown.happened')) - ->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->activities->record('test-plugin.entry.updated', data: [])) +it('attributes unauthenticated HTTP activity to an anonymous actor', function () { + Route::get('test/activity-actor', fn () => ActivitiesFacade::record( + new TestPluginEntryUpdated(reason: 'Public request'), + )->actorType->value); + + get('test/activity-actor') + ->assertOk() + ->assertSeeText(ActivityActorType::Anonymous->value); +}); + +it('rejects invalid payload sections', function () { + expect(fn () => $this->activities->record(new TestPluginEntryUpdated( + reason: 'Edited', + changes: [['type' => 'field', 'id' => 'summary']], + )))->toThrow(ValidationException::class) + ->and(fn () => $this->activities->record(new TestPluginPayload(['value']))) ->toThrow(ValidationException::class) - ->and(fn () => $this->activities->record( - 'test-plugin.entry.updated', - data: ['reason' => 'Edited'], - changes: [['type' => 'field', 'id' => 'summary']], - ))->toThrow(ValidationException::class); + ->and(fn () => $this->activities->record(new TestPluginPayload(['value' => NAN]))) + ->toThrow(ValidationException::class) + ->and(fn () => $this->activities->record(new TestPluginEntryUpdated( + reason: 'Edited', + changes: [[ + 'type' => 'field', + 'id' => 'summary', + 'label' => 'Summary', + 'old' => NAN, + 'new' => null, + ]], + )))->toThrow(ValidationException::class); }); it('rolls records back with their semantic action', function () { DB::beginTransaction(); - $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'Edited']); + $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); DB::rollBack(); @@ -140,14 +157,28 @@ $subject = new ActivitySubject('document', 'one', 'Document one'); $otherSubject = new ActivitySubject('document', 'two', 'Document two'); - - $first = $this->activities->record('test-plugin.entry.updated', subject: $subject, data: ['reason' => 'First']); - $second = $this->activities->record('test-plugin.entry.updated', subject: $subject, data: ['reason' => 'Second']); - $this->activities->record('test-plugin.entry.updated', subject: $otherSubject, data: ['reason' => 'Other']); + $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('test-plugin.entry.updated') + ->eventTypes(TestPluginEntryUpdated::class) ->actor(ActivityActor::system()) ->source('test-plugin') ->occurredFrom(Date::parse('2026-08-25 00:00:00')) @@ -155,30 +186,27 @@ ->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(); -}); - -it('blocks updates and deletions through model instances', function () { - $event = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'Recorded']); - - expect(fn () => $event->update(['source' => 'changed'])) - ->toThrow(LogicException::class) - ->and(fn () => $event->delete()) - ->toThrow(LogicException::class); + ->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('test-plugin.entry.updated', data: ['reason' => 'Early']); + $early = $this->activities->record(new TestPluginEntryUpdated(reason: 'Early')); Date::setTestNow('2026-08-25 12:00:02'); - $late = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'Late']); + $late = $this->activities->record(new TestPluginEntryUpdated(reason: 'Late')); $bound = Date::parse('2026-08-25 12:00:01'); $from = $this->activities->query()->occurredFrom($bound)->get(); @@ -194,15 +222,12 @@ $site = Sites::getSiteById(Site::factory()->create()->id); $otherSite = Sites::getSiteById(Site::factory()->create()->id); - $neutral = $this->activities->record('test-plugin.entry.updated', data: ['reason' => 'Neutral']); - $matching = $this->activities->record('test-plugin.entry.updated', site: $site, data: ['reason' => 'Matching']); - $this->activities->record('test-plugin.entry.updated', site: $otherSite, data: ['reason' => 'Other']); + $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)->get()) - ->sequence( - fn ($event) => $event->id->toBe($matching->id), - fn ($event) => $event->id->toBe($neutral->id), - ); + 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 () { @@ -210,101 +235,165 @@ $subject = Entry::factory()->createElement(); $siteModel = Site::factory()->create(); - $event = $this->activities->record( - 'test-plugin.entry.updated', + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Edited', subject: $subject, actor: $actor, site: Sites::getSiteById($siteModel->id), - data: ['reason' => 'Edited'], - ); + )); 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(); - expect($this->activities->query()->first()->id)->toBe($event->id); -}); + $retained = $this->activities->query()->firstOrFail(); -it('rejects duplicate plugin event registrations', function () { - expect(fn () => ActivitiesFacade::extend( - eventType: 'test-plugin.entry.updated', - source: $this->source, - label: 'Entry updated', - ))->toThrow(LogicException::class); + 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 for the requested locale as text or safe HTML', function () { - ActivitiesFacade::extend( - eventType: 'test-plugin.entry.published', - source: $this->source, - label: 'Entry published', - formatter: fn (ActivityEvent $event, string $locale): string => "$locale: {$event->data['reason']}", - rules: ['reason' => ['required', 'string']], - icon: 'bullhorn', - ); - ActivitiesFacade::extend( - eventType: 'test-plugin.entry.featured', - source: $this->source, - label: 'Entry featured', - formatter: fn (): HtmlString => new HtmlString('Entry featured'), - ); - ActivitiesFacade::extend( - eventType: 'test-plugin.entry.translated', - source: new ActivitySource('test-plugin', 'Test Plugin', 'app'), - label: 'Save', - ); - - $textEvent = ActivitiesFacade::record('test-plugin.entry.published', data: ['reason' => 'Klaar']); - $htmlEvent = ActivitiesFacade::record('test-plugin.entry.featured'); - $translatedEvent = ActivitiesFacade::record('test-plugin.entry.translated'); - - expect(ActivitiesFacade::format($textEvent, 'nl'))->toBe('nl: Klaar') +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, 'en'))->toBeInstanceOf(HtmlString::class) - ->and(ActivitiesFacade::format($htmlEvent, 'en')->toHtml())->toBe('Entry featured') + ->and(ActivitiesFacade::format($htmlEvent))->toBeInstanceOf(Htmlable::class) + ->and(ActivitiesFacade::format($htmlEvent)->toHtml())->toBe('Entry featured') ->and(ActivitiesFacade::format( - ActivitiesFacade::record('test-plugin.entry.updated', data: ['reason' => 'Klaar']), - 'nl', + ActivitiesFacade::record(new TestPluginEntryUpdated(reason: 'Klaar')), ))->toBe('Entry updated') - ->and(ActivitiesFacade::format($translatedEvent, 'nl'))->toBe('Bewaren'); + ->and(ActivitiesFacade::format($translatedEvent))->toBe('Bewaren'); }); -it('reports formatter failures and keeps retained plugin events readable without registration', function () { - Exceptions::fake(); - ActivitiesFacade::extend( - eventType: 'test-plugin.entry.failed', - source: $this->source, - label: 'Entry formatting failed', - formatter: fn () => throw new RuntimeException('Formatter failed.'), - ); - - $event = ActivitiesFacade::record('test-plugin.entry.failed'); - $eventTypes = new ActivityEventTypes; - $htmlSanitizers = app(HtmlSanitizerManager::class); - $events = new ActivityEventRecorder($eventTypes, app(Impersonation::class)); - $retainedActivities = new Activities( - $eventTypes, - $htmlSanitizers, - $events, - ); - - expect(ActivitiesFacade::format($event))->toBe('Entry formatting failed') - ->and($retainedActivities->format($event))->toBe('Entry formatting failed') - ->and($retainedActivities->icon($event))->toBe('wave-pulse'); - Exceptions::assertReported(RuntimeException::class); +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 invalid formatter output and uses the captured fallback', function () { +it('reports formatter failures and keeps retained events readable when their class is unavailable', function () { Exceptions::fake(); - ActivitiesFacade::extend( - eventType: 'test-plugin.entry.invalid', - source: $this->source, - label: 'Entry formatter invalid', - formatter: fn (): int => 42, - ); - $event = ActivitiesFacade::record('test-plugin.entry.invalid'); + $event = ActivitiesFacade::record(new TestPluginEntryFailed); + $retainedEvent = clone $event; + $retainedEvent->eventType = 'Missing\\ActivityEventType'; - expect(ActivitiesFacade::format($event))->toBe('Entry formatter invalid'); - Exceptions::assertReported(UnexpectedValueException::class); + expect(ActivitiesFacade::format($event))->toBe('Entry formatting failed') + ->and(ActivitiesFacade::format($retainedEvent))->toBe('Entry formatting failed') + ->and(ActivitiesFacade::icon($retainedEvent))->toBe('wave-pulse'); + Exceptions::assertReported(RuntimeException::class); }); + +abstract class TestPluginActivityEventType extends ActivityEventType +{ + public static function source(): ActivitySource + { + return ActivitySource::fromPlugin(TestPlugin::getInstance()); + } +} + +class TestPluginPayload extends TestPluginActivityEventType +{ + public function __construct(private readonly array $payload) + { + parent::__construct(); + } + + public function data(): array + { + return $this->payload; + } +} + +class TestPluginEntryUpdated extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry updated'; + + public function __construct( + private readonly string $reason, + ElementInterface|ActivitySubject|null $subject = null, + UserElement|ActivityActor|null $actor = null, + ?SiteData $site = null, + array $changes = [], + ) { + parent::__construct($subject, $actor, $site, $changes); + } + + public function data(): array + { + return ['reason' => $this->reason]; + } + + public static function rules(): array + { + return ['reason' => ['required', 'string']]; + } +} + +class TestPluginEntryPublished extends TestPluginEntryUpdated +{ + protected const string LABEL = 'Entry published'; + + protected const string ICON = 'bullhorn'; + + public static function rules(): array + { + return ['reason' => ['required', 'string']]; + } + + 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 index 795be6e46dc..f7869f4e2b2 100644 --- a/tests/Feature/Activity/AssetActivityTest.php +++ b/tests/Feature/Activity/AssetActivityTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use CraftCms\Cms\Activity\Activities; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\EventTypes\AssetFileReplaced; use CraftCms\Cms\Asset\Assets; use CraftCms\Cms\Asset\Elements\Asset; use CraftCms\Cms\Asset\Folders; @@ -49,9 +51,9 @@ File::put($replacement, 'replacement'); app(Assets::class)->replaceAssetFile($asset, $replacement, 'replacement.txt', 'text/plain'); - $event = app(Activities::class)->query()->subject($asset)->firstOrFail(); + $event = app(Activities::class)->query()->subject(ActivitySubject::fromElement($asset))->firstOrFail(); - expect($event->eventType)->toBe('craft.asset.file-replaced') + expect($event->eventType)->toBe(AssetFileReplaced::class) ->and($event->siteId)->toBe($asset->siteId) ->and($event->data)->toBe([ 'oldFilename' => 'original.txt', @@ -62,10 +64,5 @@ 'newSize' => 11, ]) ->and(app(Activities::class)->format($event)) - ->toBe('Replaced original.txt (text/plain, 3 B) with replacement.txt (text/plain, 11 B).'); - - DB::table(Table::ACTIVITYEVENTS)->delete(); - - expect(Elements::saveElement($asset))->toBeFalse() - ->and(app(Activities::class)->query()->subject($asset)->get())->toBeEmpty(); + ->toBe('Replaced original.txt with replacement.txt.'); }); diff --git a/tests/Feature/Activity/ElementLifecycleActivityTest.php b/tests/Feature/Activity/ElementLifecycleActivityTest.php index e4e86bd4244..13aba534ebe 100644 --- a/tests/Feature/Activity/ElementLifecycleActivityTest.php +++ b/tests/Feature/Activity/ElementLifecycleActivityTest.php @@ -4,6 +4,11 @@ use CraftCms\Cms\Activity\Activities; use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\EventTypes\ElementDeleted as ElementDeletedActivity; +use CraftCms\Cms\Activity\EventTypes\ElementRestored as ElementRestoredActivity; +use CraftCms\Cms\Activity\EventTypes\ElementSiteAdded; +use CraftCms\Cms\Activity\EventTypes\ElementSiteRemoved; +use CraftCms\Cms\Activity\EventTypes\ElementTrashed; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Enums\PropagationMethod; use CraftCms\Cms\Element\Events\ElementDeleted; @@ -47,9 +52,9 @@ $events = $this->activities->query()->subject($subject)->get()->reverse()->values(); expect($events->pluck('eventType')->all())->toBe([ - 'craft.element.trashed', - 'craft.element.restored', - 'craft.element.deleted', + 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(); @@ -96,26 +101,21 @@ ->and($this->activities->query()->get())->toBeEmpty(); }); -it('dispatches the post-delete event after committing deletion activity', function () { +it('dispatches the post-delete event within a surrounding transaction', function () { $entry = EntryModel::factory()->createElement(); DB::table(Table::ACTIVITYEVENTS)->delete(); - $transactionLevel = DB::transactionLevel(); - $eventTransactionLevel = null; - Event::listen(function (ElementDeleted $event) use ($entry, &$eventTransactionLevel) { + Event::listen(function (ElementDeleted $event) use ($entry) { if ($event->element === $entry) { - $eventTransactionLevel = DB::transactionLevel(); - throw new RuntimeException('Deletion failed.'); } }); - expect(fn () => Elements::deleteElement($entry)) + expect(fn () => DB::transaction(fn () => Elements::deleteElement($entry))) ->toThrow(RuntimeException::class, 'Deletion failed.'); - expect($eventTransactionLevel)->toBe($transactionLevel) - ->and($this->activities->query()->eventTypes('craft.element.trashed')->count())->toBe(1) - ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->trashed()->exists())->toBeTrue(); + 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 () { @@ -138,7 +138,7 @@ expect(Elements::restoreElements($entries))->toBeTrue(); - $events = $this->activities->query()->eventTypes('craft.element.restored')->get(); + $events = $this->activities->query()->eventTypes(ElementRestoredActivity::class)->get(); expect($events)->toHaveCount(2) ->and($events->pluck('subjectId')->unique())->toHaveCount(2); @@ -156,11 +156,11 @@ Elements::deleteElementForSite($secondaryEntry); expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); - $events = $this->activities->query()->subject($entry)->get()->reverse()->values(); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get()->reverse()->values(); expect($events->pluck('eventType')->all())->toBe([ - 'craft.element.site-removed', - 'craft.element.site-added', + ElementSiteRemoved::class, + ElementSiteAdded::class, ])->and($events->pluck('siteId')->all())->toBe([ $secondarySite->id, $secondarySite->id, @@ -188,7 +188,7 @@ expect($event->snapshots['actor']['label'])->toBe('Ada Lovelace'); }); -it('rolls back site removal activity when the action fails', function () { +it('rolls back site removal when its post-delete event fails', function () { [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); $secondaryEntry = Entry::find() ->id($entry->id) @@ -199,19 +199,35 @@ Event::listen(function (ElementDeletedForSite $event) use ($secondaryEntry) { if ($event->element === $secondaryEntry) { - throw new RuntimeException('Site removal failed.'); + throw new RuntimeException('Site deletion failed.'); } }); expect(fn () => Elements::deleteElementForSite($secondaryEntry)) - ->toThrow(RuntimeException::class, 'Site removal failed.'); + ->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(); - expect($this->activities->query()->get())->toBeEmpty() - ->and(Entry::find()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists()) - ->toBeTrue(); + 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 after committing site addition activity', function () { +it('dispatches the post-save event within a surrounding transaction', function () { [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); $secondaryEntry = Entry::find() ->id($entry->id) @@ -220,23 +236,18 @@ ->one(); Elements::deleteElementForSite($secondaryEntry); DB::table(Table::ACTIVITYEVENTS)->delete(); - $transactionLevel = DB::transactionLevel(); - $eventTransactionLevel = null; - Event::listen(function (ElementSaved $event) use ($entry, &$eventTransactionLevel) { + Event::listen(function (ElementSaved $event) use ($entry) { if ($event->element === $entry) { - $eventTransactionLevel = DB::transactionLevel(); - throw new RuntimeException('Save failed.'); } }); - expect(fn () => Elements::saveElement($entry, updateSearchIndex: false)) + expect(fn () => DB::transaction(fn () => Elements::saveElement($entry, updateSearchIndex: false))) ->toThrow(RuntimeException::class, 'Save failed.'); - expect($eventTransactionLevel)->toBe($transactionLevel) - ->and($this->activities->query()->eventTypes('craft.element.site-added')->count())->toBe(1) - ->and(Entry::find()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists())->toBeTrue(); + 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 diff --git a/tests/Feature/Activity/EntryActivityTest.php b/tests/Feature/Activity/EntryActivityTest.php index db22cd99aac..4553a169b24 100644 --- a/tests/Feature/Activity/EntryActivityTest.php +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -3,9 +3,20 @@ declare(strict_types=1); use CraftCms\Cms\Activity\Activities; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\EventTypes\DraftApplied; +use CraftCms\Cms\Activity\EventTypes\DraftCreated; +use CraftCms\Cms\Activity\EventTypes\DraftDiscarded; +use CraftCms\Cms\Activity\EventTypes\DraftSaved; +use CraftCms\Cms\Activity\EventTypes\ElementCreated; +use CraftCms\Cms\Activity\EventTypes\ElementStatusChanged; +use CraftCms\Cms\Activity\EventTypes\ElementUpdated; +use CraftCms\Cms\Activity\EventTypes\RevisionRestored; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Drafts; +use CraftCms\Cms\Element\Events\ElementSaved; use CraftCms\Cms\Element\Events\ElementSaving; +use CraftCms\Cms\Element\Events\RevertedToRevision; use CraftCms\Cms\Element\Revisions; use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Entry\Models\Entry as EntryModel; @@ -48,8 +59,8 @@ $entry = Entry::find()->sectionId($section->id)->title('New entry')->status(null)->one(); $events = $this->activities->query() - ->subject($entry) - ->eventTypes('craft.element.created') + ->subject(ActivitySubject::fromElement($entry)) + ->eventTypes(ElementCreated::class) ->get(); expect($events)->toHaveCount(2) @@ -71,11 +82,11 @@ expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); $event = $this->activities->query() - ->subject($entry) - ->eventTypes('craft.element.updated') + ->subject(ActivitySubject::fromElement($entry)) + ->eventTypes(ElementUpdated::class) ->firstOrFail(); - expect($event->changes)->toBe([ + expect($event->changes)->toEqualCanonicalizing([ [ 'type' => 'attribute', 'id' => 'title', @@ -106,10 +117,10 @@ expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); - $events = $this->activities->query()->subject($entry)->get(); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); expect($events)->toHaveCount(1) - ->and($events->first()->eventType)->toBe('craft.element.status-changed') + ->and($events->first()->eventType)->toBe(ElementStatusChanged::class) ->and($events->first()->data)->toBe(['oldStatus' => 'live', 'newStatus' => 'disabled']) ->and($this->activities->format($events->first()))->toBe('Status changed from Live to Disabled.') ->and($events->first()->changes)->toContain([ @@ -133,9 +144,9 @@ expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); - $event = $this->activities->query()->subject($entry)->firstOrFail(); + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->firstOrFail(); - expect($event->eventType)->toBe('craft.element.updated') + expect($event->eventType)->toBe(ElementUpdated::class) ->and($event->changes)->toBeEmpty(); }); @@ -150,7 +161,7 @@ }); expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeFalse() - ->and($this->activities->query()->subject($entry)->get())->toBeEmpty(); + ->and($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); }); it('does not record no-op, draft, resave, or rolled-back work', function () { @@ -162,14 +173,16 @@ $entry->setDirtyAttributes(['expiryDate']); expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue() - ->and($this->activities->query()->subject($entry)->get())->toBeEmpty(); + ->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(); - $entry->resaving = true; - $entry->title = 'Resaved title'; - expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue() - ->and($this->activities->query()->subject($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'; @@ -178,9 +191,9 @@ expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); DB::rollBack(); - expect($this->activities->query()->subject($entry)->get())->toBeEmpty() + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty() ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one()->title) - ->toBe('Resaved title'); + ->toBe('Original title'); }); it('records draft work against the canonical entry', function () { @@ -194,12 +207,12 @@ app(Drafts::class)->applyDraft($draft); - $events = $this->activities->query()->subject($entry)->get(); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); expect($events->pluck('eventType')->all())->toBe([ - 'craft.draft.applied', - 'craft.draft.saved', - 'craft.draft.created', + DraftApplied::class, + DraftSaved::class, + DraftCreated::class, ])->and($events->pluck('siteId')->unique()->all())->toBe([$entry->siteId]); }); @@ -213,9 +226,9 @@ app(Drafts::class)->applyDraft($draft); - $event = $this->activities->query()->subject($entry)->sole(); + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->sole(); - expect($event->eventType)->toBe('craft.element.updated') + expect($event->eventType)->toBe(ElementUpdated::class) ->and($event->snapshots['subject']['label'])->toBe('Updated title') ->and($event->changes)->toContain([ 'type' => 'attribute', @@ -237,27 +250,46 @@ 'title' => 'Draft title', ])->assertOk(); - $events = $this->activities->query()->subject($entry)->get(); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); - expect($events->pluck('eventType')->all())->toBe(['craft.draft.saved', 'craft.draft.created']); + expect($events->pluck('eventType')->all())->toBe([DraftSaved::class, DraftCreated::class]); }); -it('ignores provisional creation and autosave but records its discard', function () { +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'); - expect(app(Drafts::class)->discardDraft($draft))->toBeTrue(); + 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(); - expect(app(Drafts::class)->discardDraft($provisional))->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($entry)->pluck('eventType')->all())->toBe([ - 'craft.draft.discarded', - 'craft.draft.discarded', - 'craft.draft.created', + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->pluck('eventType')->all())->toBe([ + DraftDiscarded::class, ]); }); @@ -267,9 +299,9 @@ expect(app(Drafts::class)->saveElementAsDraft($entry, User::findOne()->id))->toBeTrue(); - $event = $this->activities->query()->subject($entry)->firstOrFail(); + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->firstOrFail(); - expect($event->eventType)->toBe('craft.draft.created') + expect($event->eventType)->toBe(DraftCreated::class) ->and($event->siteId)->toBe($entry->siteId); }); @@ -278,16 +310,26 @@ $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); DB::table(Table::ACTIVITYEVENTS)->delete(); - $draft->isProvisionalDraft = false; - expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue(); + $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($entry)->sole(); + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->sole(); - expect($event->eventType)->toBe('craft.draft.created'); + expect($event->eventType)->toBe(DraftCreated::class); DB::table(Table::ACTIVITYEVENTS)->delete(); - expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue() - ->and($this->activities->query()->subject($entry)->get())->toBeEmpty(); + 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 () { @@ -298,11 +340,32 @@ app(Revisions::class)->revertToRevision($revision, User::findOne()->id); - $events = $this->activities->query()->subject($entry)->get(); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); expect($events)->toHaveCount(1) - ->and($events->first()->eventType)->toBe('craft.revision.restored') + ->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 index 07b53ad4390..daa6254a073 100644 --- a/tests/Feature/Activity/StructuralElementActivityTest.php +++ b/tests/Feature/Activity/StructuralElementActivityTest.php @@ -3,6 +3,10 @@ declare(strict_types=1); use CraftCms\Cms\Activity\Activities; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\EventTypes\ElementDuplicated; +use CraftCms\Cms\Activity\EventTypes\ElementMerged; +use CraftCms\Cms\Activity\EventTypes\ElementMoved; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Actions\Duplicate; use CraftCms\Cms\Element\Operations\ElementDeletions; @@ -31,10 +35,10 @@ DB::table(Table::ACTIVITYEVENTS)->delete(); $duplicate = app(ElementDuplicates::class)->duplicateElement($source); - $events = $this->activities->query()->subject($duplicate)->get(); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($duplicate))->get(); expect($events)->toHaveCount(1) - ->and($events->first()->eventType)->toBe('craft.element.duplicated') + ->and($events->first()->eventType)->toBe(ElementDuplicated::class) ->and($events->first()->data['source'])->toBe([ 'type' => $source::class, 'id' => $source->uid, @@ -56,8 +60,8 @@ $duplicate = app(ElementDuplicates::class)->duplicateElement($source); $events = $this->activities->query() - ->subject($duplicate) - ->eventTypes('craft.element.duplicated') + ->subject(ActivitySubject::fromElement($duplicate)) + ->eventTypes(ElementDuplicated::class) ->get(); expect($events)->toHaveCount(2) @@ -74,7 +78,7 @@ expect($action->performAction($query))->toBeTrue(); - $events = $this->activities->query()->eventTypes('craft.element.duplicated')->get(); + $events = $this->activities->query()->eventTypes(ElementDuplicated::class)->get(); expect($events)->toHaveCount(2) ->and($events->pluck('subjectId')->unique())->toHaveCount(2); @@ -91,11 +95,11 @@ expect(app(Structures::class)->append($structure->id, $moved, $parent))->toBeTrue(); $event = $this->activities->query() - ->subject($moved) - ->eventTypes('craft.element.moved') + ->subject(ActivitySubject::fromElement($moved)) + ->eventTypes(ElementMoved::class) ->firstOrFail(); - expect($event->data)->toBe([ + expect($event->data)->toMatchArray([ 'origin' => [ 'structure' => $structure->uid, 'parent' => [ @@ -123,18 +127,6 @@ ); }); -it('does not record technical structure movement', function () { - [ - 'structure' => $structure, - 'children' => [$parent, $moved], - ] = createStructureHierarchy(); - DB::table(Table::ACTIVITYEVENTS)->delete(); - $moved->resaving = true; - - expect(app(Structures::class)->append($structure->id, $moved, $parent))->toBeTrue() - ->and($this->activities->query()->get())->toBeEmpty(); -}); - 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']); @@ -148,7 +140,7 @@ $prevailingEvent = $events->firstWhere('subjectId', $prevailing->uid); expect($events)->toHaveCount(2) - ->and($events->pluck('eventType')->unique()->all())->toBe(['craft.element.merged']) + ->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 index dcf6e1a0864..e7160daa847 100644 --- a/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php +++ b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php @@ -4,6 +4,8 @@ use CraftCms\Cms\Activity\Activities; use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\EventTypes\ElementCreated; +use CraftCms\Cms\Activity\EventTypes\ElementUpdated; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Cms; use CraftCms\Cms\GarbageCollection\Actions\PurgeExpiredActivity; @@ -13,15 +15,14 @@ it('leaves activity intact when retention is unlimited', function () { Date::setTestNow('2025-08-26 12:00:00'); - $event = app(Activities::class)->record( - 'craft.element.created', + $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()->pluck('id')->all())->toBe([$event->id]); + expect(ActivityEvent::query()->whereKey($event->id)->exists())->toBeTrue(); }); it('purges activity older than the retention duration', function () { @@ -30,27 +31,26 @@ $subject = new ActivitySubject('document', 'one', 'Document one'); Date::setTestNow('2026-08-26 10:00:00'); - $expired = $activities->record('craft.element.created', subject: $subject); + $expired = $activities->record(new ElementCreated(subject: $subject)); Date::setTestNow('2026-08-26 12:00:00'); - $retained = $activities->record('craft.element.updated', subject: $subject); + $retained = $activities->record(new ElementUpdated(subject: $subject)); app(PurgeExpiredActivity::class)(); - expect(ActivityEvent::query()->pluck('id')->all())->toBe([$retained->id]) - ->and(ActivityEvent::query()->find($expired->id))->toBeNull(); + 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( - 'craft.element.created', + $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()->pluck('id')->all())->toBe([$event->id]); + 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 7ceb3240bcd..02c9ca03eeb 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\Elements; @@ -242,7 +243,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/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; }); From 7a986c042e392eee654e575584ab622e91239bcc Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 12:25:04 +0200 Subject: [PATCH 03/10] Fall back to email for unnamed activity actors --- src/Activity/Data/ActivityActor.php | 2 +- tests/Feature/Activity/ActivitiesTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Activity/Data/ActivityActor.php b/src/Activity/Data/ActivityActor.php index 2100d48bd8a..1ddf6a3b2ce 100644 --- a/src/Activity/Data/ActivityActor.php +++ b/src/Activity/Data/ActivityActor.php @@ -34,7 +34,7 @@ public static function user(User $user): self throw new InvalidArgumentException('Activity actors must be saved users.'); } - return new self(ActivityActorType::User, $user->name, $user->id); + return new self(ActivityActorType::User, $user->name ?: $user->email ?: "User #{$user->id}", $user->id); } public static function system(): self diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index 63978615956..d17f9905f90 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -111,6 +111,16 @@ ]); }); +it('uses the email for unnamed user actors', function () { + $actor = User::factory()->createElement(['email' => 'editor@example.com']); + $actor->setName(''); + $this->actingAs($actor); + + $event = $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); + + expect($event->snapshots['actor']['label'])->toBe('editor@example.com'); +}); + it('attributes unauthenticated HTTP activity to an anonymous actor', function () { Route::get('test/activity-actor', fn () => ActivitiesFacade::record( new TestPluginEntryUpdated(reason: 'Public request'), From e9c6cf3d0061e83fdccd1e2c680e3809e0f4b7af Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 12:38:39 +0200 Subject: [PATCH 04/10] Compare activity JSON without key order --- tests/Feature/Activity/AssetActivityTest.php | 2 +- tests/Feature/Activity/EntryActivityTest.php | 6 +++--- tests/Feature/Activity/StructuralElementActivityTest.php | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Feature/Activity/AssetActivityTest.php b/tests/Feature/Activity/AssetActivityTest.php index f7869f4e2b2..4653fb978a2 100644 --- a/tests/Feature/Activity/AssetActivityTest.php +++ b/tests/Feature/Activity/AssetActivityTest.php @@ -55,7 +55,7 @@ expect($event->eventType)->toBe(AssetFileReplaced::class) ->and($event->siteId)->toBe($asset->siteId) - ->and($event->data)->toBe([ + ->and($event->data)->toEqual([ 'oldFilename' => 'original.txt', 'newFilename' => 'replacement.txt', 'oldMimeType' => 'text/plain', diff --git a/tests/Feature/Activity/EntryActivityTest.php b/tests/Feature/Activity/EntryActivityTest.php index 4553a169b24..e328798b7e8 100644 --- a/tests/Feature/Activity/EntryActivityTest.php +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -121,9 +121,9 @@ expect($events)->toHaveCount(1) ->and($events->first()->eventType)->toBe(ElementStatusChanged::class) - ->and($events->first()->data)->toBe(['oldStatus' => 'live', 'newStatus' => 'disabled']) + ->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)->toContain([ + ->and($events->first()->changes)->toContainEqual([ 'type' => 'field', 'id' => $field->layoutElement->uid, 'label' => $field->name, @@ -230,7 +230,7 @@ expect($event->eventType)->toBe(ElementUpdated::class) ->and($event->snapshots['subject']['label'])->toBe('Updated title') - ->and($event->changes)->toContain([ + ->and($event->changes)->toContainEqual([ 'type' => 'attribute', 'id' => 'title', 'label' => 'Title', diff --git a/tests/Feature/Activity/StructuralElementActivityTest.php b/tests/Feature/Activity/StructuralElementActivityTest.php index daa6254a073..2c3d8063959 100644 --- a/tests/Feature/Activity/StructuralElementActivityTest.php +++ b/tests/Feature/Activity/StructuralElementActivityTest.php @@ -39,7 +39,7 @@ expect($events)->toHaveCount(1) ->and($events->first()->eventType)->toBe(ElementDuplicated::class) - ->and($events->first()->data['source'])->toBe([ + ->and($events->first()->data['source'])->toEqual([ 'type' => $source::class, 'id' => $source->uid, 'label' => $source->getUiLabel(), From c873abdc0f53425b46870425d8415b13edb0ad44 Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 13:21:14 +0200 Subject: [PATCH 05/10] Define activity changes with a DTO --- src/Activity/ActivityEventRecorder.php | 10 ++--- src/Activity/ActivityEventType.php | 3 +- .../Contracts/ActivityEventTypeInterface.php | 3 +- src/Activity/Data/ActivityChange.php | 41 +++++++++++++++++++ src/Activity/EntryActivity.php | 7 ++-- .../EventTypes/ElementStatusChanged.php | 3 +- tests/Feature/Activity/ActivitiesTest.php | 23 +++-------- 7 files changed, 61 insertions(+), 29 deletions(-) create mode 100644 src/Activity/Data/ActivityChange.php diff --git a/src/Activity/ActivityEventRecorder.php b/src/Activity/ActivityEventRecorder.php index ea24ef08890..72c81565c09 100644 --- a/src/Activity/ActivityEventRecorder.php +++ b/src/Activity/ActivityEventRecorder.php @@ -7,6 +7,7 @@ use Closure; use CraftCms\Cms\Activity\Contracts\ActivityEventTypeInterface; use CraftCms\Cms\Activity\Data\ActivityActor; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Support\Json; @@ -28,7 +29,10 @@ public function __construct( public function record(ActivityEventTypeInterface $event): ActivityEvent { $data = $event->data(); - $changes = $event->changes(); + $changes = array_map( + fn (ActivityChange $change) => $change->toArray(), + $event->changes(), + ); $this->validatePayload($data, $changes, $event::rules()); @@ -117,10 +121,6 @@ static function (string $attribute, mixed $value, Closure $fail): void { $validJson, ], 'changes' => ['list', $validJson], - 'changes.*' => ['array:type,id,label,old,new'], - 'changes.*.type' => ['required', 'string'], - 'changes.*.id' => ['required', 'string'], - 'changes.*.label' => ['required', 'string'], ...Arr::prependKeysWith($rules, 'data.'), ])->validate(); } diff --git a/src/Activity/ActivityEventType.php b/src/Activity/ActivityEventType.php index fa9e91c95d1..3d9a318eba4 100644 --- a/src/Activity/ActivityEventType.php +++ b/src/Activity/ActivityEventType.php @@ -6,6 +6,7 @@ use CraftCms\Cms\Activity\Contracts\ActivityEventTypeInterface; use CraftCms\Cms\Activity\Data\ActivityActor; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Data\ActivitySource; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Models\ActivityEvent; @@ -21,7 +22,7 @@ abstract class ActivityEventType implements ActivityEventTypeInterface protected const string ICON = 'wave-pulse'; /** - * @param list> $changes + * @param list $changes */ public function __construct( private readonly ElementInterface|ActivitySubject|null $subject = null, diff --git a/src/Activity/Contracts/ActivityEventTypeInterface.php b/src/Activity/Contracts/ActivityEventTypeInterface.php index 4cbf5ea2951..caf3344aa93 100644 --- a/src/Activity/Contracts/ActivityEventTypeInterface.php +++ b/src/Activity/Contracts/ActivityEventTypeInterface.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Activity\Contracts; use CraftCms\Cms\Activity\Data\ActivityActor; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Data\ActivitySource; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Models\ActivityEvent; @@ -22,7 +23,7 @@ public function site(): ?Site; /** @return array */ public function data(): array; - /** @return list> */ + /** @return list */ public function changes(): array; public static function source(): ActivitySource; diff --git a/src/Activity/Data/ActivityChange.php b/src/Activity/Data/ActivityChange.php new file mode 100644 index 00000000000..a06a993440a --- /dev/null +++ b/src/Activity/Data/ActivityChange.php @@ -0,0 +1,41 @@ +type === '' || $this->id === '' || $this->label === '') { + throw new InvalidArgumentException('Activity changes require a type, ID, and label.'); + } + } + + /** @return array{type: string, id: string, label: string, old: mixed, new: mixed} */ + public function toArray(): array + { + return [ + 'type' => $this->type, + 'id' => $this->id, + 'label' => $this->label, + 'old' => $this->old, + 'new' => $this->new, + ]; + } +} diff --git a/src/Activity/EntryActivity.php b/src/Activity/EntryActivity.php index 57f953c9af1..a268d08aacd 100644 --- a/src/Activity/EntryActivity.php +++ b/src/Activity/EntryActivity.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Activity; use BackedEnum; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\EventTypes\ElementCreated; use CraftCms\Cms\Activity\EventTypes\ElementStatusChanged; use CraftCms\Cms\Activity\EventTypes\ElementUpdated; @@ -86,7 +87,7 @@ public static function recordUpdated( /** * @param string[] $dirtyAttributes * @param string[] $dirtyFields - * @return array{list, bool} + * @return array{list, bool} */ private static function changes( Entry $entry, @@ -128,7 +129,7 @@ private static function changes( return [$changes, $contentChanged]; } - /** @param list $changes */ + /** @param list $changes */ private static function appendChange( array &$changes, bool &$contentChanged, @@ -155,7 +156,7 @@ private static function appendChange( return; } - $changes[] = compact('type', 'id', 'label', 'old', 'new'); + $changes[] = new ActivityChange($type, $id, $label, $old, $new); } private static function attributeValue(Entry $entry, string $attribute): mixed diff --git a/src/Activity/EventTypes/ElementStatusChanged.php b/src/Activity/EventTypes/ElementStatusChanged.php index 7dc4ce896fd..edfd51de61f 100644 --- a/src/Activity/EventTypes/ElementStatusChanged.php +++ b/src/Activity/EventTypes/ElementStatusChanged.php @@ -5,6 +5,7 @@ namespace CraftCms\Cms\Activity\EventTypes; use CraftCms\Cms\Activity\ActivityEventType; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Site\Data\Site; @@ -19,7 +20,7 @@ class ElementStatusChanged extends ActivityEventType protected const string ICON = 'circle-half-stroke'; /** - * @param list> $changes + * @param list $changes */ public function __construct( ElementInterface $subject, diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index d17f9905f90..6feeffc2ff0 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Activity\Activities; use CraftCms\Cms\Activity\ActivityEventType; use CraftCms\Cms\Activity\Data\ActivityActor; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Data\ActivitySource; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Enums\ActivityActorType; @@ -54,13 +55,7 @@ reason: 'Published', subject: $draft, site: $site, - changes: [[ - 'type' => 'field', - 'id' => 'summary', - 'label' => 'Summary', - 'old' => null, - 'new' => 'Ready', - ]], + changes: [new ActivityChange('field', 'summary', 'Summary', null, 'Ready')], )); expect($event->id)->toBeString() @@ -132,23 +127,15 @@ }); it('rejects invalid payload sections', function () { - expect(fn () => $this->activities->record(new TestPluginEntryUpdated( - reason: 'Edited', - changes: [['type' => 'field', 'id' => 'summary']], - )))->toThrow(ValidationException::class) + expect(fn () => new ActivityChange('field', 'summary', '', null, 'Ready')) + ->toThrow(InvalidArgumentException::class) ->and(fn () => $this->activities->record(new TestPluginPayload(['value']))) ->toThrow(ValidationException::class) ->and(fn () => $this->activities->record(new TestPluginPayload(['value' => NAN]))) ->toThrow(ValidationException::class) ->and(fn () => $this->activities->record(new TestPluginEntryUpdated( reason: 'Edited', - changes: [[ - 'type' => 'field', - 'id' => 'summary', - 'label' => 'Summary', - 'old' => NAN, - 'new' => null, - ]], + changes: [new ActivityChange('field', 'summary', 'Summary', NAN, null)], )))->toThrow(ValidationException::class); }); From 17c31a14235ca607fadf730a45d02b2bfba35e5b Mon Sep 17 00:00:00 2001 From: Rias Date: Thu, 27 Aug 2026 13:27:17 +0200 Subject: [PATCH 06/10] Return activity changes as DTOs --- src/Activity/Data/ActivityChange.php | 12 +++++++ src/Activity/Models/ActivityEvent.php | 10 ++++-- tests/Feature/Activity/ActivitiesTest.php | 10 ++---- tests/Feature/Activity/EntryActivityTest.php | 37 +++++--------------- 4 files changed, 31 insertions(+), 38 deletions(-) diff --git a/src/Activity/Data/ActivityChange.php b/src/Activity/Data/ActivityChange.php index a06a993440a..d600a42ec0c 100644 --- a/src/Activity/Data/ActivityChange.php +++ b/src/Activity/Data/ActivityChange.php @@ -38,4 +38,16 @@ public function toArray(): array 'new' => $this->new, ]; } + + /** @param array{type: string, id: string, label: string, old: mixed, new: mixed} $change */ + public static function fromArray(array $change): self + { + return new self( + $change['type'], + $change['id'], + $change['label'], + $change['old'], + $change['new'], + ); + } } diff --git a/src/Activity/Models/ActivityEvent.php b/src/Activity/Models/ActivityEvent.php index 032a27cbe70..d4093cb7955 100644 --- a/src/Activity/Models/ActivityEvent.php +++ b/src/Activity/Models/ActivityEvent.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use CraftCms\Cms\Activity\Data\ActivityActor; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\Enums\ActivityActorType; use CraftCms\Cms\Database\Table; @@ -28,7 +29,7 @@ * @property int|null $siteId * @property array{snapshots: array>, changes: list>, data: array} $payload * @property array> $snapshots - * @property list> $changes + * @property list $changes * @property array $data * @property CarbonImmutable $occurredAt */ @@ -59,10 +60,13 @@ protected function snapshots(): Attribute return Attribute::get(fn () => $this->payload['snapshots']); } - /** @return Attribute>, never> */ + /** @return Attribute, never> */ protected function changes(): Attribute { - return Attribute::get(fn () => $this->payload['changes']); + return Attribute::get(fn () => array_map( + ActivityChange::fromArray(...), + $this->payload['changes'], + )); } /** @return Attribute, never> */ diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index 6feeffc2ff0..959184936e7 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -73,13 +73,9 @@ 'source' => ['label' => 'Test Plugin'], 'event' => ['label' => 'Entry updated'], ]) - ->and($event->changes)->toBe([[ - 'type' => 'field', - 'id' => 'summary', - 'label' => 'Summary', - 'old' => null, - 'new' => 'Ready', - ]]) + ->and($event->changes)->toEqual([ + new ActivityChange('field', 'summary', 'Summary', null, 'Ready'), + ]) ->and($event->data)->toBe(['reason' => 'Published']); }); diff --git a/tests/Feature/Activity/EntryActivityTest.php b/tests/Feature/Activity/EntryActivityTest.php index e328798b7e8..73db53b99b3 100644 --- a/tests/Feature/Activity/EntryActivityTest.php +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use CraftCms\Cms\Activity\Activities; +use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Data\ActivitySubject; use CraftCms\Cms\Activity\EventTypes\DraftApplied; use CraftCms\Cms\Activity\EventTypes\DraftCreated; @@ -87,20 +88,8 @@ ->firstOrFail(); expect($event->changes)->toEqualCanonicalizing([ - [ - 'type' => 'attribute', - 'id' => 'title', - 'label' => 'Title', - 'old' => 'Old title', - 'new' => 'New title', - ], - [ - 'type' => 'field', - 'id' => $field->layoutElement->uid, - 'label' => $field->name, - 'old' => 'Old body', - 'new' => 'New body', - ], + new ActivityChange('attribute', 'title', 'Title', 'Old title', 'New title'), + new ActivityChange('field', $field->layoutElement->uid, $field->name, 'Old body', 'New body'), ]); }); @@ -123,13 +112,9 @@ ->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([ - 'type' => 'field', - 'id' => $field->layoutElement->uid, - 'label' => $field->name, - 'old' => 'Old body', - 'new' => 'New body', - ]); + ->and($events->first()->changes)->toContainEqual( + new ActivityChange('field', $field->layoutElement->uid, $field->name, 'Old body', 'New body'), + ); }); it('records an update while omitting unsafe field values', function () { @@ -230,13 +215,9 @@ expect($event->eventType)->toBe(ElementUpdated::class) ->and($event->snapshots['subject']['label'])->toBe('Updated title') - ->and($event->changes)->toContainEqual([ - 'type' => 'attribute', - 'id' => 'title', - 'label' => 'Title', - 'old' => 'Original title', - 'new' => 'Updated title', - ]); + ->and($event->changes)->toContainEqual( + new ActivityChange('attribute', 'title', 'Title', 'Original title', 'Updated title'), + ); }); it('records draft creation and its initial save', function () { From 1488f979844970ab74139d1c74659e65297f752b Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 1 Sep 2026 15:18:36 +0200 Subject: [PATCH 07/10] Document activity logging --- docs/activity-logging.md | 314 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) create mode 100644 docs/activity-logging.md diff --git a/docs/activity-logging.md b/docs/activity-logging.md new file mode 100644 index 00000000000..365f8afe795 --- /dev/null +++ b/docs/activity-logging.md @@ -0,0 +1,314 @@ +# 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: Validate data and changes + 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. Validates event data with the event type's rules and checks that the payload can be encoded as JSON. +3. Resolves the actor when the event type did not supply one. +4. Captures labels for the source, event, actor, subject, and site. +5. 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 displays the captured event label and the default activity icon. + +### 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. Add Laravel validation rules for every value the event relies on. + +Use `ActivityChange` when consumers need a consistent old-versus-new representation: + +```php +use CraftCms\Cms\Activity\Data\ActivityChange; + +new ActivityChange( + type: 'field', + id: $field->layoutElement->uid, + label: $field->name, + old: 'Draft', + new: 'Approved', +); +``` + +The change type groups similar values. The change ID must be stable, and the label is captured for display. Old and new values must be JSON-encodable. 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 rules(): array + { + return [ + 'provider' => ['required', 'string'], + 'deliveryId' => ['required', 'string'], + 'recipientCount' => ['required', 'integer', 'min:0'], + ]; + } + + 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()`. + +Use `rules()` for the event's `data()` keys. The recorder prefixes them with `data.`, so rules must use keys such as `provider` rather than `data.provider`. + +### 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()` provides the same fallback behavior for icons. + +## 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. From e5358d3861b4dd9bc59f7da34c86845af7d144aa Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 1 Sep 2026 16:20:07 +0200 Subject: [PATCH 08/10] Remove activity event validation rules --- docs/activity-logging.md | 17 +++-------------- src/Activity/ActivityEventRecorder.php | 7 ++----- src/Activity/ActivityEventType.php | 5 ----- .../Contracts/ActivityEventTypeInterface.php | 3 --- src/Activity/EventTypes/AssetFileReplaced.php | 12 ------------ src/Activity/EventTypes/ElementDuplicated.php | 10 ---------- src/Activity/EventTypes/ElementMerged.php | 11 ----------- src/Activity/EventTypes/ElementMoved.php | 18 ------------------ .../EventTypes/ElementStatusChanged.php | 8 -------- src/Activity/EventTypes/RevisionRestored.php | 5 ----- tests/Feature/Activity/ActivitiesTest.php | 10 ---------- 11 files changed, 5 insertions(+), 101 deletions(-) diff --git a/docs/activity-logging.md b/docs/activity-logging.md index 365f8afe795..64a53003e03 100644 --- a/docs/activity-logging.md +++ b/docs/activity-logging.md @@ -19,7 +19,7 @@ sequenceDiagram Action->>Type: Construct after the action succeeds Action->>Activities: record($event) Activities->>Recorder: record($event) - Recorder->>Recorder: Validate data and changes + Recorder->>Recorder: Check payload shape and JSON encoding Recorder->>Recorder: Resolve actor, subject, site, and labels Recorder->>DB: Insert event and snapshots DB-->>Action: ActivityEvent model @@ -28,7 +28,7 @@ sequenceDiagram The recorder performs these steps: 1. Reads the event type's source, subject, actor, site, data, and changes. -2. Validates event data with the event type's rules and checks that the payload can be encoded as JSON. +2. Checks that event data is a JSON object and that the payload can be encoded as JSON. 3. Resolves the actor when the event type did not supply one. 4. Captures labels for the source, event, actor, subject, and site. 5. Inserts an `ActivityEvent` with the current time. @@ -103,7 +103,7 @@ Do not use a translated label, mutable handle, or array index as the subject ID. ### 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. Add Laravel validation rules for every value the event relies on. +`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: @@ -196,15 +196,6 @@ class CampaignSent extends CampaignActivityEventType ]; } - public static function rules(): array - { - return [ - 'provider' => ['required', 'string'], - 'deliveryId' => ['required', 'string'], - 'recipientCount' => ['required', 'integer', 'min:0'], - ]; - } - public static function format(ActivityEvent $event): string { return t( @@ -221,8 +212,6 @@ class CampaignSent extends CampaignActivityEventType `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()`. -Use `rules()` for the event's `data()` keys. The recorder prefixes them with `data.`, so rules must use keys such as `provider` rather than `data.provider`. - ### Record the event at the action boundary Record the event where the plugin knows that the operation succeeded: diff --git a/src/Activity/ActivityEventRecorder.php b/src/Activity/ActivityEventRecorder.php index 72c81565c09..f124eb6898d 100644 --- a/src/Activity/ActivityEventRecorder.php +++ b/src/Activity/ActivityEventRecorder.php @@ -12,7 +12,6 @@ use CraftCms\Cms\Auth\Impersonation; use CraftCms\Cms\Support\Json; use Illuminate\Container\Attributes\Scoped; -use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; use JsonException; @@ -34,7 +33,7 @@ public function record(ActivityEventTypeInterface $event): ActivityEvent $event->changes(), ); - $this->validatePayload($data, $changes, $event::rules()); + $this->validatePayload($data, $changes); $subject = $event->subject(); $actor = $this->resolveActor($event->actor()); @@ -98,9 +97,8 @@ private function resolveActor(?ActivityActor $actor): ActivityActor /** * @param array $data * @param list> $changes - * @param array $rules */ - private function validatePayload(array $data, array $changes, array $rules): void + private function validatePayload(array $data, array $changes): void { $validJson = static function (string $attribute, mixed $value, Closure $fail): void { try { @@ -121,7 +119,6 @@ static function (string $attribute, mixed $value, Closure $fail): void { $validJson, ], 'changes' => ['list', $validJson], - ...Arr::prependKeysWith($rules, 'data.'), ])->validate(); } } diff --git a/src/Activity/ActivityEventType.php b/src/Activity/ActivityEventType.php index 3d9a318eba4..e592773fb03 100644 --- a/src/Activity/ActivityEventType.php +++ b/src/Activity/ActivityEventType.php @@ -79,11 +79,6 @@ public static function icon(): string return static::ICON; } - public static function rules(): array - { - return []; - } - public static function format(ActivityEvent $event): string|Htmlable|null { return null; diff --git a/src/Activity/Contracts/ActivityEventTypeInterface.php b/src/Activity/Contracts/ActivityEventTypeInterface.php index caf3344aa93..25e292a5ebe 100644 --- a/src/Activity/Contracts/ActivityEventTypeInterface.php +++ b/src/Activity/Contracts/ActivityEventTypeInterface.php @@ -32,8 +32,5 @@ public static function label(): string; public static function icon(): string; - /** @return array */ - public static function rules(): array; - public static function format(ActivityEvent $event): string|Htmlable|null; } diff --git a/src/Activity/EventTypes/AssetFileReplaced.php b/src/Activity/EventTypes/AssetFileReplaced.php index c0c9470c66f..35915a8ed7e 100644 --- a/src/Activity/EventTypes/AssetFileReplaced.php +++ b/src/Activity/EventTypes/AssetFileReplaced.php @@ -42,18 +42,6 @@ public function data(): array ]; } - public static function rules(): array - { - return [ - 'oldFilename' => ['required', 'string'], - 'newFilename' => ['required', 'string'], - 'oldMimeType' => ['nullable', 'string'], - 'newMimeType' => ['nullable', 'string'], - 'oldSize' => ['nullable', 'integer'], - 'newSize' => ['nullable', 'integer'], - ]; - } - public static function format(ActivityEvent $event): string { return t( diff --git a/src/Activity/EventTypes/ElementDuplicated.php b/src/Activity/EventTypes/ElementDuplicated.php index a01a003f0ad..2f36decb371 100644 --- a/src/Activity/EventTypes/ElementDuplicated.php +++ b/src/Activity/EventTypes/ElementDuplicated.php @@ -39,16 +39,6 @@ public function data(): array ]]; } - public static function rules(): array - { - return [ - 'source' => ['required', 'array:type,id,label'], - 'source.type' => ['required', 'string'], - 'source.id' => ['required', 'string'], - 'source.label' => ['required', 'string'], - ]; - } - public static function format(ActivityEvent $event): string { return t( diff --git a/src/Activity/EventTypes/ElementMerged.php b/src/Activity/EventTypes/ElementMerged.php index c54bce7a045..684c28981f4 100644 --- a/src/Activity/EventTypes/ElementMerged.php +++ b/src/Activity/EventTypes/ElementMerged.php @@ -37,17 +37,6 @@ public function data(): array ]; } - public static function rules(): array - { - return [ - 'role' => ['required', 'in:merged,prevailing'], - 'other' => ['required', 'array:type,id,label'], - 'other.type' => ['required', 'string'], - 'other.id' => ['required', 'string'], - 'other.label' => ['required', 'string'], - ]; - } - public static function format(ActivityEvent $event): string { $other = $event->data['other']['label']; diff --git a/src/Activity/EventTypes/ElementMoved.php b/src/Activity/EventTypes/ElementMoved.php index 4e705d1b579..366816d1277 100644 --- a/src/Activity/EventTypes/ElementMoved.php +++ b/src/Activity/EventTypes/ElementMoved.php @@ -39,24 +39,6 @@ public function data(): array ]; } - public static function rules(): array - { - return [ - 'origin' => ['required', 'array:structure,parent,previousSibling'], - 'origin.structure' => ['required', 'uuid'], - 'origin.parent' => ['nullable', 'array:type,id,label'], - 'origin.previousSibling' => ['nullable', 'array:type,id,label'], - 'origin.parent.*' => ['string'], - 'origin.previousSibling.*' => ['string'], - 'destination' => ['required', 'array:structure,parent,previousSibling'], - 'destination.structure' => ['required', 'uuid'], - 'destination.parent' => ['nullable', 'array:type,id,label'], - 'destination.previousSibling' => ['nullable', 'array:type,id,label'], - 'destination.parent.*' => ['string'], - 'destination.previousSibling.*' => ['string'], - ]; - } - public static function format(ActivityEvent $event): string { return t( diff --git a/src/Activity/EventTypes/ElementStatusChanged.php b/src/Activity/EventTypes/ElementStatusChanged.php index edfd51de61f..63d1a5d3656 100644 --- a/src/Activity/EventTypes/ElementStatusChanged.php +++ b/src/Activity/EventTypes/ElementStatusChanged.php @@ -40,14 +40,6 @@ public function data(): array ]; } - public static function rules(): array - { - return [ - 'oldStatus' => ['required', 'string'], - 'newStatus' => ['required', 'string'], - ]; - } - public static function format(ActivityEvent $event): string { return t( diff --git a/src/Activity/EventTypes/RevisionRestored.php b/src/Activity/EventTypes/RevisionRestored.php index 3d497acb7fb..5625923061b 100644 --- a/src/Activity/EventTypes/RevisionRestored.php +++ b/src/Activity/EventTypes/RevisionRestored.php @@ -30,11 +30,6 @@ public function data(): array return ['revisionNum' => $this->revisionNum]; } - public static function rules(): array - { - return ['revisionNum' => ['required', 'integer']]; - } - public static function format(ActivityEvent $event): string { return t( diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index 959184936e7..328a428f920 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -337,11 +337,6 @@ public function data(): array { return ['reason' => $this->reason]; } - - public static function rules(): array - { - return ['reason' => ['required', 'string']]; - } } class TestPluginEntryPublished extends TestPluginEntryUpdated @@ -350,11 +345,6 @@ class TestPluginEntryPublished extends TestPluginEntryUpdated protected const string ICON = 'bullhorn'; - public static function rules(): array - { - return ['reason' => ['required', 'string']]; - } - public static function format(ActivityEvent $event): string { return $event->data['reason']; From 0215cab18a80d896718ca65be3853d38764a2a9d Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 1 Sep 2026 16:27:49 +0200 Subject: [PATCH 09/10] Remove activity payload validation --- docs/activity-logging.md | 10 +++---- src/Activity/ActivityEventRecorder.php | 34 ----------------------- tests/Feature/Activity/ActivitiesTest.php | 26 ++--------------- 3 files changed, 6 insertions(+), 64 deletions(-) diff --git a/docs/activity-logging.md b/docs/activity-logging.md index 64a53003e03..407450e9105 100644 --- a/docs/activity-logging.md +++ b/docs/activity-logging.md @@ -19,7 +19,6 @@ sequenceDiagram Action->>Type: Construct after the action succeeds Action->>Activities: record($event) Activities->>Recorder: record($event) - Recorder->>Recorder: Check payload shape and JSON encoding Recorder->>Recorder: Resolve actor, subject, site, and labels Recorder->>DB: Insert event and snapshots DB-->>Action: ActivityEvent model @@ -28,10 +27,9 @@ sequenceDiagram The recorder performs these steps: 1. Reads the event type's source, subject, actor, site, data, and changes. -2. Checks that event data is a JSON object and that the payload can be encoded as JSON. -3. Resolves the actor when the event type did not supply one. -4. Captures labels for the source, event, actor, subject, and site. -5. Inserts an `ActivityEvent` with the current time. +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. @@ -119,7 +117,7 @@ new ActivityChange( ); ``` -The change type groups similar values. The change ID must be stable, and the label is captured for display. Old and new values must be JSON-encodable. Avoid secrets, access tokens, full request bodies, and other data that should not remain in an audit history. +The change type groups similar values. The change ID must be stable, and 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 diff --git a/src/Activity/ActivityEventRecorder.php b/src/Activity/ActivityEventRecorder.php index f124eb6898d..3f95a8b4195 100644 --- a/src/Activity/ActivityEventRecorder.php +++ b/src/Activity/ActivityEventRecorder.php @@ -4,16 +4,12 @@ namespace CraftCms\Cms\Activity; -use Closure; use CraftCms\Cms\Activity\Contracts\ActivityEventTypeInterface; use CraftCms\Cms\Activity\Data\ActivityActor; use CraftCms\Cms\Activity\Data\ActivityChange; use CraftCms\Cms\Activity\Models\ActivityEvent; use CraftCms\Cms\Auth\Impersonation; -use CraftCms\Cms\Support\Json; use Illuminate\Container\Attributes\Scoped; -use Illuminate\Support\Facades\Validator; -use JsonException; use function CraftCms\Cms\currentUserElement; use function CraftCms\Cms\t; @@ -33,8 +29,6 @@ public function record(ActivityEventTypeInterface $event): ActivityEvent $event->changes(), ); - $this->validatePayload($data, $changes); - $subject = $event->subject(); $actor = $this->resolveActor($event->actor()); $site = $event->site(); @@ -93,32 +87,4 @@ private function resolveActor(?ActivityActor $actor): ActivityActor ? ActivityActor::anonymous() : ActivityActor::system(); } - - /** - * @param array $data - * @param list> $changes - */ - private function validatePayload(array $data, array $changes): void - { - $validJson = static function (string $attribute, mixed $value, Closure $fail): void { - try { - Json::encode($value, JSON_THROW_ON_ERROR); - } catch (JsonException) { - $fail("The $attribute must be valid JSON."); - } - }; - - Validator::make(['data' => $data, 'changes' => $changes], [ - 'data' => [ - 'array', - static function (string $attribute, mixed $value, Closure $fail): void { - if ($value !== [] && array_is_list($value)) { - $fail('The activity event data must be a JSON object.'); - } - }, - $validJson, - ], - 'changes' => ['list', $validJson], - ])->validate(); - } } diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index 328a428f920..18caf5b4dc7 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -29,7 +29,6 @@ use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Route; use Illuminate\Support\HtmlString; -use Illuminate\Validation\ValidationException; use function CraftCms\Cms\t; use function Pest\Laravel\get; @@ -122,17 +121,9 @@ ->assertSeeText(ActivityActorType::Anonymous->value); }); -it('rejects invalid payload sections', function () { +it('rejects invalid changes', function () { expect(fn () => new ActivityChange('field', 'summary', '', null, 'Ready')) - ->toThrow(InvalidArgumentException::class) - ->and(fn () => $this->activities->record(new TestPluginPayload(['value']))) - ->toThrow(ValidationException::class) - ->and(fn () => $this->activities->record(new TestPluginPayload(['value' => NAN]))) - ->toThrow(ValidationException::class) - ->and(fn () => $this->activities->record(new TestPluginEntryUpdated( - reason: 'Edited', - changes: [new ActivityChange('field', 'summary', 'Summary', NAN, null)], - )))->toThrow(ValidationException::class); + ->toThrow(InvalidArgumentException::class); }); it('rolls records back with their semantic action', function () { @@ -306,19 +297,6 @@ public static function source(): ActivitySource } } -class TestPluginPayload extends TestPluginActivityEventType -{ - public function __construct(private readonly array $payload) - { - parent::__construct(); - } - - public function data(): array - { - return $this->payload; - } -} - class TestPluginEntryUpdated extends TestPluginActivityEventType { protected const string LABEL = 'Entry updated'; From 166b6919aa1bff4dd9caef4ac6983d79342fa2de Mon Sep 17 00:00:00 2001 From: Rias Date: Tue, 1 Sep 2026 18:42:46 +0200 Subject: [PATCH 10/10] Remove activity change type and ID --- docs/activity-logging.md | 4 +--- src/Activity/Data/ActivityChange.php | 16 ++++------------ src/Activity/EntryActivity.php | 8 ++------ tests/Feature/Activity/ActivitiesTest.php | 6 +++--- tests/Feature/Activity/EntryActivityTest.php | 8 ++++---- 5 files changed, 14 insertions(+), 28 deletions(-) diff --git a/docs/activity-logging.md b/docs/activity-logging.md index 407450e9105..b5279fbb47f 100644 --- a/docs/activity-logging.md +++ b/docs/activity-logging.md @@ -109,15 +109,13 @@ Use `ActivityChange` when consumers need a consistent old-versus-new representat use CraftCms\Cms\Activity\Data\ActivityChange; new ActivityChange( - type: 'field', - id: $field->layoutElement->uid, label: $field->name, old: 'Draft', new: 'Approved', ); ``` -The change type groups similar values. The change ID must be stable, and 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. +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 diff --git a/src/Activity/Data/ActivityChange.php b/src/Activity/Data/ActivityChange.php index d600a42ec0c..d8b98dfd526 100644 --- a/src/Activity/Data/ActivityChange.php +++ b/src/Activity/Data/ActivityChange.php @@ -9,42 +9,34 @@ readonly class ActivityChange { /** - * @param string $type The kind of value that changed, such as `attribute` or `field`. - * @param string $id The changed value's stable identifier, such as an attribute name or field layout element UID. * @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 $type, - public string $id, public string $label, public mixed $old, public mixed $new, ) { - if ($this->type === '' || $this->id === '' || $this->label === '') { - throw new InvalidArgumentException('Activity changes require a type, ID, and label.'); + if ($this->label === '') { + throw new InvalidArgumentException('Activity changes require a label.'); } } - /** @return array{type: string, id: string, label: string, old: mixed, new: mixed} */ + /** @return array{label: string, old: mixed, new: mixed} */ public function toArray(): array { return [ - 'type' => $this->type, - 'id' => $this->id, 'label' => $this->label, 'old' => $this->old, 'new' => $this->new, ]; } - /** @param array{type: string, id: string, label: string, old: mixed, new: mixed} $change */ + /** @param array{label: string, old: mixed, new: mixed} $change */ public static function fromArray(array $change): self { return new self( - $change['type'], - $change['id'], $change['label'], $change['old'], $change['new'], diff --git a/src/Activity/EntryActivity.php b/src/Activity/EntryActivity.php index a268d08aacd..e5219aa91e7 100644 --- a/src/Activity/EntryActivity.php +++ b/src/Activity/EntryActivity.php @@ -105,7 +105,7 @@ private static function changes( $old = self::attributeValue($original, $attribute); $new = self::attributeValue($entry, $attribute); - self::appendChange($changes, $contentChanged, 'attribute', $attribute, t($label), $old, $new); + self::appendChange($changes, $contentChanged, t($label), $old, $new); } foreach ($entry->getFieldLayout()?->getCustomFields() ?? [] as $field) { @@ -118,8 +118,6 @@ private static function changes( self::appendChange( $changes, $contentChanged, - 'field', - $field->layoutElement->uid, t($field->name, category: 'site'), $old, $new, @@ -133,8 +131,6 @@ private static function changes( private static function appendChange( array &$changes, bool &$contentChanged, - string $type, - string $id, string $label, mixed $old, mixed $new, @@ -156,7 +152,7 @@ private static function appendChange( return; } - $changes[] = new ActivityChange($type, $id, $label, $old, $new); + $changes[] = new ActivityChange($label, $old, $new); } private static function attributeValue(Entry $entry, string $attribute): mixed diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php index 18caf5b4dc7..5e57f0fc642 100644 --- a/tests/Feature/Activity/ActivitiesTest.php +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -54,7 +54,7 @@ reason: 'Published', subject: $draft, site: $site, - changes: [new ActivityChange('field', 'summary', 'Summary', null, 'Ready')], + changes: [new ActivityChange('Summary', null, 'Ready')], )); expect($event->id)->toBeString() @@ -73,7 +73,7 @@ 'event' => ['label' => 'Entry updated'], ]) ->and($event->changes)->toEqual([ - new ActivityChange('field', 'summary', 'Summary', null, 'Ready'), + new ActivityChange('Summary', null, 'Ready'), ]) ->and($event->data)->toBe(['reason' => 'Published']); }); @@ -122,7 +122,7 @@ }); it('rejects invalid changes', function () { - expect(fn () => new ActivityChange('field', 'summary', '', null, 'Ready')) + expect(fn () => new ActivityChange('', null, 'Ready')) ->toThrow(InvalidArgumentException::class); }); diff --git a/tests/Feature/Activity/EntryActivityTest.php b/tests/Feature/Activity/EntryActivityTest.php index 73db53b99b3..dc9d143476b 100644 --- a/tests/Feature/Activity/EntryActivityTest.php +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -88,8 +88,8 @@ ->firstOrFail(); expect($event->changes)->toEqualCanonicalizing([ - new ActivityChange('attribute', 'title', 'Title', 'Old title', 'New title'), - new ActivityChange('field', $field->layoutElement->uid, $field->name, 'Old body', 'New body'), + new ActivityChange('Title', 'Old title', 'New title'), + new ActivityChange($field->name, 'Old body', 'New body'), ]); }); @@ -113,7 +113,7 @@ ->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', $field->layoutElement->uid, $field->name, 'Old body', 'New body'), + new ActivityChange($field->name, 'Old body', 'New body'), ); }); @@ -216,7 +216,7 @@ expect($event->eventType)->toBe(ElementUpdated::class) ->and($event->snapshots['subject']['label'])->toBe('Updated title') ->and($event->changes)->toContainEqual( - new ActivityChange('attribute', 'title', 'Title', 'Original title', 'Updated title'), + new ActivityChange('Title', 'Original title', 'Updated title'), ); });