From 36b6b1143c31a289004f256f8250bef0540046bf Mon Sep 17 00:00:00 2001 From: Mateo Date: Tue, 14 Apr 2026 22:48:05 +0200 Subject: [PATCH] feat: add task event tracking for creation, assignment, and movement --- app/Http/Controllers/TaskController.php | 3 +- .../Controllers/TaskSequenceController.php | 2 +- app/Http/Resources/TaskResource.php | 15 + app/Models/Task.php | 8 + app/Models/TaskEvent.php | 54 +++ app/Services/TaskService.php | 93 ++++- ..._04_14_090000_create_task_events_table.php | 34 ++ .../js/components/tasks/TaskEditDialog.vue | 365 +++++++++++------- .../components/tasks/TaskRichTextEditor.vue | 15 +- resources/js/pages/Welcome.vue | 2 +- resources/js/types/index.d.ts | 12 + tests/Feature/TaskSequenceTest.php | 7 + tests/Feature/TaskTest.php | 30 ++ 13 files changed, 490 insertions(+), 150 deletions(-) create mode 100644 app/Models/TaskEvent.php create mode 100644 database/migrations/2026_04_14_090000_create_task_events_table.php diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index eaab7c6..fa1a00a 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -39,6 +39,7 @@ public function index(Request $request): Response 'comments' => fn($query) => $query ->whereNull('parent_id') ->with(['user:id,name', 'replies']), + 'events.actor:id,name', ]) ->orderBy('order') ->paginate(10) @@ -69,7 +70,7 @@ public function update(TaskUpdateRequest $request, Task $task): RedirectResponse abort(403, 'You are not authorized to update this task.'); } - $this->taskService->updateTask($task, $request->validated()); + $this->taskService->updateTask($task, $request->validated(), $request->user()); return back(); } diff --git a/app/Http/Controllers/TaskSequenceController.php b/app/Http/Controllers/TaskSequenceController.php index ac2aeee..973c199 100644 --- a/app/Http/Controllers/TaskSequenceController.php +++ b/app/Http/Controllers/TaskSequenceController.php @@ -21,7 +21,7 @@ public function update(TaskSequenceUpdateRequest $request, Task $task): Redirect $validated = $request->validated(); - $this->taskService->updateSequence($task, $validated['column_id'], $validated['order']); + $this->taskService->updateSequence($task, $validated['column_id'], $validated['order'], $request->user()); return back(); } diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php index fa1d698..a563cb1 100644 --- a/app/Http/Resources/TaskResource.php +++ b/app/Http/Resources/TaskResource.php @@ -69,6 +69,21 @@ public function toArray(Request $request): array ->map(fn($comment) => $this->serializeComment($comment)) ->values() ), + 'events' => $this->whenLoaded( + 'events', + fn() => $this->events->map(fn($event) => [ + 'id' => $event->id, + 'type' => $event->type, + 'created_at' => $event->created_at?->toIso8601String(), + 'actor' => $event->relationLoaded('actor') && $event->actor + ? [ + 'id' => $event->actor->id, + 'name' => $event->actor->name, + ] + : null, + 'metadata' => $event->metadata ?? [], + ])->values(), + ), ]; } } diff --git a/app/Models/Task.php b/app/Models/Task.php index cc71c1e..e0d82d6 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -82,4 +82,12 @@ public function comments(): HasMany { return $this->hasMany(TaskComment::class)->orderBy('created_at'); } + + /** + * Get the activity events for the task. + */ + public function events(): HasMany + { + return $this->hasMany(TaskEvent::class)->orderBy('created_at'); + } } diff --git a/app/Models/TaskEvent.php b/app/Models/TaskEvent.php new file mode 100644 index 0000000..2bc3d23 --- /dev/null +++ b/app/Models/TaskEvent.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'task_id', + 'team_id', + 'actor_id', + 'type', + 'metadata', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'metadata' => 'array', + ]; + } + + /** + * Get the task that owns the event. + */ + public function task(): BelongsTo + { + return $this->belongsTo(Task::class); + } + + /** + * Get the user who performed the event. + */ + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_id'); + } +} diff --git a/app/Services/TaskService.php b/app/Services/TaskService.php index 7830e60..99a93dd 100644 --- a/app/Services/TaskService.php +++ b/app/Services/TaskService.php @@ -25,33 +25,90 @@ public function createTask(array $data, User $user): Task unset($data['column_id']); - return Task::create(array_merge($data, [ - 'team_id' => $user->team_id, - 'created_by' => $user->id, - 'column_id' => $columnId, - 'order' => $order, - 'column_updated_at' => now(), // Initialize column timing - ])); + $columnName = Column::query()->where('id', $columnId)->value('name'); + + return DB::transaction(function () use ($data, $user, $columnId, $order, $columnName) { + $task = Task::create(array_merge($data, [ + 'team_id' => $user->team_id, + 'created_by' => $user->id, + 'column_id' => $columnId, + 'order' => $order, + 'column_updated_at' => now(), // Initialize column timing + ])); + + $task->events()->create([ + 'team_id' => $task->team_id, + 'actor_id' => $user->id, + 'type' => 'created', + 'metadata' => [ + 'column_id' => $columnId, + 'column_name' => $columnName, + ], + ]); + + if (!empty($task->assigned_to)) { + $assignedUserName = User::query()->where('id', $task->assigned_to)->value('name'); + + $task->events()->create([ + 'team_id' => $task->team_id, + 'actor_id' => $user->id, + 'type' => 'assigned', + 'metadata' => [ + 'assigned_to' => $task->assigned_to, + 'assigned_to_name' => $assignedUserName, + ], + ]); + } + + return $task; + }); } /** * Update an existing task. */ - public function updateTask(Task $task, array $data): bool + public function updateTask(Task $task, array $data, User $actor): bool { - return $task->update($data); + $oldAssignedTo = $task->assigned_to; + $updated = $task->update($data); + + if (!$updated) { + return false; + } + + if (array_key_exists('assigned_to', $data) && $oldAssignedTo !== $task->assigned_to) { + $assignedUserName = $task->assigned_to + ? User::query()->where('id', $task->assigned_to)->value('name') + : null; + + $task->events()->create([ + 'team_id' => $task->team_id, + 'actor_id' => $actor->id, + 'type' => 'assigned', + 'metadata' => [ + 'assigned_to' => $task->assigned_to, + 'assigned_to_name' => $assignedUserName, + 'previous_assigned_to' => $oldAssignedTo, + ], + ]); + } + + return true; } /** * Update the sequence and column of a task inside the Kanban board. */ - public function updateSequence(Task $task, int $newColumnId, int $newOrder): void + public function updateSequence(Task $task, int $newColumnId, int $newOrder, User $actor): void { $oldColumnId = $task->column_id; $oldOrder = $task->order; $isDifferentColumn = $oldColumnId != $newColumnId; + $columnNames = Column::query() + ->whereIn('id', [$oldColumnId, $newColumnId]) + ->pluck('name', 'id'); - DB::transaction(function () use ($task, $newColumnId, $newOrder, $oldColumnId, $oldOrder, $isDifferentColumn) { + DB::transaction(function () use ($task, $newColumnId, $newOrder, $oldColumnId, $oldOrder, $isDifferentColumn, $actor, $columnNames) { if (!$isDifferentColumn) { // Moving within the same column if ($oldOrder < $newOrder) { @@ -90,6 +147,20 @@ public function updateSequence(Task $task, int $newColumnId, int $newOrder): voi } $task->update($updateData); + + if ($isDifferentColumn) { + $task->events()->create([ + 'team_id' => $task->team_id, + 'actor_id' => $actor->id, + 'type' => 'moved', + 'metadata' => [ + 'from_column_id' => $oldColumnId, + 'from_column_name' => $columnNames[$oldColumnId] ?? null, + 'to_column_id' => $newColumnId, + 'to_column_name' => $columnNames[$newColumnId] ?? null, + ], + ]); + } }); } diff --git a/database/migrations/2026_04_14_090000_create_task_events_table.php b/database/migrations/2026_04_14_090000_create_task_events_table.php new file mode 100644 index 0000000..5b6d5d9 --- /dev/null +++ b/database/migrations/2026_04_14_090000_create_task_events_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('task_id')->constrained()->cascadeOnDelete(); + $table->foreignId('team_id')->constrained()->cascadeOnDelete(); + $table->foreignId('actor_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('type', 50); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['task_id', 'created_at']); + $table->index(['team_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('task_events'); + } +}; diff --git a/resources/js/components/tasks/TaskEditDialog.vue b/resources/js/components/tasks/TaskEditDialog.vue index 5e1426a..c3b232b 100644 --- a/resources/js/components/tasks/TaskEditDialog.vue +++ b/resources/js/components/tasks/TaskEditDialog.vue @@ -19,7 +19,13 @@ import { Spinner } from '@/components/ui/spinner'; import { useInitials } from '@/composables/useInitials'; import { update } from '@/routes/tasks'; import comments from '@/routes/tasks/comments'; -import type { AppPageProps, Task, TaskComment, TeamMember } from '@/types'; +import type { + AppPageProps, + Task, + TaskComment, + TaskEvent, + TeamMember, +} from '@/types'; import { useForm, usePage } from '@inertiajs/vue3'; import { Calendar, @@ -102,6 +108,54 @@ const formatDate = (value: string) => { }).format(new Date(value)); }; +const formatTimelineDate = (value: string) => { + return new Intl.DateTimeFormat('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +}; + +const timelineEvents = computed(() => { + return [...(props.task?.events ?? [])].sort((a, b) => { + return ( + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + }); +}); + +const getEventLabel = (event: TaskEvent) => { + const actorName = event.actor?.name ?? 'Someone'; + + if (event.type === 'created') { + return `Created by ${actorName}`; + } + + if (event.type === 'moved') { + const destination = + (event.metadata?.to_column_name as string | undefined) ?? + 'another column'; + + return `Moved to ${destination} by ${actorName}`; + } + + if (event.type === 'assigned') { + const assignedName = event.metadata?.assigned_to_name as + | string + | undefined; + + if (assignedName) { + return `Assigned to ${assignedName} by ${actorName}`; + } + + return `Unassigned by ${actorName}`; + } + + return `Updated by ${actorName}`; +}; + const submit = () => { if (!props.task) return; @@ -183,12 +237,12 @@ watch([() => props.task, isOpen], ([task, open]) => {