refactoring of system messages
Some checks failed
linter / quality (push) Failing after 1m3s
tests / ci (8.3) (push) Failing after 48s
tests / ci (8.4) (push) Failing after 1m5s
tests / ci (8.5) (push) Failing after 1m3s

This commit is contained in:
Daan Meijer 2026-06-17 00:57:43 +02:00
parent b9d2988e8c
commit 3c414e1ef1
8 changed files with 138 additions and 175 deletions

View File

@ -115,8 +115,10 @@ class DynamicInvitationController extends Controller
// Log to Dynamic chat activity log! // Log to Dynamic chat activity log!
$dynamic->chat->messages()->create([ $dynamic->chat->messages()->create([
'user_id' => $request->user()->id, 'user_id' => null,
'content' => "System: {$request->user()->name} joined the Dynamic as a " . strtoupper($invitation->role) . " after accepting an invitation.", '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 // Delete the invitation record

View File

@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Message extends Model class Message extends Model
{ {
/** @use HasFactory<MessageFactory> */ /** @use HasFactory<MessageFactory> */
@ -16,6 +18,8 @@ class Message extends Model
'chat_id', 'chat_id',
'user_id', 'user_id',
'content', 'content',
'subject_id',
'subject_type',
]; ];
public function chat(): BelongsTo public function chat(): BelongsTo
@ -28,6 +32,11 @@ class Message extends Model
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function subject(): MorphTo
{
return $this->morphTo();
}
public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany public function media(): \Illuminate\Database\Eloquent\Relations\MorphMany
{ {
return $this->morphMany(Media::class, 'mediable'); return $this->morphMany(Media::class, 'mediable');

View File

@ -9,6 +9,7 @@ use App\Models\Mutation;
use App\Models\Message; use App\Models\Message;
use App\Models\ReadCursor; use App\Models\ReadCursor;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class ActivityService 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 = []; if ($entity instanceof Dynamic) {
$chatId = $entity->chat->id;
// 1. Chat Messages } elseif ($entity instanceof Ledger) {
if ($dynamic->chat) { $chatId = $entity->dynamic->chat->id;
$messages = Message::where('chat_id', $dynamic->chat->id) } else {
->with('user') return [];
->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 $messages = Message::where('chat_id', $chatId)
$ledgers = Ledger::where('dynamic_id', $dynamic->id)->get(); ->with(['user', 'subject'])
foreach ($ledgers as $ledger) { ->latest()
$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(); ->get();
foreach ($mutations as $mutation) { return $messages->map(function ($message) {
// Log creation of mutation $messageData = $message->toArray();
$verb = $mutation->type === 'penalty' ? 'issued demerit' : ($mutation->type === 'reward' ? 'credited points' : 'submitted entry'); $messageData['url'] = $this->getUrlForMessage($message);
$activities[] = [ return $messageData;
'id' => "mutation_created_{$mutation->id}", })->all();
'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;
} }
/** /**
@ -167,27 +76,15 @@ class ActivityService
public function getUnreadEntitiesGrouped(User $user): array public function getUnreadEntitiesGrouped(User $user): array
{ {
$groupedEntities = []; $groupedEntities = [];
$participatingDynamics = $user->dynamics()->with('ledgers')->get();
// 1. Get all participating Dynamics $entities = $participatingDynamics->concat($participatingDynamics->flatMap(fn ($d) => $d->ledgers));
$dynamics = $user->dynamics()->get();
foreach ($dynamics as $dynamic) { foreach ($entities as $entity) {
$readAt = $this->getCursorReadAt($user, $dynamic); $readAt = $this->getCursorReadAt($user, $entity);
$activities = $this->getDynamicActivities($dynamic); $activities = $this->getActivitiesForEntity($entity);
$this->partitionActivities($activities, $readAt, $dynamic, 'Dynamic', route('dynamics.show', $dynamic->id), $groupedEntities); $this->partitionActivities($activities, $readAt, $entity, get_class($entity), $this->getUrlForEntity($entity), $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; return $groupedEntities;
@ -202,7 +99,7 @@ class ActivityService
$unread = []; $unread = [];
foreach ($activities as $act) { foreach ($activities as $act) {
if ($act['created_at']->gt($readAt)) { if (Carbon::parse($act['created_at'])->gt($readAt)) {
$unread[] = $act; $unread[] = $act;
} else { } else {
$alreadyRead[] = $act; $alreadyRead[] = $act;
@ -210,24 +107,43 @@ class ActivityService
} }
if (!empty($unread)) { if (!empty($unread)) {
// We have unread activity! Let's pull the last two read items as context $context = array_slice($alreadyRead, 0, 2);
$context = array_slice($alreadyRead, -2);
// Format timestamps for serializing to frontend
$formatActivity = function ($act) {
$act['created_at'] = $act['created_at']->toIso8601String();
return $act;
};
$groupedEntities[] = [ $groupedEntities[] = [
'id' => $entity->id, 'id' => $entity->id,
'name' => $entity->name, 'name' => $entity->name,
'type' => $type, 'type' => Str::afterLast($type, '\\'),
'url' => $url, 'url' => $url,
'unread_count' => count($unread), 'unread_count' => count($unread),
'context_activities' => array_map($formatActivity, $context), 'context_activities' => $context,
'new_activities' => array_map($formatActivity, $unread), '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 '';
}
} }

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('messages', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -108,7 +108,7 @@ function closeLightbox() {
'c-chat__message', 'c-chat__message',
{ {
'c-chat__message--system': 'c-chat__message--system':
message.content.startsWith('System:'), message.user.id === 0,
'c-chat__message--own': isOwnMessage(message.user.id), 'c-chat__message--own': isOwnMessage(message.user.id),
'c-chat__message--other': !isOwnMessage( 'c-chat__message--other': !isOwnMessage(
message.user.id, message.user.id,

View File

@ -21,18 +21,20 @@ defineProps<{
url: string; url: string;
unread_count: number; unread_count: number;
context_activities: Array<{ context_activities: Array<{
id: string; id: number;
type: string; content: string;
description: string; user: { name: string } | null;
user: { name: string }; subject: { name: string; url: string } | null;
created_at: string; created_at: string;
url: string;
}>; }>;
new_activities: Array<{ new_activities: Array<{
id: string; id: number;
type: string; content: string;
description: string; user: { name: string } | null;
user: { name: string }; subject: { name: string; url: string } | null;
created_at: string; created_at: string;
url: string;
}>; }>;
}>; }>;
}>(); }>();
@ -88,17 +90,19 @@ function formatTime(isoString: string): string {
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--read" class="c-dashboard__activity-item c-dashboard__activity-item--read"
> >
<div class="c-dashboard__activity-meta"> <Link :href="activity.url" class="block">
<span class="c-dashboard__activity-user"> <div class="c-dashboard__activity-meta">
{{ activity.user.name }} <span class="c-dashboard__activity-user">
</span> {{ activity.user?.name || 'System' }}
<span class="c-dashboard__activity-time"> </span>
{{ formatTime(activity.created_at) }} <span class="c-dashboard__activity-time">
</span> {{ formatTime(activity.created_at) }}
</div> </span>
<p class="c-dashboard__activity-desc"> </div>
{{ activity.description }} <p class="c-dashboard__activity-desc">
</p> {{ activity.content }}
</p>
</Link>
</div> </div>
<!-- Unread Separator Line --> <!-- Unread Separator Line -->
@ -117,18 +121,20 @@ function formatTime(isoString: string): string {
:key="activity.id" :key="activity.id"
class="c-dashboard__activity-item c-dashboard__activity-item--unread" class="c-dashboard__activity-item c-dashboard__activity-item--unread"
> >
<div class="c-dashboard__activity-meta"> <Link :href="activity.url" class="block">
<span class="c-dashboard__activity-user"> <div class="c-dashboard__activity-meta">
{{ activity.user.name }} <span class="c-dashboard__activity-user">
</span> {{ activity.user?.name || 'System' }}
<span class="c-dashboard__activity-time"> </span>
{{ formatTime(activity.created_at) }} <span class="c-dashboard__activity-time">
</span> {{ formatTime(activity.created_at) }}
<span class="c-dashboard__new-badge">NEW</span> </span>
</div> <span class="c-dashboard__new-badge">NEW</span>
<p class="c-dashboard__activity-desc"> </div>
{{ activity.description }} <p class="c-dashboard__activity-desc">
</p> {{ activity.content }}
</p>
</Link>
</div> </div>
</div> </div>
</div> </div>

View File

@ -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.name', 'Testing Dynamic')
->where('unreadEntities.0.unread_count', 1) ->where('unreadEntities.0.unread_count', 1)
->has('unreadEntities.0.context_activities', 1) // Should have old message as context ->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 ->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 // Now visit the Dynamic, which clears the unread count

View File

@ -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 // Verify system notification is added to Dynamic activity chat
$chatMessages = $dynamic->chat->messages; $chatMessages = $dynamic->chat->messages;
expect($chatMessages)->not->toBeEmpty(); 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");
}); });