refactoring of system messages
This commit is contained in:
parent
b9d2988e8c
commit
3c414e1ef1
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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 '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user