diff --git a/Concerns/LogsWorkspaceActivity.php b/Concerns/LogsWorkspaceActivity.php new file mode 100644 index 0000000..416adff --- /dev/null +++ b/Concerns/LogsWorkspaceActivity.php @@ -0,0 +1,136 @@ +logActivity('member.role_changed', ['old_role' => 'member', 'new_role' => 'admin']); + */ +trait LogsWorkspaceActivity +{ + /** + * Boot the trait — optionally auto-log on create/update/delete. + */ + protected static function bootLogsWorkspaceActivity(): void + { + if (static::shouldAutoLog()) { + static::created(function (Model $model) { + $model->logActivityIfWorkspaced('created'); + }); + + static::updated(function (Model $model) { + $model->logActivityIfWorkspaced('updated', [ + 'changed' => $model->getChanges(), + ]); + }); + + static::deleted(function (Model $model) { + $model->logActivityIfWorkspaced('deleted'); + }); + } + } + + /** + * Whether this model should auto-log create/update/delete events. + * + * Override in model to enable: protected static bool $autoLogActivity = true; + */ + protected static function shouldAutoLog(): bool + { + return property_exists(static::class, 'autoLogActivity') + && static::$autoLogActivity === true; + } + + /** + * Log an activity for this model within its workspace context. + */ + public function logActivity(string $action, ?array $metadata = null, ?User $user = null): WorkspaceActivity + { + $workspaceId = $this->getActivityWorkspaceId(); + + return WorkspaceActivity::record( + workspace: $workspaceId, + action: $action, + subject: $this, + user: $user, + metadata: $metadata, + ); + } + + /** + * Log activity only if the model has a workspace context. + */ + protected function logActivityIfWorkspaced(string $suffix, ?array $metadata = null): void + { + $workspaceId = $this->getActivityWorkspaceId(); + + if ($workspaceId === null) { + return; + } + + $action = static::getActivityActionPrefix() . '.' . $suffix; + + WorkspaceActivity::record( + workspace: $workspaceId, + action: $action, + subject: $this, + metadata: $metadata, + ); + } + + /** + * Get the workspace ID for this model's activity log. + * + * Override in model if the workspace relationship differs. + */ + protected function getActivityWorkspaceId(): ?int + { + if (property_exists($this, 'workspace_id') || $this->getAttribute('workspace_id')) { + return $this->getAttribute('workspace_id'); + } + + // Try to resolve from the current workspace context + $workspace = Workspace::current(); + + return $workspace?->id; + } + + /** + * Get the action prefix for auto-logged events. + * + * Override via: protected static string $activityActionPrefix = 'custom_prefix'; + */ + protected static function getActivityActionPrefix(): string + { + if (property_exists(static::class, 'activityActionPrefix')) { + return static::$activityActionPrefix; + } + + // Derive from class name: WorkspaceMember -> workspace_member + return strtolower((string) preg_replace('/(?id(); + $table->foreignId('workspace_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('action'); + $table->nullableMorphs('subject'); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['workspace_id', 'action'], 'ws_activity_ws_action_idx'); + $table->index(['workspace_id', 'created_at'], 'ws_activity_ws_created_idx'); + $table->index(['subject_type', 'subject_id'], 'ws_activity_subject_idx'); + $table->index('user_id', 'ws_activity_user_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspace_activities'); + } +}; diff --git a/Models/Workspace.php b/Models/Workspace.php index 490bc64..09d5ed8 100644 --- a/Models/Workspace.php +++ b/Models/Workspace.php @@ -250,6 +250,14 @@ class Workspace extends Model return $this->hasMany(EntitlementLog::class); } + /** + * Activity audit log for this workspace. + */ + public function activities(): HasMany + { + return $this->hasMany(WorkspaceActivity::class); + } + /** * Usage alert history for this workspace. */ diff --git a/Models/WorkspaceActivity.php b/Models/WorkspaceActivity.php new file mode 100644 index 0000000..bdee318 --- /dev/null +++ b/Models/WorkspaceActivity.php @@ -0,0 +1,256 @@ + 'array', + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Action Constants — Workspace + // ───────────────────────────────────────────────────────────────────────── + + public const ACTION_WORKSPACE_CREATED = 'workspace.created'; + + public const ACTION_WORKSPACE_UPDATED = 'workspace.updated'; + + public const ACTION_WORKSPACE_DELETED = 'workspace.deleted'; + + public const ACTION_WORKSPACE_SETTINGS_CHANGED = 'workspace.settings_changed'; + + // ───────────────────────────────────────────────────────────────────────── + // Action Constants — Membership + // ───────────────────────────────────────────────────────────────────────── + + public const ACTION_MEMBER_INVITED = 'member.invited'; + + public const ACTION_MEMBER_JOINED = 'member.joined'; + + public const ACTION_MEMBER_REMOVED = 'member.removed'; + + public const ACTION_MEMBER_ROLE_CHANGED = 'member.role_changed'; + + public const ACTION_MEMBER_TEAM_CHANGED = 'member.team_changed'; + + // ───────────────────────────────────────────────────────────────────────── + // Action Constants — Entitlements + // ───────────────────────────────────────────────────────────────────────── + + public const ACTION_PACKAGE_ASSIGNED = 'package.assigned'; + + public const ACTION_PACKAGE_REMOVED = 'package.removed'; + + public const ACTION_BOOST_APPLIED = 'boost.applied'; + + public const ACTION_BOOST_REMOVED = 'boost.removed'; + + // ───────────────────────────────────────────────────────────────────────── + // Action Constants — Security Events + // ───────────────────────────────────────────────────────────────────────── + + public const ACTION_API_KEY_CREATED = 'api_key.created'; + + public const ACTION_API_KEY_REVOKED = 'api_key.revoked'; + + public const ACTION_WEBHOOK_CREATED = 'webhook.created'; + + public const ACTION_WEBHOOK_DELETED = 'webhook.deleted'; + + // ───────────────────────────────────────────────────────────────────────── + // Relationships + // ───────────────────────────────────────────────────────────────────────── + + /** + * The workspace this activity belongs to. + */ + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + /** + * The user who performed this action. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * The subject (polymorphic) of this activity. + */ + public function subject(): MorphTo + { + return $this->morphTo(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Scopes + // ───────────────────────────────────────────────────────────────────────── + + /** + * Scope to a specific workspace. + */ + public function scopeForWorkspace($query, Workspace|int $workspace) + { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return $query->where('workspace_id', $workspaceId); + } + + /** + * Scope to a specific action. + */ + public function scopeForAction($query, string $action) + { + return $query->where('action', $action); + } + + /** + * Scope to a specific action prefix (e.g. 'member.' matches all member actions). + */ + public function scopeForActionGroup($query, string $prefix) + { + return $query->where('action', 'like', $prefix . '%'); + } + + /** + * Scope to a specific subject. + */ + public function scopeForSubject($query, Model $subject) + { + return $query + ->where('subject_type', $subject->getMorphClass()) + ->where('subject_id', $subject->getKey()); + } + + /** + * Scope to a specific user. + */ + public function scopeByUser($query, User|int $user) + { + $userId = $user instanceof User ? $user->id : $user; + + return $query->where('user_id', $userId); + } + + /** + * Scope to activities within a date range. + */ + public function scopeBetween($query, $from, $to) + { + return $query->whereBetween('created_at', [$from, $to]); + } + + /** + * Order by most recent first. + */ + public function scopeLatestFirst($query) + { + return $query->orderByDesc('created_at'); + } + + // ───────────────────────────────────────────────────────────────────────── + // Factory Methods + // ───────────────────────────────────────────────────────────────────────── + + /** + * Record a workspace activity. + */ + public static function record( + Workspace|int $workspace, + string $action, + ?Model $subject = null, + ?User $user = null, + ?array $metadata = null, + ): self { + $workspaceId = $workspace instanceof Workspace ? $workspace->id : $workspace; + + return self::create([ + 'workspace_id' => $workspaceId, + 'user_id' => $user?->id ?? auth()->id(), + 'action' => $action, + 'subject_type' => $subject?->getMorphClass(), + 'subject_id' => $subject?->getKey(), + 'metadata' => $metadata, + ]); + } + + /** + * Record a membership change. + */ + public static function recordMembershipChange( + Workspace|int $workspace, + string $action, + WorkspaceMember|User $subject, + ?User $performedBy = null, + ?array $metadata = null, + ): self { + return self::record($workspace, $action, $subject, $performedBy, $metadata); + } + + /** + * Record an entitlement change. + */ + public static function recordEntitlementChange( + Workspace|int $workspace, + string $action, + Model $subject, + ?User $performedBy = null, + ?array $metadata = null, + ): self { + return self::record($workspace, $action, $subject, $performedBy, $metadata); + } + + /** + * Record a security event. + */ + public static function recordSecurityEvent( + Workspace|int $workspace, + string $action, + ?Model $subject = null, + ?User $performedBy = null, + ?array $metadata = null, + ): self { + return self::record($workspace, $action, $subject, $performedBy, $metadata); + } +}