Compare commits
1 commit
dev
...
feat/works
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3244c3d108 |
4 changed files with 436 additions and 0 deletions
136
Concerns/LogsWorkspaceActivity.php
Normal file
136
Concerns/LogsWorkspaceActivity.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Tenant\Concerns;
|
||||||
|
|
||||||
|
use Core\Tenant\Models\User;
|
||||||
|
use Core\Tenant\Models\Workspace;
|
||||||
|
use Core\Tenant\Models\WorkspaceActivity;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for models that should log workspace activity on changes.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* class WorkspaceMember extends Model {
|
||||||
|
* use LogsWorkspaceActivity;
|
||||||
|
*
|
||||||
|
* protected static string $activityActionPrefix = 'member';
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* For auto-logging create/update/delete:
|
||||||
|
* class WorkspaceMember extends Model {
|
||||||
|
* use LogsWorkspaceActivity;
|
||||||
|
*
|
||||||
|
* protected static bool $autoLogActivity = true;
|
||||||
|
* protected static string $activityActionPrefix = 'member';
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* For manual logging:
|
||||||
|
* $model->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('/(?<!^)[A-Z]/', '_$0', class_basename(static::class)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Workspace activity audit log for tracking significant workspace actions.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('workspace_activities', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -250,6 +250,14 @@ class Workspace extends Model
|
||||||
return $this->hasMany(EntitlementLog::class);
|
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.
|
* Usage alert history for this workspace.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
256
Models/WorkspaceActivity.php
Normal file
256
Models/WorkspaceActivity.php
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Tenant\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace Activity - audit log for significant workspace actions.
|
||||||
|
*
|
||||||
|
* Tracks security events, membership changes, entitlement changes, and
|
||||||
|
* other significant actions within a workspace.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property int $workspace_id
|
||||||
|
* @property int|null $user_id
|
||||||
|
* @property string $action
|
||||||
|
* @property string|null $subject_type
|
||||||
|
* @property int|null $subject_id
|
||||||
|
* @property array|null $metadata
|
||||||
|
* @property \Carbon\Carbon|null $created_at
|
||||||
|
*/
|
||||||
|
class WorkspaceActivity extends Model
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Disable updated_at since this is an append-only audit log.
|
||||||
|
*/
|
||||||
|
public const UPDATED_AT = null;
|
||||||
|
|
||||||
|
protected $table = 'workspace_activities';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'workspace_id',
|
||||||
|
'user_id',
|
||||||
|
'action',
|
||||||
|
'subject_type',
|
||||||
|
'subject_id',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue