From 2811cee59b0f2c757498b3492787c9b36b3f6249 Mon Sep 17 00:00:00 2001 From: Louis Charette Date: Fri, 7 Aug 2026 11:47:38 -0400 Subject: [PATCH 01/31] feat(activity): Enhance activity logging with context and subject relations - Added new fields to the activity model: context_id, context_type, subject_id, subject_type, and metadata. - Added new ActivityRecorder service to handle activity logging with context and subject relations. - Refactored code to use ActivityRecorder instead of UserActivityLogger. - Updated UserActivityLoggerTest to mark it as deprecated. - Introduced GroupActivityTypes and RoleActivityTypes enums for structured activity types. - Added MorphableModelInterface to define polymorphic model contracts and updated existing models and interfaces to implement MorphableModelInterface. --- .github/copilot-instructions.md | 2 +- .vscode/settings.json | 9 +- packages/sprinkle-account/app/src/Account.php | 3 + .../app/src/Bakery/CreateUser.php | 12 +- .../EmailVerificationValidationAction.php | 14 +- .../ForgetPasswordSetPasswordAction.php | 30 ++-- .../app/src/Controller/ProfileEditAction.php | 13 +- .../src/Controller/ProfileEmailEditAction.php | 13 +- .../app/src/Controller/RegisterAction.php | 12 +- .../app/src/Controller/SettingsEditAction.php | 13 +- .../Migrations/v400/ActivitiesTable.php | 2 +- .../Migrations/v610/ActivitiesV2Table.php | 70 ++++++++++ .../app/src/Database/Models/Activity.php | 37 ++++- .../Models/Interfaces/ActivityInterface.php | 35 +++-- .../Models/Interfaces/GroupInterface.php | 3 +- .../Models/Interfaces/PermissionInterface.php | 3 +- .../Interfaces/PersistenceInterface.php | 3 +- .../Models/Interfaces/RoleInterface.php | 3 +- .../Models/Interfaces/UserInterface.php | 3 +- .../Interfaces/UserVerificationInterface.php | 3 +- .../app/src/Database/Models/RoleUsers.php | 4 +- .../app/src/Listener/UpgradePassword.php | 12 +- .../app/src/Listener/UserLogoutActivity.php | 14 +- .../app/src/Listener/UserSignInActivity.php | 13 +- .../app/src/Log/AccountActivityTypes.php | 23 +++ .../app/src/Log/ActivityRecorder.php | 49 +++++++ .../app/src/Log/ActivityRecorderInterface.php | 55 ++++++++ .../src/Log/UserActivityDatabaseHandler.php | 5 + .../app/src/Log/UserActivityLogger.php | 2 + .../src/Log/UserActivityLoggerInterface.php | 2 + .../src/ServicesProvider/LoggersService.php | 5 +- .../app/tests/Bakery/CreateAdminUserTest.php | 10 +- .../Database/Migrations/MigrationsTest.php | 5 + .../tests/Database/Models/ActivityTest.php | 56 +++++++- .../tests/Listener/UpgradePasswordTest.php | 14 +- .../app/tests/Log/ActivityRecorderTest.php | 132 ++++++++++++++++++ .../app/tests/Log/UserActivityLoggerTest.php | 5 +- .../Controller/Group/GroupCreateAction.php | 15 +- .../Controller/Group/GroupDeleteAction.php | 14 +- .../src/Controller/Group/GroupEditAction.php | 14 +- .../src/Controller/Role/RoleCreateAction.php | 14 +- .../src/Controller/Role/RoleDeleteAction.php | 14 +- .../src/Controller/Role/RoleEditAction.php | 14 +- .../Controller/Role/RoleUpdateFieldAction.php | 18 ++- .../src/Controller/User/UserCreateAction.php | 14 +- .../src/Controller/User/UserDeleteAction.php | 15 +- .../src/Controller/User/UserEditAction.php | 14 +- .../Controller/User/UserUpdateFieldAction.php | 18 ++- .../app/src/Log/GroupActivityTypes.php | 20 +++ .../app/src/Log/RoleActivityTypes.php | 21 +++ packages/sprinkle-core/CHANGELOG.md | 1 + .../Interfaces/MorphableModelInterface.php | 42 ++++++ .../Interfaces/SessionModelInterface.php | 2 +- .../Interfaces/ThrottleModelInterface.php | 2 +- .../app/src/Database/Models/Model.php | 3 +- .../Models/ActivitySubjectInterfaceTest.php | 36 +++++ 56 files changed, 797 insertions(+), 183 deletions(-) create mode 100644 packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php create mode 100644 packages/sprinkle-account/app/src/Log/AccountActivityTypes.php create mode 100644 packages/sprinkle-account/app/src/Log/ActivityRecorder.php create mode 100644 packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php create mode 100644 packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php create mode 100644 packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php create mode 100644 packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php create mode 100644 packages/sprinkle-core/app/src/Database/Models/Interfaces/MorphableModelInterface.php create mode 100644 packages/sprinkle-core/app/tests/Unit/Database/Models/ActivitySubjectInterfaceTest.php diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 529af71a8..fb937004f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -223,7 +223,7 @@ These **must** be run before committing code, from the root of the monorepo: ## Changelog Guidelines -**Every change must include a changelog update** — do this automatically, without waiting to be asked: +Before committing, **every change must include a changelog update**. Once a change is completed, ask the user if he wants to update the changelog. If yes, follow these steps: 1. Update the relevant `packages/*/CHANGELOG.md` file(s) for affected packages 2. Add entries under the `## [Unreleased]` section diff --git a/.vscode/settings.json b/.vscode/settings.json index 1dda7349c..9d3227c58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,12 @@ "package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .prettier*, prettier*, .editorconfig", "composer.json": "composer.lock, phpunit.xml*, phpstan.neon*, phpstan-baseline.neon*, .php-cs*, .php_cs*, .styleci.yml*, .phpdoc.*" }, - "cSpell.words": ["axios", "pinia", "Sprunjer", "tdate", "TOTP"] + "cSpell.words": [ + "axios", + "Morphable", + "pinia", + "Sprunjer", + "tdate", + "TOTP" + ] } diff --git a/packages/sprinkle-account/app/src/Account.php b/packages/sprinkle-account/app/src/Account.php index ebbe61804..ce562ec8b 100644 --- a/packages/sprinkle-account/app/src/Account.php +++ b/packages/sprinkle-account/app/src/Account.php @@ -35,6 +35,7 @@ use UserFrosting\Sprinkle\Account\Database\Migrations\v600\DropVerificationsTable; use UserFrosting\Sprinkle\Account\Database\Migrations\v600\UpdateUsersTable as V600UpdateUsersTable; use UserFrosting\Sprinkle\Account\Database\Migrations\v600\UserVerificationTable; +use UserFrosting\Sprinkle\Account\Database\Migrations\v610\ActivitiesV2Table; use UserFrosting\Sprinkle\Account\Database\Seeds\DefaultGroups; use UserFrosting\Sprinkle\Account\Database\Seeds\DefaultPermissions; use UserFrosting\Sprinkle\Account\Database\Seeds\DefaultRoles; @@ -171,6 +172,8 @@ public function getMigrations(): array V600UpdateUsersTable::class, DropPasswordResetsTable::class, DropVerificationsTable::class, + // v610 + ActivitiesV2Table::class, ]; } diff --git a/packages/sprinkle-account/app/src/Bakery/CreateUser.php b/packages/sprinkle-account/app/src/Bakery/CreateUser.php index 255bc039a..4880a133f 100644 --- a/packages/sprinkle-account/app/src/Bakery/CreateUser.php +++ b/packages/sprinkle-account/app/src/Bakery/CreateUser.php @@ -36,7 +36,7 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\AccountException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Core\Bakery\Helper\DatabaseTest; @@ -70,7 +70,7 @@ class CreateUser extends Command protected EventDispatcherInterface $eventDispatcher; #[Inject] - protected UserActivityLoggerInterface $logger; + protected ActivityRecorderInterface $logger; #[Inject] protected Capsule $capsule; @@ -190,10 +190,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $user = $this->eventDispatcher->dispatch($event)->user; // Create activity record - $this->logger->info("User {$user->user_name} account was created.", [ - 'type' => UserActivityTypes::REGISTER, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::REGISTER + ); return $user; }); diff --git a/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php b/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php index 25cb8f523..c42ce1810 100644 --- a/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php +++ b/packages/sprinkle-account/app/src/Controller/EmailVerificationValidationAction.php @@ -27,7 +27,7 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\FailedVerificationException; use UserFrosting\Sprinkle\Account\Exceptions\VerificationDisabledException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Throttle\Throttler; @@ -73,7 +73,7 @@ class EmailVerificationValidationAction * @param RequestDataTransformer $transformer * @param ServerSideValidator $validator * @param UserInterface $userModel - * @param UserActivityLoggerInterface $logger + * @param ActivityRecorderInterface $logger * @param Connection $db * @param Throttler $throttler */ @@ -84,7 +84,7 @@ public function __construct( protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, protected UserInterface $userModel, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected Connection $db, protected Throttler $throttler, protected Config $config, @@ -163,10 +163,10 @@ protected function handle(Request $request): void $user->save(); // Create activity record - $this->logger->info("User {$user->user_name} verified it's account.", [ - 'type' => UserActivityTypes::VERIFIED, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::VERIFIED + ); }); } diff --git a/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php b/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php index 4470a3215..ca34cc567 100644 --- a/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php +++ b/packages/sprinkle-account/app/src/Controller/ForgetPasswordSetPasswordAction.php @@ -25,7 +25,7 @@ use UserFrosting\Sprinkle\Account\Authenticate\Interfaces\EmailVerificationProvider; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\PasswordResetInvalidException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -56,15 +56,15 @@ class ForgetPasswordSetPasswordAction /** * Inject dependencies. * - * @param Translator $translator - * @param Config $config - * @param RouteParserInterface $routeParser - * @param EmailVerificationProvider $emailVerification - * @param RequestDataTransformer $transformer - * @param ServerSideValidator $validator - * @param Connection $db - * @param UserInterface $userModel - * @param UserActivityLoggerInterface $logger + * @param Translator $translator + * @param Config $config + * @param RouteParserInterface $routeParser + * @param EmailVerificationProvider $emailVerification + * @param RequestDataTransformer $transformer + * @param ServerSideValidator $validator + * @param Connection $db + * @param UserInterface $userModel + * @param ActivityRecorderInterface $logger */ public function __construct( protected Translator $translator, @@ -75,7 +75,7 @@ public function __construct( protected ServerSideValidator $validator, protected Connection $db, protected UserInterface $userModel, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } @@ -134,10 +134,10 @@ protected function handle(Request $request): void $user->save(); // Create activity record - $this->logger->info("User {$user->user_name} reset it's password.", [ - 'type' => UserActivityTypes::PASSWORD_RESET, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::PASSWORD_RESET + ); }); } diff --git a/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php b/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php index f8cee8b4c..ed830e1c6 100644 --- a/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php +++ b/packages/sprinkle-account/app/src/Controller/ProfileEditAction.php @@ -23,7 +23,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\LocaleNotFoundException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\I18n\SiteLocale; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -53,7 +54,7 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected SiteLocale $locale, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator ) { @@ -128,10 +129,10 @@ protected function handle(Request $request): void $currentUser->save(); // Create activity record - $this->logger->info("User {$currentUser->user_name} updated their profile settings.", [ - 'type' => 'update_profile_settings', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_PROFILE_SETTINGS + ); } /** diff --git a/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php b/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php index 7d10b0d7c..b0cf3e3a5 100644 --- a/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php +++ b/packages/sprinkle-account/app/src/Controller/ProfileEmailEditAction.php @@ -25,7 +25,8 @@ use UserFrosting\Sprinkle\Account\Exceptions\EmailNotUniqueException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\PasswordInvalidException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -55,7 +56,7 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected Config $config, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator @@ -130,10 +131,10 @@ protected function handle(Request $request): void $currentUser->save(); // Create activity record - $this->logger->info("User {$currentUser->user_name} updated their account settings.", [ - 'type' => 'update_account_settings', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_ACCOUNT_SETTINGS + ); } /** diff --git a/packages/sprinkle-account/app/src/Controller/RegisterAction.php b/packages/sprinkle-account/app/src/Controller/RegisterAction.php index 75a4a8697..c3e33b417 100644 --- a/packages/sprinkle-account/app/src/Controller/RegisterAction.php +++ b/packages/sprinkle-account/app/src/Controller/RegisterAction.php @@ -29,7 +29,7 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\RegistrationException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; @@ -78,7 +78,7 @@ public function __construct( protected Session $session, protected SiteLocale $locale, protected Throttler $throttler, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected UserValidation $userValidation, protected EmailVerificationProvider $emailVerification, @@ -174,10 +174,10 @@ protected function handle(Request $request): array $user = $this->eventDispatcher->dispatch($event)->user; // Create activity record - $this->logger->info("User {$user->user_name} registered for a new account.", [ - 'type' => UserActivityTypes::REGISTER, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $user, + type: UserActivityTypes::REGISTER + ); // Send activation email if ($this->requireEmailVerification() === true) { diff --git a/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php b/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php index dc511c1d3..ac938675d 100644 --- a/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php +++ b/packages/sprinkle-account/app/src/Controller/SettingsEditAction.php @@ -24,7 +24,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\PasswordInvalidException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -54,7 +55,7 @@ public function __construct( protected Translator $translator, protected Authenticator $authenticator, protected Config $config, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator @@ -127,10 +128,10 @@ protected function handle(Request $request): void $currentUser->save(); // Create activity record - $this->logger->info("User {$currentUser->user_name} updated their account settings.", [ - 'type' => 'update_account_settings', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_ACCOUNT_SETTINGS + ); } /** diff --git a/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php b/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php index c3a12572d..ab5f8e244 100644 --- a/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php +++ b/packages/sprinkle-account/app/src/Database/Migrations/v400/ActivitiesTable.php @@ -16,7 +16,7 @@ use UserFrosting\Sprinkle\Core\Database\Migration; /** - * Sessions table migration + * Activities table migration * Version 4.0.0. */ class ActivitiesTable extends Migration diff --git a/packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php b/packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php new file mode 100644 index 000000000..a5e6a4e5f --- /dev/null +++ b/packages/sprinkle-account/app/src/Database/Migrations/v610/ActivitiesV2Table.php @@ -0,0 +1,70 @@ +schema->hasColumn('activities', 'context_type')) { + $this->schema->table('activities', function (Blueprint $table) { + $table->string('context_type', 100)->nullable(); + $table->char('context_id', 36)->nullable(); + $table->string('subject_type', 100)->nullable(); + $table->char('subject_id', 36)->nullable(); + $table->json('metadata')->nullable(); + + $table->index(['context_type', 'context_id']); + $table->index(['subject_type', 'subject_id']); + }); + } + } + + /** + * {@inheritdoc} + */ + public function down(): void + { + if ($this->schema->hasColumn('activities', 'context_type')) { + $this->schema->withoutForeignKeyConstraints(function () { + $this->schema->table('activities', function (Blueprint $table) { + $table->dropColumn([ + 'context_type', + 'context_id', + 'subject_type', + 'subject_id', + 'metadata', + ]); + }); + }); + } + } +} diff --git a/packages/sprinkle-account/app/src/Database/Models/Activity.php b/packages/sprinkle-account/app/src/Database/Models/Activity.php index 8a493a983..274f4190f 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Activity.php +++ b/packages/sprinkle-account/app/src/Database/Models/Activity.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Query\Builder as QueryBuilder; use UserFrosting\Sprinkle\Account\Database\Factories\ActivityFactory; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; @@ -25,7 +26,17 @@ /** * Activity Model. * - * Represents a single user activity at a specified point in time. + * Represents an activity generated by a user at a specified point in time. + * Provide a context and subject to the activity to link it to other models. + * The context is the model that the activity belongs to, while the subject + * is the model that the activity is targeting. + * + * For example, if a user comments on a post, the context would be the post, + * and the subject would be the comment. This allows for a more flexible + * representation of activities, as they can be linked to any model in the + * application, and thus displayed in a more meaningful and specific way to the + * users (eg. show activities related to a specific post, comment, or a + * specific user). * * @mixin \Illuminate\Database\Eloquent\Builder */ @@ -44,9 +55,14 @@ class Activity extends Model implements ActivityInterface protected $fillable = [ 'ip_address', 'user_id', + 'context_type', + 'context_id', + 'subject_type', + 'subject_id', 'type', + 'metadata', 'occurred_at', - 'description', + 'description', // @deprecated 6.1 kept for backward compatibility ]; /** @@ -54,6 +70,7 @@ class Activity extends Model implements ActivityInterface */ protected $casts = [ 'user_id' => 'integer', + 'metadata' => 'array', 'occurred_at' => 'datetime', ]; @@ -102,6 +119,22 @@ public function user(): BelongsTo return $this->belongsTo($relation, 'user_id'); } + /** + * Get the contextual model this activity belongs to. + */ + public function context(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the secondary model targeted by this activity. + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + /** * Create a new factory instance for the model. * diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php index a4f0f0384..1c49a8c05 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php @@ -15,7 +15,9 @@ use DateTime; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Query\Builder as QueryBuilder; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -25,18 +27,25 @@ * @mixin \Illuminate\Database\Eloquent\Model * @mixin \UserFrosting\Sprinkle\Core\Database\Models\Model * - * @property int $id - * @property string|null $ip_address - * @property int $user_id - * @property string $type - * @property Datetime|null $occurred_at - * @property string $description - * @property UserInterface $user + * @property int $id + * @property string|null $ip_address + * @property int $user_id + * @property string $type + * @property Datetime|null $occurred_at + * @property string $description @deprecated 6.1 + * @property UserInterface $user + * @property string|null $context_type + * @property string|null $context_id + * @property string|null $subject_type + * @property string|null $subject_id + * @property array|null $metadata + * + * @property-read UserInterface|null $user * * @method $this joinUser() * @method static $this joinUser() */ -interface ActivityInterface +interface ActivityInterface extends MorphableModelInterface { /** * Users which belong to this activity. @@ -45,6 +54,16 @@ interface ActivityInterface */ public function user(): BelongsTo; + /** + * Get the contextual model this activity belongs to. + */ + public function context(): MorphTo; + + /** + * Get the secondary model targeted by this activity. + */ + public function subject(): MorphTo; + /** * Scope a query to only include specific type. * diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php index ea53d2454..685c7f94a 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/GroupInterface.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\HasMany; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -32,7 +33,7 @@ * @property \DateTimeImmutable $updated_at * @property Collection $users */ -interface GroupInterface +interface GroupInterface extends MorphableModelInterface { /** * Users which belong to this group. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php index d4aa872dc..c2a08d0aa 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PermissionInterface.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder as QueryBuilder; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyThrough; @@ -39,7 +40,7 @@ * @method $this notForRole(int|RoleInterface $role) * @method static $this notForRole(int|RoleInterface $role) */ -interface PermissionInterface +interface PermissionInterface extends MorphableModelInterface { /** * Get a list of roles to which this permission is assigned. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php index 9109b929f..33d680587 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/PersistenceInterface.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Query\Builder as QueryBuilder; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -37,7 +38,7 @@ * @method $this notExpired() * @method static $this notExpired() */ -interface PersistenceInterface +interface PersistenceInterface extends MorphableModelInterface { /** * Relation with the user table. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php index f8427b93e..ab3bb87c3 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/RoleInterface.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -33,7 +34,7 @@ * @method $this forUser(int|UserInterface $user) * @method static $this forUser(int|UserInterface $user) */ -interface RoleInterface +interface RoleInterface extends MorphableModelInterface { /** * Get a list of permissions assigned to this role. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php index 69836512c..d46f924c3 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserInterface.php @@ -21,6 +21,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Query\Builder as QueryBuilder; use UserFrosting\Sprinkle\Account\Database\Models\Activity; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; use UserFrosting\Sprinkle\Core\Database\Relations\BelongsToManyThrough; @@ -61,7 +62,7 @@ * @method $this forRole(int|RoleInterface $role) * @method static $this forRole(int|RoleInterface $role) */ -interface UserInterface +interface UserInterface extends MorphableModelInterface { /** * Allows you to get the full name of the user using `$user->full_name`. diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php index 8351ee95c..fd735e4c4 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/UserVerificationInterface.php @@ -14,6 +14,7 @@ use DateTime; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; use UserFrosting\Sprinkle\Core\Database\Models\Model; /** @@ -36,7 +37,7 @@ * @method $this expired() Link to the expired scope * @method $this notExpired() Link to the not expired scope */ -interface UserVerificationInterface +interface UserVerificationInterface extends MorphableModelInterface { /** * User associated with this verification request. diff --git a/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php b/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php index 05d2da12d..6bd022071 100644 --- a/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php +++ b/packages/sprinkle-account/app/src/Database/Models/RoleUsers.php @@ -13,14 +13,14 @@ namespace UserFrosting\Sprinkle\Account\Database\Models; use Illuminate\Database\Eloquent\Relations\Pivot; -use UserFrosting\Sprinkle\Core\Database\Models\Model; +use UserFrosting\Sprinkle\Core\Database\Models\Interfaces\MorphableModelInterface; /** * Represents a the User-Role many-to-many relationship intermediate table. * * @mixin \Illuminate\Database\Eloquent\Builder */ -class RoleUsers extends Pivot +class RoleUsers extends Pivot implements MorphableModelInterface { /** * @var string The name of the table for the current model. diff --git a/packages/sprinkle-account/app/src/Listener/UpgradePassword.php b/packages/sprinkle-account/app/src/Listener/UpgradePassword.php index 41cb12524..39f572c7a 100644 --- a/packages/sprinkle-account/app/src/Listener/UpgradePassword.php +++ b/packages/sprinkle-account/app/src/Listener/UpgradePassword.php @@ -14,7 +14,7 @@ use UserFrosting\Sprinkle\Account\Authenticate\Hasher; use UserFrosting\Sprinkle\Account\Event\UserAuthenticatedEvent; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; /** @@ -24,7 +24,7 @@ class UpgradePassword { public function __construct( protected Hasher $hasher, - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } @@ -39,10 +39,10 @@ public function __invoke(UserAuthenticatedEvent $event): void $event->user->save(); // Save changes // Add a sign in activity (time is automatically set by database) - $this->logger->debug("User {$event->user->user_name} outdated password hash has been automatically updated to modern hashing.", [ - 'type' => UserActivityTypes::PASSWORD_UPGRADED, - 'user_id' => $event->user->id, - ]); + $this->logger->record( + user: $event->user, + type: UserActivityTypes::PASSWORD_UPGRADED + ); } } } diff --git a/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php b/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php index 9a4eabb84..585622cca 100644 --- a/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php +++ b/packages/sprinkle-account/app/src/Listener/UserLogoutActivity.php @@ -13,7 +13,7 @@ namespace UserFrosting\Sprinkle\Account\Listener; use UserFrosting\Sprinkle\Account\Event\UserLoggedOutEvent; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; /** @@ -22,16 +22,16 @@ class UserLogoutActivity { public function __construct( - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } public function __invoke(UserLoggedOutEvent $event): void { - // Add a sign in activity (time is automatically set by database) - $this->logger->info("User {$event->user->user_name} signed out.", [ - 'type' => UserActivityTypes::LOGGED_OUT, - 'user_id' => $event->user->id, - ]); + // Add a sign out activity (time is automatically set by database) + $this->logger->record( + user: $event->user, + type: UserActivityTypes::LOGGED_OUT + ); } } diff --git a/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php b/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php index f9627559f..8c1c1060a 100644 --- a/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php +++ b/packages/sprinkle-account/app/src/Listener/UserSignInActivity.php @@ -13,7 +13,7 @@ namespace UserFrosting\Sprinkle\Account\Listener; use UserFrosting\Sprinkle\Account\Event\UserLoggedInEvent; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; /** @@ -22,16 +22,15 @@ class UserSignInActivity { public function __construct( - protected UserActivityLoggerInterface $logger, + protected ActivityRecorderInterface $logger, ) { } public function __invoke(UserLoggedInEvent $event): void { - // Add a sign in activity (time is automatically set by database) - $this->logger->info("User {$event->user->user_name} signed in.", [ - 'type' => UserActivityTypes::LOGGED_IN, - 'user_id' => $event->user->id, - ]); + $this->logger->record( + user: $event->user, + type: UserActivityTypes::LOGGED_IN + ); } } diff --git a/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php new file mode 100644 index 000000000..cd0e1417d --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php @@ -0,0 +1,23 @@ + isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null, + 'user_id' => $user->getKey(), + 'context_type' => $context?->getMorphClass(), + 'context_id' => $context?->getKey(), + 'subject_type' => $subject?->getMorphClass(), + 'subject_id' => $subject?->getKey(), + 'type' => $type->value, + 'metadata' => $metadata, + 'occurred_at' => new DateTimeImmutable(), + ]); + + $activity->save(); + + return $activity; + } +} diff --git a/packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php b/packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php new file mode 100644 index 000000000..63c484579 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityRecorderInterface.php @@ -0,0 +1,55 @@ +|null> $metadata Additional event payload for UI rendering. + * @param MorphableModelInterface|null $context Primary related model (polymorphic context). + * @param MorphableModelInterface|null $subject Secondary related model (polymorphic subject). + * + * @return ActivityInterface + */ + public function record( + UserInterface $user, + BackedEnum $type, + array $metadata = [], + ?MorphableModelInterface $context = null, + ?MorphableModelInterface $subject = null, + ): ActivityInterface; +} diff --git a/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php b/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php index e607f6516..df8987e77 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityDatabaseHandler.php @@ -21,6 +21,8 @@ /** * Monolog handler for storing user activities to the database. + * + * @deprecated 6.1 Use ActivityRecorder instead */ class UserActivityDatabaseHandler extends AbstractProcessingHandler { @@ -52,6 +54,9 @@ public function __construct( */ protected function write(LogRecord $record): void { + // Trigger deprecation warning + trigger_error('UserActivityLogger/UserActivityLoggerInterface is deprecated since UserFrosting 6.1 and will be removed in 6.2. Use ActivityRecorder instead.', E_USER_DEPRECATED); + if (!is_array($record['context']) || !isset($record['context']['user_id'])) { throw new LogicException('UserActivityLogger requires a `user_id` to be set in the context.'); } diff --git a/packages/sprinkle-account/app/src/Log/UserActivityLogger.php b/packages/sprinkle-account/app/src/Log/UserActivityLogger.php index 96b9efa20..cfc11ba68 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityLogger.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityLogger.php @@ -18,6 +18,8 @@ * User Activity Logger. * * @todo : We could bring back the processor, to add the current user into the context + * + * @deprecated 6.1 Use ActivityRecorder instead */ class UserActivityLogger extends Logger implements UserActivityLoggerInterface { diff --git a/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php b/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php index c2b26acf1..e4ffd7c4a 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityLoggerInterface.php @@ -16,6 +16,8 @@ /** * Monolog alias for dependency injection. + * + * @deprecated 6.1 Use ActivityRecorder instead */ interface UserActivityLoggerInterface extends LoggerInterface { diff --git a/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php b/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php index 5b3767a2a..154cac5fa 100644 --- a/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php +++ b/packages/sprinkle-account/app/src/ServicesProvider/LoggersService.php @@ -13,6 +13,8 @@ namespace UserFrosting\Sprinkle\Account\ServicesProvider; use UserFrosting\ServicesProvider\ServicesProviderInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Log\AuthLogger; use UserFrosting\Sprinkle\Account\Log\AuthLoggerInterface; use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; @@ -24,7 +26,8 @@ public function register(): array { return [ AuthLoggerInterface::class => \DI\autowire(AuthLogger::class), - UserActivityLoggerInterface::class => \DI\autowire(UserActivityLogger::class), + UserActivityLoggerInterface::class => \DI\autowire(UserActivityLogger::class), // @phpstan-ignore-line classConstant.deprecatedInterface classConstant.deprecatedClass + ActivityRecorderInterface::class => \DI\autowire(ActivityRecorder::class), ]; } } diff --git a/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php b/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php index 48e092115..71c8c0aeb 100644 --- a/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php +++ b/packages/sprinkle-account/app/tests/Bakery/CreateAdminUserTest.php @@ -20,8 +20,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\UsernameNotUniqueException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Core\Database\Migrator\MigrationRepositoryInterface; @@ -65,10 +65,10 @@ public function testCommandWithUserInput(): void $this->getContainer()->set(EventDispatcherInterface::class, $eventDispatcher); // Mock userActivityLogger to assert it's being called properly. - $userActivityLogger = Mockery::mock(UserActivityLogger::class) - ->shouldReceive('info')->once() + $userActivityLogger = Mockery::mock(ActivityRecorder::class) + ->shouldReceive('record')->once() ->getMock(); - $this->getContainer()->set(UserActivityLoggerInterface::class, $userActivityLogger); + $this->getContainer()->set(ActivityRecorderInterface::class, $userActivityLogger); /** @var CreateAdminUser */ $command = $this->getService(CreateAdminUser::class); diff --git a/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php b/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php index c4d128b6b..eadc0a24d 100644 --- a/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php +++ b/packages/sprinkle-account/app/tests/Database/Migrations/MigrationsTest.php @@ -62,6 +62,11 @@ public function tablesProvider(): array 'occurred_at', 'type', 'user_id', + 'context_id', + 'context_type', + 'subject_id', + 'subject_type', + 'metadata', ], 'groups' => [ 'id', diff --git a/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php b/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php index 1b89fc339..07cb3ab51 100644 --- a/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php +++ b/packages/sprinkle-account/app/tests/Database/Models/ActivityTest.php @@ -14,8 +14,10 @@ use Illuminate\Database\Eloquent\Factories\Sequence; use UserFrosting\Sprinkle\Account\Database\Models\Activity; +use UserFrosting\Sprinkle\Account\Database\Models\Group; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; +use UserFrosting\Sprinkle\Account\Database\Models\Role; use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -51,7 +53,6 @@ public function testActivity(): void ]); $activity->user()->associate($user); $activity->save(); - $this->assertInstanceOf(ActivityInterface::class, $activity); // @phpstan-ignore-line // Assert new state $this->assertSame(1, Activity::count()); @@ -121,6 +122,50 @@ public function testUserRelation(): void $this->assertSame(0, Activity::count()); } + public function testContextSubjectRelations(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var Role */ + $context = Role::create([ + 'slug' => 'test_role', + 'name' => 'Test Role', + 'description' => 'A role for testing purposes.', + ]); + + /** @var Group */ + $subject = Group::create([ + 'slug' => 'test_group', + 'name' => 'Test Group', + 'description' => 'A group for testing purposes.', + ]); + + /** @var Activity */ + $activity = Activity::create([ + 'user_id' => $user->id, + 'context_type' => 'role', + 'context_id' => $context->id, + 'subject_type' => 'group', + 'subject_id' => $subject->id, + 'type' => 'role_created', + 'metadata' => ['role_name' => $context->name], + 'occurred_at' => '2026-01-01 10:00:00', + ]); + + // Refetch the activity to ensure relations are loaded correctly + $activity->refresh(); + + // Assert results + $this->assertEquals($user->id, $activity->user?->id); + $this->assertEquals($context->id, $activity->context_id); + $this->assertEquals($subject->id, $activity->subject_id); + $this->assertEquals('role', $activity->context_type); + $this->assertEquals('group', $activity->subject_type); + $this->assertEquals($context->id, $activity->context->id); + $this->assertEquals($subject->id, $activity->subject->id); + } + public function testNoLastActivity(): void { /** @var User */ @@ -133,6 +178,7 @@ public function testNoLastActivity(): void /** * Test for join operation for Sprunje. + * N.B.: Apply to the activity related helper from the User model. */ public function testUserJoinLastActivity(): void { @@ -185,8 +231,8 @@ public function testJoinUser(): void // Default order is 'foo', 'bar' $nonSortedActivity = Activity::all(); $this->assertContainsOnlyInstancesOf(ActivityInterface::class, $nonSortedActivity); - $this->assertSame('foo', $nonSortedActivity[0]->user->user_name); - $this->assertSame('bar', $nonSortedActivity[1]->user->user_name); + $this->assertSame('foo', $nonSortedActivity[0]->user?->user_name); + $this->assertSame('bar', $nonSortedActivity[1]->user?->user_name); $this->assertSame([$userFoo->id, $userBar->id], $nonSortedActivity->pluck('id')->toArray()); // @phpstan-ignore-line // Sort by lastActivity, order will be 2, 1 @@ -195,8 +241,8 @@ public function testJoinUser(): void ->get(); $this->assertContainsOnlyInstancesOf(ActivityInterface::class, $sortedUsers); - $this->assertSame('bar', $sortedUsers[0]->user->user_name); - $this->assertSame('foo', $sortedUsers[1]->user->user_name); + $this->assertSame('bar', $sortedUsers[0]->user?->user_name); + $this->assertSame('foo', $sortedUsers[1]->user?->user_name); $this->assertSame([$userBar->id, $userFoo->id], $sortedUsers->pluck('id')->toArray()); // @phpstan-ignore-line } } diff --git a/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php b/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php index 9ecb165ac..4e78ab84d 100644 --- a/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php +++ b/packages/sprinkle-account/app/tests/Listener/UpgradePasswordTest.php @@ -15,8 +15,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Event\UserAuthenticatedEvent; use UserFrosting\Sprinkle\Account\Listener\UpgradePassword; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; -use UserFrosting\Sprinkle\Account\Log\UserActivityLoggerInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorder; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; /** @@ -45,19 +45,17 @@ public function testNoNeedToUpgrade(): void public function testUpgrade(): void { - /** @var Mockery\MockInterface&UserActivityLogger */ - $logger = Mockery::mock(UserActivityLogger::class) - ->shouldReceive('debug')->once() + /** @var Mockery\MockInterface&ActivityRecorderInterface */ + $logger = Mockery::mock(ActivityRecorder::class) + ->shouldReceive('record')->once() ->getMock(); - $this->getContainer()->set(UserActivityLoggerInterface::class, $logger); + $this->getContainer()->set(ActivityRecorderInterface::class, $logger); /** @var User */ $user = Mockery::mock(User::class) ->shouldReceive('getAttribute')->with('password')->once()->andReturn('87e995bde9ebdc73fc58cc75a9fadc4ae630d8207650fbe94e148ccc8058d5de5') ->shouldReceive('setAttribute')->with('password', 'MyPassword')->once() ->shouldReceive('save')->once() - ->shouldReceive('getAttribute')->with('user_name')->once()->andReturn('My Username') - ->shouldReceive('getAttribute')->with('id')->once()->andReturn(1) ->getMock(); // Create event diff --git a/packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php b/packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php new file mode 100644 index 000000000..00b0ff6b3 --- /dev/null +++ b/packages/sprinkle-account/app/tests/Log/ActivityRecorderTest.php @@ -0,0 +1,132 @@ +refreshDatabase(); + } + + public function testRecord(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var Role */ + $context = Role::create([ + 'slug' => 'test_role', + 'name' => 'Test Role', + 'description' => 'A role for testing purposes.', + ]); + + /** @var Group */ + $subject = Group::create([ + 'slug' => 'test_group', + 'name' => 'Test Group', + 'description' => 'A group for testing purposes.', + ]); + + $metadata = [ + 'key1' => 'value1', + 'key2' => 'value2', + ]; + + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: $user, + type: TestActivityTypes::TEST_ACTIVITY, + metadata: $metadata, + context: $context, + subject: $subject + ); + + $this->assertEquals($user->id, $activity->user?->id); + $this->assertEquals($context->id, $activity->context_id); + $this->assertEquals($subject->id, $activity->subject_id); + $this->assertEquals('role', $activity->context_type); + $this->assertEquals('group', $activity->subject_type); + $this->assertEquals($context->id, $activity->context->id); + $this->assertEquals($subject->id, $activity->subject->id); + $this->assertSame('TEST_ACTIVITY', $activity->type); + $this->assertSame($metadata, $activity->metadata); + $this->assertNotNull($activity->occurred_at); + } + + public function testBuiltinModelsImplementActivitySubjectInterface(): void + { + $models = [ + new Activity(), + new Group(), + new Permission(), + new Persistence(), + new Role(), + new RoleUsers(), + new User(), + new UserVerification(), + ]; + + foreach ($models as $model) { + // @phpstan-ignore-next-line method.alreadyNarrowedType + $this->assertInstanceOf(MorphableModelInterface::class, $model); + } + } + + public function testRecordForNullRelations(): void + { + /** @var User */ + $user = User::factory()->create(); + + /** @var ActivityRecorderInterface */ + $recorder = $this->getService(ActivityRecorderInterface::class); + $activity = $recorder->record( + user: $user, + type: TestActivityTypes::TEST_NULL_RELATIONS, + metadata: ['test' => 'success'] + ); + + /** @var Activity|null $fetched */ + $fetched = Activity::find($activity->id); + + $this->assertNotNull($fetched); + $this->assertNull($fetched->context_type); + $this->assertNull($fetched->context_id); + $this->assertNull($fetched->subject_type); + $this->assertNull($fetched->subject_id); + $this->assertSame(['test' => 'success'], $fetched->metadata); + } +} + +enum TestActivityTypes: string +{ + case TEST_ACTIVITY = 'TEST_ACTIVITY'; + case TEST_NULL_RELATIONS = 'TEST_NULL_RELATIONS'; +} diff --git a/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php b/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php index 7cd03a639..37d443057 100644 --- a/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php +++ b/packages/sprinkle-account/app/tests/Log/UserActivityLoggerTest.php @@ -20,6 +20,9 @@ use UserFrosting\Sprinkle\Account\Tests\AccountTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; +/** + * @deprecated 6.1 + */ final class UserActivityLoggerTest extends AccountTestCase { use RefreshDatabase; @@ -55,7 +58,7 @@ public function testLogger(): void $this->assertSame('User did a test', $activity->description); $this->assertSame('test', $activity->type); $this->assertSame($user->id, $activity->user_id); - $this->assertSame($user->id, $activity->user->id); + $this->assertSame($user->id, $activity->user?->id); } public function testLoggerWithNoDefaultData(): void diff --git a/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php b/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php index 9af83760f..308238c62 100644 --- a/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Group/GroupCreateAction.php @@ -24,8 +24,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\GroupInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\GroupException; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -54,7 +55,7 @@ public function __construct( protected Authenticator $authenticator, protected Connection $db, protected GroupInterface $groupModel, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -110,15 +111,15 @@ protected function handle(Request $request): GroupInterface // All checks passed! log events/activities and create group // Begin transaction - DB will be rolled back if an exception occurs $group = $this->db->transaction(function () use ($data, $currentUser) { - // Create the group $group = new $this->groupModel($data); $group->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} created group {$group->name}.", [ - 'type' => 'group_create', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: GroupActivityTypes::CREATE, + context: $group + ); return $group; }); diff --git a/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php b/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php index 14c4b86fa..fcddfc8b9 100644 --- a/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Group/GroupDeleteAction.php @@ -21,8 +21,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\GroupInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\GroupException; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -49,7 +50,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, ) { } @@ -113,10 +114,11 @@ protected function handle(GroupInterface $group): UserMessage $group->delete(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} deleted group {$group->name}.", [ - 'type' => 'group_delete', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: GroupActivityTypes::DELETE, + context: $group + ); }); return new UserMessage('GROUP.DELETION_SUCCESSFUL', [ diff --git a/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php b/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php index f8e71d610..947dcc07b 100644 --- a/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Group/GroupEditAction.php @@ -25,8 +25,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\GroupInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\GroupException; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -55,7 +56,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected GroupInterface $groupModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, @@ -137,10 +138,11 @@ protected function handle(GroupInterface $group, Request $request): GroupInterfa $group->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated details for group {$group->name}.", [ - 'type' => 'group_update_info', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: GroupActivityTypes::UPDATE_INFO, + context: $group + ); }); return $group; diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php index 95270443c..ca23994cd 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleCreateAction.php @@ -24,8 +24,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\RoleException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -54,7 +55,7 @@ public function __construct( protected Authenticator $authenticator, protected Connection $db, protected RoleInterface $roleModel, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -117,10 +118,11 @@ protected function handle(Request $request): RoleInterface $role->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} created role {$role->name}.", [ - 'type' => 'role_create', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::CREATE, + context: $role + ); return $role; }); diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php index ecf89bb18..180ffa0fd 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleDeleteAction.php @@ -21,8 +21,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\RoleException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -49,7 +50,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, ) { } @@ -116,10 +117,11 @@ protected function handle(RoleInterface $role): void $role->delete(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} deleted role {$role->name}.", [ - 'type' => 'role_delete', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::DELETE, + context: $role + ); }); } diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php index eab0d4e99..6e03ea256 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleEditAction.php @@ -25,8 +25,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\RoleException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -55,7 +56,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RoleInterface $roleModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, @@ -137,10 +138,11 @@ protected function handle(RoleInterface $role, Request $request): RoleInterface $role->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated details for role {$role->name}.", [ - 'type' => 'role_update_info', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::UPDATE_INFO, + context: $role + ); return $role; }); diff --git a/packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php b/packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php index 285cbfa0e..14b57ee9c 100644 --- a/packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php +++ b/packages/sprinkle-admin/app/src/Controller/Role/RoleUpdateFieldAction.php @@ -26,8 +26,9 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\RoleInterface; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\MissingRequiredParamException; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; use UserFrosting\Support\Message\UserMessage; @@ -56,7 +57,7 @@ public function __construct( protected Config $config, protected Cache $cache, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -151,10 +152,15 @@ protected function handle( } // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated property '$fieldName' for role {$role->name}.", [ - 'type' => 'role_update_field', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: RoleActivityTypes::UPDATE_FIELD, + context: $role, + metadata: [ + 'field' => $fieldName, + 'value' => $fieldValue, + ] + ); }); // Add success messages diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php index d1027b32c..56ba338be 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserCreateAction.php @@ -28,7 +28,8 @@ use UserFrosting\Sprinkle\Account\Event\UserCreatedEvent; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; use UserFrosting\Sprinkle\Account\Exceptions\LocaleNotFoundException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Account\Validators\UserValidation; use UserFrosting\Sprinkle\Admin\Mail\UserCreatedEmail; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; @@ -67,7 +68,7 @@ public function __construct( protected EventDispatcherInterface $eventDispatcher, protected GroupInterface $groupModel, protected SiteLocaleInterface $siteLocale, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected UserCreatedEmail $userCreationEmail, protected UserInterface $userModel, protected UserValidation $userValidation, @@ -159,10 +160,11 @@ protected function handle(Request $request): UserInterface $user = $this->eventDispatcher->dispatch($event)->user; // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} created a new account for {$user->user_name}.", [ - 'type' => 'account_create', // UserActivityLogger::TYPE_REGISTER, - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::CREATE, + context: $user + ); // Send an email to the user who's been created $this->userCreationEmail->send($user); diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php index e99ac2eab..73a4c1f28 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserDeleteAction.php @@ -21,7 +21,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\AccountException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\AccountNotFoundException; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -52,7 +53,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, ) { } @@ -99,10 +100,12 @@ protected function handle(UserInterface $user): void $user->delete(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} deleted the account for {$username}.", [ - 'type' => 'account_delete', - 'user_id' => $currentUser->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::DELETE, + context: $user, + metadata: ['username' => $username] + ); }); } diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php index 4bbb638b0..eecb7f9de 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserEditAction.php @@ -25,7 +25,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface; use UserFrosting\Sprinkle\Account\Exceptions\EmailNotUniqueException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -53,7 +54,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected UserInterface $userModel, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, @@ -162,10 +163,11 @@ protected function handle(UserInterface $user, Request $request): UserInterface $user->save(); // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated basic account info for user {$user->user_name}.", [ - 'type' => 'account_update_info', - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_INFO, + context: $user + ); return $user; }); diff --git a/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php b/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php index 12ab7304a..e2e0f106d 100644 --- a/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php +++ b/packages/sprinkle-admin/app/src/Controller/User/UserUpdateFieldAction.php @@ -26,7 +26,8 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Exceptions\AccountException; use UserFrosting\Sprinkle\Account\Exceptions\ForbiddenException; -use UserFrosting\Sprinkle\Account\Log\UserActivityLogger; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityRecorderInterface; use UserFrosting\Sprinkle\Admin\Exceptions\MissingRequiredParamException; use UserFrosting\Sprinkle\Core\Exceptions\ValidationException; use UserFrosting\Sprinkle\Core\Util\ApiResponse; @@ -65,7 +66,7 @@ public function __construct( protected Authenticator $authenticator, protected Config $config, protected Connection $db, - protected UserActivityLogger $userActivityLogger, + protected ActivityRecorderInterface $logger, protected RequestDataTransformer $transformer, protected ServerSideValidator $validator, ) { @@ -193,10 +194,15 @@ protected function handle( } // Create activity record - $this->userActivityLogger->info("User {$currentUser->user_name} updated property '$fieldName' for user {$user->user_name}.", [ - 'type' => 'account_update_field', - 'user_id' => $user->id, - ]); + $this->logger->record( + user: $currentUser, + type: AccountActivityTypes::UPDATE_FIELD, + context: $user, + metadata: [ + 'field' => $fieldName, + 'value' => $fieldValue, + ] + ); }); // Return success messages diff --git a/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php new file mode 100644 index 000000000..0189ac1d5 --- /dev/null +++ b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php @@ -0,0 +1,20 @@ +assertInstanceOf(MorphableModelInterface::class, $model); + } + } +} From bab5465cc520a41477482a8138d003a1e7076fb6 Mon Sep 17 00:00:00 2001 From: Louis Charette Date: Fri, 7 Aug 2026 11:49:45 -0400 Subject: [PATCH 02/31] Making StyleCI happy --- .../app/src/Database/Models/Interfaces/ActivityInterface.php | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php index 1c49a8c05..8e1d8b8a9 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php @@ -39,7 +39,6 @@ * @property string|null $subject_type * @property string|null $subject_id * @property array|null $metadata - * * @property-read UserInterface|null $user * * @method $this joinUser() From c866901cb57d7f1af3025d550845a886e5dfbdaf Mon Sep 17 00:00:00 2001 From: Louis Charette Date: Tue, 11 Aug 2026 10:25:34 -0400 Subject: [PATCH 03/31] Added: Get activity description from i18n dictionary based on the activity Enum --- .../app/locale/en_US/messages.php | 16 ++ .../app/locale/fr_FR/messages.php | 16 ++ packages/sprinkle-account/app/src/Account.php | 17 ++ .../app/src/Database/Models/Activity.php | 15 ++ .../Models/Interfaces/ActivityInterface.php | 5 +- .../app/src/Log/AccountActivityTypes.php | 18 +- .../src/Log/ActivityTypeRegistryInterface.php | 32 ++++ .../app/src/Log/ActivityTypes.php | 25 +++ .../src/Log/SprinkleActivityTypeRegistry.php | 126 ++++++++++++++ .../app/src/Log/UserActivityTypes.php | 18 +- .../src/ServicesProvider/ActivityService.php | 27 +++ .../src/Sprinkle/Recipe/ActivityRecipe.php | 26 +++ .../Log/SprinkleActivityTypeRegistryTest.php | 130 ++++++++++++++ .../ServicesProvider/ActivityServiceTest.php | 28 ++++ .../app/locale/en_US/messages.php | 13 ++ .../app/locale/fr_FR/messages.php | 13 ++ packages/sprinkle-admin/app/src/Admin.php | 16 +- .../app/src/Log/GroupActivityTypes.php | 17 +- .../app/src/Log/RoleActivityTypes.php | 18 +- .../app/src/Sprunje/ActivitySprunje.php | 61 ++++++- .../app/tests/Sprunje/ActivitySprunjeTest.php | 158 ++++++++++++++++-- 21 files changed, 774 insertions(+), 21 deletions(-) create mode 100644 packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php create mode 100644 packages/sprinkle-account/app/src/Log/ActivityTypes.php create mode 100644 packages/sprinkle-account/app/src/Log/SprinkleActivityTypeRegistry.php create mode 100644 packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php create mode 100644 packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php create mode 100644 packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php create mode 100644 packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php diff --git a/packages/sprinkle-account/app/locale/en_US/messages.php b/packages/sprinkle-account/app/locale/en_US/messages.php index 6d3b995ea..ea1f0b2f2 100644 --- a/packages/sprinkle-account/app/locale/en_US/messages.php +++ b/packages/sprinkle-account/app/locale/en_US/messages.php @@ -17,6 +17,22 @@ 'ACCOUNT' => [ '@TRANSLATION' => 'Account', + 'ACTIVITY' => [ + 'CREATE' => 'Account created', + 'DELETE' => 'Account deleted', + 'UPDATE_INFO' => 'Account information updated', + 'UPDATE_FIELD' => 'Account field {{field}} updated', + 'UPDATE_PROFILE_SETTINGS' => 'Profile settings updated', + 'UPDATE_ACCOUNT_SETTINGS' => 'Account settings updated', + + 'REGISTER' => 'User registered', + 'VERIFIED' => 'User verified', + 'PASSWORD_RESET' => 'Password reset', + 'LOGGED_IN' => 'User signed in', + 'LOGGED_OUT' => 'User signed out', + 'PASSWORD_UPGRADED' => 'Password upgraded', + ], + 'EXCEPTION' => [ 'TITLE' => 'Account Exception', 'DESCRIPTION' => 'An unspecified error with he account has been encountered.', diff --git a/packages/sprinkle-account/app/locale/fr_FR/messages.php b/packages/sprinkle-account/app/locale/fr_FR/messages.php index 45b6c50e9..f1bc09ed2 100644 --- a/packages/sprinkle-account/app/locale/fr_FR/messages.php +++ b/packages/sprinkle-account/app/locale/fr_FR/messages.php @@ -17,6 +17,22 @@ 'ACCOUNT' => [ '@TRANSLATION' => 'Compte d\'utilisateur', + 'ACTIVITY' => [ + 'CREATE' => 'Compte créé', + 'DELETE' => 'Compte supprimé', + 'UPDATE_INFO' => 'Informations du compte mises à jour', + 'UPDATE_FIELD' => 'Champ du compte {{field}} mis à jour', + 'UPDATE_PROFILE_SETTINGS' => 'Paramètres du profil mis à jour', + 'UPDATE_ACCOUNT_SETTINGS' => 'Paramètres du compte mis à jour', + + 'REGISTER' => 'Utilisateur inscrit', + 'VERIFIED' => 'Utilisateur vérifié', + 'PASSWORD_RESET' => 'Mot de passe réinitialisé', + 'LOGGED_IN' => 'Utilisateur connecté', + 'LOGGED_OUT' => 'Utilisateur déconnecté', + 'PASSWORD_UPGRADED' => 'Mot de passe amélioré', + ], + 'EXCEPTION' => [ 'TITLE' => 'Exception de compte', 'DESCRIPTION' => 'Une erreur non spécifiée avec le compte a été rencontrée.', diff --git a/packages/sprinkle-account/app/src/Account.php b/packages/sprinkle-account/app/src/Account.php index ce562ec8b..6ba1e9246 100644 --- a/packages/sprinkle-account/app/src/Account.php +++ b/packages/sprinkle-account/app/src/Account.php @@ -49,8 +49,11 @@ use UserFrosting\Sprinkle\Account\Listener\UpgradePassword; use UserFrosting\Sprinkle\Account\Listener\UserLogoutActivity; use UserFrosting\Sprinkle\Account\Listener\UserSignInActivity; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\UserActivityTypes; use UserFrosting\Sprinkle\Account\Routes\AuthRoutes; use UserFrosting\Sprinkle\Account\ServicesProvider\AccessConditionsService; +use UserFrosting\Sprinkle\Account\ServicesProvider\ActivityService; use UserFrosting\Sprinkle\Account\ServicesProvider\AuthorizationService; use UserFrosting\Sprinkle\Account\ServicesProvider\AuthService; use UserFrosting\Sprinkle\Account\ServicesProvider\I18nService; @@ -58,6 +61,7 @@ use UserFrosting\Sprinkle\Account\ServicesProvider\MFAServices; use UserFrosting\Sprinkle\Account\ServicesProvider\ModelsService; use UserFrosting\Sprinkle\Account\ServicesProvider\MorphMapProvider; +use UserFrosting\Sprinkle\Account\Sprinkle\Recipe\ActivityRecipe; use UserFrosting\Sprinkle\Account\Twig\AccountExtension; use UserFrosting\Sprinkle\BakeryRecipe; use UserFrosting\Sprinkle\Core\Bakery\Event\BakeCommandEvent; @@ -69,6 +73,7 @@ class Account implements SprinkleRecipe, + ActivityRecipe, MigrationRecipe, SeedRecipe, EventListenerRecipe, @@ -114,6 +119,17 @@ public function getSprinkles(): array ]; } + /** + * {@inheritDoc} + */ + public function getActivityTypes(): array + { + return [ + UserActivityTypes::class, + AccountActivityTypes::class, + ]; + } + /** * {@inheritDoc} */ @@ -131,6 +147,7 @@ public function getServices(): array { return [ AccessConditionsService::class, + ActivityService::class, AuthorizationService::class, AuthService::class, ModelsService::class, diff --git a/packages/sprinkle-account/app/src/Database/Models/Activity.php b/packages/sprinkle-account/app/src/Database/Models/Activity.php index 274f4190f..7af483ff2 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Activity.php +++ b/packages/sprinkle-account/app/src/Database/Models/Activity.php @@ -74,6 +74,21 @@ class Activity extends Model implements ActivityInterface 'occurred_at' => 'datetime', ]; + /** + * @var array The attributes that should be visible in arrays. + */ + protected $visible = [ + 'id', + 'ip_address', + 'type', + 'occurred_at', + 'metadata', + 'description', + 'user', + 'context', + 'subject', + ]; + /** * @var bool Disable timestamps for this class. */ diff --git a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php index 8e1d8b8a9..57e5ed743 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php +++ b/packages/sprinkle-account/app/src/Database/Models/Interfaces/ActivityInterface.php @@ -41,8 +41,9 @@ * @property array|null $metadata * @property-read UserInterface|null $user * - * @method $this joinUser() - * @method static $this joinUser() + * @method $this joinUser() + * @method static $this joinUser() + * @method Builder newQuery() */ interface ActivityInterface extends MorphableModelInterface { diff --git a/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php index cd0e1417d..af1deea27 100644 --- a/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php +++ b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php @@ -12,7 +12,7 @@ namespace UserFrosting\Sprinkle\Account\Log; -enum AccountActivityTypes: string +enum AccountActivityTypes: string implements ActivityTypes { case CREATE = 'account_create'; case DELETE = 'account_delete'; @@ -20,4 +20,20 @@ enum AccountActivityTypes: string case UPDATE_FIELD = 'account_update_field'; case UPDATE_PROFILE_SETTINGS = 'update_profile_settings'; case UPDATE_ACCOUNT_SETTINGS = 'update_account_settings'; + + /** + * {@inheritDoc} + */ + public static function getI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'ACCOUNT.ACTIVITY.CREATE', + self::DELETE => 'ACCOUNT.ACTIVITY.DELETE', + self::UPDATE_INFO => 'ACCOUNT.ACTIVITY.UPDATE_INFO', + self::UPDATE_FIELD => 'ACCOUNT.ACTIVITY.UPDATE_FIELD', + self::UPDATE_PROFILE_SETTINGS => 'ACCOUNT.ACTIVITY.UPDATE_PROFILE_SETTINGS', + self::UPDATE_ACCOUNT_SETTINGS => 'ACCOUNT.ACTIVITY.UPDATE_ACCOUNT_SETTINGS', + default => null, + }; + } } diff --git a/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php b/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php new file mode 100644 index 000000000..0fcba5895 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php @@ -0,0 +1,32 @@ + + */ +interface ActivityTypeRegistryInterface extends ClassRepositoryInterface +{ + /** + * Return the i18n key for a persisted activity type value. + * + * @param string $value + * + * @return string|null + */ + public function getI18nKey(string $value): ?string; +} diff --git a/packages/sprinkle-account/app/src/Log/ActivityTypes.php b/packages/sprinkle-account/app/src/Log/ActivityTypes.php new file mode 100644 index 000000000..a98faaaf1 --- /dev/null +++ b/packages/sprinkle-account/app/src/Log/ActivityTypes.php @@ -0,0 +1,25 @@ + + */ +final class SprinkleActivityTypeRegistry extends ClassRepository implements ActivityTypeRegistryInterface +{ + /** + * @var ActivityTypes[]|null + */ + protected ?array $activityTypes = null; + + /** + * @var array|null + */ + protected ?array $activityTypesByValue = null; + + public function __construct( + protected SprinkleManager $sprinkleManager, + ) { + } + + /** + * {@inheritDoc} + */ + public function all(): array + { + $this->loadActivityTypes(); + + return $this->activityTypes ?? []; + } + + /** + * {@inheritDoc} + */ + public function getI18nKey(string $value): ?string + { + $this->loadActivityTypes(); + + $activityType = $this->activityTypesByValue[$value] ?? null; + if ($activityType === null) { + return null; + } + + return get_class($activityType)::getI18nKey($value); + } + + /** + * Expand the activity enum classes registered by all loaded Sprinkles. + */ + protected function loadActivityTypes(): void + { + if ($this->activityTypes !== null) { + return; + } + + /** @var ActivityTypes[] $activityTypes */ + $activityTypes = []; + + /** @var array $activityTypesByValue */ + $activityTypesByValue = []; + + foreach ($this->sprinkleManager->getSprinkles() as $sprinkle) { + if (!$sprinkle instanceof ActivityRecipe) { + continue; + } + + foreach ($sprinkle->getActivityTypes() as $activityTypeClass) { + foreach ($this->getActivityTypeCases($activityTypeClass) as $activityType) { + if (isset($activityTypesByValue[$activityType->value])) { + throw new LogicException("Activity type value `{$activityType->value}` is registered more than once."); + } + + $activityTypes[] = $activityType; + $activityTypesByValue[$activityType->value] = $activityType; + } + } + } + + $this->activityTypes = $activityTypes; + $this->activityTypesByValue = $activityTypesByValue; + } + + /** + * Validate a registered class and return all of its enum cases. + * + * @param string $activityTypeClass + * + * @return array + */ + protected function getActivityTypeCases(string $activityTypeClass): array + { + if (!class_exists($activityTypeClass)) { + throw new BadClassNameException("Activity type class `$activityTypeClass` not found."); + } + + if (!is_subclass_of($activityTypeClass, BackedEnum::class) || !is_subclass_of($activityTypeClass, ActivityTypes::class)) { + throw new BadInstanceOfException("Activity type class `$activityTypeClass` doesn't implement " . ActivityTypes::class . ' and ' . BackedEnum::class . '.'); + } + + /** @var class-string $activityTypeClass */ + return $activityTypeClass::cases(); + } +} diff --git a/packages/sprinkle-account/app/src/Log/UserActivityTypes.php b/packages/sprinkle-account/app/src/Log/UserActivityTypes.php index 00237bbdf..fb44f9f13 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityTypes.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityTypes.php @@ -12,7 +12,7 @@ namespace UserFrosting\Sprinkle\Account\Log; -enum UserActivityTypes: string +enum UserActivityTypes: string implements ActivityTypes { case REGISTER = 'sign_up'; case VERIFIED = 'verified'; @@ -20,4 +20,20 @@ enum UserActivityTypes: string case LOGGED_IN = 'sign_in'; case LOGGED_OUT = 'sign_out'; case PASSWORD_UPGRADED = 'password_upgraded'; + + /** + * {@inheritDoc} + */ + public static function getI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::REGISTER => 'ACCOUNT.ACTIVITY.REGISTER', + self::VERIFIED => 'ACCOUNT.ACTIVITY.VERIFIED', + self::PASSWORD_RESET => 'ACCOUNT.ACTIVITY.PASSWORD_RESET', + self::LOGGED_IN => 'ACCOUNT.ACTIVITY.LOGGED_IN', + self::LOGGED_OUT => 'ACCOUNT.ACTIVITY.LOGGED_OUT', + self::PASSWORD_UPGRADED => 'ACCOUNT.ACTIVITY.PASSWORD_UPGRADED', + default => null, + }; + } } diff --git a/packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php b/packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php new file mode 100644 index 000000000..35cbd7a66 --- /dev/null +++ b/packages/sprinkle-account/app/src/ServicesProvider/ActivityService.php @@ -0,0 +1,27 @@ + \DI\autowire(SprinkleActivityTypeRegistry::class), + ]; + } +} diff --git a/packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php b/packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php new file mode 100644 index 000000000..dc1377be9 --- /dev/null +++ b/packages/sprinkle-account/app/src/Sprinkle/Recipe/ActivityRecipe.php @@ -0,0 +1,26 @@ +[] + */ + public function getActivityTypes(): array; +} diff --git a/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php b/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php new file mode 100644 index 000000000..a97c64ab1 --- /dev/null +++ b/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php @@ -0,0 +1,130 @@ +shouldReceive('getActivityTypes')->andReturn([ + UserActivityTypes::class, + AccountActivityTypes::class, + GroupActivityTypes::class, + RoleActivityTypes::class, + ])->getMock(); + $nonActivitySprinkle = Mockery::mock(SprinkleRecipe::class); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$nonActivitySprinkle, $recipe])->getMock(); + + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->assertCount(19, $registry->all()); + $this->assertCount(19, $registry->all()); + $this->assertTrue($registry->has(UserActivityTypes::class)); + $this->assertInstanceOf(UserActivityTypes::class, $registry->get(UserActivityTypes::class)); + $this->assertSame(19, $registry->count()); + $this->assertSame('ACCOUNT.ACTIVITY.REGISTER', $registry->getI18nKey('sign_up')); + $this->assertSame('ROLE.ACTIVITY.UPDATE_FIELD', $registry->getI18nKey('role_update_field')); + $this->assertNull($registry->getI18nKey('unknown_activity')); + + $this->assertSame('ACCOUNT.ACTIVITY.VERIFIED', UserActivityTypes::getI18nKey('verified')); + $this->assertSame('ACCOUNT.ACTIVITY.PASSWORD_RESET', UserActivityTypes::getI18nKey('password_reset')); + $this->assertSame('ACCOUNT.ACTIVITY.LOGGED_IN', UserActivityTypes::getI18nKey('sign_in')); + $this->assertSame('ACCOUNT.ACTIVITY.LOGGED_OUT', UserActivityTypes::getI18nKey('sign_out')); + $this->assertSame('ACCOUNT.ACTIVITY.PASSWORD_UPGRADED', UserActivityTypes::getI18nKey('password_upgraded')); + $this->assertSame('ACCOUNT.ACTIVITY.CREATE', AccountActivityTypes::getI18nKey('account_create')); + $this->assertSame('ACCOUNT.ACTIVITY.DELETE', AccountActivityTypes::getI18nKey('account_delete')); + $this->assertSame('ACCOUNT.ACTIVITY.UPDATE_INFO', AccountActivityTypes::getI18nKey('account_update_info')); + $this->assertSame('ACCOUNT.ACTIVITY.UPDATE_FIELD', AccountActivityTypes::getI18nKey('account_update_field')); + $this->assertSame('ACCOUNT.ACTIVITY.UPDATE_PROFILE_SETTINGS', AccountActivityTypes::getI18nKey('update_profile_settings')); + $this->assertSame('ACCOUNT.ACTIVITY.UPDATE_ACCOUNT_SETTINGS', AccountActivityTypes::getI18nKey('update_account_settings')); + $this->assertSame('GROUP.ACTIVITY.CREATE', GroupActivityTypes::getI18nKey('group_create')); + $this->assertSame('GROUP.ACTIVITY.DELETE', GroupActivityTypes::getI18nKey('group_delete')); + $this->assertSame('GROUP.ACTIVITY.UPDATE_INFO', GroupActivityTypes::getI18nKey('group_update_info')); + $this->assertSame('ROLE.ACTIVITY.CREATE', RoleActivityTypes::getI18nKey('role_create')); + $this->assertSame('ROLE.ACTIVITY.DELETE', RoleActivityTypes::getI18nKey('role_delete')); + $this->assertSame('ROLE.ACTIVITY.UPDATE_INFO', RoleActivityTypes::getI18nKey('role_update_info')); + $this->assertNull(UserActivityTypes::getI18nKey('unknown_activity')); + $this->assertNull(AccountActivityTypes::getI18nKey('unknown_activity')); + $this->assertNull(GroupActivityTypes::getI18nKey('unknown_activity')); + $this->assertNull(RoleActivityTypes::getI18nKey('unknown_activity')); + } + + public function testMissingClassIsRejected(): void + { + $recipe = Mockery::mock(ActivityRecipe::class) + ->shouldReceive('getActivityTypes')->andReturn(['/Not/An/ActivityType'])->getMock(); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$recipe])->getMock(); + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->expectException(BadClassNameException::class); + $this->expectExceptionMessage('Activity type class `/Not/An/ActivityType` not found.'); + $registry->all(); + } + + public function testClassWithWrongInterfaceIsRejected(): void + { + $recipe = Mockery::mock(ActivityRecipe::class) + ->shouldReceive('getActivityTypes')->andReturn([stdClass::class])->getMock(); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$recipe])->getMock(); + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->expectException(BadInstanceOfException::class); + $this->expectExceptionMessage('Activity type class `stdClass` doesn\'t implement ' . ActivityTypes::class); + $registry->all(); + } + + public function testDuplicateValuesAreRejected(): void + { + $recipe = Mockery::mock(ActivityRecipe::class) + ->shouldReceive('getActivityTypes')->andReturn([ + UserActivityTypes::class, + DuplicateActivityType::class, + ])->getMock(); + /** @var SprinkleManager&Mockery\MockInterface $manager */ + $manager = Mockery::mock(SprinkleManager::class) + ->shouldReceive('getSprinkles')->andReturn([$recipe])->getMock(); + $registry = new SprinkleActivityTypeRegistry($manager); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Activity type value `sign_up` is registered more than once.'); + $registry->all(); + } +} diff --git a/packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php b/packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php new file mode 100644 index 000000000..2b6bf16bf --- /dev/null +++ b/packages/sprinkle-account/app/tests/Unit/ServicesProvider/ActivityServiceTest.php @@ -0,0 +1,28 @@ +register()); + $manager = Mockery::mock(SprinkleManager::class); + $container->set(SprinkleManager::class, $manager); + + $this->assertInstanceOf(SprinkleActivityTypeRegistry::class, $container->get(ActivityTypeRegistryInterface::class)); + } +} diff --git a/packages/sprinkle-admin/app/locale/en_US/messages.php b/packages/sprinkle-admin/app/locale/en_US/messages.php index 515b92cc8..badd7f2ba 100644 --- a/packages/sprinkle-admin/app/locale/en_US/messages.php +++ b/packages/sprinkle-admin/app/locale/en_US/messages.php @@ -63,6 +63,12 @@ 'PAGE_DESCRIPTION' => 'A listing of the groups for your site. Provides management tools for editing and deleting groups.', 'UPDATE' => 'Details updated for group {{name}}', 'USERS' => 'Users in this group', + + 'ACTIVITY' => [ + 'CREATE' => 'Group created', + 'DELETE' => 'Group deleted', + 'UPDATE_INFO' => 'Group information updated', + ], ], 'MANUALLY_ACTIVATED' => "{{user_name}}'s account has been manually activated", @@ -114,6 +120,13 @@ 'UPDATE' => 'Update Roles', 'UPDATED' => 'Details updated for role {{name}}', 'USERS' => 'Users with this role', + + 'ACTIVITY' => [ + 'CREATE' => 'Role created', + 'DELETE' => 'Role deleted', + 'UPDATE_INFO' => 'Role information updated', + 'UPDATE_FIELD' => 'Role field {{field}} updated', + ], ], 'SITE_CONFIG' => [ diff --git a/packages/sprinkle-admin/app/locale/fr_FR/messages.php b/packages/sprinkle-admin/app/locale/fr_FR/messages.php index d5d0b10fe..f71fd5ad1 100644 --- a/packages/sprinkle-admin/app/locale/fr_FR/messages.php +++ b/packages/sprinkle-admin/app/locale/fr_FR/messages.php @@ -63,6 +63,12 @@ 'PAGE_DESCRIPTION' => 'Une liste des groupes pour votre site. Fournit des outils de gestion pour éditer et supprimer des groupes.', 'UPDATE' => 'Les détails du groupe {{name}} ont été enregistrés', 'USERS' => 'Utilisateurs dans ce groupe', + + 'ACTIVITY' => [ + 'CREATE' => 'Groupe créé', + 'DELETE' => 'Groupe supprimé', + 'UPDATE_INFO' => 'Informations du groupe {{context.name}} mises à jour', + ], ], 'MANUALLY_ACTIVATED' => 'Le compte de {{user_name}} a été activé manuellement', @@ -114,6 +120,13 @@ 'UPDATE' => 'Mettre à jour les rôles', 'UPDATED' => 'Détails mis à jour pour le rôle {{name}}', 'USERS' => 'Utilisateurs avec ce rôle', + + 'ACTIVITY' => [ + 'CREATE' => 'Rôle créé', + 'DELETE' => 'Rôle supprimé', + 'UPDATE_INFO' => 'Informations du rôle mises à jour', + 'UPDATE_FIELD' => 'Champ du rôle {{field}} mis à jour', + ], ], 'SITE_CONFIG' => [ diff --git a/packages/sprinkle-admin/app/src/Admin.php b/packages/sprinkle-admin/app/src/Admin.php index 40cfd4d88..617e8a586 100644 --- a/packages/sprinkle-admin/app/src/Admin.php +++ b/packages/sprinkle-admin/app/src/Admin.php @@ -11,6 +11,9 @@ namespace UserFrosting\Sprinkle\Admin; use UserFrosting\Sprinkle\Account\Account; +use UserFrosting\Sprinkle\Account\Sprinkle\Recipe\ActivityRecipe; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; +use UserFrosting\Sprinkle\Admin\Log\RoleActivityTypes; use UserFrosting\Sprinkle\Admin\Routes\ActivitiesRoutes; use UserFrosting\Sprinkle\Admin\Routes\ConfigRoutes; use UserFrosting\Sprinkle\Admin\Routes\DashboardRoutes; @@ -21,7 +24,7 @@ use UserFrosting\Sprinkle\Core\Core; use UserFrosting\Sprinkle\SprinkleRecipe; -class Admin implements SprinkleRecipe +class Admin implements SprinkleRecipe, ActivityRecipe { /** * {@inheritdoc} @@ -50,6 +53,17 @@ public function getSprinkles(): array ]; } + /** + * {@inheritDoc} + */ + public function getActivityTypes(): array + { + return [ + GroupActivityTypes::class, + RoleActivityTypes::class, + ]; + } + /** * {@inheritDoc} */ diff --git a/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php index 0189ac1d5..fff969ed0 100644 --- a/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php +++ b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php @@ -12,9 +12,24 @@ namespace UserFrosting\Sprinkle\Admin\Log; -enum GroupActivityTypes: string +use UserFrosting\Sprinkle\Account\Log\ActivityTypes; + +enum GroupActivityTypes: string implements ActivityTypes { case CREATE = 'group_create'; case DELETE = 'group_delete'; case UPDATE_INFO = 'group_update_info'; + + /** + * {@inheritDoc} + */ + public static function getI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'GROUP.ACTIVITY.CREATE', + self::DELETE => 'GROUP.ACTIVITY.DELETE', + self::UPDATE_INFO => 'GROUP.ACTIVITY.UPDATE_INFO', + default => null, + }; + } } diff --git a/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php b/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php index 82b1adc5c..cd34ba957 100644 --- a/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php +++ b/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php @@ -12,10 +12,26 @@ namespace UserFrosting\Sprinkle\Admin\Log; -enum RoleActivityTypes: string +use UserFrosting\Sprinkle\Account\Log\ActivityTypes; + +enum RoleActivityTypes: string implements ActivityTypes { case CREATE = 'role_create'; case DELETE = 'role_delete'; case UPDATE_INFO = 'role_update_info'; case UPDATE_FIELD = 'role_update_field'; + + /** + * {@inheritDoc} + */ + public static function getI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'ROLE.ACTIVITY.CREATE', + self::DELETE => 'ROLE.ACTIVITY.DELETE', + self::UPDATE_INFO => 'ROLE.ACTIVITY.UPDATE_INFO', + self::UPDATE_FIELD => 'ROLE.ACTIVITY.UPDATE_FIELD', + default => null, + }; + } } diff --git a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php index 802133347..3f6b38ffc 100644 --- a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php +++ b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php @@ -16,7 +16,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Collection; +use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; +use UserFrosting\Sprinkle\Account\Log\ActivityTypeRegistryInterface; use UserFrosting\Sprinkle\Core\Sprunje\Sprunje; /** @@ -40,18 +43,72 @@ class ActivitySprunje extends Sprunje public function __construct( protected ActivityInterface $activityModel, + protected ActivityTypeRegistryInterface $activityTypeRegistry, + protected Translator $translator, ) { parent::__construct(); } + /** + * Translate activity types after the database query has been executed. + * + * @param Collection $collection + * + * @return Collection + */ + protected function applyTransformations(Collection $collection): Collection + { + return $collection->each(function (Model $activity): void { + $type = (string) $activity->getAttribute('type'); + $i18nKey = $this->activityTypeRegistry->getI18nKey($type); + + if ($i18nKey === null || !$this->translator->getDictionary()->has($i18nKey)) { + $conventionKey = 'ACTIVITY.TYPE.' . $type; + $i18nKey = $this->translator->getDictionary()->has($conventionKey) + ? $conventionKey + : null; + } + + if ($i18nKey !== null) { + $placeholders = $activity->getAttribute('metadata'); + if (!is_array($placeholders)) { + $placeholders = []; + } + $placeholders['context'] = $activity->getRelationValue('context'); + $placeholders['subject'] = $activity->getRelationValue('subject'); + + $activity->setAttribute( + 'description', + $this->translator->translate($i18nKey, $placeholders) + ); + + return; + } + + $description = $activity->getAttribute('description'); + if (!is_string($description) || $description === '') { + $activity->setAttribute('description', $type); + } + }); + } + /** * Set the initial query used by your Sprunje. * {@inheritDoc} */ - protected function baseQuery() + protected function baseQuery(): EloquentBuilder { // @phpstan-ignore-next-line Activity interface mixin Model and non-static method. - return $this->activityModel->joinUser(); + $query = $this->activityModel->newQuery(); + $query->getQuery() + ->leftJoin('users', 'activities.user_id', '=', 'users.id') + ->select('activities.*'); + + return $query + ->with(['user' => function ($query) { + $query->withTrashed(); + }, 'context', 'subject']) + ->latest('occurred_at'); } /** diff --git a/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php b/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php index 88934914d..ba44d91a9 100644 --- a/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php +++ b/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php @@ -13,7 +13,17 @@ namespace UserFrosting\Sprinkle\Admin\Tests\Sprunje; use Illuminate\Database\Eloquent\Collection as EloquentCollection; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; +use Mockery; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; +use stdClass; +use UserFrosting\I18n\DictionaryInterface; +use UserFrosting\I18n\Translator; +use UserFrosting\Sprinkle\Account\Database\Models\Activity; use UserFrosting\Sprinkle\Account\Database\Models\User; +use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; +use UserFrosting\Sprinkle\Account\Log\ActivityTypeRegistryInterface; use UserFrosting\Sprinkle\Admin\Sprunje\ActivitySprunje; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -24,6 +34,7 @@ class ActivitySprunjeTest extends AdminTestCase { use RefreshDatabase; + use MockeryPHPUnitIntegration; /** @var EloquentCollection */ protected EloquentCollection $users; @@ -88,23 +99,23 @@ public function testWithUserSort(): void $this->assertEquals(6, $data['count']); $this->assertEquals(6, $data['count_filtered']); $this->assertCount(6, $data['rows']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][0]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][1]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][2]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][3]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][4]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][5]['user_id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][0]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][1]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][2]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][3]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][4]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][5]['user']['id']); // @phpstan-ignore-line $sprunje->setOptions([ 'sorts' => ['user' => 'asc'], ]); $data = $sprunje->getArray(); - $this->assertEquals($this->users[0]->id, $data['rows'][0]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][1]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[0]->id, $data['rows'][2]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][3]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][4]['user_id']); // @phpstan-ignore-line - $this->assertEquals($this->users[1]->id, $data['rows'][5]['user_id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][0]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][1]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[0]->id, $data['rows'][2]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][3]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][4]['user']['id']); // @phpstan-ignore-line + $this->assertEquals($this->users[1]->id, $data['rows'][5]['user']['id']); // @phpstan-ignore-line } public function testWithOccurredAtSort(): void @@ -171,4 +182,127 @@ public function testWithUserFilter(): void ]); $this->assertEquals(6, $sprunje->getArray()['count_filtered']); } + + public function testActivityDescriptions(): void + { + $userId = $this->users[0]->id; + /** @var Activity $registered */ + $registered = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::UPDATE_FIELD->value, + 'metadata' => ['field' => 'email'], + 'description' => 'Legacy registered description', + ]); + /** @var Activity $legacy */ + $legacy = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => 'unregistered_event', + 'description' => 'Legacy description', + ]); + /** @var Activity $raw */ + $raw = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => 'unknown_event', + 'description' => null, + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $rows = $sprunje->getArray()['rows']; + + $rowsById = []; + foreach ($rows as $row) { + $rowsById[$row['id']] = $row; + } + + $this->assertSame('Account field email updated', $rowsById[$registered->id]['description']); + $this->assertSame('Legacy description', $rowsById[$legacy->id]['description']); + $this->assertSame('unknown_event', $rowsById[$raw->id]['description']); + } + + public function testConventionFallbackUsesMockedDictionary(): void + { + $activity = new Activity([ + 'type' => 'legacy_event', + 'description' => 'Legacy convention description', + ]); + + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getI18nKey')->with('legacy_event')->once()->andReturnNull() + ->getMock(); + + /** @var Mockery\MockInterface&DictionaryInterface $dictionary */ + $dictionary = Mockery::mock(DictionaryInterface::class) + ->shouldReceive('has')->with('ACTIVITY.TYPE.legacy_event')->once()->andReturnTrue() + ->getMock(); + + /** @var Mockery\MockInterface&Translator $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('getDictionary')->once()->andReturn($dictionary) + ->shouldReceive('translate')->once()->with('ACTIVITY.TYPE.legacy_event', [ + 'context' => null, + 'subject' => null, + ])->andReturn('Legacy event')->getMock(); + + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + $sprunje->transform(new Collection([$activity])); + + $this->assertSame('Legacy event', $activity->getAttribute('description')); + } + + public function testActivityDescriptionPlaceholdersIncludeRelations(): void + { + $context = new stdClass(); + $subject = new stdClass(); + $activity = new Activity([ + 'type' => 'test_event', + 'metadata' => [ + 'field' => 'email', + 'context' => 'metadata context', + 'subject' => 'metadata subject', + ], + ]); + $activity->setRelation('context', $context); + $activity->setRelation('subject', $subject); + + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getI18nKey')->with('test_event')->once()->andReturn('TEST.ACTIVITY') + ->getMock(); + + /** @var Mockery\MockInterface&DictionaryInterface $dictionary */ + $dictionary = Mockery::mock(DictionaryInterface::class) + ->shouldReceive('has')->with('TEST.ACTIVITY')->once()->andReturnTrue() + ->getMock(); + + /** @var Mockery\MockInterface&Translator $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('getDictionary')->once()->andReturn($dictionary) + ->shouldReceive('translate')->once()->with('TEST.ACTIVITY', Mockery::on(function (array $placeholders) use ($context, $subject): bool { + return $placeholders === [ + 'field' => 'email', + 'context' => $context, + 'subject' => $subject, + ]; + }))->andReturn('translated')->getMock(); + + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + $sprunje->transform(new Collection([$activity])); + + $this->assertSame('translated', $activity->getAttribute('description')); + } +} + +class TestableActivitySprunje extends ActivitySprunje +{ + /** + * @param Collection $collection + * + * @return Collection + */ + public function transform(Collection $collection): Collection + { + return $this->applyTransformations($collection); + } } From 357a26de47f78b384ca2928e6509c3ab4b35a051 Mon Sep 17 00:00:00 2001 From: Louis Charette Date: Tue, 11 Aug 2026 10:51:32 -0400 Subject: [PATCH 04/31] Fix sort order --- .../sprinkle-admin/app/src/Sprunje/ActivitySprunje.php | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php index 3f6b38ffc..158a01f59 100644 --- a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php +++ b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php @@ -98,17 +98,13 @@ protected function applyTransformations(Collection $collection): Collection */ protected function baseQuery(): EloquentBuilder { - // @phpstan-ignore-next-line Activity interface mixin Model and non-static method. - $query = $this->activityModel->newQuery(); - $query->getQuery() - ->leftJoin('users', 'activities.user_id', '=', 'users.id') - ->select('activities.*'); + // @phpstan-ignore-next-line staticMethod.dynamicCall + $query = $this->activityModel->joinUser(); return $query ->with(['user' => function ($query) { $query->withTrashed(); - }, 'context', 'subject']) - ->latest('occurred_at'); + }, 'context', 'subject']); } /** From f702bee9f6afc9aea35a391e65bc2e9bbaf0a593 Mon Sep 17 00:00:00 2001 From: Louis Charette Date: Tue, 11 Aug 2026 11:31:16 -0400 Subject: [PATCH 05/31] feat(activity): Add localized labels to sort and filter activities with listable --- .../app/locale/en_US/messages.php | 15 ++ .../app/locale/fr_FR/messages.php | 15 ++ .../app/src/Database/Models/Activity.php | 1 + .../app/src/Log/AccountActivityTypes.php | 16 ++ .../src/Log/ActivityTypeRegistryInterface.php | 9 + .../app/src/Log/ActivityTypes.php | 9 + .../src/Log/SprinkleActivityTypeRegistry.php | 15 ++ .../app/src/Log/UserActivityTypes.php | 16 ++ .../Log/SprinkleActivityTypeRegistryTest.php | 59 +++++- .../app/locale/en_US/messages.php | 13 ++ .../app/locale/fr_FR/messages.php | 13 ++ .../app/src/Log/GroupActivityTypes.php | 13 ++ .../app/src/Log/RoleActivityTypes.php | 14 ++ .../app/src/Sprunje/ActivitySprunje.php | 152 +++++++++++++- .../app/tests/Sprunje/ActivitySprunjeTest.php | 189 +++++++++++++++++- .../app/locale/en_US/messages.php | 1 + .../app/locale/fr_FR/messages.php | 1 + .../Pages/Admin/User/UserActivities.vue | 2 +- .../components/Sprunjer/SprunjeFilters.vue | 2 +- .../src/views/Admin/PageActivities.vue | 2 +- 20 files changed, 546 insertions(+), 11 deletions(-) diff --git a/packages/sprinkle-account/app/locale/en_US/messages.php b/packages/sprinkle-account/app/locale/en_US/messages.php index ea1f0b2f2..4d0db6d57 100644 --- a/packages/sprinkle-account/app/locale/en_US/messages.php +++ b/packages/sprinkle-account/app/locale/en_US/messages.php @@ -31,6 +31,21 @@ 'LOGGED_IN' => 'User signed in', 'LOGGED_OUT' => 'User signed out', 'PASSWORD_UPGRADED' => 'Password upgraded', + + 'LABEL' => [ + 'CREATE' => 'Account created', + 'DELETE' => 'Account deleted', + 'UPDATE_INFO' => 'Account updated', + 'UPDATE_FIELD' => 'Account field updated', + 'UPDATE_PROFILE_SETTINGS' => 'Profile settings updated', + 'UPDATE_ACCOUNT_SETTINGS' => 'Account settings updated', + 'REGISTER' => 'User registered', + 'VERIFIED' => 'User verified', + 'PASSWORD_RESET' => 'Password reset', + 'LOGGED_IN' => 'User signed in', + 'LOGGED_OUT' => 'User signed out', + 'PASSWORD_UPGRADED' => 'Password upgraded', + ], ], 'EXCEPTION' => [ diff --git a/packages/sprinkle-account/app/locale/fr_FR/messages.php b/packages/sprinkle-account/app/locale/fr_FR/messages.php index f1bc09ed2..bb13d6f69 100644 --- a/packages/sprinkle-account/app/locale/fr_FR/messages.php +++ b/packages/sprinkle-account/app/locale/fr_FR/messages.php @@ -31,6 +31,21 @@ 'LOGGED_IN' => 'Utilisateur connecté', 'LOGGED_OUT' => 'Utilisateur déconnecté', 'PASSWORD_UPGRADED' => 'Mot de passe amélioré', + + 'LABEL' => [ + 'CREATE' => 'Compte créé', + 'DELETE' => 'Compte supprimé', + 'UPDATE_INFO' => 'Compte mis à jour', + 'UPDATE_FIELD' => 'Champ du compte mis à jour', + 'UPDATE_PROFILE_SETTINGS' => 'Paramètres du profil mis à jour', + 'UPDATE_ACCOUNT_SETTINGS' => 'Paramètres du compte mis à jour', + 'REGISTER' => 'Utilisateur inscrit', + 'VERIFIED' => 'Utilisateur vérifié', + 'PASSWORD_RESET' => 'Mot de passe réinitialisé', + 'LOGGED_IN' => 'Utilisateur connecté', + 'LOGGED_OUT' => 'Utilisateur déconnecté', + 'PASSWORD_UPGRADED' => 'Mot de passe amélioré', + ], ], 'EXCEPTION' => [ diff --git a/packages/sprinkle-account/app/src/Database/Models/Activity.php b/packages/sprinkle-account/app/src/Database/Models/Activity.php index 7af483ff2..b7f0edc39 100644 --- a/packages/sprinkle-account/app/src/Database/Models/Activity.php +++ b/packages/sprinkle-account/app/src/Database/Models/Activity.php @@ -84,6 +84,7 @@ class Activity extends Model implements ActivityInterface 'occurred_at', 'metadata', 'description', + 'label', 'user', 'context', 'subject', diff --git a/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php index af1deea27..f4fc5e279 100644 --- a/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php +++ b/packages/sprinkle-account/app/src/Log/AccountActivityTypes.php @@ -36,4 +36,20 @@ public static function getI18nKey(string $value): ?string default => null, }; } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'ACCOUNT.ACTIVITY.LABEL.CREATE', + self::DELETE => 'ACCOUNT.ACTIVITY.LABEL.DELETE', + self::UPDATE_INFO => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_INFO', + self::UPDATE_FIELD => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_FIELD', + self::UPDATE_PROFILE_SETTINGS => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_PROFILE_SETTINGS', + self::UPDATE_ACCOUNT_SETTINGS => 'ACCOUNT.ACTIVITY.LABEL.UPDATE_ACCOUNT_SETTINGS', + default => null, + }; + } } diff --git a/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php b/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php index 0fcba5895..8382c9867 100644 --- a/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php +++ b/packages/sprinkle-account/app/src/Log/ActivityTypeRegistryInterface.php @@ -29,4 +29,13 @@ interface ActivityTypeRegistryInterface extends ClassRepositoryInterface * @return string|null */ public function getI18nKey(string $value): ?string; + + /** + * Return the i18n key for a concise label for a persisted activity type value. + * + * @param string $value + * + * @return string|null + */ + public function getLabelI18nKey(string $value): ?string; } diff --git a/packages/sprinkle-account/app/src/Log/ActivityTypes.php b/packages/sprinkle-account/app/src/Log/ActivityTypes.php index a98faaaf1..4b8abefe7 100644 --- a/packages/sprinkle-account/app/src/Log/ActivityTypes.php +++ b/packages/sprinkle-account/app/src/Log/ActivityTypes.php @@ -22,4 +22,13 @@ interface ActivityTypes * @return string|null */ public static function getI18nKey(string $value): ?string; + + /** + * Return the i18n key for a concise label for a persisted activity type value. + * + * @param string $value + * + * @return string|null + */ + public static function getLabelI18nKey(string $value): ?string; } diff --git a/packages/sprinkle-account/app/src/Log/SprinkleActivityTypeRegistry.php b/packages/sprinkle-account/app/src/Log/SprinkleActivityTypeRegistry.php index 8b9e0c63f..dc272a03d 100644 --- a/packages/sprinkle-account/app/src/Log/SprinkleActivityTypeRegistry.php +++ b/packages/sprinkle-account/app/src/Log/SprinkleActivityTypeRegistry.php @@ -67,6 +67,21 @@ public function getI18nKey(string $value): ?string return get_class($activityType)::getI18nKey($value); } + /** + * {@inheritDoc} + */ + public function getLabelI18nKey(string $value): ?string + { + $this->loadActivityTypes(); + + $activityType = $this->activityTypesByValue[$value] ?? null; + if ($activityType === null) { + return null; + } + + return get_class($activityType)::getLabelI18nKey($value); + } + /** * Expand the activity enum classes registered by all loaded Sprinkles. */ diff --git a/packages/sprinkle-account/app/src/Log/UserActivityTypes.php b/packages/sprinkle-account/app/src/Log/UserActivityTypes.php index fb44f9f13..2a122880e 100644 --- a/packages/sprinkle-account/app/src/Log/UserActivityTypes.php +++ b/packages/sprinkle-account/app/src/Log/UserActivityTypes.php @@ -36,4 +36,20 @@ public static function getI18nKey(string $value): ?string default => null, }; } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::REGISTER => 'ACCOUNT.ACTIVITY.LABEL.REGISTER', + self::VERIFIED => 'ACCOUNT.ACTIVITY.LABEL.VERIFIED', + self::PASSWORD_RESET => 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_RESET', + self::LOGGED_IN => 'ACCOUNT.ACTIVITY.LABEL.LOGGED_IN', + self::LOGGED_OUT => 'ACCOUNT.ACTIVITY.LABEL.LOGGED_OUT', + self::PASSWORD_UPGRADED => 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_UPGRADED', + default => null, + }; + } } diff --git a/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php b/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php index a97c64ab1..940b78a28 100644 --- a/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php +++ b/packages/sprinkle-account/app/tests/Unit/Log/SprinkleActivityTypeRegistryTest.php @@ -29,6 +29,11 @@ public static function getI18nKey(string $value): ?string { return null; } + + public static function getLabelI18nKey(string $value): ?string + { + return null; + } } class SprinkleActivityTypeRegistryTest extends TestCase @@ -51,14 +56,17 @@ public function testActivityTypesAreExpandedAndResolved(): void $registry = new SprinkleActivityTypeRegistry($manager); - $this->assertCount(19, $registry->all()); - $this->assertCount(19, $registry->all()); + $this->assertCount(20, $registry->all()); + $this->assertCount(20, $registry->all()); $this->assertTrue($registry->has(UserActivityTypes::class)); $this->assertInstanceOf(UserActivityTypes::class, $registry->get(UserActivityTypes::class)); - $this->assertSame(19, $registry->count()); + $this->assertSame(20, $registry->count()); $this->assertSame('ACCOUNT.ACTIVITY.REGISTER', $registry->getI18nKey('sign_up')); + $this->assertSame('ACCOUNT.ACTIVITY.LABEL.REGISTER', $registry->getLabelI18nKey('sign_up')); $this->assertSame('ROLE.ACTIVITY.UPDATE_FIELD', $registry->getI18nKey('role_update_field')); + $this->assertSame('ROLE.ACTIVITY.LABEL.UPDATE_FIELD', $registry->getLabelI18nKey('role_update_field')); $this->assertNull($registry->getI18nKey('unknown_activity')); + $this->assertNull($registry->getLabelI18nKey('unknown_activity')); $this->assertSame('ACCOUNT.ACTIVITY.VERIFIED', UserActivityTypes::getI18nKey('verified')); $this->assertSame('ACCOUNT.ACTIVITY.PASSWORD_RESET', UserActivityTypes::getI18nKey('password_reset')); @@ -77,10 +85,55 @@ public function testActivityTypesAreExpandedAndResolved(): void $this->assertSame('ROLE.ACTIVITY.CREATE', RoleActivityTypes::getI18nKey('role_create')); $this->assertSame('ROLE.ACTIVITY.DELETE', RoleActivityTypes::getI18nKey('role_delete')); $this->assertSame('ROLE.ACTIVITY.UPDATE_INFO', RoleActivityTypes::getI18nKey('role_update_info')); + + $this->assertSame([ + 'ACCOUNT.ACTIVITY.LABEL.REGISTER', + 'ACCOUNT.ACTIVITY.LABEL.VERIFIED', + 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_RESET', + 'ACCOUNT.ACTIVITY.LABEL.LOGGED_IN', + 'ACCOUNT.ACTIVITY.LABEL.LOGGED_OUT', + 'ACCOUNT.ACTIVITY.LABEL.PASSWORD_UPGRADED', + ], array_map( + static fn (UserActivityTypes $activityType): ?string => UserActivityTypes::getLabelI18nKey($activityType->value), + UserActivityTypes::cases() + )); + $this->assertSame([ + 'ACCOUNT.ACTIVITY.LABEL.CREATE', + 'ACCOUNT.ACTIVITY.LABEL.DELETE', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_INFO', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_FIELD', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_PROFILE_SETTINGS', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_ACCOUNT_SETTINGS', + 'ACCOUNT.ACTIVITY.LABEL.UPDATE_EMAIL', + ], array_map( + static fn (AccountActivityTypes $activityType): ?string => AccountActivityTypes::getLabelI18nKey($activityType->value), + AccountActivityTypes::cases() + )); + $this->assertSame([ + 'GROUP.ACTIVITY.LABEL.CREATE', + 'GROUP.ACTIVITY.LABEL.DELETE', + 'GROUP.ACTIVITY.LABEL.UPDATE_INFO', + ], array_map( + static fn (GroupActivityTypes $activityType): ?string => GroupActivityTypes::getLabelI18nKey($activityType->value), + GroupActivityTypes::cases() + )); + $this->assertSame([ + 'ROLE.ACTIVITY.LABEL.CREATE', + 'ROLE.ACTIVITY.LABEL.DELETE', + 'ROLE.ACTIVITY.LABEL.UPDATE_INFO', + 'ROLE.ACTIVITY.LABEL.UPDATE_FIELD', + ], array_map( + static fn (RoleActivityTypes $activityType): ?string => RoleActivityTypes::getLabelI18nKey($activityType->value), + RoleActivityTypes::cases() + )); $this->assertNull(UserActivityTypes::getI18nKey('unknown_activity')); $this->assertNull(AccountActivityTypes::getI18nKey('unknown_activity')); $this->assertNull(GroupActivityTypes::getI18nKey('unknown_activity')); $this->assertNull(RoleActivityTypes::getI18nKey('unknown_activity')); + $this->assertNull(UserActivityTypes::getLabelI18nKey('unknown_activity')); + $this->assertNull(AccountActivityTypes::getLabelI18nKey('unknown_activity')); + $this->assertNull(GroupActivityTypes::getLabelI18nKey('unknown_activity')); + $this->assertNull(RoleActivityTypes::getLabelI18nKey('unknown_activity')); } public function testMissingClassIsRejected(): void diff --git a/packages/sprinkle-admin/app/locale/en_US/messages.php b/packages/sprinkle-admin/app/locale/en_US/messages.php index badd7f2ba..2a483c693 100644 --- a/packages/sprinkle-admin/app/locale/en_US/messages.php +++ b/packages/sprinkle-admin/app/locale/en_US/messages.php @@ -68,6 +68,12 @@ 'CREATE' => 'Group created', 'DELETE' => 'Group deleted', 'UPDATE_INFO' => 'Group information updated', + + 'LABEL' => [ + 'CREATE' => 'Group created', + 'DELETE' => 'Group deleted', + 'UPDATE_INFO' => 'Group updated', + ], ], ], @@ -126,6 +132,13 @@ 'DELETE' => 'Role deleted', 'UPDATE_INFO' => 'Role information updated', 'UPDATE_FIELD' => 'Role field {{field}} updated', + + 'LABEL' => [ + 'CREATE' => 'Role created', + 'DELETE' => 'Role deleted', + 'UPDATE_INFO' => 'Role updated', + 'UPDATE_FIELD' => 'Role field updated', + ], ], ], diff --git a/packages/sprinkle-admin/app/locale/fr_FR/messages.php b/packages/sprinkle-admin/app/locale/fr_FR/messages.php index f71fd5ad1..5d7033f9a 100644 --- a/packages/sprinkle-admin/app/locale/fr_FR/messages.php +++ b/packages/sprinkle-admin/app/locale/fr_FR/messages.php @@ -68,6 +68,12 @@ 'CREATE' => 'Groupe créé', 'DELETE' => 'Groupe supprimé', 'UPDATE_INFO' => 'Informations du groupe {{context.name}} mises à jour', + + 'LABEL' => [ + 'CREATE' => 'Groupe créé', + 'DELETE' => 'Groupe supprimé', + 'UPDATE_INFO' => 'Groupe mis à jour', + ], ], ], @@ -126,6 +132,13 @@ 'DELETE' => 'Rôle supprimé', 'UPDATE_INFO' => 'Informations du rôle mises à jour', 'UPDATE_FIELD' => 'Champ du rôle {{field}} mis à jour', + + 'LABEL' => [ + 'CREATE' => 'Rôle créé', + 'DELETE' => 'Rôle supprimé', + 'UPDATE_INFO' => 'Rôle mis à jour', + 'UPDATE_FIELD' => 'Champ du rôle mis à jour', + ], ], ], diff --git a/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php index fff969ed0..83b1ad02d 100644 --- a/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php +++ b/packages/sprinkle-admin/app/src/Log/GroupActivityTypes.php @@ -32,4 +32,17 @@ public static function getI18nKey(string $value): ?string default => null, }; } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'GROUP.ACTIVITY.LABEL.CREATE', + self::DELETE => 'GROUP.ACTIVITY.LABEL.DELETE', + self::UPDATE_INFO => 'GROUP.ACTIVITY.LABEL.UPDATE_INFO', + default => null, + }; + } } diff --git a/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php b/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php index cd34ba957..ad3ea6280 100644 --- a/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php +++ b/packages/sprinkle-admin/app/src/Log/RoleActivityTypes.php @@ -34,4 +34,18 @@ public static function getI18nKey(string $value): ?string default => null, }; } + + /** + * {@inheritDoc} + */ + public static function getLabelI18nKey(string $value): ?string + { + return match (self::tryFrom($value)) { + self::CREATE => 'ROLE.ACTIVITY.LABEL.CREATE', + self::DELETE => 'ROLE.ACTIVITY.LABEL.DELETE', + self::UPDATE_INFO => 'ROLE.ACTIVITY.LABEL.UPDATE_INFO', + self::UPDATE_FIELD => 'ROLE.ACTIVITY.LABEL.UPDATE_FIELD', + default => null, + }; + } } diff --git a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php index 158a01f59..3d08b02e0 100644 --- a/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php +++ b/packages/sprinkle-admin/app/src/Sprunje/ActivitySprunje.php @@ -12,11 +12,13 @@ namespace UserFrosting\Sprinkle\Admin\Sprunje; +use BackedEnum; use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Collection; +use UserFrosting\I18n\DictionaryInterface; use UserFrosting\I18n\Translator; use UserFrosting\Sprinkle\Account\Database\Models\Interfaces\ActivityInterface; use UserFrosting\Sprinkle\Account\Log\ActivityTypeRegistryInterface; @@ -32,15 +34,24 @@ class ActivitySprunje extends Sprunje protected array $sortable = [ 'occurred_at', 'user', - 'description', + 'label', + ]; + + protected array $listable = [ + 'label', ]; protected array $filterable = [ 'occurred_at', 'user', - 'description', + 'label', ]; + /** @var array|null */ + protected ?array $activityLabels = null; + + protected ?DictionaryInterface $dictionary = null; + public function __construct( protected ActivityInterface $activityModel, protected ActivityTypeRegistryInterface $activityTypeRegistry, @@ -60,11 +71,14 @@ protected function applyTransformations(Collection $collection): Collection { return $collection->each(function (Model $activity): void { $type = (string) $activity->getAttribute('type'); + $activity->setAttribute('label', $this->getActivityLabel($type)); + $i18nKey = $this->activityTypeRegistry->getI18nKey($type); + $dictionary = $this->getDictionary(); - if ($i18nKey === null || !$this->translator->getDictionary()->has($i18nKey)) { + if ($i18nKey === null || !$dictionary->has($i18nKey)) { $conventionKey = 'ACTIVITY.TYPE.' . $type; - $i18nKey = $this->translator->getDictionary()->has($conventionKey) + $i18nKey = $dictionary->has($conventionKey) ? $conventionKey : null; } @@ -92,6 +106,136 @@ protected function applyTransformations(Collection $collection): Collection }); } + /** + * Resolve a concise localized label for a persisted activity type value. + */ + protected function getActivityLabel(string $value): string + { + $i18nKey = $this->activityTypeRegistry->getLabelI18nKey($value); + if ($i18nKey === null || !$this->getDictionary()->has($i18nKey)) { + return $value; + } + + return $this->translator->translate($i18nKey); + } + + /** + * Return localized labels indexed by their persisted activity type values. + * + * @return array + */ + protected function getActivityLabels(): array + { + if ($this->activityLabels !== null) { + return $this->activityLabels; + } + + $activityLabels = []; + foreach ($this->activityTypeRegistry->all() as $activityType) { + if (!$activityType instanceof BackedEnum) { + continue; + } + + $value = (string) $activityType->value; + $activityLabels[$value] = $this->getActivityLabel($value); + } + + return $this->activityLabels = $activityLabels; + } + + /** + * Return the possible localized activity labels. + * + * @return array{value: string, text: string}[] + */ + protected function listLabel(): array + { + /** @var array $valuesByLabel */ + $valuesByLabel = []; + foreach ($this->getActivityLabels() as $value => $text) { + $valuesByLabel[$text][] = $value; + } + + /** @var array{value: string, text: string}[] $labels */ + $labels = []; + foreach ($valuesByLabel as $text => $values) { + $labels[] = [ + 'value' => implode($this->orSeparator, $values), + 'text' => $text, + ]; + } + + usort($labels, static function (array $left, array $right): int { + $textComparison = strcasecmp($left['text'], $right['text']); + + return $textComparison !== 0 + ? $textComparison + : strcmp($left['value'], $right['value']); + }); + + return $labels; + } + + /** + * Filter by persisted activity type values represented by localized labels. + * + * @param EloquentBuilder|QueryBuilder|Relation $query + * @param string $value + * + * @return static + */ + protected function filterLabel($query, string $value): static + { + $values = array_values(array_intersect( + explode($this->orSeparator, $value), + array_keys($this->getActivityLabels()) + )); + // @phpstan-ignore-next-line - Eloquent builders expose whereIn dynamically through this union. + $query->whereIn('activities.type', $values); + + return $this; + } + + /** + * Sort by localized activity labels using persisted type values. + * + * @param EloquentBuilder|QueryBuilder|Relation $query + * @param string $direction + * + * @return static + */ + protected function sortLabel($query, string $direction): static + { + $direction = strtolower($direction) === 'desc' ? 'desc' : 'asc'; + $cases = []; + $bindings = []; + + foreach ($this->getActivityLabels() as $value => $label) { + $cases[] = 'WHEN activities.type = ? THEN ?'; + $bindings[] = $value; + $bindings[] = $label; + } + + if ($cases !== []) { + // @phpstan-ignore-next-line - Eloquent builders expose orderByRaw dynamically through this union. + $query->orderByRaw( + 'CASE ' . implode(' ', $cases) . ' ELSE activities.type END ' . $direction, + $bindings + ); + } + + // @phpstan-ignore-next-line - Eloquent builders expose orderBy dynamically through this union. + $query->orderBy('activities.type', $direction) + ->orderBy('activities.id', $direction); + + return $this; + } + + protected function getDictionary(): DictionaryInterface + { + return $this->dictionary ??= $this->translator->getDictionary(); + } + /** * Set the initial query used by your Sprunje. * {@inheritDoc} diff --git a/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php b/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php index ba44d91a9..816d9c84b 100644 --- a/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php +++ b/packages/sprinkle-admin/app/tests/Sprunje/ActivitySprunjeTest.php @@ -24,6 +24,7 @@ use UserFrosting\Sprinkle\Account\Database\Models\User; use UserFrosting\Sprinkle\Account\Log\AccountActivityTypes; use UserFrosting\Sprinkle\Account\Log\ActivityTypeRegistryInterface; +use UserFrosting\Sprinkle\Admin\Log\GroupActivityTypes; use UserFrosting\Sprinkle\Admin\Sprunje\ActivitySprunje; use UserFrosting\Sprinkle\Admin\Tests\AdminTestCase; use UserFrosting\Sprinkle\Core\Testing\RefreshDatabase; @@ -69,7 +70,13 @@ public function testBaseSprunje(): void $this->assertEquals(6, $data['count']); $this->assertEquals(6, $data['count_filtered']); $this->assertCount(6, $data['rows']); // @phpstan-ignore-line - $this->assertEquals([], $data['listable']); + $this->assertArrayHasKey('label', $data['listable']); // @phpstan-ignore-line + $this->assertContains([ + 'value' => AccountActivityTypes::CREATE->value, + 'text' => 'Account created', + ], $data['listable']['label']); // @phpstan-ignore-line + $this->assertContains('label', $data['sortable']); // @phpstan-ignore-line + $this->assertContains('label', $data['filterable']); // @phpstan-ignore-line } public function testWithPagination(): void @@ -183,6 +190,125 @@ public function testWithUserFilter(): void $this->assertEquals(6, $sprunje->getArray()['count_filtered']); } + public function testWithLabelFilter(): void + { + $userId = $this->users[0]->id; + $created = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::CREATE->value, + ]); + $deleted = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::DELETE->value, + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => [ + 'label' => AccountActivityTypes::CREATE->value . '||' . AccountActivityTypes::DELETE->value, + ], + ]); + $data = $sprunje->getArray(); + + $this->assertEquals(8, $data['count']); + $this->assertEquals(2, $data['count_filtered']); + $this->assertEqualsCanonicalizing([$created->id, $deleted->id], array_column($data['rows'], 'id')); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'filters' => ['label' => 'Account created'], + ]); + $this->assertEquals(0, $sprunje->getArray()['count_filtered']); + } + + public function testWithLocalizedLabelSortAndPagination(): void + { + $userId = $this->users[0]->id; + Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::DELETE->value, + ]); + $groupCreated = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => GroupActivityTypes::CREATE->value, + ]); + Activity::factory()->create([ + 'user_id' => $userId, + 'type' => 'sign_up', + ]); + $filter = implode('||', [ + AccountActivityTypes::DELETE->value, + GroupActivityTypes::CREATE->value, + 'sign_up', + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => ['label' => $filter], + 'sorts' => ['label' => 'asc'], + ]); + $data = $sprunje->getArray(); + + $this->assertEquals(9, $data['count']); + $this->assertEquals(3, $data['count_filtered']); + $this->assertSame([ + AccountActivityTypes::DELETE->value, + GroupActivityTypes::CREATE->value, + 'sign_up', + ], array_column($data['rows'], 'type')); // @phpstan-ignore-line + $this->assertSame('Account deleted', $data['rows'][0]['label']); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'sorts' => ['label' => 'desc'], + ]); + $data = $sprunje->getArray(); + $this->assertSame([ + 'sign_up', + GroupActivityTypes::CREATE->value, + AccountActivityTypes::DELETE->value, + ], array_column($data['rows'], 'type')); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'sorts' => ['label' => 'asc'], + 'size' => 1, + 'page' => 1, + ]); + $data = $sprunje->getArray(); + $this->assertEquals(9, $data['count']); + $this->assertEquals(3, $data['count_filtered']); + $this->assertCount(1, $data['rows']); // @phpstan-ignore-line + $this->assertSame($groupCreated->id, $data['rows'][0]['id']); // @phpstan-ignore-line + } + + public function testLabelSortUsesIdAsDeterministicTieBreaker(): void + { + $userId = $this->users[0]->id; + $first = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::CREATE->value, + ]); + $second = Activity::factory()->create([ + 'user_id' => $userId, + 'type' => AccountActivityTypes::CREATE->value, + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => ['label' => AccountActivityTypes::CREATE->value], + 'sorts' => ['label' => 'asc'], + ]); + $data = $sprunje->getArray(); + $this->assertSame([$first->id, $second->id], array_column($data['rows'], 'id')); // @phpstan-ignore-line + + $sprunje->setOptions([ + 'sorts' => ['label' => 'desc'], + ]); + $data = $sprunje->getArray(); + $this->assertSame([$second->id, $first->id], array_column($data['rows'], 'id')); // @phpstan-ignore-line + } + public function testActivityDescriptions(): void { $userId = $this->users[0]->id; @@ -216,8 +342,11 @@ public function testActivityDescriptions(): void } $this->assertSame('Account field email updated', $rowsById[$registered->id]['description']); + $this->assertSame('Account field updated', $rowsById[$registered->id]['label']); $this->assertSame('Legacy description', $rowsById[$legacy->id]['description']); + $this->assertSame('unregistered_event', $rowsById[$legacy->id]['label']); $this->assertSame('unknown_event', $rowsById[$raw->id]['description']); + $this->assertSame('unknown_event', $rowsById[$raw->id]['label']); } public function testConventionFallbackUsesMockedDictionary(): void @@ -229,6 +358,7 @@ public function testConventionFallbackUsesMockedDictionary(): void /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getLabelI18nKey')->with('legacy_event')->once()->andReturnNull() ->shouldReceive('getI18nKey')->with('legacy_event')->once()->andReturnNull() ->getMock(); @@ -268,6 +398,7 @@ public function testActivityDescriptionPlaceholdersIncludeRelations(): void /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getLabelI18nKey')->with('test_event')->once()->andReturnNull() ->shouldReceive('getI18nKey')->with('test_event')->once()->andReturn('TEST.ACTIVITY') ->getMock(); @@ -292,6 +423,62 @@ public function testActivityDescriptionPlaceholdersIncludeRelations(): void $this->assertSame('translated', $activity->getAttribute('description')); } + + public function testMissingLabelTranslationFallsBackToPersistedType(): void + { + $activity = new Activity([ + 'type' => 'test_event', + 'description' => 'Legacy description', + ]); + + /** @var Mockery\MockInterface&ActivityTypeRegistryInterface $registry */ + $registry = Mockery::mock(ActivityTypeRegistryInterface::class) + ->shouldReceive('getLabelI18nKey')->with('test_event')->once()->andReturn('TEST.LABEL') + ->shouldReceive('getI18nKey')->with('test_event')->once()->andReturn('TEST.DESCRIPTION') + ->getMock(); + + /** @var Mockery\MockInterface&DictionaryInterface $dictionary */ + $dictionary = Mockery::mock(DictionaryInterface::class) + ->shouldReceive('has')->with('TEST.LABEL')->once()->andReturnFalse() + ->shouldReceive('has')->with('TEST.DESCRIPTION')->once()->andReturnTrue() + ->getMock(); + + /** @var Mockery\MockInterface&Translator $translator */ + $translator = Mockery::mock(Translator::class) + ->shouldReceive('getDictionary')->once()->andReturn($dictionary) + ->shouldReceive('translate')->once()->with('TEST.DESCRIPTION', [ + 'context' => null, + 'subject' => null, + ])->andReturn('Translated description')->getMock(); + + $sprunje = new TestableActivitySprunje(new Activity(), $registry, $translator); + $sprunje->transform(new Collection([$activity])); + + $this->assertSame('test_event', $activity->getAttribute('label')); + $this->assertSame('Translated description', $activity->getAttribute('description')); + } + + public function testCsvIncludesLabelAndTranslatedDescription(): void + { + Activity::factory()->create([ + 'user_id' => $this->users[0]->id, + 'type' => AccountActivityTypes::UPDATE_FIELD->value, + 'metadata' => ['field' => 'email'], + 'description' => 'Legacy description', + ]); + + /** @var ActivitySprunje */ + $sprunje = $this->getService(ActivitySprunje::class); + $sprunje->setOptions([ + 'filters' => ['label' => AccountActivityTypes::UPDATE_FIELD->value], + ]); + $csv = $sprunje->getCsv()->toString(); + + $this->assertStringContainsString('label', $csv); + $this->assertStringContainsString('Account field updated', $csv); + $this->assertStringContainsString('description', $csv); + $this->assertStringContainsString('Account field email updated', $csv); + } } class TestableActivitySprunje extends ActivitySprunje diff --git a/packages/sprinkle-core/app/locale/en_US/messages.php b/packages/sprinkle-core/app/locale/en_US/messages.php index 2379b58ee..c93afd351 100644 --- a/packages/sprinkle-core/app/locale/en_US/messages.php +++ b/packages/sprinkle-core/app/locale/en_US/messages.php @@ -93,6 +93,7 @@ 'EDIT' => 'Edit', 'ENABLE' => 'Enable', 'ENABLED' => 'Enabled', + 'LABEL' => 'Label', 'MORE_OPTIONS' => 'More options', 'NO' => 'No', 'NONE' => 'None', diff --git a/packages/sprinkle-core/app/locale/fr_FR/messages.php b/packages/sprinkle-core/app/locale/fr_FR/messages.php index 19b5a8faf..729a7ca07 100644 --- a/packages/sprinkle-core/app/locale/fr_FR/messages.php +++ b/packages/sprinkle-core/app/locale/fr_FR/messages.php @@ -93,6 +93,7 @@ 'EDIT' => 'Modifier', 'ENABLE' => 'Activer', 'ENABLED' => 'Activé', + 'LABEL' => 'Libellé', 'MORE_OPTIONS' => 'Plus d\'options', 'NO' => 'Non', 'NONE' => 'Aucun', diff --git a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue index 2880cc275..dc0f445cb 100644 --- a/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue +++ b/packages/theme-pink-cupcake/src/components/Pages/Admin/User/UserActivities.vue @@ -12,7 +12,7 @@ const { user_name } = defineProps<{ :defaultSorts="{ occurred_at: 'desc' }">