$user->id, 'cursorable_id' => $entity->id, 'cursorable_type' => get_class($entity), ], [ 'read_at' => Carbon::now(), ] ); } /** * Get the read cursor timestamp for a user on a specific entity. */ public function getCursorReadAt(User $user, $entity): \Carbon\CarbonInterface { $cursor = ReadCursor::where([ 'user_id' => $user->id, 'cursorable_id' => $entity->id, 'cursorable_type' => get_class($entity), ])->first(); // If no cursor exists, default to epoch (all activities are unread) return $cursor ? $cursor->read_at : Carbon::parse('1970-01-01'); } /** * Retrieve all activities for a Dynamic. */ public function getDynamicActivities(Dynamic $dynamic): 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, ]; } } // 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') ->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; } /** * Get unread activities grouped by active entities (Dynamics, Ledgers) for the given user. */ public function getUnreadEntitiesGrouped(User $user): array { $groupedEntities = []; // 1. Get all participating Dynamics $dynamics = $user->dynamics()->get(); foreach ($dynamics as $dynamic) { $readAt = $this->getCursorReadAt($user, $dynamic); $activities = $this->getDynamicActivities($dynamic); $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); } } return $groupedEntities; } /** * Partition activities into read and unread, and construct the grouped entity metadata. */ private function partitionActivities(array $activities, \Carbon\CarbonInterface $readAt, $entity, string $type, string $url, array &$groupedEntities): void { $alreadyRead = []; $unread = []; foreach ($activities as $act) { if ($act['created_at']->gt($readAt)) { $unread[] = $act; } else { $alreadyRead[] = $act; } } 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; }; $groupedEntities[] = [ 'id' => $entity->id, 'name' => $entity->name, 'type' => $type, 'url' => $url, 'unread_count' => count($unread), 'context_activities' => array_map($formatActivity, $context), 'new_activities' => array_map($formatActivity, $unread), ]; } } }