From ba732251e5b67b819968e654a2e2f6e68b175b08 Mon Sep 17 00:00:00 2001 From: Mateo Date: Sat, 17 Oct 2026 16:22:39 +0200 Subject: [PATCH 1/3] feat: add tasks notifications when asigning & comenting and enhance event dialog with participation responses --- .../Controllers/NotificationController.php | 7 +- app/Http/Resources/EventIndexResource.php | 7 + app/Http/Resources/EventResource.php | 8 + app/Http/Resources/NotificationResource.php | 4 + app/Jobs/NotifyEventInvitees.php | 40 ++ app/Jobs/NotifyTaskAssignee.php | 34 ++ app/Jobs/NotifyTaskCommentStakeholders.php | 48 ++ app/Notifications/TaskAssigned.php | 52 ++ app/Notifications/TaskCommented.php | 59 +++ app/Policies/EventPolicy.php | 3 +- app/Services/CalendarService.php | 9 +- app/Services/CommentService.php | 7 +- app/Services/TaskService.php | 21 +- resources/js/components/NotificationBell.vue | 330 +++++++++++-- .../js/components/calendar/EventDialog.vue | 453 ++++++++++++++++-- resources/js/composables/useCalendarConfig.ts | 47 +- resources/js/composables/useDateFormatter.ts | 20 + resources/js/composables/useEventDialog.ts | 16 +- resources/js/pages/Calendar/Index.vue | 79 ++- resources/js/pages/Tasks.vue | 60 ++- resources/js/types/index.d.ts | 15 +- tests/Feature/TaskNotificationTest.php | 228 +++++++++ 22 files changed, 1389 insertions(+), 158 deletions(-) create mode 100644 app/Jobs/NotifyEventInvitees.php create mode 100644 app/Jobs/NotifyTaskAssignee.php create mode 100644 app/Jobs/NotifyTaskCommentStakeholders.php create mode 100644 app/Notifications/TaskAssigned.php create mode 100644 app/Notifications/TaskCommented.php create mode 100644 tests/Feature/TaskNotificationTest.php diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php index 01e0626..7bd6943 100644 --- a/app/Http/Controllers/NotificationController.php +++ b/app/Http/Controllers/NotificationController.php @@ -7,6 +7,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Http\Response; use Illuminate\Notifications\DatabaseNotification; class NotificationController extends Controller @@ -20,7 +21,7 @@ public function index(Request $request): JsonResponse ); } - public function markAsRead(Request $request, DatabaseNotification $notification): RedirectResponse + public function markAsRead(Request $request, DatabaseNotification $notification): RedirectResponse|Response { abort_unless( $notification->notifiable()->whereKey($request->user()->getKey())->exists(), @@ -29,6 +30,10 @@ public function markAsRead(Request $request, DatabaseNotification $notification) $notification->markAsRead(); + if ($request->wantsJson()) { + return response()->noContent(); + } + return back(); } diff --git a/app/Http/Resources/EventIndexResource.php b/app/Http/Resources/EventIndexResource.php index 4a5e2aa..8ffcaf0 100644 --- a/app/Http/Resources/EventIndexResource.php +++ b/app/Http/Resources/EventIndexResource.php @@ -14,12 +14,19 @@ class EventIndexResource extends JsonResource */ public function toArray(Request $request): array { + $isDeclined = false; + if ($this->relationLoaded('attendees')) { + $currentUserAttendee = $this->attendees->firstWhere('id', $request->user()?->id); + $isDeclined = $currentUserAttendee?->pivot?->participation_status === 'declined'; + } + return [ 'id' => $this->id, 'title' => $this->title, 'start_at' => $this->start_at?->toIso8601String(), 'end_at' => $this->end_at?->toIso8601String(), 'has_video' => $this->has_video, + 'is_declined' => $isDeclined, ]; } } diff --git a/app/Http/Resources/EventResource.php b/app/Http/Resources/EventResource.php index 41a9238..a455a42 100644 --- a/app/Http/Resources/EventResource.php +++ b/app/Http/Resources/EventResource.php @@ -26,6 +26,13 @@ public function toArray(Request $request): array 'room_name' => $this->room_name, 'has_video' => $this->has_video, 'status' => $this->status, + 'user_participation_status' => $this->whenLoaded('attendees', function () use ($request) { + if ($this->organizer_id === $request->user()?->id) { + return 'accepted'; + } + + return $this->attendees->firstWhere('id', $request->user()?->id)?->pivot?->participation_status; + }), 'organizer' => $this->whenLoaded('organizer', fn () => [ 'id' => $this->organizer->id, 'name' => $this->organizer->name, @@ -34,6 +41,7 @@ public function toArray(Request $request): array ->map(fn ($attendee) => [ 'id' => $attendee->id, 'name' => $attendee->name, + 'participation_status' => $attendee->pivot?->participation_status, ])->values()), ]; } diff --git a/app/Http/Resources/NotificationResource.php b/app/Http/Resources/NotificationResource.php index dad7d01..174e206 100644 --- a/app/Http/Resources/NotificationResource.php +++ b/app/Http/Resources/NotificationResource.php @@ -3,6 +3,8 @@ namespace App\Http\Resources; use App\Notifications\EventInvitation; +use App\Notifications\TaskAssigned; +use App\Notifications\TaskCommented; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -19,6 +21,8 @@ class NotificationResource extends JsonResource */ private const TYPE_ALIASES = [ EventInvitation::class => 'event_invitation', + TaskAssigned::class => 'task_assigned', + TaskCommented::class => 'task_commented', ]; /** diff --git a/app/Jobs/NotifyEventInvitees.php b/app/Jobs/NotifyEventInvitees.php new file mode 100644 index 0000000..0ec81c1 --- /dev/null +++ b/app/Jobs/NotifyEventInvitees.php @@ -0,0 +1,40 @@ + $userIds + */ + public function __construct( + public Event $event, + public array $userIds, + ) {} + + public function handle(): void + { + if (empty($this->userIds)) { + return; + } + + User::query() + ->whereIn('id', $this->userIds) + ->get() + ->each(fn (User $user) => $user->notify(new EventInvitation($this->event))); + } +} diff --git a/app/Jobs/NotifyTaskAssignee.php b/app/Jobs/NotifyTaskAssignee.php new file mode 100644 index 0000000..77ccc87 --- /dev/null +++ b/app/Jobs/NotifyTaskAssignee.php @@ -0,0 +1,34 @@ +task->assigned_to) || $this->task->assigned_to === $this->actor->id) { + return; + } + + $this->task->assignee?->notify(new TaskAssigned($this->task, $this->actor)); + } +} diff --git a/app/Jobs/NotifyTaskCommentStakeholders.php b/app/Jobs/NotifyTaskCommentStakeholders.php new file mode 100644 index 0000000..17176e2 --- /dev/null +++ b/app/Jobs/NotifyTaskCommentStakeholders.php @@ -0,0 +1,48 @@ +task->assigned_to, + $this->task->created_by, + $this->comment->parent?->user_id, + ]) + ->filter() + ->unique() + ->reject(fn (int $id) => $id === $this->actor->id); + + if ($recipientIds->isEmpty()) { + return; + } + + User::query() + ->whereIn('id', $recipientIds) + ->get() + ->each(fn (User $user) => $user->notify(new TaskCommented($this->task, $this->comment, $this->actor))); + } +} diff --git a/app/Notifications/TaskAssigned.php b/app/Notifications/TaskAssigned.php new file mode 100644 index 0000000..a99f06b --- /dev/null +++ b/app/Notifications/TaskAssigned.php @@ -0,0 +1,52 @@ + $this->task->id, + 'task_title' => $this->task->title, + 'assigner' => $this->actor->name, + 'due_date' => $this->task->due_date?->toISOString(), + ]; + } + + public function toArray(object $notifiable): array + { + return $this->toDatabase($notifiable); + } + + public function toMail(object $notifiable): MailMessage + { + $mail = (new MailMessage) + ->subject('You have been assigned: '.$this->task->title) + ->greeting('Hello '.$notifiable->name.',') + ->line($this->actor->name.' assigned you to a task on Taskly.') + ->line($this->task->title) + ->action('View task', url('/tasks?id='.$this->task->id)); + + if ($this->task->due_date) { + $mail->line('Due: '.$this->task->due_date->format('l, F j, Y')); + } + + return $mail->line('Open the task to see all the details.'); + } +} diff --git a/app/Notifications/TaskCommented.php b/app/Notifications/TaskCommented.php new file mode 100644 index 0000000..0392e37 --- /dev/null +++ b/app/Notifications/TaskCommented.php @@ -0,0 +1,59 @@ + $this->task->id, + 'task_title' => $this->task->title, + 'commenter' => $this->actor->name, + 'comment' => $this->excerpt(), + 'is_reply' => $this->comment->parent_id !== null, + ]; + } + + public function toArray(object $notifiable): array + { + return $this->toDatabase($notifiable); + } + + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject('New comment on: '.$this->task->title) + ->greeting('Hello '.$notifiable->name.',') + ->line($this->actor->name.' commented on a task you are following on Taskly.') + ->line($this->task->title) + ->line('"'.$this->excerpt().'"') + ->action('View comment', url('/tasks?id='.$this->task->id)) + ->line('Open the task to read the full conversation.'); + } + + /** + * A short plain-text version of the comment for previews. + */ + private function excerpt(): string + { + return Str::limit(strip_tags($this->comment->body), 140); + } +} diff --git a/app/Policies/EventPolicy.php b/app/Policies/EventPolicy.php index 1d3b742..5e23d7f 100644 --- a/app/Policies/EventPolicy.php +++ b/app/Policies/EventPolicy.php @@ -29,8 +29,7 @@ public function update(User $user, Event $event): bool */ public function delete(User $user, Event $event): bool { - return $event->calendar?->team_id !== null - && $user->canAccessTeam($event->calendar->team_id); + return $user->id === $event->organizer_id; } /** diff --git a/app/Services/CalendarService.php b/app/Services/CalendarService.php index 0cc2ddd..3bc959e 100644 --- a/app/Services/CalendarService.php +++ b/app/Services/CalendarService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Jobs\NotifyEventInvitees; use App\Models\Calendar; use App\Models\Event; use App\Models\User; @@ -45,12 +46,13 @@ public function indexData(User $user, ?string $start = null, ?string $end = null ->where(fn ($query) => $query->where('organizer_id', $user->id) ->orWhereHas('attendees', fn ($attendees) => $attendees ->where(self::ATTENDEE_TABLE.'.user_id', $user->id) - ->where(self::ATTENDEE_TABLE.'.participation_status', 'accepted'))) + ->whereIn(self::ATTENDEE_TABLE.'.participation_status', ['accepted', 'declined']))) ->whereDoesntHave('attendees', fn ($attendees) => $attendees ->where(self::ATTENDEE_TABLE.'.user_id', $user->id) ->whereNotNull(self::ATTENDEE_TABLE.'.hidden_at')) ->where('start_at', '<=', $end) ->where('end_at', '>=', $start) + ->with(['attendees' => fn ($query) => $query->where(self::ATTENDEE_TABLE.'.user_id', $user->id)]) ->orderBy('start_at') ->get(); @@ -105,7 +107,7 @@ public function create(array $data, User $user): Event ); $event->load('attendees'); - $event->attendees->each->notify(new EventInvitation($event)); + NotifyEventInvitees::dispatch($event, $event->attendees->pluck('id')->all()); return $event; } @@ -144,8 +146,7 @@ public function update(Event $event, array $data): void $addedIds = $attendeeIds->diff($existing->keys()); if ($addedIds->isNotEmpty()) { - $this->teamAttendees($event->calendar->team_id, $addedIds) - ->each->notify(new EventInvitation($event)); + NotifyEventInvitees::dispatch($event, $addedIds->all()); } $this->deleteInvitationNotifications($event, $existing->keys()->diff($attendeeIds)); diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php index b1dec6c..d2ffcb8 100644 --- a/app/Services/CommentService.php +++ b/app/Services/CommentService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Jobs\NotifyTaskCommentStakeholders; use App\Models\Task; use App\Models\TaskComment; use App\Models\User; @@ -27,11 +28,15 @@ public function listComments(Task $task): Collection */ public function createComment(Task $task, array $data, User $user): TaskComment { - return $task->comments()->create([ + $comment = $task->comments()->create([ 'user_id' => $user->id, 'parent_id' => $data['parent_id'] ?? null, 'body' => $data['body'], ]); + + NotifyTaskCommentStakeholders::dispatch($task, $comment, $user); + + return $comment; } /** diff --git a/app/Services/TaskService.php b/app/Services/TaskService.php index 31e51ea..2d7f673 100644 --- a/app/Services/TaskService.php +++ b/app/Services/TaskService.php @@ -2,11 +2,14 @@ namespace App\Services; +use App\Jobs\NotifyTaskAssignee; use App\Models\Column; use App\Models\Tag; use App\Models\Task; use App\Models\TaskAttachment; use App\Models\User; +use App\Notifications\TaskAssigned; +use App\Notifications\TaskCommented; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -91,7 +94,7 @@ public function createTask(array $data, User $user): Task $columnName = Column::query()->where('id', $columnId)->value('name'); - return DB::transaction(function () use ($data, $user, $columnId, $order, $columnName, $tagIds, $newFiles) { + $task = DB::transaction(function () use ($data, $user, $columnId, $order, $columnName, $tagIds, $newFiles) { $task = Task::create(array_merge($data, [ 'team_id' => $user->team_id, 'created_by' => $data['created_by'] ?? $user->id, @@ -144,6 +147,10 @@ public function createTask(array $data, User $user): Task return $task; }); + + $this->dispatchAssigneeNotification($task, $user); + + return $task; } /** @@ -183,6 +190,8 @@ public function updateTask(Task $task, array $data, User $actor): bool 'previous_assigned_to' => $oldAssignedTo, ], ]); + + $this->dispatchAssigneeNotification($task, $actor); } if (! empty($removedIds)) { @@ -286,6 +295,16 @@ public function updateSequence(Task $task, int $newColumnId, int $newOrder, User */ public function deleteTask(Task $task): ?bool { + DB::table('notifications') + ->whereIn('type', [TaskAssigned::class, TaskCommented::class]) + ->where('data->task_id', $task->id) + ->delete(); + return $task->delete(); } + + private function dispatchAssigneeNotification(Task $task, User $actor): void + { + NotifyTaskAssignee::dispatch($task, $actor); + } } diff --git a/resources/js/components/NotificationBell.vue b/resources/js/components/NotificationBell.vue index 7d1a011..7586373 100644 --- a/resources/js/components/NotificationBell.vue +++ b/resources/js/components/NotificationBell.vue @@ -6,6 +6,7 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Spinner } from '@/components/ui/spinner'; import { @@ -15,20 +16,29 @@ import { Check, CheckCheck, ChevronDown, + MessageSquare, + UserPlus, X, } from '@lucide/vue'; import { respond } from '@/routes/calendar/events'; -import { index, readAll } from '@/routes/notifications'; +import { index as calendarIndex } from '@/routes/calendar'; +import { index, markAsRead, readAll } from '@/routes/notifications'; +import { index as tasksIndex } from '@/routes/tasks'; import { relativeTime, formatDateTimeRange, + formatDate, + nowIso, } from '@/composables/useDateFormatter'; import { usePaginatedList } from '@/composables/usePaginatedList'; +import { useInitials } from '@/composables/useInitials'; import type { AppNotification, NotificationType } from '@/types'; const isType = (notification: AppNotification, type: NotificationType) => notification.type === type; +const { getInitials } = useInitials(); + const page = usePage(); const unreadCount = computed(() => page.props.notifications.unreadCount); const { @@ -67,13 +77,107 @@ const eventDateTime = (notification: AppNotification): string | null => notification.data.end_time ?? null, ); +const isTaskNotification = (notification: AppNotification) => + isType(notification, 'task_assigned') || + isType(notification, 'task_commented'); + +const iconFor = (notification: AppNotification) => { + if (isType(notification, 'task_assigned')) return UserPlus; + if (isType(notification, 'task_commented')) return MessageSquare; + return CalendarDays; +}; + +const actorNameFor = (notification: AppNotification): string | null => { + if (isType(notification, 'task_assigned')) { + return notification.data.assigner ?? null; + } + if (isType(notification, 'task_commented')) { + return notification.data.commenter ?? null; + } + if (isType(notification, 'event_invitation')) { + return notification.data.inviter ?? null; + } + return null; +}; + +const initialsFor = (notification: AppNotification): string => + getInitials(actorNameFor(notification) ?? ''); + +const dueDate = (notification: AppNotification): string | null => + notification.data.due_date ? formatDate(notification.data.due_date) : null; + const markAllAsRead = () => router.post(readAll().url, {}, { preserveScroll: true }); +const openTask = async (notification: AppNotification) => { + const taskId = notification.data.task_id; + if (typeof taskId !== 'number') return; + + notificationsLoaded.value = false; + + if (!notification.read_at) { + notification.read_at = nowIso(); + + try { + await fetch(markAsRead({ notification: notification.id }).url, { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + if (page.props.notifications.unreadCount > 0) { + page.props.notifications.unreadCount--; + } + } catch {} + } + + const currentPath = window.location.pathname; + if (currentPath === '/tasks' || currentPath.startsWith('/tasks?')) { + window.dispatchEvent( + new CustomEvent('open-task', { detail: { taskId } }), + ); + } else { + router.visit(tasksIndex({ query: { id: taskId } }).url, { + preserveScroll: false, + }); + } +}; + +const openEvent = async (notification: AppNotification) => { + const eventId = notification.data.event_id; + if (typeof eventId !== 'number') return; + + notificationsLoaded.value = false; + + if (!notification.read_at) { + notification.read_at = nowIso(); + + try { + await fetch(markAsRead({ notification: notification.id }).url, { + method: 'POST', + headers: { Accept: 'application/json' }, + }); + if (page.props.notifications.unreadCount > 0) { + page.props.notifications.unreadCount--; + } + } catch {} + } + + const currentPath = window.location.pathname; + if (currentPath === '/calendar' || currentPath.startsWith('/calendar?')) { + window.dispatchEvent( + new CustomEvent('open-event', { detail: { eventId } }), + ); + } else { + router.visit(calendarIndex({ query: { id: eventId } }).url, { + preserveScroll: false, + }); + } +}; + const respondTo = ( notification: AppNotification, response: 'accepted' | 'declined', -) => +) => { + const wasUnread = !notification.read_at; router.post( respond({ event: notification.data.event_id!, response }).url, {}, @@ -81,10 +185,14 @@ const respondTo = ( preserveScroll: true, onSuccess: () => { notification.data.response = response; - notification.read_at = new Date().toISOString(); + notification.read_at = nowIso(); + if (wasUnread && page.props.notifications.unreadCount > 0) { + page.props.notifications.unreadCount--; + } }, }, ); +}; @@ -237,8 +495,8 @@ const respondTo = (

You're all caught up

- Notifications for invitations and updates will appear - here. + Notifications for invitations, assignments and comments + will appear here.

diff --git a/resources/js/components/calendar/EventDialog.vue b/resources/js/components/calendar/EventDialog.vue index 3db4450..a6f7612 100644 --- a/resources/js/components/calendar/EventDialog.vue +++ b/resources/js/components/calendar/EventDialog.vue @@ -1,5 +1,5 @@