Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz

This commit is contained in:
2026-03-22 09:13:39 +01:00
parent e8b5edf5d2
commit 2608be7420
80 changed files with 3991 additions and 723 deletions

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Collection;
class ConversationDeltaService
{
public function __construct(
private readonly MessagingPayloadFactory $payloadFactory,
) {}
public function messagesAfter(Conversation $conversation, User $viewer, int $afterMessageId, ?int $limit = null): Collection
{
$maxMessages = max(1, (int) config('messaging.recovery.max_messages', 100));
$effectiveLimit = min($limit ?? $maxMessages, $maxMessages);
return Message::withTrashed()
->where('conversation_id', $conversation->id)
->where('id', '>', $afterMessageId)
->with(['sender:id,username,name', 'reactions', 'attachments'])
->orderBy('id')
->limit($effectiveLimit)
->get()
->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $viewer->id))
->values();
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageRead;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class ConversationReadService
{
public function __construct(
private readonly ConversationStateService $conversationState,
) {}
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
{
/** @var ConversationParticipant $participant */
$participant = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $user->id)
->whereNull('left_at')
->firstOrFail();
$lastReadableMessage = Message::query()
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
->orderByDesc('id')
->first();
$readAt = now();
$participant->forceFill([
'last_read_at' => $readAt,
'last_read_message_id' => $lastReadableMessage?->id,
])->save();
if ($lastReadableMessage) {
$messageReads = Message::query()
->select(['id'])
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->where('id', '<=', $lastReadableMessage->id)
->get()
->map(fn (Message $message) => [
'message_id' => $message->id,
'user_id' => $user->id,
'read_at' => $readAt,
])
->all();
if (! empty($messageReads)) {
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
}
}
$participantIds = $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
DB::afterCommit(function () use ($conversation, $participant, $user, $participantIds): void {
event(new MessageRead($conversation, $participant, $user));
foreach ($participantIds as $participantId) {
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
}
});
return $participant->fresh(['user']);
}
}

View File

@@ -2,14 +2,9 @@
namespace App\Services\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageRead;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class ConversationStateService
{
@@ -37,62 +32,4 @@ class ConversationStateService
Cache::increment($versionKey);
}
}
public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant
{
/** @var ConversationParticipant $participant */
$participant = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $user->id)
->whereNull('left_at')
->firstOrFail();
$lastReadableMessage = Message::query()
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->when($messageId, fn ($query) => $query->where('id', '<=', $messageId))
->orderByDesc('id')
->first();
$readAt = now();
$participant->update([
'last_read_at' => $readAt,
'last_read_message_id' => $lastReadableMessage?->id,
]);
if ($lastReadableMessage) {
$messageReads = Message::query()
->select(['id'])
->where('conversation_id', $conversation->id)
->whereNull('deleted_at')
->where('sender_id', '!=', $user->id)
->where('id', '<=', $lastReadableMessage->id)
->get()
->map(fn (Message $message) => [
'message_id' => $message->id,
'user_id' => $user->id,
'read_at' => $readAt,
])
->all();
if (! empty($messageReads)) {
DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']);
}
}
$participantIds = $this->activeParticipantIds($conversation);
$this->touchConversationCachesForUsers($participantIds);
DB::afterCommit(function () use ($conversation, $participant, $user): void {
event(new MessageRead($conversation, $participant, $user));
foreach ($this->activeParticipantIds($conversation) as $participantId) {
event(new ConversationUpdated($participantId, $conversation, 'message.read'));
}
});
return $participant->fresh(['user']);
}
}
}

View File

