diff --git a/composer.json b/composer.json index dc6f2a3c243..3dbcb1518f6 100644 --- a/composer.json +++ b/composer.json @@ -184,6 +184,7 @@ "CraftCms\\Cms\\Providers\\CraftServiceProvider" ], "aliases": { + "Activities": "CraftCms\\Cms\\Support\\Facades\\Activities", "Addresses": "CraftCms\\Cms\\Support\\Facades\\Addresses", "Announcements": "CraftCms\\Cms\\Support\\Facades\\Announcements", "AssetIndexer": "CraftCms\\Cms\\Support\\Facades\\AssetIndexer", diff --git a/composer.lock b/composer.lock index bdd689f9397..008ad152f03 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": "53b079bacf2caa8635e37279f2dd3a1e", + "content-hash": "72f75a6b94df5818e51da8cc6c2ae76f", "packages": [ { "name": "bacon/bacon-qr-code", diff --git a/docs/activity-logging.md b/docs/activity-logging.md new file mode 100644 index 00000000000..b5279fbb47f --- /dev/null +++ b/docs/activity-logging.md @@ -0,0 +1,299 @@ +# Activity logging + +Craft records durable activity events for actions that users may need to inspect later, such as creating an entry, applying a draft, moving an element, or replacing an asset file. Each event records what happened, who caused it, what it affected, and when it occurred. + +Activity events are application data stored in the `activityevents` database table. They are not application log messages, so do not write them with Laravel's `Log` facade. + +## How an event is recorded + +Core and plugin code describe an action with an activity event type, then pass an instance to the `Activities` facade. Recording is synchronous. + +```mermaid +sequenceDiagram + participant Action as Business action + participant Type as Activity event type + participant Activities + participant Recorder as ActivityEventRecorder + participant DB as activityevents + + Action->>Type: Construct after the action succeeds + Action->>Activities: record($event) + Activities->>Recorder: record($event) + Recorder->>Recorder: Resolve actor, subject, site, and labels + Recorder->>DB: Insert event and snapshots + DB-->>Action: ActivityEvent model +``` + +The recorder performs these steps: + +1. Reads the event type's source, subject, actor, site, data, and changes. +2. Resolves the actor when the event type did not supply one. +3. Captures labels for the source, event, actor, subject, and site. +4. Inserts an `ActivityEvent` with the current time. + +The insert uses the caller's database transaction. If the action and activity event run in one transaction, rolling back the action also removes the event. Record an event only after the corresponding action has succeeded, but before committing its transaction. + +Craft records its built-in events at the shared write and lifecycle boundaries. For example, entry writes compare the saved entry with its previous state, omit no-op saves, and record only JSON-safe field values. Draft, element lifecycle, structural, and asset replacement operations record their events at their own successful completion points. + +## What an event stores + +An event separates identifiers that support queries from descriptive data that preserves history. + +```mermaid +flowchart LR + Event[ActivityEvent] + Event --> Identity[Queryable identity] + Identity --> EventType[eventType] + Identity --> Source[source] + Identity --> Actor[actorType + actorId] + Identity --> Subject[subjectType + subjectId] + Identity --> Site[siteId] + Event --> Payload[JSON payload] + Payload --> Snapshots[snapshots] + Payload --> Changes[changes] + Payload --> Data[event-specific data] + Event --> Time[occurredAt] +``` + +| Value | Purpose | +| -------------------------- | ----------------------------------------------------- | +| `eventType` | Fully qualified event type class name | +| `source` | Stable source ID, normally `craft` or a plugin handle | +| `actorType`, `actorId` | User, system, or anonymous actor identity | +| `subjectType`, `subjectId` | Stable identity of the affected object | +| `siteId` | Site context, or `null` for a site-neutral event | +| `payload.snapshots` | Labels captured when the event occurred | +| `payload.changes` | Structured old and new values | +| `payload.data` | Data defined by the event type | +| `occurredAt` | Time the action occurred | + +Snapshots keep an event readable after a user, subject, site, or plugin has been removed. If the event type class is no longer available, Craft 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. Event type constructors should use specific parameter types; validate untrusted values before constructing the event. + +Use `ActivityChange` when consumers need a consistent old-versus-new representation: + +```php +use CraftCms\Cms\Activity\Data\ActivityChange; + +new ActivityChange( + label: $field->name, + old: 'Draft', + new: 'Approved', +); +``` + +The label is captured for display. Old and new values must be JSON-encodable. Laravel throws while applying the payload cast if encoding fails. Avoid secrets, access tokens, full request bodies, and other data that should not remain in an audit history. + +## Logging activity from a plugin + +A plugin owns its activity event classes. The stored class name identifies the event type, while the plugin handle identifies its source. + +The following example comes from a campaign plugin that sends an entry through an email provider. It records the campaign entry, site, provider response, recipient count, and responsible user. + +### Define the plugin source once + +Create a base event type so each plugin event reports the same source and translation category: + +```php + $this->provider, + 'deliveryId' => $this->deliveryId, + 'recipientCount' => $this->recipientCount, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Sent with {provider} to {count} recipients.', + [ + 'provider' => $event->data['provider'], + 'count' => $event->data['recipientCount'], + ], + category: self::source()->translationCategory, + ); + } +} +``` + +`LABEL` is the short fallback description. Craft translates it using the source's translation category. `format()` may return a string, an `Htmlable`, or `null`. Returning `null` tells Craft to use the translated label. Craft sanitizes strings and HTML before returning them from `Activities::format()`. + +### Record the event at the action boundary + +Record the event where the plugin knows that the operation succeeded: + +```php +use Acme\Campaigns\Activity\CampaignSent; +use CraftCms\Cms\Support\Facades\Activities; +use CraftCms\Cms\Support\Facades\Sites; + +$delivery = $campaignClient->send($entry); + +Activities::record(new CampaignSent( + subject: $entry, + site: Sites::getSiteById($entry->siteId), + provider: $delivery->provider, + deliveryId: $delivery->id, + recipientCount: $delivery->recipientCount, +)); +``` + +An authenticated request supplies the actor automatically. A queued job should pass the user who requested the send when that attribution is available: + +```php +Activities::record(new CampaignSent( + subject: $entry, + site: Sites::getSiteById($entry->siteId), + provider: $delivery->provider, + deliveryId: $delivery->id, + recipientCount: $delivery->recipientCount, + actor: $requestedBy, +)); +``` + +Do not insert an `ActivityEvent` model directly. The facade supplies actor resolution, validation, snapshots, translation metadata, and a consistent occurrence time. + +## Querying activity + +`Activities::query()` returns an Eloquent builder ordered by `occurredAt` and then `id`, both newest first. The ID tie-breaker makes cursor pagination stable when events share a timestamp. + +```php +use Acme\Campaigns\Activity\CampaignSent; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Support\Facades\Activities; + +$events = Activities::query() + ->subject(ActivitySubject::fromElement($entry)) + ->site($site) + ->source('campaigns') + ->eventTypes(CampaignSent::class) + ->occurredFrom(now()->subMonth()) + ->cursorPaginate(50); + +foreach ($events as $event) { + $label = Activities::format($event); + $icon = Activities::icon($event); +} +``` + +Available query scopes are: + +| Scope | Matches | +| ---------------------------------------- | ----------------------------------------------- | +| `subject(ActivitySubject $subject)` | One subject type and ID | +| `site($site)` | One `Site` or site ID, plus site-neutral events | +| `eventTypes($eventTypes)` | One event type class name or an array of names | +| `actor(ActivityActor $actor)` | One actor type and ID | +| `source(string $source)` | One source ID | +| `occurredFrom(DateTimeInterface $date)` | Events on or after the date | +| `occurredUntil(DateTimeInterface $date)` | Events on or before the date | +| `newestFirst()` | Newest timestamp and ID first | + +Use the formatter instead of calling an event type's `format()` method yourself. `Activities::format()` handles translation, sanitization, missing event classes, and formatter failures. `Activities::icon()` 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. diff --git a/resources/translations/cs/app.php b/resources/translations/cs/app.php index f3c4720ee49..bb2ae33905b 100644 --- a/resources/translations/cs/app.php +++ b/resources/translations/cs/app.php @@ -2316,9 +2316,9 @@ 'six' => 'šest', 'tag' => 'značka', 'tags' => 'tagy', - 'test_email_body' => "Ahoj {{user.friendlyName|e}}, + 'test_email_body' => 'Ahoj {{user.friendlyName|e}}, -Gratulujeme, Craftu se podařilo odeslat email.Byla pro to použita následující nastavení:{{ settings }}", +Gratulujeme, Craftu se podařilo odeslat email.Byla pro to použita následující nastavení:{{ settings }}', 'test_email_heading' => 'Když testujete nastavení emailu:', 'test_email_subject' => 'Toto je testovací email z Craft CMS', 'three' => 'tři', diff --git a/resources/translations/da/app.php b/resources/translations/da/app.php index 7ff0e2ab047..cf7940325a8 100644 --- a/resources/translations/da/app.php +++ b/resources/translations/da/app.php @@ -2233,11 +2233,11 @@ 'You’re viewing a revision. None of the {type}’s fields are editable.' => 'Du ser en revision. Ingen af {type}s felter kan redigeres.', 'Zip Code' => 'Postnummer', '`sectionId` and `fieldId` cannot both be set on an entry.' => '`sectionId` og `fieldId` kan ikke begge indstilles på en post.', - 'account_activation_body' => "Hej {{user.friendlyName|e}}. Tak fordi du opretter en konto med {{siteName}}! Aktiver din konto ved at klikke på følgende link: + 'account_activation_body' => 'Hej {{user.friendlyName|e}}. Tak fordi du opretter en konto med {{siteName}}! Aktiver din konto ved at klikke på følgende link: <{{link}}> -Hvis du ikke forventede denne e-mail, bare ignorere den.", +Hvis du ikke forventede denne e-mail, bare ignorere den.', 'account_activation_heading' => 'Når nogen opretter en bruger:', 'account_activation_subject' => 'Aktiver din bruger', 'address' => 'adresse', @@ -2331,11 +2331,11 @@ 'user' => 'bruger', 'users' => 'brugere', 'validation.required' => 'validation.required', - 'verify_new_email_body' => "Hej {{user.friendlyName|e}}. Bekræft din nye e-mailadresse ved at klikke på dette link: + 'verify_new_email_body' => 'Hej {{user.friendlyName|e}}. Bekræft din nye e-mailadresse ved at klikke på dette link: <{{link}}> -Hvis du ikke forventede denne e-mail, bare ignorere den.", +Hvis du ikke forventede denne e-mail, bare ignorere den.', 'verify_new_email_heading' => 'Når nogen ændrer deres e-mailadresse:', 'verify_new_email_subject' => 'Bekræft din nye e-mailadresse', 'week' => 'uge', diff --git a/resources/translations/de-CH/app.php b/resources/translations/de-CH/app.php index 19984b65383..69a42450337 100644 --- a/resources/translations/de-CH/app.php +++ b/resources/translations/de-CH/app.php @@ -2316,9 +2316,9 @@ 'six' => 'sechs', 'tag' => 'Schlagwort', 'tags' => 'Schlagwörter', - 'test_email_body' => "Hallo {{user.friendlyName|e}}, - -Glückwunsch! Craft konnte erfolgreich eine E-Mail versenden. Hier sind die von dir verwendeten Einstellungen: {{ settings }}", + 'test_email_body' => 'Hallo {{user.friendlyName|e}}, + +Glückwunsch! Craft konnte erfolgreich eine E-Mail versenden. Hier sind die von dir verwendeten Einstellungen: {{ settings }}', 'test_email_heading' => 'Wenn du deine E-Mail-Einstellungen testest:', 'test_email_subject' => 'Dies ist eine Test-E-Mail von Craft.', 'three' => 'drei', diff --git a/resources/translations/de/app.php b/resources/translations/de/app.php index 3b398e0b0bf..b127df148cc 100644 --- a/resources/translations/de/app.php +++ b/resources/translations/de/app.php @@ -2316,9 +2316,9 @@ 'six' => 'sechs', 'tag' => 'Schlagwort', 'tags' => 'Schlagwörter', - 'test_email_body' => "Hallo {{user.friendlyName|e}}, + 'test_email_body' => 'Hallo {{user.friendlyName|e}}, -Glückwunsch! Craft konnte erfolgreich eine E-Mail versenden. Hier sind die von Ihnen verwendeten Einstellungen: {{ settings }}", +Glückwunsch! Craft konnte erfolgreich eine E-Mail versenden. Hier sind die von Ihnen verwendeten Einstellungen: {{ settings }}', 'test_email_heading' => 'Wenn Sie Ihre E-Mail-Einstellungen testen:', 'test_email_subject' => 'Dies ist eine Test-E-Mail von Craft.', 'three' => 'drei', diff --git a/resources/translations/hu/app.php b/resources/translations/hu/app.php index 0bddfb603cf..79b8ba32471 100644 --- a/resources/translations/hu/app.php +++ b/resources/translations/hu/app.php @@ -2233,11 +2233,11 @@ 'You’re viewing a revision. None of the {type}’s fields are editable.' => 'Ön egy revíziót néz. A {type} egyik mezője sem szerkeszthető.', 'Zip Code' => 'Zip-kód', '`sectionId` and `fieldId` cannot both be set on an entry.' => 'A „sectionId” és a „fieldId” nem lehet egyszerre beállítva egy bejegyzésben.', - 'account_activation_body' => "Hello {{user.friendlyName|e}}, + 'account_activation_body' => 'Hello {{user.friendlyName|e}}, Köszönjük, hogy regsiztráltál, a felhasználói filókod aktiváláshoz kattints az alábbi linkre: -<{{link}}>", +<{{link}}>', 'account_activation_heading' => 'ha valaki készít egy fiókot:', 'account_activation_subject' => 'Aktiváld a felhasználói fiókodat', 'address' => 'cím', diff --git a/resources/translations/is/app.php b/resources/translations/is/app.php index 1f29f877731..6394f9a767d 100644 --- a/resources/translations/is/app.php +++ b/resources/translations/is/app.php @@ -2233,7 +2233,7 @@ 'You’re viewing a revision. None of the {type}’s fields are editable.' => 'Þú ert að skoða endurskoðun. Enginn af reitum {type} er hægt að breyta.', 'Zip Code' => 'Póstnúmer', '`sectionId` and `fieldId` cannot both be set on an entry.' => 'Ekki er bæði hægt að stilla `sectionId` og `fieldId` á færslu.', - 'account_activation_body' => "Halló {{user.friendlyName|e}},\\n\\nÞakka þér fyrir að stofna reikning hjá {{siteName}}! Vinsamlegast smelltu á eftirfarandi tengil til að virkja reikninginn þinn:\\n\\n<{{link}}>\\n\\nEf þú áttir ekki von á þessum tölvupósti geturðu einfaldlega hunsað hann.", + 'account_activation_body' => 'Halló {{user.friendlyName|e}},\\n\\nÞakka þér fyrir að stofna reikning hjá {{siteName}}! Vinsamlegast smelltu á eftirfarandi tengil til að virkja reikninginn þinn:\\n\\n<{{link}}>\\n\\nEf þú áttir ekki von á þessum tölvupósti geturðu einfaldlega hunsað hann.', 'account_activation_heading' => 'Þegar einhver stofnar reikning:', 'account_activation_subject' => 'Virkjaðu reikninginn þinn', 'address' => 'heimilisfang', @@ -2268,7 +2268,7 @@ 'files' => 'skrár', 'five' => 'fimm', 'folders' => 'möppur', - 'forgot_password_body' => "Halló, {{user.friendlyName|e}}!\\n\\nSmelltu á þennan tengil til að endurstilla lykilorðið þitt fyrir {{siteName}}:\\n\\n<{{link}}>\\n\\nEf þú áttir ekki von á þessum skilaboðum þarftu ekki að gera neitt, bara hunsa það.", + 'forgot_password_body' => 'Halló, {{user.friendlyName|e}}!\\n\\nSmelltu á þennan tengil til að endurstilla lykilorðið þitt fyrir {{siteName}}:\\n\\n<{{link}}>\\n\\nEf þú áttir ekki von á þessum skilaboðum þarftu ekki að gera neitt, bara hunsa það.', 'forgot_password_heading' => 'Þegar einhver gleymir lykilorðinu sínu:', 'forgot_password_subject' => 'Endurstilla lykilorð', 'four' => 'fjögur', @@ -2316,7 +2316,7 @@ 'six' => 'sex', 'tag' => 'merki', 'tags' => 'merki', - 'test_email_body' => "Halló {{user.friendlyName|e}},\\n\\nTil hamingju! Craft sendi tölvupóst.\\n\\nHér eru stillingarnar sem þú notaðir:\\n\\n{{ stillingar }}", + 'test_email_body' => 'Halló {{user.friendlyName|e}},\\n\\nTil hamingju! Craft sendi tölvupóst.\\n\\nHér eru stillingarnar sem þú notaðir:\\n\\n{{ stillingar }}', 'test_email_heading' => 'Þegar þú prófar tölvupóststillingar þínar:', 'test_email_subject' => 'Þetta er prufuskilaboð frá Craft', 'three' => 'þrír', @@ -2327,7 +2327,7 @@ 'user' => 'notandi', 'users' => 'notendur', 'validation.required' => 'validation.required', - 'verify_new_email_body' => "Halló, {{user.friendlyName|e}}!\\n\\nVinsamlegast staðfestu nýja netfangið þitt með því að smella á þennan tengil:\\n\\n<{{hlekkur}}>\\n\\nEf þú bjóst ekki við að fá þennan tölvupóst, vinsamlegast hunsa það.", + 'verify_new_email_body' => 'Halló, {{user.friendlyName|e}}!\\n\\nVinsamlegast staðfestu nýja netfangið þitt með því að smella á þennan tengil:\\n\\n<{{hlekkur}}>\\n\\nEf þú bjóst ekki við að fá þennan tölvupóst, vinsamlegast hunsa það.', 'verify_new_email_heading' => 'Þegar einhver breytir netfanginu sínu:', 'verify_new_email_subject' => 'Staðfestu nýja netfangið þitt', 'week' => 'viku', diff --git a/resources/translations/it/app.php b/resources/translations/it/app.php index f9d9611fcef..346e8028e62 100644 --- a/resources/translations/it/app.php +++ b/resources/translations/it/app.php @@ -2316,9 +2316,9 @@ 'six' => 'sei', 'tag' => 'tag', 'tags' => 'tag', - 'test_email_body' => "Ciao {{user.friendlyName|e}}, + 'test_email_body' => 'Ciao {{user.friendlyName|e}}, -Congratulazioni! Craft ti ha inviato una email con successo. Ecco le impostazioni da te usate: {{ settings }}", +Congratulazioni! Craft ti ha inviato una email con successo. Ecco le impostazioni da te usate: {{ settings }}', 'test_email_heading' => 'Quando si stanno testando le impostazioni email:', 'test_email_subject' => 'Questa è una email di prova da Craft', 'three' => 'tre', diff --git a/resources/translations/nb/app.php b/resources/translations/nb/app.php index 5783f91b2c3..3042b58721f 100644 --- a/resources/translations/nb/app.php +++ b/resources/translations/nb/app.php @@ -2316,9 +2316,9 @@ 'six' => 'seks', 'tag' => 'merke', 'tags' => 'stikkord', - 'test_email_body' => "Hei, {{user.friendlyName|e}} + 'test_email_body' => 'Hei, {{user.friendlyName|e}} -Gratulerer! Craft var i stand til å sende e-post. Her er innstillingene du benyttet: {{ settings }}", +Gratulerer! Craft var i stand til å sende e-post. Her er innstillingene du benyttet: {{ settings }}', 'test_email_heading' => 'Når du tester e-postinnstillingene:', 'test_email_subject' => 'Dette er en test av e-post fra Craft', 'three' => 'tre', diff --git a/resources/translations/pl/app.php b/resources/translations/pl/app.php index a0909445502..4ae3e699000 100644 --- a/resources/translations/pl/app.php +++ b/resources/translations/pl/app.php @@ -2316,9 +2316,9 @@ 'six' => 'sześć', 'tag' => 'znacznik', 'tags' => 'tagi', - 'test_email_body' => "Cześć {{user.friendlyName|e}}, + 'test_email_body' => 'Cześć {{user.friendlyName|e}}, -Gratulacje! Craft z powodzeniem może wysyłać e-maile. Zostały użyte poniższe ustawienia: {{ settings }}", +Gratulacje! Craft z powodzeniem może wysyłać e-maile. Zostały użyte poniższe ustawienia: {{ settings }}', 'test_email_heading' => 'Podczas testowania ustawień e-mail:', 'test_email_subject' => 'To jest testowy e-mail od Craft', 'three' => 'trzy', diff --git a/resources/translations/tr/app.php b/resources/translations/tr/app.php index 3595bd6cc79..da6485ae0e5 100644 --- a/resources/translations/tr/app.php +++ b/resources/translations/tr/app.php @@ -2316,9 +2316,9 @@ 'six' => 'altı', 'tag' => 'etiket', 'tags' => 'etiketler', - 'test_email_body' => "Merhaba {{user.friendlyName|e}}, + 'test_email_body' => 'Merhaba {{user.friendlyName|e}}, -Tebrikler! Craft eposta göndermeyi başardı.Gerkli ayarlar şu şekilde: {{ settings }}", +Tebrikler! Craft eposta göndermeyi başardı.Gerkli ayarlar şu şekilde: {{ settings }}', 'test_email_heading' => 'Eposta ayarlarınızı test ederken:', 'test_email_subject' => 'Bu Craft\'tan bir test epostasıdır', 'three' => 'üç', diff --git a/src/Activity/Activities.php b/src/Activity/Activities.php new file mode 100644 index 00000000000..e79e096bb6a --- /dev/null +++ b/src/Activity/Activities.php @@ -0,0 +1,82 @@ +events->record($event); + } + + /** @return Builder */ + public function query(): Builder + { + return ActivityEvent::query()->newestFirst(); + } + + public function format(ActivityEvent $event): string|Htmlable + { + $type = $event->eventType; + + if (! is_a($type, ActivityEventTypeInterface::class, true)) { + return $this->capturedLabel($event); + } + + try { + $formatted = $type::format($event); + + if ($formatted === null) { + return t( + $type::label(), + category: $type::source()->translationCategory, + ) ?: $this->capturedLabel($event); + } + + if (is_string($formatted)) { + return $this->htmlSanitizers->sanitize($formatted); + } + + return new HtmlString($this->htmlSanitizers->sanitize($formatted->toHtml())); + } catch (Throwable $exception) { + report($exception); + + return $this->capturedLabel($event); + } + } + + public function icon(ActivityEvent $event): string + { + $type = $event->eventType; + + if (! is_a($type, ActivityEventTypeInterface::class, true)) { + return 'wave-pulse'; + } + + return $type::icon() ?: 'wave-pulse'; + } + + 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..3f95a8b4195 --- /dev/null +++ b/src/Activity/ActivityEventRecorder.php @@ -0,0 +1,90 @@ +data(); + $changes = array_map( + fn (ActivityChange $change) => $change->toArray(), + $event->changes(), + ); + + $subject = $event->subject(); + $actor = $this->resolveActor($event->actor()); + $site = $event->site(); + + $source = $event::source(); + + $snapshots = [ + 'actor' => ['label' => $actor->label], + 'source' => ['label' => $source->label], + 'event' => ['label' => t($event::label(), category: $source->translationCategory)], + ]; + + if ($subject !== null) { + $snapshots['subject'] = ['label' => $subject->label]; + } + + if ($site !== null) { + $snapshots['site'] = ['name' => $site->getName(false)]; + } + + if (($impersonator = $this->impersonation->getImpersonator()) !== null) { + $snapshots['impersonator'] = ['id' => $impersonator->id, 'label' => $impersonator->name]; + } + + return ActivityEvent::query()->create([ + 'eventType' => $event::class, + 'source' => $source->id, + 'actorType' => $actor->type->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(?ActivityActor $actor): ActivityActor + { + if ($actor !== null) { + return $actor; + } + + if (($user = currentUserElement()) !== null) { + return ActivityActor::user($user); + } + + $isHttpRequest = ! app()->runningInConsole() + || (app()->bound('request') && request()->route() !== null); + + return $isHttpRequest + ? ActivityActor::anonymous() + : ActivityActor::system(); + } +} diff --git a/src/Activity/ActivityEventType.php b/src/Activity/ActivityEventType.php new file mode 100644 index 00000000000..e592773fb03 --- /dev/null +++ b/src/Activity/ActivityEventType.php @@ -0,0 +1,86 @@ + $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 format(ActivityEvent $event): string|Htmlable|null + { + return null; + } +} diff --git a/src/Activity/AssetActivity.php b/src/Activity/AssetActivity.php new file mode 100644 index 00000000000..7adceff528e --- /dev/null +++ b/src/Activity/AssetActivity.php @@ -0,0 +1,48 @@ +propagating && + $asset->tempFilePath !== null && + $asset->ruleset->getScenario() === AssetRules::SCENARIO_REPLACE; + } + + public static function original(Asset $asset): Asset + { + if ($asset->id === null) { + throw new LogicException('Only existing asset files can be replaced.'); + } + + return Asset::find()->id($asset->id)->siteId($asset->siteId)->status(null)->one() + ?? throw new LogicException("Could not load asset $asset->id before replacing its file."); + } + + public static function recordReplaced(Asset $asset, Asset $original): void + { + Activities::record(new AssetFileReplaced( + subject: $asset, + site: Sites::getSiteById($asset->siteId), + 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..25e292a5ebe --- /dev/null +++ b/src/Activity/Contracts/ActivityEventTypeInterface.php @@ -0,0 +1,36 @@ + */ + public function data(): array; + + /** @return list */ + public function changes(): array; + + public static function source(): ActivitySource; + + public static function label(): string; + + public static function icon(): string; + + public static function format(ActivityEvent $event): string|Htmlable|null; +} diff --git a/src/Activity/Data/ActivityActor.php b/src/Activity/Data/ActivityActor.php new file mode 100644 index 00000000000..1ddf6a3b2ce --- /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->name ?: $user->email ?: "User #{$user->id}", $user->id); + } + + public static function system(): self + { + return new self(ActivityActorType::System, 'Craft CMS'); + } + + public static function anonymous(): self + { + return new self(ActivityActorType::Anonymous, 'Anonymous'); + } +} diff --git a/src/Activity/Data/ActivityChange.php b/src/Activity/Data/ActivityChange.php new file mode 100644 index 00000000000..d8b98dfd526 --- /dev/null +++ b/src/Activity/Data/ActivityChange.php @@ -0,0 +1,45 @@ +label === '') { + throw new InvalidArgumentException('Activity changes require a label.'); + } + } + + /** @return array{label: string, old: mixed, new: mixed} */ + public function toArray(): array + { + return [ + 'label' => $this->label, + 'old' => $this->old, + 'new' => $this->new, + ]; + } + + /** @param array{label: string, old: mixed, new: mixed} $change */ + public static function fromArray(array $change): self + { + return new self( + $change['label'], + $change['old'], + $change['new'], + ); + } +} diff --git a/src/Activity/Data/ActivitySource.php b/src/Activity/Data/ActivitySource.php new file mode 100644 index 00000000000..f3424445b84 --- /dev/null +++ b/src/Activity/Data/ActivitySource.php @@ -0,0 +1,30 @@ +id === '' || $this->label === '' || $this->translationCategory === '') { + throw new InvalidArgumentException('Activity sources require an ID, label, and translation category.'); + } + } + + public static function fromPlugin(PluginInterface $plugin): self + { + return new self( + $plugin->handle, + $plugin->name ?? $plugin->handle, + $plugin->t9nCategory ?? $plugin->handle, + ); + } +} diff --git a/src/Activity/Data/ActivitySubject.php b/src/Activity/Data/ActivitySubject.php new file mode 100644 index 00000000000..4c5264bcd82 --- /dev/null +++ b/src/Activity/Data/ActivitySubject.php @@ -0,0 +1,38 @@ +type === '' || $this->id === '' || $this->label === '') { + throw new InvalidArgumentException('Activity subjects require a type, ID, and label.'); + } + } + + public static function fromElement(ElementInterface $element): self + { + $canonical = $element->getCanonical(); + + if ($canonical->uid === null) { + throw new InvalidArgumentException('Activity subjects must be saved elements.'); + } + + $label = $canonical->getUiLabel(); + + return new self( + $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/ElementActivity.php b/src/Activity/ElementActivity.php new file mode 100644 index 00000000000..7f6a751160c --- /dev/null +++ b/src/Activity/ElementActivity.php @@ -0,0 +1,30 @@ +getIsCanonical() && + ! $element->getIsDraft() && + ! $element->getIsRevision() && + ! $element->updatingFromDerivative && + (! $element instanceof NestedElementInterface || $element->getPrimaryOwnerId() === null); + } + + public static function shouldRecordWrite(ElementInterface $element, bool $recordActivity = true): bool + { + return $recordActivity && + self::shouldRecord($element) && + ! $element->propagating && + ! $element->resaving && + ! $element->mergingCanonicalChanges; + } +} diff --git a/src/Activity/ElementWriteActivity.php b/src/Activity/ElementWriteActivity.php new file mode 100644 index 00000000000..94ceb04a21e --- /dev/null +++ b/src/Activity/ElementWriteActivity.php @@ -0,0 +1,108 @@ +drafts->capture($element) : null, + ); + } + + /** @param string[] $dirtyFields */ + public function captureContentChanges( + ElementWriteActivityState $state, + ElementInterface $element, + array $dirtyFields, + ): void { + $this->drafts->captureContentChanges($state->draft, $element, $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(new ElementSiteAdded( + subject: $siteElement, + site: $this->sites->getSiteById($siteElement->siteId), + )); + } + } + + $this->drafts->recordWrite($state->draft, $element); + } +} diff --git a/src/Activity/EntryActivity.php b/src/Activity/EntryActivity.php new file mode 100644 index 00000000000..e5219aa91e7 --- /dev/null +++ b/src/Activity/EntryActivity.php @@ -0,0 +1,195 @@ + */ + private const array Attributes = [ + 'title' => 'Title', + 'slug' => 'Slug', + 'enabled' => 'Enabled', + 'enabledForSite' => 'Enabled for site', + 'postDate' => 'Post Date', + 'expiryDate' => 'Expiry Date', + 'authorIds' => 'Authors', + ]; + + public static function shouldRecord(Entry $entry, bool $recordActivity): bool + { + return ElementActivity::shouldRecordWrite($entry, $recordActivity); + } + + public static function original(Entry $entry): ?Entry + { + return Entry::find() + ->id($entry->id) + ->siteId($entry->siteId) + ->status(null) + ->one(); + } + + public static function recordCreated(Entry $entry): void + { + Activities::record(new ElementCreated( + subject: $entry, + site: Sites::getSiteById($entry->siteId), + )); + } + + /** + * @param string[] $dirtyAttributes + * @param string[] $dirtyFields + */ + public static function recordUpdated( + Entry $entry, + Entry $original, + array $dirtyAttributes, + array $dirtyFields, + ): void { + [$changes, $contentChanged] = self::changes($entry, $original, $dirtyAttributes, $dirtyFields); + $oldStatus = $original->getStatus(); + $newStatus = $entry->getStatus(); + + if ($oldStatus === $newStatus && ! $contentChanged) { + return; + } + + $site = Sites::getSiteById($entry->siteId); + $event = $oldStatus === $newStatus + ? new ElementUpdated(subject: $entry, site: $site, changes: $changes) + : new ElementStatusChanged( + subject: $entry, + site: $site, + oldStatus: $oldStatus, + newStatus: $newStatus, + changes: $changes, + ); + + Activities::record($event); + } + + /** + * @param string[] $dirtyAttributes + * @param string[] $dirtyFields + * @return array{list, bool} + */ + private static function changes( + Entry $entry, + Entry $original, + array $dirtyAttributes, + array $dirtyFields, + ): array { + $changes = []; + $contentChanged = false; + + foreach (self::Attributes as $attribute => $label) { + if (! in_array($attribute, $dirtyAttributes, true)) { + continue; + } + + $old = self::attributeValue($original, $attribute); + $new = self::attributeValue($entry, $attribute); + self::appendChange($changes, $contentChanged, t($label), $old, $new); + } + + foreach ($entry->getFieldLayout()?->getCustomFields() ?? [] as $field) { + if (! in_array($field->handle, $dirtyFields, true)) { + continue; + } + + $old = $field->serializeValue($original->getFieldValue($field->handle), $original); + $new = $field->serializeValue($entry->getFieldValue($field->handle), $entry); + self::appendChange( + $changes, + $contentChanged, + t($field->name, category: 'site'), + $old, + $new, + ); + } + + return [$changes, $contentChanged]; + } + + /** @param list $changes */ + private static function appendChange( + array &$changes, + bool &$contentChanged, + string $label, + mixed $old, + mixed $new, + ): void { + if ($old === $new) { + return; + } + + $oldSafe = self::normalizeSafeValue($old); + $newSafe = self::normalizeSafeValue($new); + + if ($oldSafe && $newSafe && $old === $new) { + return; + } + + $contentChanged = true; + + if (! $oldSafe || ! $newSafe) { + return; + } + + $changes[] = new ActivityChange($label, $old, $new); + } + + private static function attributeValue(Entry $entry, string $attribute): mixed + { + return match ($attribute) { + 'enabledForSite' => $entry->getEnabledForSite(), + 'authorIds' => $entry->getAuthorIds(), + default => $entry->{$attribute}, + }; + } + + private static function normalizeSafeValue(mixed &$value): bool + { + if ($value instanceof BackedEnum) { + $value = $value->value; + } + + if ($value instanceof DateTimeInterface) { + $value = $value->format(DateTimeInterface::ATOM); + } + + if (is_string($value)) { + return mb_check_encoding($value) && strip_tags($value) === $value; + } + + if (is_float($value)) { + return is_finite($value); + } + + if (is_int($value) || is_bool($value) || $value === null) { + return true; + } + + if (! is_array($value)) { + return false; + } + + return array_all($value, fn ($item) => self::normalizeSafeValue($item)); + } +} diff --git a/src/Activity/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 @@ + $this->oldFilename, + 'newFilename' => $this->newFilename, + 'oldMimeType' => $this->oldMimeType, + 'newMimeType' => $this->newMimeType, + 'oldSize' => $this->oldSize, + 'newSize' => $this->newSize, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Replaced {oldFilename} with {newFilename}.', + [ + 'oldFilename' => $event->data['oldFilename'], + 'newFilename' => $event->data['newFilename'], + ], + ); + } +} diff --git a/src/Activity/EventTypes/DraftApplied.php b/src/Activity/EventTypes/DraftApplied.php new file mode 100644 index 00000000000..7e952aff0db --- /dev/null +++ b/src/Activity/EventTypes/DraftApplied.php @@ -0,0 +1,14 @@ +source = ActivitySubject::fromElement($source); + } + + public function data(): array + { + return ['source' => [ + 'type' => $this->source->type, + 'id' => $this->source->id, + 'label' => $this->source->label, + ]]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Duplicated from {source}.', + ['source' => $event->data['source']['label']], + ); + } +} diff --git a/src/Activity/EventTypes/ElementMerged.php b/src/Activity/EventTypes/ElementMerged.php new file mode 100644 index 00000000000..684c28981f4 --- /dev/null +++ b/src/Activity/EventTypes/ElementMerged.php @@ -0,0 +1,50 @@ + $this->role, + 'other' => [ + 'type' => $this->other->type, + 'id' => $this->other->id, + 'label' => $this->other->label, + ], + ]; + } + + public static function format(ActivityEvent $event): string + { + $other = $event->data['other']['label']; + + return match ($event->data['role']) { + 'merged' => t('Merged into {other}.', compact('other')), + 'prevailing' => t('Merged {other} into this element.', compact('other')), + default => throw new UnexpectedValueException('Unknown activity merge role.'), + }; + } +} diff --git a/src/Activity/EventTypes/ElementMoved.php b/src/Activity/EventTypes/ElementMoved.php new file mode 100644 index 00000000000..366816d1277 --- /dev/null +++ b/src/Activity/EventTypes/ElementMoved.php @@ -0,0 +1,78 @@ + $this->origin, + 'destination' => $this->destination, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Moved from {origin} to {destination}.', + [ + 'origin' => self::positionDescription($event->data['origin']), + 'destination' => self::positionDescription($event->data['destination']), + ], + ); + } + + private static function positionDescription(mixed $position): string + { + if (! is_array($position)) { + throw new UnexpectedValueException('Activity movement positions must be arrays.'); + } + + $parent = $position['parent']['label'] ?? null; + $previousSibling = $position['previousSibling']['label'] ?? null; + + return match (true) { + $parent !== null && $previousSibling !== null => t( + 'the position after {previousSibling} in {parent}', + compact('parent', 'previousSibling'), + ), + $parent !== null => t( + 'the first position in {parent}', + compact('parent'), + ), + $previousSibling !== null => t( + 'the position after {previousSibling} at the top level', + compact('previousSibling'), + ), + default => t('the first position at the top level'), + }; + } +} diff --git a/src/Activity/EventTypes/ElementRestored.php b/src/Activity/EventTypes/ElementRestored.php new file mode 100644 index 00000000000..d49e67a5b0b --- /dev/null +++ b/src/Activity/EventTypes/ElementRestored.php @@ -0,0 +1,14 @@ + $event->snapshots['site']['name']], + ); + } +} diff --git a/src/Activity/EventTypes/ElementSiteRemoved.php b/src/Activity/EventTypes/ElementSiteRemoved.php new file mode 100644 index 00000000000..b35d9486bd2 --- /dev/null +++ b/src/Activity/EventTypes/ElementSiteRemoved.php @@ -0,0 +1,25 @@ + $event->snapshots['site']['name']], + ); + } +} diff --git a/src/Activity/EventTypes/ElementStatusChanged.php b/src/Activity/EventTypes/ElementStatusChanged.php new file mode 100644 index 00000000000..63d1a5d3656 --- /dev/null +++ b/src/Activity/EventTypes/ElementStatusChanged.php @@ -0,0 +1,53 @@ + $changes + */ + public function __construct( + ElementInterface $subject, + ?Site $site, + private readonly string $oldStatus, + private readonly string $newStatus, + array $changes = [], + ) { + parent::__construct(subject: $subject, site: $site, changes: $changes); + } + + public function data(): array + { + return [ + 'oldStatus' => $this->oldStatus, + 'newStatus' => $this->newStatus, + ]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Status changed from {oldStatus} to {newStatus}.', + [ + 'oldStatus' => t(Str::headline($event->data['oldStatus'])), + 'newStatus' => t(Str::headline($event->data['newStatus'])), + ], + ); + } +} diff --git a/src/Activity/EventTypes/ElementTrashed.php b/src/Activity/EventTypes/ElementTrashed.php new file mode 100644 index 00000000000..17572a6fe34 --- /dev/null +++ b/src/Activity/EventTypes/ElementTrashed.php @@ -0,0 +1,14 @@ + $this->revisionNum]; + } + + public static function format(ActivityEvent $event): string + { + return t( + 'Restored revision {revision}.', + ['revision' => $event->data['revisionNum']], + ); + } +} diff --git a/src/Activity/Models/ActivityEvent.php b/src/Activity/Models/ActivityEvent.php new file mode 100644 index 00000000000..d4093cb7955 --- /dev/null +++ b/src/Activity/Models/ActivityEvent.php @@ -0,0 +1,182 @@ +>, changes: list>, data: array} $payload + * @property array> $snapshots + * @property list $changes + * @property array $data + * @property CarbonImmutable $occurredAt + */ +class ActivityEvent extends BaseModel +{ + #[\Override] + protected $table = Table::ACTIVITYEVENTS; + + #[\Override] + public $timestamps = false; + + #[\Override] + protected function casts(): array + { + return [ + 'id' => 'string', + '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 () => array_map( + ActivityChange::fromArray(...), + $this->payload['changes'], + )); + } + + /** @return Attribute, never> */ + protected function data(): Attribute + { + return Attribute::get(fn () => $this->payload['data']); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function subject(Builder $query, ActivitySubject $subject): Builder + { + return $query + ->where('subjectType', $subject->type) + ->where('subjectId', $subject->id); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function site(Builder $query, Site|int $site): Builder + { + $siteId = $site instanceof Site ? $site->id : $site; + + if ($siteId === null) { + throw new InvalidArgumentException('Activity site criteria require a saved site.'); + } + + return $query->where(fn (Builder $query) => $query + ->whereNull('siteId') + ->orWhere('siteId', $siteId)); + } + + /** + * @param Builder $query + * @param string|list $eventTypes + * @return Builder + */ + #[Scope] + protected function eventTypes(Builder $query, string|array $eventTypes): Builder + { + $eventTypes = (array) $eventTypes; + + if ($eventTypes === []) { + throw new InvalidArgumentException('Activity event type criteria cannot be empty.'); + } + + return $query->whereIn('eventType', $eventTypes); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function actor(Builder $query, ActivityActor $actor): Builder + { + return $query + ->where('actorType', $actor->type) + ->where('actorId', $actor->id); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function source(Builder $query, string $source): Builder + { + if ($source === '') { + throw new InvalidArgumentException('Activity source criteria cannot be empty.'); + } + + return $query->where('source', $source); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function occurredFrom(Builder $query, DateTimeInterface $date): Builder + { + return $query->where('occurredAt', '>=', $date); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function occurredUntil(Builder $query, DateTimeInterface $date): Builder + { + return $query->where('occurredAt', '<=', $date); + } + + /** + * @param Builder $query + * @return Builder + */ + #[Scope] + protected function newestFirst(Builder $query): Builder + { + return $query + ->orderByDesc('occurredAt') + ->orderByDesc('id'); + } +} diff --git a/src/Activity/StructuralElementActivity.php b/src/Activity/StructuralElementActivity.php new file mode 100644 index 00000000000..c766491c444 --- /dev/null +++ b/src/Activity/StructuralElementActivity.php @@ -0,0 +1,105 @@ +siteId ? Sites::getSiteById($duplicate->siteId) : null, + source: $source, + )); + } + + /** + * @param array $origin + * @param array $destination + */ + public static function recordMoved(ElementInterface $element, array $origin, array $destination): void + { + if (! self::shouldRecordMovement($element) || $origin === $destination) { + return; + } + + Activities::record(new ElementMoved( + subject: $element, + site: $element->siteId ? Sites::getSiteById($element->siteId) : null, + origin: $origin, + destination: $destination, + )); + } + + public static function recordMerged(ActivitySubject $merged, ActivitySubject $prevailing): void + { + Activities::record(new ElementMerged( + subject: $merged, + role: 'merged', + other: $prevailing, + )); + + Activities::record(new ElementMerged( + subject: $prevailing, + role: 'prevailing', + other: $merged, + )); + } + + /** @return array{structure: string, parent: array{type: string, id: string, label: string}|null, previousSibling: array{type: string, id: string, label: string}|null} */ + public static function position(string $structureUid, ElementInterface $element): array + { + return [ + 'structure' => $structureUid, + 'parent' => self::nullableReference($element->getParent()), + 'previousSibling' => self::nullableReference($element->getPrevSibling()), + ]; + } + + /** @return array{type: string, id: string, label: string} */ + private static function reference(ElementInterface $element): array + { + return self::subjectReference(ActivitySubject::fromElement($element)); + } + + /** @return array{type: string, id: string, label: string}|null */ + private static function nullableReference(?ElementInterface $element): ?array + { + return $element ? self::reference($element) : null; + } + + /** @return array{type: string, id: string, label: string} */ + private static function subjectReference(ActivitySubject $subject): array + { + return [ + 'type' => $subject->type, + 'id' => $subject->id, + 'label' => $subject->label, + ]; + } +} diff --git a/src/Config/GeneralConfig.php b/src/Config/GeneralConfig.php index 2b6c60a15dc..97885436a3a 100644 --- a/src/Config/GeneralConfig.php +++ b/src/Config/GeneralConfig.php @@ -87,6 +87,28 @@ class GeneralConfig extends BaseConfig */ public string $actionTrigger = 'actions'; + /** + * @var mixed The maximum age of activity events before garbage collection deletes them. + * + * Set to `0` to retain activity indefinitely. + * + * See {@see ConfigHelper::durationInSeconds()} for a list of supported value types. + * + * ::: code + * ```php Static Config + * ->activityRetentionDuration('P90D') + * ``` + * ```shell Environment Override + * CRAFT_ACTIVITY_RETENTION_DURATION=P90D + * ``` + * ::: + * + * @group Garbage Collection + * + * @defaultAlt Unlimited + */ + public mixed $activityRetentionDuration = 0; + /** * @var mixed The URI that users without access to the control panel should be redirected to after activating their account. * @@ -3057,6 +3079,7 @@ public function __construct() ->allowedFileExtensions($this->allowedFileExtensions) ->extraAllowedFileExtensions($this->extraAllowedFileExtensions) // durations + ->activityRetentionDuration($this->activityRetentionDuration) ->cacheDuration($this->cacheDuration) ->cooldownDuration($this->cooldownDuration) ->defaultTokenDuration($this->defaultTokenDuration) @@ -3123,6 +3146,36 @@ public function actionTrigger(string $value): self return $this; } + /** + * The maximum age of activity events before garbage collection deletes them. + * + * Set to `0` to retain activity indefinitely. + * + * See {@see ConfigHelper::durationInSeconds()} for a list of supported value types. + * + * ```php + * ->activityRetentionDuration('P90D') + * ``` + * + * @group Garbage Collection + * + * @defaultAlt Unlimited + * + * @see $activityRetentionDuration + */ + public function activityRetentionDuration(mixed $value): self + { + $duration = ConfigHelper::durationInSeconds($value); + + if ($duration < 0) { + throw new InvalidArgumentException('Activity retention duration must be zero or greater.'); + } + + $this->activityRetentionDuration = $duration; + + return $this; + } + /** * The URI that users without access to the control panel should be redirected to after activating their account. * diff --git a/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php b/src/Database/Migrations/2026_08_25_000000_create_activityevents_table.php new file mode 100644 index 00000000000..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 0b11ba09844..2036fccf936 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..7662e7bbee8 100644 --- a/src/Element/Drafts.php +++ b/src/Element/Drafts.php @@ -4,6 +4,10 @@ namespace CraftCms\Cms\Element; +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; @@ -15,7 +19,10 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Element\Validation\ElementRules; +use CraftCms\Cms\Entry\Elements\Entry; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Activities; +use CraftCms\Cms\Support\Facades\Sites; use CraftCms\Cms\Support\Facades\Structures; use CraftCms\Cms\User\Elements\User; use Exception; @@ -39,6 +46,7 @@ public function __construct( private Elements $elements, + private DraftActivity $activity, ) {} /** @@ -144,6 +152,13 @@ public function createDraft( }), ); + if (! $provisional) { + Activities::record(new DraftCreatedActivityEvent( + subject: $canonical, + site: Sites::getSiteById($canonical->siteId), + )); + } + DB::commit(); } catch (Throwable $e) { DB::rollBack(); @@ -252,12 +267,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 +305,15 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): $newCanonical = $draft; } + if ($entryActivity !== null && $newCanonical instanceof Entry) { + $this->activity->recordProvisionalApplied($newCanonical, ...$entryActivity); + } elseif (! $draft->isProvisionalDraft) { + Activities::record(new DraftAppliedActivityEvent( + subject: $newCanonical, + site: Sites::getSiteById($newCanonical->siteId), + )); + } + DB::commit(); } catch (Throwable $e) { DB::rollBack(); @@ -306,6 +343,24 @@ public function applyDraft(ElementInterface $draft, array $newAttributes = []): return $newCanonical; } + public function discardDraft(ElementInterface $draft): bool + { + return DB::transaction(function () use ($draft) { + $canonical = $draft->getCanonical(); + + if (! $this->elements->deleteElement($draft, true)) { + return false; + } + + Activities::record(new DraftDiscardedActivityEvent( + subject: $canonical, + site: Sites::getSiteById($canonical->siteId), + )); + + return true; + }); + } + /** * Removes draft data from the given draft. * diff --git a/src/Element/Operations/ElementDeletions.php b/src/Element/Operations/ElementDeletions.php index c1a4de808c4..43a081cd14b 100644 --- a/src/Element/Operations/ElementDeletions.php +++ b/src/Element/Operations/ElementDeletions.php @@ -4,6 +4,13 @@ namespace CraftCms\Cms\Element\Operations; +use CraftCms\Cms\Activity\Data\ActivitySubject; +use CraftCms\Cms\Activity\ElementActivity; +use CraftCms\Cms\Activity\EventTypes\ElementDeleted as ElementDeletedActivity; +use CraftCms\Cms\Activity\EventTypes\ElementRestored as ElementRestoredActivity; +use CraftCms\Cms\Activity\EventTypes\ElementSiteRemoved; +use CraftCms\Cms\Activity\EventTypes\ElementTrashed; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -26,6 +33,7 @@ use CraftCms\Cms\Structure\Models\StructureElement as StructureElementModel; use CraftCms\Cms\Support\Arr; use CraftCms\Cms\Support\DateTimeHelper; +use CraftCms\Cms\Support\Facades\Activities; use CraftCms\Cms\Support\Facades\BulkOps; use CraftCms\Cms\Support\Facades\I18N; use CraftCms\Cms\Support\Facades\Sites; @@ -61,7 +69,10 @@ public function mergeElementsByIds(int $mergedElementId, int $prevailingElementI public function mergeElements(ElementInterface $mergedElement, ElementInterface $prevailingElement): bool { - return DB::transaction(function () use ($mergedElement, $prevailingElement) { + $mergedSubject = ActivitySubject::fromElement($mergedElement); + $prevailingSubject = ActivitySubject::fromElement($prevailingElement); + + return DB::transaction(function () use ($mergedElement, $prevailingElement, $mergedSubject, $prevailingSubject) { $data = DB::table(Table::RELATIONS, 'r') ->select(['r.sourceId', 'r.sourceSiteId', 'e.type']) ->join(new Alias(Table::ELEMENTS, 'e'), 'e.id', 'r.sourceId') @@ -182,7 +193,13 @@ public function mergeElements(ElementInterface $mergedElement, ElementInterface event(new ElementsMerged($mergedElement->id, $prevailingElement->id)); - return $this->deleteElement($mergedElement); + if (! $this->deleteElement($mergedElement, recordActivity: false)) { + return false; + } + + StructuralElementActivity::recordMerged($mergedSubject, $prevailingSubject); + + return true; }); } @@ -220,8 +237,11 @@ public function deleteElementById( return $this->deleteElement($element, $hardDelete); } - public function deleteElement(ElementInterface $element, bool $hardDelete = false): bool - { + public function deleteElement( + ElementInterface $element, + bool $hardDelete = false, + bool $recordActivity = true, + ): bool { event($event = new ElementDeleting($element, $hardDelete)); $element->hardDelete = $hardDelete || $event->hardDelete; @@ -234,9 +254,32 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals return false; } - BulkOps::ensure(function () use ($element) { + $recordActivity = $recordActivity && + $this->shouldRecordLifecycleActivity($element); + + return BulkOps::ensure(function () use ($element, $recordActivity) { DB::beginTransaction(); + DateTimeHelper::pause(); + try { + $elementRecord = DB::table(Table::ELEMENTS) + ->select('dateDeleted') + ->where('id', $element->id) + ->lockForUpdate() + ->first(); + + if ($elementRecord === null) { + DB::rollBack(); + + return false; + } + + if (! $element->hardDelete && $elementRecord->dateDeleted !== null) { + DB::commit(); + + return true; + } + while (($record = StructureElementModel::where('elementId', $element->id)->first()) !== null) { while (($child = $record->children(1)->first()) !== null) { /** @var StructureElementModel $child */ @@ -248,8 +291,6 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals $this->elementCaches->invalidateForElement($element); - DateTimeHelper::pause(); - if ($element->hardDelete) { DB::table(Table::ELEMENTS)->delete($element->id); DB::table(Table::SEARCHINDEX) @@ -270,6 +311,20 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals $element->dateDeleted = now(); $element->afterDelete(); + if ($recordActivity) { + $event = $element->hardDelete + ? new ElementDeletedActivity( + subject: $element, + site: Sites::getSiteById($element->siteId), + ) + : new ElementTrashed( + subject: $element, + site: Sites::getSiteById($element->siteId), + ); + + Activities::record($event); + } + if (! $element->hardDelete) { BulkOps::trackElement($element); } @@ -282,11 +337,11 @@ public function deleteElement(ElementInterface $element, bool $hardDelete = fals } finally { DateTimeHelper::resume(); } - }); - event(new ElementDeleted($element)); + event(new ElementDeleted($element)); - return true; + return true; + }); } public function deleteElementForSite(ElementInterface $element): void @@ -312,64 +367,89 @@ public function deleteElementsForSite(array $elements): void } } - $multiSiteElementIds = $firstElement::find() - ->id(Arr::pluck($elements, 'id')) - ->status(null) - ->drafts(null) - ->siteId(['not', $firstElement->siteId]) - ->unique() - ->pluck('elements.id') - ->all(); + DB::transaction(function () use ($elements, $firstElement) { + $siteElementIds = DB::table(Table::ELEMENTS_SITES) + ->whereIn('elementId', Arr::pluck($elements, 'id')) + ->where('siteId', $firstElement->siteId) + ->lockForUpdate() + ->pluck('elementId') + ->flip(); - $multiSiteElementIdsIdx = array_flip($multiSiteElementIds); - $multiSiteElements = []; - $singleSiteElements = []; + $elements = array_filter( + $elements, + fn (ElementInterface $element) => $siteElementIds->has($element->id), + ); - foreach ($elements as $element) { - if (isset($multiSiteElementIdsIdx[$element->id])) { - $multiSiteElements[] = $element; - } else { - $singleSiteElements[] = $element; + if ($elements === []) { + return; } - } - if (! empty($multiSiteElements)) { - foreach ($multiSiteElements as $element) { - event(new ElementDeletingForSite($element)); - } + $multiSiteElementIds = $firstElement::find() + ->id(Arr::pluck($elements, 'id')) + ->status(null) + ->drafts(null) + ->siteId(['not', $firstElement->siteId]) + ->unique() + ->pluck('elements.id') + ->all(); - foreach ($multiSiteElements as $element) { - $element->beforeDeleteForSite(); + $multiSiteElementIdsIdx = array_flip($multiSiteElementIds); + $multiSiteElements = []; + $singleSiteElements = []; + + foreach ($elements as $element) { + if (isset($multiSiteElementIdsIdx[$element->id])) { + $multiSiteElements[] = $element; + } else { + $singleSiteElements[] = $element; + } } - DB::table(Table::ELEMENTS_SITES) - ->whereIn('elementId', $multiSiteElementIds) - ->where('siteId', $firstElement->siteId) - ->delete(); - - $this->elementWrites->resaveElements( - query: $firstElement::find() - ->id($multiSiteElementIds) - ->status(null) - ->drafts(null) - ->site('*') - ->unique(), - continueOnError: true, - updateSearchIndex: false, - ); + if (! empty($multiSiteElements)) { + foreach ($multiSiteElements as $element) { + event(new ElementDeletingForSite($element)); + } - foreach ($multiSiteElements as $element) { - $element->afterDeleteForSite(); - } + foreach ($multiSiteElements as $element) { + $element->beforeDeleteForSite(); + } + + DB::table(Table::ELEMENTS_SITES) + ->whereIn('elementId', $multiSiteElementIds) + ->where('siteId', $firstElement->siteId) + ->delete(); - foreach ($multiSiteElements as $element) { - event(new ElementDeletedForSite($element)); + $this->elementWrites->resaveElements( + query: $firstElement::find() + ->id($multiSiteElementIds) + ->status(null) + ->drafts(null) + ->site('*') + ->unique(), + continueOnError: true, + updateSearchIndex: false, + ); + + foreach ($multiSiteElements as $element) { + $element->afterDeleteForSite(); + + if ($this->shouldRecordLifecycleActivity($element)) { + Activities::record(new ElementSiteRemoved( + subject: $element, + site: Sites::getSiteById($element->siteId), + )); + } + } + + foreach ($multiSiteElements as $element) { + event(new ElementDeletedForSite($element)); + } } - } - foreach ($singleSiteElements as $element) { - $this->deleteElement($element, true); - } + foreach ($singleSiteElements as $element) { + $this->deleteElement($element, true); + } + }); } public function restoreElement(ElementInterface $element): bool @@ -393,6 +473,23 @@ public function restoreElements(array $elements): bool DB::beginTransaction(); try { + $recordActivity = []; + $elementStates = DB::table(Table::ELEMENTS) + ->whereIn('id', Arr::pluck($elements, 'id')) + ->lockForUpdate() + ->pluck('dateDeleted', 'id'); + + foreach ($elements as $element) { + if (! $elementStates->has($element->id) && $element->uid !== null) { + DB::rollBack(); + + return false; + } + + $recordActivity[spl_object_id($element)] = $this->shouldRecordLifecycleActivity($element) && + $elementStates->get($element->id) !== null; + } + /** @var Element $element */ foreach ($elements as $element) { $supportedSites = Arr::keyBy(ElementHelper::supportedSitesForElement($element), 'siteId'); @@ -463,6 +560,13 @@ public function restoreElements(array $elements): bool $element->dateDeleted = null; $element->deletedWithOwner = null; + if ($recordActivity[spl_object_id($element)]) { + Activities::record(new ElementRestoredActivity( + subject: $element, + site: Sites::getSiteById($element->siteId), + )); + } + event(new ElementRestored($element)); } @@ -476,6 +580,12 @@ public function restoreElements(array $elements): bool return true; } + private function shouldRecordLifecycleActivity(ElementInterface $element): bool + { + return $element->uid !== null && + ElementActivity::shouldRecord($element); + } + private function setDraftAndRevisionDeletionState(int $canonicalId, bool $delete = true): void { foreach (['draftId' => Table::DRAFTS, 'revisionId' => Table::REVISIONS] as $foreignKey => $table) { diff --git a/src/Element/Operations/ElementDuplicates.php b/src/Element/Operations/ElementDuplicates.php index e337c7480d1..656f9331ba6 100644 --- a/src/Element/Operations/ElementDuplicates.php +++ b/src/Element/Operations/ElementDuplicates.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Element\Operations; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; @@ -172,6 +173,8 @@ public function duplicateElement( $this->copyModifiedFields($element, $mainClone); } + StructuralElementActivity::recordDuplicated($element, $mainClone); + if ( $placeInStructure && $mainClone->getIsCanonical() && @@ -261,6 +264,8 @@ public function duplicateElement( $this->copyModifiedFields($siteElement, $siteClone); } + StructuralElementActivity::recordDuplicated($siteElement, $siteClone); + $propagatedTo[$siteClone->siteId] = true; if ($siteClone->isNewForSite) { $mainClone->newSiteIds[] = $siteClone->siteId; @@ -280,6 +285,10 @@ public function duplicateElement( } $propagatedTo[$siteId] = true; $mainClone->newSiteIds[] = $siteId; + + if ($siteClone instanceof ElementInterface) { + StructuralElementActivity::recordDuplicated($element, $siteClone); + } } } } diff --git a/src/Element/Operations/ElementWrites.php b/src/Element/Operations/ElementWrites.php index 6e97e9dda00..d67052d7d81 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..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; @@ -14,6 +15,8 @@ use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Jobs\PruneRevisions; use CraftCms\Cms\Support\Arr; +use CraftCms\Cms\Support\Facades\Activities; +use CraftCms\Cms\Support\Facades\Sites; use Illuminate\Container\Attributes\Singleton; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Date; @@ -189,30 +192,38 @@ public function createRevision( */ public function revertToRevision(ElementInterface $revision, int $creatorId): ElementInterface { - $canonical = $revision->getCanonical(); - - event(new ElementRevertingToRevision( - canonical: $canonical, - revisionNum: $revision->revisionNum, - creatorId: $creatorId, - revisionNotes: $revision->revisionNotes, - revision: $revision, - )); - - // "Duplicate" the revision with the source element’s ID and UID - $newSource = $this->elements->updateCanonicalElement($revision, [ - 'revisionCreatorId' => $creatorId, - 'revisionNotes' => t('Reverted content from revision {num}.', ['num' => $revision->revisionNum]), - ]); - - event(new RevertedToRevision( - canonical: $canonical, - revisionNum: $revision->revisionNum, - creatorId: $creatorId, - revisionNotes: $revision->revisionNotes, - revision: $revision, - )); - - return $newSource; + return DB::transaction(function () use ($revision, $creatorId) { + $canonical = $revision->getCanonical(); + + event(new ElementRevertingToRevision( + canonical: $canonical, + revisionNum: $revision->revisionNum, + creatorId: $creatorId, + revisionNotes: $revision->revisionNotes, + revision: $revision, + )); + + // "Duplicate" the revision with the source element’s ID and UID + $newSource = $this->elements->updateCanonicalElement($revision, [ + 'revisionCreatorId' => $creatorId, + 'revisionNotes' => t('Reverted content from revision {num}.', ['num' => $revision->revisionNum]), + ]); + + Activities::record(new RevisionRestored( + subject: $newSource, + site: Sites::getSiteById($newSource->siteId), + revisionNum: $revision->revisionNum, + )); + + event(new RevertedToRevision( + canonical: $canonical, + revisionNum: $revision->revisionNum, + creatorId: $creatorId, + revisionNotes: $revision->revisionNotes, + revision: $revision, + )); + + return $newSource; + }); } } diff --git a/src/GarbageCollection/Actions/PurgeExpiredActivity.php b/src/GarbageCollection/Actions/PurgeExpiredActivity.php new file mode 100644 index 00000000000..b76d978bfc0 --- /dev/null +++ b/src/GarbageCollection/Actions/PurgeExpiredActivity.php @@ -0,0 +1,35 @@ +generalConfig->activityRetentionDuration === 0) { + return; + } + + $this->components->task( + 'purging expired activity', + function () { + DB::table(Table::ACTIVITYEVENTS) + ->select('id') + ->where('occurredAt', '<', now()->subSeconds($this->generalConfig->activityRetentionDuration)) + ->orderBy('id') + ->chunkById( + $this->garbageCollection::CHUNK_SIZE, + fn (Collection $events) => DB::table(Table::ACTIVITYEVENTS) + ->whereIn('id', $events->pluck('id')) + ->delete(), + ); + }, + ); + } +} diff --git a/src/GarbageCollection/GarbageCollection.php b/src/GarbageCollection/GarbageCollection.php index 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/Http/Controllers/Elements/ElementDraftsController.php b/src/Http/Controllers/Elements/ElementDraftsController.php index f1f292b753c..d66cd665717 100644 --- a/src/Http/Controllers/Elements/ElementDraftsController.php +++ b/src/Http/Controllers/Elements/ElementDraftsController.php @@ -415,7 +415,7 @@ public function destroy(): Response Gate::authorize('delete', $element); - if (! $this->elements->deleteElement($element, true)) { + if (! $this->drafts->discardDraft($element)) { return new ElementResponse()->failure($element, t('Couldn’t delete {type}.', [ 'type' => t('draft'), ])); diff --git a/src/Structure/Structures.php b/src/Structure/Structures.php index c0cbd43864d..f0a823ce7e7 100644 --- a/src/Structure/Structures.php +++ b/src/Structure/Structures.php @@ -4,6 +4,7 @@ namespace CraftCms\Cms\Structure; +use CraftCms\Cms\Activity\StructuralElementActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -418,6 +419,16 @@ private function doIt( $mode = Mode::Insert; } + $recordMove = $mode === Mode::Update && StructuralElementActivity::shouldRecordMovement($element); + $structureUid = null; + $origin = null; + + if ($recordMove) { + $structureUid = $this->getStructureById($structureId)->uid + ?? throw new Exception("Structure $structureId does not have a UID."); + $origin = StructuralElementActivity::position($structureUid, $element); + } + /** @var Mode::Insert|Mode::Update $mode */ [$beforeEvent, $afterEvent] = match ($mode) { Mode::Insert => [StructureElementInserted::class, ElementInserted::class], @@ -471,6 +482,21 @@ private function doIt( // Tell the element about it $element->afterMoveInStructure($structureId); + if ($recordMove) { + $movedElement = $element::find() + ->id($element->id) + ->siteId($element->siteId) + ->structureId($structureId) + ->status(null) + ->one() ?? throw new Exception('Unable to capture the moved element position.'); + + StructuralElementActivity::recordMoved( + $movedElement, + $origin, + StructuralElementActivity::position($structureUid, $movedElement), + ); + } + DB::commit(); $this->releaseLock($structureId, $ownsLock); } catch (Throwable $e) { diff --git a/src/Support/Facades/Activities.php b/src/Support/Facades/Activities.php new file mode 100644 index 00000000000..02d963a625b --- /dev/null +++ b/src/Support/Facades/Activities.php @@ -0,0 +1,28 @@ + query() + * @method static string|Htmlable format(ActivityEvent $event) + * @method static string icon(ActivityEvent $event) + * + * @see \CraftCms\Cms\Activity\Activities + */ +class Activities extends Facade +{ + #[\Override] + protected static function getFacadeAccessor(): string + { + return \CraftCms\Cms\Activity\Activities::class; + } +} diff --git a/tests/Feature/Activity/ActivitiesTest.php b/tests/Feature/Activity/ActivitiesTest.php new file mode 100644 index 00000000000..5e57f0fc642 --- /dev/null +++ b/tests/Feature/Activity/ActivitiesTest.php @@ -0,0 +1,360 @@ +activities = app(Activities::class); +}); + +afterEach(function () { + Date::setTestNow(); +}); + +it('records durable actor subject site and payload snapshots', function () { + $actor = User::factory()->createElement(['fullName' => 'Ada Lovelace']); + $subject = Entry::factory()->createElement(['title' => 'Release notes']); + $site = Sites::getSiteById(Site::factory()->create()->id); + $draft = app(Drafts::class)->createDraft($subject, $actor->id); + + $this->actingAs($actor); + + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Published', + subject: $draft, + site: $site, + changes: [new ActivityChange('Summary', null, 'Ready')], + )); + + expect($event->id)->toBeString() + ->and($event->eventType)->toBe(TestPluginEntryUpdated::class) + ->and($event->source)->toBe('test-plugin') + ->and($event->actorType)->toBe(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)->toEqual([ + new ActivityChange('Summary', null, 'Ready'), + ]) + ->and($event->data)->toBe(['reason' => 'Published']); +}); + +it('distinguishes system anonymous and known user actors and captures impersonation', function () { + $system = $this->activities->record(new TestPluginEntryUpdated(reason: 'System')); + $anonymous = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Public form', + actor: ActivityActor::anonymous(), + )); + + $operator = User::factory()->createElement(['fullName' => 'Operator', 'admin' => true]); + $actor = User::factory()->createElement(['fullName' => 'Editor']); + $this->actingAs($actor); + app(Impersonation::class)->setImpersonatorId($operator->id); + + $user = $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); + + expect($system->actorType)->toBe(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('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'), + )->actorType->value); + + get('test/activity-actor') + ->assertOk() + ->assertSeeText(ActivityActorType::Anonymous->value); +}); + +it('rejects invalid changes', function () { + expect(fn () => new ActivityChange('', null, 'Ready')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rolls records back with their semantic action', function () { + DB::beginTransaction(); + + $this->activities->record(new TestPluginEntryUpdated(reason: 'Edited')); + + DB::rollBack(); + + expect($this->activities->query()->get())->toBeEmpty(); +}); + +it('queries fixed criteria and paginates equal timestamps without gaps', function () { + Date::setTestNow('2026-08-25 12:00:00'); + + $subject = new ActivitySubject('document', 'one', 'Document one'); + $otherSubject = new ActivitySubject('document', 'two', 'Document two'); + $craftSubject = Entry::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $craftEvent = $this->activities->record(new ElementStatusChanged( + subject: $craftSubject, + site: null, + oldStatus: 'pending', + newStatus: 'disabled', + )); + $first = $this->activities->record(new TestPluginEntryUpdated(reason: 'First', subject: $subject)); + $second = $this->activities->record(new TestPluginEntryUpdated(reason: 'Second', subject: $subject)); + $this->activities->record(new TestPluginEntryUpdated(reason: 'Other', subject: $otherSubject)); + $this->activities->record(new TestPluginEntryUpdated( + reason: 'Anonymous', + subject: $subject, + actor: ActivityActor::anonymous(), + )); + $this->activities->record(new TestPluginEntryFeatured(subject: $subject)); + + $page = $this->activities->query() + ->subject($subject) + ->eventTypes(TestPluginEntryUpdated::class) + ->actor(ActivityActor::system()) + ->source('test-plugin') + ->occurredFrom(Date::parse('2026-08-25 00:00:00')) + ->occurredUntil(Date::parse('2026-08-25 23:59:59')) + ->cursorPaginate(1); + $nextPage = $this->activities->query() + ->subject($subject) + ->eventTypes(TestPluginEntryUpdated::class) + ->actor(ActivityActor::system()) + ->source('test-plugin') + ->occurredFrom(Date::parse('2026-08-25 00:00:00')) + ->occurredUntil(Date::parse('2026-08-25 23:59:59')) + ->cursorPaginate(1, cursor: $page->nextCursor()); + + expect($page->items())->toHaveCount(1) + ->and($page->items()[0]->id)->toBe($second->id) + ->and($nextPage->items())->toHaveCount(1) + ->and($nextPage->items()[0]->id)->toBe($first->id) + ->and($nextPage->nextCursor())->toBeNull() + ->and($this->activities->query()->source('craft')->sole()->id)->toBe($craftEvent->id); +}); + +it('applies occurrence bounds', function () { + Date::setTestNow('2026-08-25 12:00:00'); + $early = $this->activities->record(new TestPluginEntryUpdated(reason: 'Early')); + + Date::setTestNow('2026-08-25 12:00:02'); + $late = $this->activities->record(new TestPluginEntryUpdated(reason: 'Late')); + + $bound = Date::parse('2026-08-25 12:00:01'); + $from = $this->activities->query()->occurredFrom($bound)->get(); + $until = $this->activities->query()->occurredUntil($bound)->get(); + + expect($from)->toHaveCount(1) + ->and($from[0]->id)->toBe($late->id) + ->and($until)->toHaveCount(1) + ->and($until[0]->id)->toBe($early->id); +}); + +it('keeps site-neutral events in site-scoped queries', function () { + $site = Sites::getSiteById(Site::factory()->create()->id); + $otherSite = Sites::getSiteById(Site::factory()->create()->id); + + $neutral = $this->activities->record(new TestPluginEntryUpdated(reason: 'Neutral')); + $matching = $this->activities->record(new TestPluginEntryUpdated(reason: 'Matching', site: $site)); + $this->activities->record(new TestPluginEntryUpdated(reason: 'Other', site: $otherSite)); + + expect($this->activities->query()->site($site)->pluck('id')->all()) + ->toEqualCanonicalizing([$matching->id, $neutral->id]); +}); + +it('does not bind retained events to mutable Craft records', function () { + $actor = User::factory()->createElement(); + $subject = Entry::factory()->createElement(); + $siteModel = Site::factory()->create(); + + $event = $this->activities->record(new TestPluginEntryUpdated( + reason: 'Edited', + subject: $subject, + actor: $actor, + site: Sites::getSiteById($siteModel->id), + )); + + DB::table(Table::USERS)->where('id', $actor->id)->delete(); + DB::table(Table::ELEMENTS)->where('id', $subject->id)->delete(); + DB::table(Table::SITES)->where('id', $siteModel->id)->delete(); + + $retained = $this->activities->query()->firstOrFail(); + + expect($retained->id)->toBe($event->id) + ->and($retained->snapshots)->toMatchArray([ + 'actor' => ['label' => $actor->name], + 'subject' => ['label' => $subject->getUiLabel()], + 'site' => ['name' => $siteModel->name], + ]); +}); + +it('formats plugin events in the application locale as text or safe HTML', function () { + app()->setLocale('nl'); + + $textEvent = ActivitiesFacade::record(new TestPluginEntryPublished( + reason: 'Klaar', + )); + $htmlEvent = ActivitiesFacade::record(new TestPluginEntryFeatured); + $translatedEvent = ActivitiesFacade::record(new TestPluginEntryTranslated); + + expect(ActivitiesFacade::format($textEvent))->toBe('Klaar') + ->and(ActivitiesFacade::icon($textEvent))->toBe('bullhorn') + ->and(ActivitiesFacade::format($htmlEvent))->toBeInstanceOf(Htmlable::class) + ->and(ActivitiesFacade::format($htmlEvent)->toHtml())->toBe('Entry featured') + ->and(ActivitiesFacade::format( + ActivitiesFacade::record(new TestPluginEntryUpdated(reason: 'Klaar')), + ))->toBe('Entry updated') + ->and(ActivitiesFacade::format($translatedEvent))->toBe('Bewaren'); +}); + +it('translates status labels for the application locale', function () { + app()->setLocale('nl'); + + $event = ActivitiesFacade::record(new ElementStatusChanged( + subject: Entry::factory()->createElement(), + site: null, + oldStatus: 'pending', + newStatus: 'disabled', + )); + + expect(ActivitiesFacade::format($event)) + ->toContain(t('Pending')) + ->toContain(t('Disabled')) + ->not->toContain('Pending') + ->not->toContain('Disabled'); +}); + +it('reports formatter failures and keeps retained events readable when their class is unavailable', function () { + Exceptions::fake(); + + $event = ActivitiesFacade::record(new TestPluginEntryFailed); + $retainedEvent = clone $event; + $retainedEvent->eventType = 'Missing\\ActivityEventType'; + + expect(ActivitiesFacade::format($event))->toBe('Entry formatting failed') + ->and(ActivitiesFacade::format($retainedEvent))->toBe('Entry formatting failed') + ->and(ActivitiesFacade::icon($retainedEvent))->toBe('wave-pulse'); + Exceptions::assertReported(RuntimeException::class); +}); + +abstract class TestPluginActivityEventType extends ActivityEventType +{ + public static function source(): ActivitySource + { + return ActivitySource::fromPlugin(TestPlugin::getInstance()); + } +} + +class TestPluginEntryUpdated extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry updated'; + + public function __construct( + private readonly string $reason, + ElementInterface|ActivitySubject|null $subject = null, + UserElement|ActivityActor|null $actor = null, + ?SiteData $site = null, + array $changes = [], + ) { + parent::__construct($subject, $actor, $site, $changes); + } + + public function data(): array + { + return ['reason' => $this->reason]; + } +} + +class TestPluginEntryPublished extends TestPluginEntryUpdated +{ + protected const string LABEL = 'Entry published'; + + protected const string ICON = 'bullhorn'; + + public static function format(ActivityEvent $event): string + { + return $event->data['reason']; + } +} + +class TestPluginEntryFeatured extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry featured'; + + public static function format(ActivityEvent $event): HtmlString + { + return new HtmlString('Entry featured'); + } +} + +class TestPluginEntryTranslated extends TestPluginActivityEventType +{ + protected const string LABEL = 'Save'; + + public static function source(): ActivitySource + { + return new ActivitySource('test-plugin', 'Test Plugin', 'app'); + } +} + +class TestPluginEntryFailed extends TestPluginActivityEventType +{ + protected const string LABEL = 'Entry formatting failed'; + + public static function format(ActivityEvent $event): never + { + throw new RuntimeException('Formatter failed.'); + } +} diff --git a/tests/Feature/Activity/AssetActivityTest.php b/tests/Feature/Activity/AssetActivityTest.php new file mode 100644 index 00000000000..4653fb978a2 --- /dev/null +++ b/tests/Feature/Activity/AssetActivityTest.php @@ -0,0 +1,68 @@ +set('filesystems.disks.activity-assets', ['driver' => 'local', 'root' => $root]); + + $volume = Volume::factory()->create([ + 'name' => 'Activity assets', + 'handle' => 'activityAssets', + 'fs' => 'disk:activity-assets', + ]); + $folder = app(Folders::class)->getRootFolderByVolumeId($volume->id); + $original = Path::temp('original.txt'); + File::put($original, 'old'); + + $asset = Elements::createElement([ + 'type' => Asset::class, + 'volumeId' => $volume->id, + 'newFolderId' => $folder->id, + 'tempFilePath' => $original, + 'newFilename' => 'original.txt', + ]); + $asset->ruleset->useScenario(AssetRules::SCENARIO_CREATE); + expect(Elements::saveElement($asset))->toBeTrue(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $replacement = Path::temp('replacement.txt'); + File::put($replacement, 'replacement'); + app(Assets::class)->replaceAssetFile($asset, $replacement, 'replacement.txt', 'text/plain'); + + $event = app(Activities::class)->query()->subject(ActivitySubject::fromElement($asset))->firstOrFail(); + + expect($event->eventType)->toBe(AssetFileReplaced::class) + ->and($event->siteId)->toBe($asset->siteId) + ->and($event->data)->toEqual([ + 'oldFilename' => 'original.txt', + 'newFilename' => 'replacement.txt', + 'oldMimeType' => 'text/plain', + 'newMimeType' => 'text/plain', + 'oldSize' => 3, + 'newSize' => 11, + ]) + ->and(app(Activities::class)->format($event)) + ->toBe('Replaced original.txt with replacement.txt.'); +}); diff --git a/tests/Feature/Activity/ElementLifecycleActivityTest.php b/tests/Feature/Activity/ElementLifecycleActivityTest.php new file mode 100644 index 00000000000..13aba534ebe --- /dev/null +++ b/tests/Feature/Activity/ElementLifecycleActivityTest.php @@ -0,0 +1,291 @@ +activities = app(Activities::class); +}); + +it('records trash restore and permanent deletion with durable snapshots', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Release notes']); + $subject = ActivitySubject::fromElement($entry); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry))->toBeTrue(); + + $entry = Entry::find()->id($entry->id)->siteId($entry->siteId)->trashed()->one(); + + expect(Elements::restoreElement($entry))->toBeTrue() + ->and(Elements::deleteElement($entry, true))->toBeTrue(); + + $events = $this->activities->query()->subject($subject)->get()->reverse()->values(); + + expect($events->pluck('eventType')->all())->toBe([ + ElementTrashed::class, + ElementRestoredActivity::class, + ElementDeletedActivity::class, + ])->and($events->pluck('siteId')->unique()->all())->toBe([$entry->siteId]) + ->and($events->pluck('snapshots.subject.label')->unique()->all())->toBe(['Release notes']) + ->and(Entry::find()->id($entry->id)->status(null)->trashed(null)->exists())->toBeFalse(); +}); + +it('does not record cancelled no-op or rolled-back lifecycle actions', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + $cancelNextDelete = true; + + Event::listen(function (ElementDeleting $event) use ($entry, &$cancelNextDelete) { + if (! $cancelNextDelete || $event->element !== $entry) { + return; + } + + $event->isValid = false; + $cancelNextDelete = false; + }); + + expect(Elements::deleteElement($entry))->toBeFalse() + ->and($this->activities->query()->get())->toBeEmpty(); + + expect(Elements::restoreElement($entry))->toBeTrue() + ->and($this->activities->query()->get())->toBeEmpty(); + + DB::beginTransaction(); + expect(Elements::deleteElement($entry))->toBeTrue(); + DB::rollBack(); + + expect($this->activities->query()->get())->toBeEmpty(); + + $entry = Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one(); + expect(Elements::deleteElement($entry))->toBeTrue(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry))->toBeTrue() + ->and($this->activities->query()->get())->toBeEmpty(); + + expect(Elements::deleteElement($entry, true))->toBeTrue(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry, true))->toBeFalse() + ->and(Elements::restoreElement($entry))->toBeFalse() + ->and($this->activities->query()->get())->toBeEmpty(); +}); + +it('dispatches the post-delete event within a surrounding transaction', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementDeleted $event) use ($entry) { + if ($event->element === $entry) { + throw new RuntimeException('Deletion failed.'); + } + }); + + expect(fn () => DB::transaction(fn () => Elements::deleteElement($entry))) + ->toThrow(RuntimeException::class, 'Deletion failed.'); + + expect($this->activities->query()->eventTypes(ElementTrashed::class)->count())->toBe(0) + ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->exists())->toBeTrue(); +}); + +it('records one event per restored element', function () { + $entries = [ + EntryModel::factory()->createElement(), + EntryModel::factory()->createElement(), + ]; + + foreach ($entries as $entry) { + Elements::deleteElement($entry); + } + + $entries = Entry::find() + ->id(array_column($entries, 'id')) + ->siteId($entries[0]->siteId) + ->trashed() + ->all(); + + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::restoreElements($entries))->toBeTrue(); + + $events = $this->activities->query()->eventTypes(ElementRestoredActivity::class)->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('subjectId')->unique())->toHaveCount(2); +}); + +it('records site removal and addition without generic propagation events', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Elements::deleteElementForSite($secondaryEntry); + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get()->reverse()->values(); + + expect($events->pluck('eventType')->all())->toBe([ + ElementSiteRemoved::class, + ElementSiteAdded::class, + ])->and($events->pluck('siteId')->all())->toBe([ + $secondarySite->id, + $secondarySite->id, + ])->and($events->pluck('snapshots.site.name')->unique()->all())->toBe(['Secondary Site']) + ->and($this->activities->format($events[0]))->toBe('Removed from Secondary Site.') + ->and($this->activities->format($events[1]))->toBe('Added to Secondary Site.'); +}); + +it('keeps actor labels after the actor is permanently deleted', function () { + $admin = User::findOne(); + $actor = UserModel::factory()->createElement(['fullName' => 'Ada Lovelace']); + $entry = EntryModel::factory()->createElement(); + $subject = ActivitySubject::fromElement($entry); + actingAs($actor); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(Elements::deleteElement($entry))->toBeTrue(); + + actingAs($admin); + expect(Elements::deleteElement($actor, true))->toBeTrue() + ->and(User::find()->id($actor->id)->status(null)->exists())->toBeFalse(); + + $event = $this->activities->query()->subject($subject)->firstOrFail(); + + expect($event->snapshots['actor']['label'])->toBe('Ada Lovelace'); +}); + +it('rolls back site removal when its post-delete event fails', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementDeletedForSite $event) use ($secondaryEntry) { + if ($event->element === $secondaryEntry) { + throw new RuntimeException('Site deletion failed.'); + } + }); + + expect(fn () => Elements::deleteElementForSite($secondaryEntry)) + ->toThrow(RuntimeException::class, 'Site deletion failed.'); + + expect(Entry::find()->id($secondaryEntry->id)->siteId($secondaryEntry->siteId)->status(null)->exists())->toBeTrue() + ->and($this->activities->query()->eventTypes(ElementSiteRemoved::class)->count())->toBe(0); +}); + +it('rolls back single-site deletion when its post-delete event fails', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementDeleted $event) use ($entry) { + if ($event->element === $entry) { + throw new RuntimeException('Site deletion failed.'); + } + }); + + expect(fn () => Elements::deleteElementForSite($entry)) + ->toThrow(RuntimeException::class, 'Site deletion failed.'); + + expect(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->exists())->toBeTrue() + ->and($this->activities->query()->eventTypes(ElementDeletedActivity::class)->count())->toBe(0); +}); + +it('dispatches the post-save event within a surrounding transaction', function () { + [$entry, $secondarySite] = createLifecycleMultiSiteEntry(); + $secondaryEntry = Entry::find() + ->id($entry->id) + ->siteId($secondarySite->id) + ->status(null) + ->one(); + Elements::deleteElementForSite($secondaryEntry); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen(function (ElementSaved $event) use ($entry) { + if ($event->element === $entry) { + throw new RuntimeException('Save failed.'); + } + }); + + expect(fn () => DB::transaction(fn () => Elements::saveElement($entry, updateSearchIndex: false))) + ->toThrow(RuntimeException::class, 'Save failed.'); + + expect($this->activities->query()->eventTypes(ElementSiteAdded::class)->count())->toBe(0) + ->and(Entry::find()->id($entry->id)->siteId($secondarySite->id)->status(null)->exists())->toBeFalse(); +}); + +function createLifecycleMultiSiteEntry(): array +{ + $secondarySite = Site::factory()->create([ + 'handle' => 'secondary', + 'name' => 'Secondary Site', + ]); + + Sites::refreshSites(); + + $section = Section::factory()->withEntryTypes( + $entryType = EntryType::factory()->create(), + )->create([ + 'propagationMethod' => PropagationMethod::Custom, + ]); + + SectionSiteSettings::factory()->create([ + 'sectionId' => $section->id, + 'siteId' => $secondarySite->id, + 'hasUrls' => true, + 'dateCreated' => $section->dateCreated, + 'dateUpdated' => $section->dateUpdated, + ]); + + app(Fields::class)->invalidateCaches(); + app(Fields::class)->refreshFields(); + + $entry = EntryModel::factory() + ->forSection($section) + ->forEntryType($entryType) + ->createElement(['title' => 'Multi-site entry']); + + $entry->setEnabledForSite([ + $entry->siteId => true, + $secondarySite->id => true, + ]); + Elements::saveElement($entry); + + return [$entry, $secondarySite]; +} diff --git a/tests/Feature/Activity/EntryActivityTest.php b/tests/Feature/Activity/EntryActivityTest.php new file mode 100644 index 00000000000..dc9d143476b --- /dev/null +++ b/tests/Feature/Activity/EntryActivityTest.php @@ -0,0 +1,352 @@ +activities = app(Activities::class); +}); + +it('records entry creation once for each supported site', function () { + $otherSite = Site::factory()->create(); + Sites::refreshSites(); + $entryType = EntryType::factory()->create(); + $section = Section::factory()->withEntryTypes($entryType)->withSites($otherSite)->create(); + + post(action(StoreEntryController::class), [ + 'sectionId' => $section->id, + 'typeId' => $entryType->id, + 'title' => 'New entry', + 'enabled' => true, + ])->assertRedirect()->assertSessionHasNoErrors(); + + $entry = Entry::find()->sectionId($section->id)->title('New entry')->status(null)->one(); + + $events = $this->activities->query() + ->subject(ActivitySubject::fromElement($entry)) + ->eventTypes(ElementCreated::class) + ->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('siteId')->all()) + ->toEqualCanonicalizing([Sites::getPrimarySite()->id, $otherSite->id]); +}); + +it('records normalized entry content changes', function () { + $result = EntryModel::factory() + ->withField('bodyField', PlainText::class, value: 'Old body') + ->createElementWithFields(['title' => 'Old title']); + $entry = $result->element; + $field = $entry->getFieldLayout()->getFieldByHandle('bodyField'); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $entry->title = 'New title'; + $entry->setFieldValue($field->handle, 'New body'); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $event = $this->activities->query() + ->subject(ActivitySubject::fromElement($entry)) + ->eventTypes(ElementUpdated::class) + ->firstOrFail(); + + expect($event->changes)->toEqualCanonicalizing([ + new ActivityChange('Title', 'Old title', 'New title'), + new ActivityChange($field->name, 'Old body', 'New body'), + ]); +}); + +it('records a status change instead of a generic update', function () { + $result = EntryModel::factory() + ->withField('bodyField', PlainText::class, value: 'Old body') + ->createElementWithFields(); + $entry = $result->element; + $field = $entry->getFieldLayout()->getFieldByHandle('bodyField'); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $entry->setEnabledForSite(false); + $entry->setFieldValue($field->handle, 'New body'); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe(ElementStatusChanged::class) + ->and($events->first()->data)->toEqual(['oldStatus' => 'live', 'newStatus' => 'disabled']) + ->and($this->activities->format($events->first()))->toBe('Status changed from Live to Disabled.') + ->and($events->first()->changes)->toContainEqual( + new ActivityChange($field->name, 'Old body', 'New body'), + ); +}); + +it('records an update while omitting unsafe field values', function () { + $result = EntryModel::factory() + ->withField('bodyField', PlainText::class, value: 'Old body') + ->createElementWithFields(['title' => 'Old title']); + $entry = $result->element; + $field = $entry->getFieldLayout()->getFieldByHandle('bodyField'); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $entry->setFieldValue($field->handle, 'Rendered HTML'); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->firstOrFail(); + + expect($event->eventType)->toBe(ElementUpdated::class) + ->and($event->changes)->toBeEmpty(); +}); + +it('does not record cancelled saves', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $entry->title = 'Cancelled title'; + + Event::listen(function (ElementSaving $event) use ($entry) { + if ($event->element === $entry) { + $event->isValid = false; + } + }); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeFalse() + ->and($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); +}); + +it('does not record no-op, draft, resave, or rolled-back work', function () { + $entry = EntryModel::factory()->createElement([ + 'title' => 'Original title', + 'expiryDate' => now()->addDay(), + ]); + $entry->expiryDate = Date::parse($entry->expiryDate->format(DATE_ATOM)); + $entry->setDirtyAttributes(['expiryDate']); + + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue() + ->and($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); + + app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); + + Elements::resaveElements( + Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null), + updateSearchIndex: false, + ); + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); + + $entry = Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one(); + $entry->title = 'Rolled back title'; + + DB::beginTransaction(); + expect(Elements::saveElement($entry, updateSearchIndex: false))->toBeTrue(); + DB::rollBack(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty() + ->and(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one()->title) + ->toBe('Original title'); +}); + +it('records draft work against the canonical entry', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, name: 'Campaign draft'); + + $draft->title = 'Draft title'; + expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue(); + + app(Drafts::class)->applyDraft($draft); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events->pluck('eventType')->all())->toBe([ + DraftApplied::class, + DraftSaved::class, + DraftCreated::class, + ])->and($events->pluck('siteId')->unique()->all())->toBe([$entry->siteId]); +}); + +it('records applying a provisional draft as an entry update', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft->title = 'Updated title'; + expect(Elements::saveElement($draft, updateSearchIndex: false))->toBeTrue(); + + app(Drafts::class)->applyDraft($draft); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->sole(); + + expect($event->eventType)->toBe(ElementUpdated::class) + ->and($event->snapshots['subject']['label'])->toBe('Updated title') + ->and($event->changes)->toContainEqual( + new ActivityChange('Title', 'Original title', 'Updated title'), + ); +}); + +it('records draft creation and its initial save', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + postJson(action([ElementDraftsController::class, 'store']), [ + 'elementType' => Entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + 'title' => 'Draft title', + ])->assertOk(); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events->pluck('eventType')->all())->toBe([DraftSaved::class, DraftCreated::class]); +}); + +it('records named draft discard through the endpoint', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, name: 'Discard me'); + postJson(action([ElementDraftsController::class, 'destroy']), [ + 'elementType' => Entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + 'draftId' => $draft->draftId, + ])->assertOk(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->pluck('eventType')->all())->toBe([ + DraftDiscarded::class, + DraftCreated::class, + ]); +}); + +it('ignores provisional creation and autosave but records its endpoint discard', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $provisional = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + $provisional->title = 'Autosaved title'; + expect(Elements::saveElement($provisional, updateSearchIndex: false))->toBeTrue(); + postJson(action([ElementDraftsController::class, 'destroy']), [ + 'elementType' => Entry::class, + 'elementId' => $entry->id, + 'siteId' => $entry->siteId, + 'draftId' => $provisional->draftId, + 'provisional' => 1, + ])->assertOk(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->pluck('eventType')->all())->toBe([ + DraftDiscarded::class, + ]); +}); + +it('records an explicitly saved unpublished draft as created', function () { + $entry = EntryModel::factory()->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(app(Drafts::class)->saveElementAsDraft($entry, User::findOne()->id))->toBeTrue(); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->firstOrFail(); + + expect($event->eventType)->toBe(DraftCreated::class) + ->and($event->siteId)->toBe($entry->siteId); +}); + +it('records provisional draft promotion as creation and ignores no-op saves', function () { + $entry = EntryModel::factory()->createElement(); + $draft = app(Drafts::class)->createDraft($entry, User::findOne()->id, provisional: true); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $payload = [ + 'elementType' => Entry::class, + 'draftId' => $draft->draftId, + 'siteId' => $draft->siteId, + 'title' => 'Saved draft', + ]; + + postJson(action([ElementDraftsController::class, 'store']), [ + ...$payload, + 'dropProvisional' => true, + ])->assertOk(); + + $event = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->sole(); + + expect($event->eventType)->toBe(DraftCreated::class); + + DB::table(Table::ACTIVITYEVENTS)->delete(); + postJson(action([ElementDraftsController::class, 'store']), $payload)->assertOk(); + + expect($this->activities->query()->subject(ActivitySubject::fromElement($entry))->get())->toBeEmpty(); +}); + +it('records revision restoration without a generic update', function () { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $revisionId = app(Revisions::class)->createRevision($entry, User::findOne()->id, force: true); + $revision = Entry::find()->id($revisionId)->revisions()->status(null)->one(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + app(Revisions::class)->revertToRevision($revision, User::findOne()->id); + + $events = $this->activities->query()->subject(ActivitySubject::fromElement($entry))->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe(RevisionRestored::class) + ->and($events->first()->data)->toBe(['revisionNum' => $revision->revisionNum]) + ->and($events->first()->siteId)->toBe($entry->siteId) + ->and($this->activities->format($events->first()))->toBe("Restored revision {$revision->revisionNum}."); +}); + +it('rolls back revision restoration when a post-save event fails', function (string $eventType) { + $entry = EntryModel::factory()->createElement(['title' => 'Original title']); + $revisionId = app(Revisions::class)->createRevision($entry, User::findOne()->id, force: true); + $revision = Entry::find()->id($revisionId)->revisions()->status(null)->one(); + $entry->title = 'Current title'; + Elements::saveElement($entry, updateSearchIndex: false); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + Event::listen($eventType, function (object $event) use ($entry) { + if (! $event instanceof ElementSaved || $event->element->id === $entry->id) { + throw new RuntimeException('Post-save event failed.'); + } + }); + + expect(fn () => app(Revisions::class)->revertToRevision($revision, User::findOne()->id)) + ->toThrow(RuntimeException::class, 'Post-save event failed.'); + + expect(Entry::find()->id($entry->id)->siteId($entry->siteId)->status(null)->one()->title)->toBe('Current title') + ->and($this->activities->query()->eventTypes(RevisionRestored::class)->count())->toBe(0); +})->with([ElementSaved::class, RevertedToRevision::class]); diff --git a/tests/Feature/Activity/StructuralElementActivityTest.php b/tests/Feature/Activity/StructuralElementActivityTest.php new file mode 100644 index 00000000000..2c3d8063959 --- /dev/null +++ b/tests/Feature/Activity/StructuralElementActivityTest.php @@ -0,0 +1,147 @@ +activities = app(Activities::class); +}); + +it('records duplication instead of nested creation', function () { + $source = EntryModel::factory()->createElement(['title' => 'Source entry']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $duplicate = app(ElementDuplicates::class)->duplicateElement($source); + $events = $this->activities->query()->subject(ActivitySubject::fromElement($duplicate))->get(); + + expect($events)->toHaveCount(1) + ->and($events->first()->eventType)->toBe(ElementDuplicated::class) + ->and($events->first()->data['source'])->toEqual([ + 'type' => $source::class, + 'id' => $source->uid, + 'label' => $source->getUiLabel(), + ]) + ->and($this->activities->format($events->first()))->toBe('Duplicated from Source entry.'); +}); + +it('records one duplication event for each affected site', function () { + $otherSite = Site::factory()->create(); + Sites::refreshSites(); + $entryType = EntryType::factory()->create(); + $section = Section::factory()->withEntryTypes($entryType)->withSites($otherSite)->create(); + $source = EntryModel::factory() + ->forSection($section) + ->forEntryType($entryType) + ->createElement(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $duplicate = app(ElementDuplicates::class)->duplicateElement($source); + $events = $this->activities->query() + ->subject(ActivitySubject::fromElement($duplicate)) + ->eventTypes(ElementDuplicated::class) + ->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('siteId')->all()) + ->toEqualCanonicalizing([Sites::getPrimarySite()->id, $otherSite->id]); +}); + +it('records one event per bulk duplication subject', function () { + $sources = EntryModel::factory()->count(2)->create(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + $action = new Duplicate; + $query = Entry::find()->id($sources->pluck('id'))->status(null); + + expect($action->performAction($query))->toBeTrue(); + + $events = $this->activities->query()->eventTypes(ElementDuplicated::class)->get(); + + expect($events)->toHaveCount(2) + ->and($events->pluck('subjectId')->unique())->toHaveCount(2); +}); + +it('records captured structure movement positions', function () { + [ + 'structure' => $structure, + 'root' => $root, + 'children' => [$parent, $moved], + ] = createStructureHierarchy(); + DB::table(Table::ACTIVITYEVENTS)->delete(); + + expect(app(Structures::class)->append($structure->id, $moved, $parent))->toBeTrue(); + + $event = $this->activities->query() + ->subject(ActivitySubject::fromElement($moved)) + ->eventTypes(ElementMoved::class) + ->firstOrFail(); + + expect($event->data)->toMatchArray([ + 'origin' => [ + 'structure' => $structure->uid, + 'parent' => [ + 'type' => $root::class, + 'id' => $root->uid, + 'label' => $root->getUiLabel(), + ], + 'previousSibling' => [ + 'type' => $parent::class, + 'id' => $parent->uid, + 'label' => $parent->getUiLabel(), + ], + ], + 'destination' => [ + 'structure' => $structure->uid, + 'parent' => [ + 'type' => $parent::class, + 'id' => $parent->uid, + 'label' => $parent->getUiLabel(), + ], + 'previousSibling' => null, + ], + ])->and($this->activities->format($event))->toBe( + "Moved from the position after {$parent->getUiLabel()} in {$root->getUiLabel()} to the first position in {$parent->getUiLabel()}.", + ); +}); + +it('records both merge subjects without nested updates or deletion', function () { + $merged = EntryModel::factory()->createElement(['title' => 'Merged entry']); + $prevailing = EntryModel::factory()->createElement(['title' => 'Prevailing entry']); + DB::table(Table::ACTIVITYEVENTS)->delete(); + Queue::fake(); + + expect(app(ElementDeletions::class)->mergeElements($merged, $prevailing))->toBeTrue(); + + $events = $this->activities->query()->get(); + $mergedEvent = $events->firstWhere('subjectId', $merged->uid); + $prevailingEvent = $events->firstWhere('subjectId', $prevailing->uid); + + expect($events)->toHaveCount(2) + ->and($events->pluck('eventType')->unique()->all())->toBe([ElementMerged::class]) + ->and($events->pluck('subjectId')->all())->toEqualCanonicalizing([$merged->uid, $prevailing->uid]) + ->and($this->activities->format($mergedEvent))->toBe('Merged into Prevailing entry.') + ->and($this->activities->format($prevailingEvent))->toBe('Merged Merged entry into this element.'); +}); diff --git a/tests/Feature/Element/ElementEagerLoaderTest.php b/tests/Feature/Element/ElementEagerLoaderTest.php index b552b2ec014..12942af8bcb 100644 --- a/tests/Feature/Element/ElementEagerLoaderTest.php +++ b/tests/Feature/Element/ElementEagerLoaderTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\DraftActivity; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; @@ -366,7 +367,10 @@ function invokeElementEagerLoaderMethod(ElementEagerLoader $loader, string $meth }); it('uses custom element factories and provisional drafts when requested', function () { - $loader = app(ElementEagerLoader::class, ['drafts' => new TestElementEagerLoaderDrafts(app(Elements::class))]); + $loader = app(ElementEagerLoader::class, ['drafts' => new TestElementEagerLoaderDrafts( + app(Elements::class), + app(DraftActivity::class), + )]); $source = new TestElementEagerLoaderSourceElement(['id' => 1]); TestElementEagerLoaderSourceElement::setTestEagerLoadingMap('drafty', [ diff --git a/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php new file mode 100644 index 00000000000..e7160daa847 --- /dev/null +++ b/tests/Feature/GarbageCollection/Actions/PurgeExpiredActivityTest.php @@ -0,0 +1,56 @@ + Date::setTestNow()); + +it('leaves activity intact when retention is unlimited', function () { + Date::setTestNow('2025-08-26 12:00:00'); + $event = app(Activities::class)->record(new ElementCreated( + subject: new ActivitySubject('document', 'one', 'Document one'), + )); + + Date::setTestNow('2026-08-26 12:00:00'); + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->whereKey($event->id)->exists())->toBeTrue(); +}); + +it('purges activity older than the retention duration', function () { + Cms::config()->activityRetentionDuration(3600); + $activities = app(Activities::class); + $subject = new ActivitySubject('document', 'one', 'Document one'); + + Date::setTestNow('2026-08-26 10:00:00'); + $expired = $activities->record(new ElementCreated(subject: $subject)); + + Date::setTestNow('2026-08-26 12:00:00'); + $retained = $activities->record(new ElementUpdated(subject: $subject)); + + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->whereKey($retained->id)->exists())->toBeTrue() + ->and(ActivityEvent::query()->whereKey($expired->id)->exists())->toBeFalse(); +}); + +it('retains events until they cross the cutoff', function () { + Cms::config()->activityRetentionDuration(3600); + Date::setTestNow('2026-08-26 11:00:00'); + $event = app(Activities::class)->record(new ElementCreated( + subject: new ActivitySubject('document', 'one', 'Document one'), + )); + + Date::setTestNow('2026-08-26 12:00:00'); + app(PurgeExpiredActivity::class)(); + + expect(ActivityEvent::query()->whereKey($event->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php index bcd802d9b0f..9ca218c8e1e 100644 --- a/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/CreateElementControllerTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\DraftActivity; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; use CraftCms\Cms\Element\Elements; @@ -70,7 +71,7 @@ function createElementControllerPayload(object $section, object $entryType, arra }); it('returns a failure response when saving the draft fails', function () { - app()->instance(Drafts::class, new readonly class(app(Elements::class)) extends Drafts + app()->instance(Drafts::class, new readonly class(app(Elements::class), app(DraftActivity::class)) extends Drafts { public function saveElementAsDraft( ElementInterface $element, diff --git a/tests/Feature/Http/Controllers/MatrixControllerTest.php b/tests/Feature/Http/Controllers/MatrixControllerTest.php index 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/Config/GeneralConfigTest.php b/tests/Unit/Config/GeneralConfigTest.php index 1f59f1b27d8..a04b522b9e3 100644 --- a/tests/Unit/Config/GeneralConfigTest.php +++ b/tests/Unit/Config/GeneralConfigTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Config\GeneralConfig; use Illuminate\Support\Facades\Config; +use InvalidArgumentException; it('can get from container', function () { expect(app(GeneralConfig::class))->toBe(Config::get('craft.general')); @@ -35,6 +36,14 @@ expect($config->compiledTemplatesPath)->toBe('@storage/custom-compiled-templates'); }); +it('normalizes activity retention durations and rejects negative values', function () { + $config = GeneralConfig::create(); + + expect($config->activityRetentionDuration)->toBe(0) + ->and($config->activityRetentionDuration('P1D')->activityRetentionDuration)->toBe(86400) + ->and(fn () => $config->activityRetentionDuration(-1))->toThrow(InvalidArgumentException::class); +}); + it('requires a default Asset Transformer', function () { $config = GeneralConfig::create(); diff --git a/tests/Unit/Element/ElementWrites/PropagateElementTest.php b/tests/Unit/Element/ElementWrites/PropagateElementTest.php index e8c141adc21..106c8c6d54f 100644 --- a/tests/Unit/Element/ElementWrites/PropagateElementTest.php +++ b/tests/Unit/Element/ElementWrites/PropagateElementTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Cms; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -39,6 +40,7 @@ Mockery::mock(ElementCaches::class), Mockery::mock(Search::class), $this->sites, + Mockery::mock(ElementWriteActivity::class), ); $this->primarySite = new Site([ @@ -444,6 +446,7 @@ protected function saveInternal( bool $saveContent = false, ?ElementSiteSettings &$siteSettingsRecord = null, ?bool $inheritedUpdateSearchIndex = null, + bool $recordActivity = true, ): bool { $this->saveCalls[] = [ 'siteElement' => $element, diff --git a/tests/Unit/Element/ElementWrites/PropagateElementsTest.php b/tests/Unit/Element/ElementWrites/PropagateElementsTest.php index da5780a1dc5..486670cf58d 100644 --- a/tests/Unit/Element/ElementWrites/PropagateElementsTest.php +++ b/tests/Unit/Element/ElementWrites/PropagateElementsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Element\BulkOp\BulkOps; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Element; @@ -122,6 +123,7 @@ public function getCurrentSite(): Site $this->elementCaches, Mockery::mock(Search::class), app(SitesService::class), + Mockery::mock(ElementWriteActivity::class), ); $this->writes = $this->action; }); diff --git a/tests/Unit/Element/ElementWrites/ResaveElementsTest.php b/tests/Unit/Element/ElementWrites/ResaveElementsTest.php index 6f29b557b18..3eab5d33c7d 100644 --- a/tests/Unit/Element/ElementWrites/ResaveElementsTest.php +++ b/tests/Unit/Element/ElementWrites/ResaveElementsTest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use CraftCms\Cms\Activity\ElementWriteActivity; use CraftCms\Cms\Element\BulkOp\BulkOps as BulkOpsService; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Contracts\NestedElementInterface; @@ -41,6 +42,7 @@ Mockery::mock(ElementCaches::class), Mockery::mock(Search::class), Mockery::mock(Sites::class), + Mockery::mock(ElementWriteActivity::class), ); $this->saveElementAction = $this->action; });