diff --git a/app/Http/Controllers/DynamicInvitationController.php b/app/Http/Controllers/DynamicInvitationController.php index c7c3a44..a445288 100644 --- a/app/Http/Controllers/DynamicInvitationController.php +++ b/app/Http/Controllers/DynamicInvitationController.php @@ -115,8 +115,10 @@ class DynamicInvitationController extends Controller // Log to Dynamic chat activity log! $dynamic->chat->messages()->create([ - 'user_id' => $request->user()->id, - 'content' => "System: {$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role) . " after accepting an invitation.", + 'user_id' => null, + 'content' => "{$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role), + 'subject_id' => $request->user()->id, + 'subject_type' => \App\Models\User::class, ]); // Delete the invitation record diff --git a/app/Models/Message.php b/app/Models/Message.php index dce3780..6442a91 100644 --- a/app/Models/Message.php +++ b/app/Models/Message.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphTo; + class Message extends Model { /** @use HasFactory */ @@ -16,6 +18,8 @@ class Message extends Model 'chat_id', 'user_id', 'content', + 'subject_id', + 'subject_type', ]; public function chat(): BelongsTo @@ -28,6 +32,11 @@ class Message extends Model return $this->belongsTo(User::class); } + public function subject(): MorphTo + { + return $this->morphTo(); + } + public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany { return $this->morphMany(Media::class, 'mediable'); diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index 8ecbea1..9b24a49 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -9,6 +9,7 @@ use App\Models\Mutation; use App\Models\Message; use App\Models\ReadCursor; use Illuminate\Support\Carbon; +use Illuminate\Support\Str; class ActivityService { @@ -45,120 +46,28 @@ class ActivityService } /** - * Retrieve all activities for a Dynamic. + * Retrieve all activities for a given entity. */ - public function getDynamicActivities(Dynamic $dynamic): array + public function getActivitiesForEntity($entity): array { - $activities = []; - - // 1. Chat Messages - if ($dynamic->chat) { - $messages = Message::where('chat_id', $dynamic->chat->id) - ->with('user') - ->get(); - - foreach ($messages as $msg) { - $activities[] = [ - 'id' => "dynamic_msg_{$msg->id}", - 'type' => 'message', - 'description' => $msg->content, - 'user' => [ - 'name' => $msg->user ? $msg->user->name : 'Unknown User', - ], - 'created_at' => $msg->created_at, - ]; - } + if ($entity instanceof Dynamic) { + $chatId = $entity->chat->id; + } elseif ($entity instanceof Ledger) { + $chatId = $entity->dynamic->chat->id; + } else { + return []; } - // 2. Ledgers Created - $ledgers = Ledger::where('dynamic_id', $dynamic->id)->get(); - foreach ($ledgers as $ledger) { - $activities[] = [ - 'id' => "ledger_created_{$ledger->id}", - 'type' => 'ledger_created', - 'description' => "New Ledger '{$ledger->name}' was created.", - 'user' => [ - 'name' => 'System', - ], - 'created_at' => $ledger->created_at, - ]; - } - - // Sort activities chronologically ascending - usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp); - - return $activities; - } - - /** - * Retrieve all activities for a Ledger. - */ - public function getLedgerActivities(Ledger $ledger): array - { - $activities = []; - - // 1. Mutations (Creation and Status updates) - $mutations = Mutation::where('ledger_id', $ledger->id) - ->with('user') + $messages = Message::where('chat_id', $chatId) + ->with(['user', 'subject']) + ->latest() ->get(); - foreach ($mutations as $mutation) { - // Log creation of mutation - $verb = $mutation->type === 'penalty' ? 'issued demerit' : ($mutation->type === 'reward' ? 'credited points' : 'submitted entry'); - $activities[] = [ - 'id' => "mutation_created_{$mutation->id}", - 'type' => 'mutation_created', - 'description' => "{$verb} ({$mutation->amount}): \"{$mutation->description}\"", - 'user' => [ - 'name' => $mutation->user ? $mutation->user->name : 'Unknown User', - ], - 'created_at' => $mutation->created_at, - ]; - - // Log status approval/rejection update if different from creation - if ($mutation->status !== 'pending' && $mutation->updated_at->gt($mutation->created_at->addSeconds(2))) { - $activities[] = [ - 'id' => "mutation_updated_{$mutation->id}", - 'type' => 'mutation_updated', - 'description' => "Entry '{$mutation->description}' was " . strtoupper($mutation->status), - 'user' => [ - 'name' => 'System', - ], - 'created_at' => $mutation->updated_at, - ]; - } - } - - // 2. Mutation Comments - if ($mutations->isNotEmpty()) { - $comments = Message::whereHas('chat', function ($q) use ($mutations) { - $q->where('chatable_type', Mutation::class) - ->whereIn('chatable_id', $mutations->pluck('id')); - }) - ->with(['user', 'chat.chatable']) - ->get(); - - foreach ($comments as $comment) { - /** @var Mutation|null $mutationEntity */ - $mutationEntity = $comment->chat->chatable; - $context = $mutationEntity ? " on \"{$mutationEntity->description}\"" : ""; - - $activities[] = [ - 'id' => "comment_{$comment->id}", - 'type' => 'comment', - 'description' => "Commented{$context}: \"{$comment->content}\"", - 'user' => [ - 'name' => $comment->user ? $comment->user->name : 'Unknown User', - ], - 'created_at' => $comment->created_at, - ]; - } - } - - // Sort activities chronologically ascending - usort($activities, fn($a, $b) => $a['created_at']->timestamp <=> $b['created_at']->timestamp); - - return $activities; + return $messages->map(function ($message) { + $messageData = $message->toArray(); + $messageData['url'] = $this->getUrlForMessage($message); + return $messageData; + })->all(); } /** @@ -167,27 +76,15 @@ class ActivityService public function getUnreadEntitiesGrouped(User $user): array { $groupedEntities = []; + $participatingDynamics = $user->dynamics()->with('ledgers')->get(); - // 1. Get all participating Dynamics - $dynamics = $user->dynamics()->get(); + $entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers)); - foreach ($dynamics as $dynamic) { - $readAt = $this->getCursorReadAt($user, $dynamic); - $activities = $this->getDynamicActivities($dynamic); + foreach ($entities as $entity) { + $readAt = $this->getCursorReadAt($user, $entity); + $activities = $this->getActivitiesForEntity($entity); - $this->partitionActivities($activities, $readAt, $dynamic, 'Dynamic', route('dynamics.show', $dynamic->id), $groupedEntities); - } - - // 2. Get all Ledgers under those Dynamics - if ($dynamics->isNotEmpty()) { - $ledgers = Ledger::whereIn('dynamic_id', $dynamics->pluck('id'))->get(); - - foreach ($ledgers as $ledger) { - $readAt = $this->getCursorReadAt($user, $ledger); - $activities = $this->getLedgerActivities($ledger); - - $this->partitionActivities($activities, $readAt, $ledger, 'Ledger', route('dynamics.ledgers.show', [$ledger->dynamic_id, $ledger->id]), $groupedEntities); - } + $this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $groupedEntities); } return $groupedEntities; @@ -202,7 +99,7 @@ class ActivityService $unread = []; foreach ($activities as $act) { - if ($act['created_at']->gt($readAt)) { + if (Carbon::parse($act['created_at'])->gt($readAt)) { $unread[] = $act; } else { $alreadyRead[] = $act; @@ -210,24 +107,43 @@ class ActivityService } if (!empty($unread)) { - // We have unread activity! Let's pull the last two read items as context - $context = array_slice($alreadyRead, -2); - - // Format timestamps for serializing to frontend - $formatActivity = function ($act) { - $act['created_at'] = $act['created_at']->toIso8601String(); - return $act; - }; + $context = array_slice($alreadyRead, 0, 2); $groupedEntities[] = [ 'id' => $entity->id, 'name' => $entity->name, - 'type' => $type, + 'type' => Str::afterLast($type, '\\'), 'url' => $url, 'unread_count' => count($unread), - 'context_activities' => array_map($formatActivity, $context), - 'new_activities' => array_map($formatActivity, $unread), + 'context_activities' => $context, + 'new_activities' => $unread, ]; } } + + private function getUrlForEntity($entity): string + { + if ($entity instanceof Dynamic) { + return route('dynamics.show', $entity->id); + } + + if ($entity instanceof Ledger) { + return route('dynamics.ledgers.show', [$entity->dynamic_id, $entity->id]); + } + + return ''; + } + + private function getUrlForMessage(Message $message): string + { + if ($message->subject) { + return $this->getUrlForEntity($message->subject); + } + + if ($message->chat->chatable) { + return $this->getUrlForEntity($message->chat->chatable); + } + + return ''; + } } \ No newline at end of file diff --git a/database/migrations/2026_06_16_225400_update_messages_table_for_system_messages.php b/database/migrations/2026_06_16_225400_update_messages_table_for_system_messages.php new file mode 100644 index 0000000..22b8fe8 --- /dev/null +++ b/database/migrations/2026_06_16_225400_update_messages_table_for_system_messages.php @@ -0,0 +1,30 @@ +foreignId('user_id')->nullable()->change(); + $table->nullableMorphs('subject'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('messages', function (Blueprint $table) { + $table->foreignId('user_id')->nullable(false)->change(); + $table->dropMorphs('subject'); + }); + } +}; diff --git a/resources/js/components/Chat.vue b/resources/js/components/Chat.vue index c727540..cb28502 100644 --- a/resources/js/components/Chat.vue +++ b/resources/js/components/Chat.vue @@ -108,7 +108,7 @@ function closeLightbox() { 'c-chat__message', { 'c-chat__message--system': - message.content.startsWith('System:'), + message.user.id === 0, 'c-chat__message--own': isOwnMessage(message.user.id), 'c-chat__message--other': !isOwnMessage( message.user.id, diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue index a2471ba..b6cd2b9 100644 --- a/resources/js/pages/Dashboard.vue +++ b/resources/js/pages/Dashboard.vue @@ -21,18 +21,20 @@ defineProps<{ url: string; unread_count: number; context_activities: Array<{ - id: string; - type: string; - description: string; - user: { name: string }; + id: number; + content: string; + user: { name: string } | null; + subject: { name: string; url: string } | null; created_at: string; + url: string; }>; new_activities: Array<{ - id: string; - type: string; - description: string; - user: { name: string }; + id: number; + content: string; + user: { name: string } | null; + subject: { name: string; url: string } | null; created_at: string; + url: string; }>; }>; }>(); @@ -88,17 +90,19 @@ function formatTime(isoString: string): string { :key="activity.id" class="c-dashboard__activity-item c-dashboard__activity-item--read" > -
- - {{ activity.user.name }} - - - {{ formatTime(activity.created_at) }} - -
-