@@ -13,6 +13,10 @@ use Illuminate\Support\Str;
class MessageNotificationService
{
public function __construct(
private readonly MessagingPresenceService $presence,
) {}
public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void
{
if (! DB::getSchemaBuilder()->hasTable('notifications')) {
@@ -36,6 +40,13 @@ class MessageNotificationService
->whereIn('id', $recipientIds)
->get()
->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender))
->filter(function (User $recipient): bool {
if (! (bool) config('messaging.notifications.offline_fallback_only', true)) {
return true;
}
return ! $this->presence->isUserOnline((int) $recipient->id);
})
->pluck('id')
->map(fn ($id) => (int) $id)
->values()

View File

@@ -56,7 +56,7 @@ class MessagingPayloadFactory
'title' => $conversation->title,
'is_active' => (bool) ($conversation->is_active ?? true),
'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(),
'unread_count' => $conversation->unreadCountFor($viewerId),
'unread_count' => app(UnreadCounterService::class)->unreadCountForConversation($conversation, $viewerId),
'my_participant' => $myParticipant ? $this->participant($myParticipant) : null,
'all_participants' => $conversation->allParticipants
->whereNull('left_at')
@@ -149,4 +149,4 @@ class MessagingPayloadFactory
return $counts;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Messaging;
use App\Models\User;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class MessagingPresenceService
{
public function touch(User|int $user, ?int $conversationId = null): void
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
$store = $this->store();
$onlineKey = $this->onlineKey($userId);
$existing = $store->get($onlineKey, []);
$previousConversationId = (int) ($existing['conversation_id'] ?? 0) ?: null;
$onlineTtl = max(30, (int) config('messaging.presence.ttl_seconds', 90));
$conversationTtl = max(15, (int) config('messaging.presence.conversation_ttl_seconds', 45));
if ($previousConversationId && $previousConversationId !== $conversationId) {
$store->forget($this->conversationKey($previousConversationId, $userId));
}
$store->put($onlineKey, [
'conversation_id' => $conversationId,
'seen_at' => now()->toIso8601String(),
], now()->addSeconds($onlineTtl));
if ($conversationId) {
$store->put($this->conversationKey($conversationId, $userId), now()->toIso8601String(), now()->addSeconds($conversationTtl));
}
}
public function isUserOnline(int $userId): bool
{
return $this->store()->has($this->onlineKey($userId));
}
public function isViewingConversation(int $conversationId, int $userId): bool
{
return $this->store()->has($this->conversationKey($conversationId, $userId));
}
private function onlineKey(int $userId): string
{
return 'messages:presence:user:' . $userId;
}
private function conversationKey(int $conversationId, int $userId): string
{
return 'messages:presence:conversation:' . $conversationId . ':user:' . $userId;
}
private function store(): Repository
{
$store = (string) config('messaging.presence.cache_store', 'redis');
if ($store === 'redis' && ! class_exists('Redis')) {
return Cache::store();
}
try {
return Cache::store($store);
} catch (\Throwable) {
return Cache::store();
}
}
}

View File

@@ -123,4 +123,4 @@ class SendMessageAction
'created_at' => now(),
]);
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UnreadCounterService
{
public function applyUnreadCountSelect(Builder $query, User|int $user, string $participantAlias = 'cp_me'): Builder
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
return $query->addSelect([
'unread_count' => Message::query()
->selectRaw('count(*)')
->whereColumn('messages.conversation_id', 'conversations.id')
->where('messages.sender_id', '!=', $userId)
->whereNull('messages.deleted_at')
->where(function ($nested) use ($participantAlias) {
$nested->where(function ($group) use ($participantAlias) {
$group->whereNull($participantAlias . '.last_read_message_id')
->whereNull($participantAlias . '.last_read_at');
})->orWhereColumn('messages.id', '>', $participantAlias . '.last_read_message_id')
->orWhereColumn('messages.created_at', '>', $participantAlias . '.last_read_at');
}),
]);
}
public function unreadCountForConversation(Conversation $conversation, User|int $user): int
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
$participant = ConversationParticipant::query()
->where('conversation_id', $conversation->id)
->where('user_id', $userId)
->whereNull('left_at')
->first();
if (! $participant) {
return 0;
}
return $this->unreadCountForParticipant($participant);
}
public function unreadCountForParticipant(ConversationParticipant $participant): int
{
$query = Message::query()
->where('conversation_id', $participant->conversation_id)
->where('sender_id', '!=', $participant->user_id)
->whereNull('deleted_at');
if ($participant->last_read_message_id) {
$query->where('id', '>', $participant->last_read_message_id);
} elseif ($participant->last_read_at) {
$query->where('created_at', '>', $participant->last_read_at);
}
return (int) $query->count();
}
public function totalUnreadForUser(User|int $user): int
{
$userId = $user instanceof User ? (int) $user->id : (int) $user;
return (int) Conversation::query()
->select('conversations.id')
->join('conversation_participants as cp_me', function ($join) use ($userId) {
$join->on('cp_me.conversation_id', '=', 'conversations.id')
->where('cp_me.user_id', '=', $userId)
->whereNull('cp_me.left_at');
})
->where('conversations.is_active', true)
->get()
->sum(fn (Conversation $conversation) => $this->unreadCountForConversation($conversation, $userId));
}
}