- {{ activity.description }} -

+ +
+ + {{ activity.user?.name || 'System' }} + + + {{ formatTime(activity.created_at) }} + +
+

+ {{ activity.content }} +

+ @@ -117,18 +121,20 @@ function formatTime(isoString: string): string { :key="activity.id" class="c-dashboard__activity-item c-dashboard__activity-item--unread" > -
- - {{ activity.user.name }} - - - {{ formatTime(activity.created_at) }} - - NEW -
-

- {{ activity.description }} -

+ +
+ + {{ activity.user?.name || 'System' }} + + + {{ formatTime(activity.created_at) }} + + NEW +
+

+ {{ activity.content }} +

+ diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index f59fa80..65cceeb 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -104,9 +104,9 @@ test('dashboard groups and filters unread entities correctly based on cursor', f ->where('unreadEntities.0.name', 'Testing Dynamic') ->where('unreadEntities.0.unread_count', 1) ->has('unreadEntities.0.context_activities', 1) // Should have old message as context - ->where('unreadEntities.0.context_activities.0.description', 'Old message context') + ->where('unreadEntities.0.context_activities.0.content', 'Old message context') ->has('unreadEntities.0.new_activities', 1) // Should have unread message - ->where('unreadEntities.0.new_activities.0.description', 'New unread message alert') + ->where('unreadEntities.0.new_activities.0.content', 'New unread message alert') ); // Now visit the Dynamic, which clears the unread count diff --git a/tests/Feature/InvitationTest.php b/tests/Feature/InvitationTest.php index fe9e7b9..ed608f7 100644 --- a/tests/Feature/InvitationTest.php +++ b/tests/Feature/InvitationTest.php @@ -99,5 +99,5 @@ test('only the user with the specified email address can accept the link', funct // Verify system notification is added to Dynamic activity chat $chatMessages = $dynamic->chat->messages; expect($chatMessages)->not->toBeEmpty(); - expect($chatMessages->last()->content)->toBe("System: {$invitee->name} joined the Dynamic as a EDITOR after accepting an invitation."); + expect($chatMessages->last()->content)->toBe("{$invitee->name} joined the Dynamic as a EDITOR"); });