From c2a5f784ae8ac5e9c55a4d37ad3673097bece74d Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 31 Mar 2026 17:45:24 +0100 Subject: [PATCH 1/5] Add bulk mark-all-read endpoint and BoardChange/System notification types Extend NotificationType enum with BoardChange and System variants. Add POST /api/notifications/mark-all-read endpoint with optional boardId filter. Wire through service, repository, and NoOp layers. Closes part of #625 --- .../Controllers/NotificationsController.cs | 12 ++++++ .../Interfaces/INotificationRepository.cs | 5 +++ .../Services/INotificationService.cs | 5 +++ .../Services/NoOpNotificationService.cs | 8 ++++ .../Services/NotificationService.cs | 41 +++++++++++++++++++ .../Taskdeck.Domain/Entities/Notification.cs | 4 +- .../Repositories/NotificationRepository.cs | 16 ++++++++ 7 files changed, 90 insertions(+), 1 deletion(-) diff --git a/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs b/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs index 9bfe517ca..e714075e2 100644 --- a/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs +++ b/backend/src/Taskdeck.Api/Controllers/NotificationsController.cs @@ -49,6 +49,18 @@ public async Task MarkAsRead(Guid id, CancellationToken cancellat return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); } + [HttpPost("mark-all-read")] + public async Task MarkAllAsRead( + [FromQuery] Guid? boardId = null, + CancellationToken cancellationToken = default) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + var result = await _notificationService.MarkAllAsReadAsync(userId, boardId, cancellationToken); + return result.IsSuccess ? Ok(new { markedCount = result.Value }) : result.ToErrorActionResult(); + } + [HttpGet("preferences")] public async Task GetPreferences(CancellationToken cancellationToken = default) { diff --git a/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs b/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs index c81d619fb..d42094bd2 100644 --- a/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs +++ b/backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs @@ -16,4 +16,9 @@ Task> GetByUserIdAsync( Guid userId, string deduplicationKey, CancellationToken cancellationToken = default); + + Task> GetUnreadByUserIdAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default); } diff --git a/backend/src/Taskdeck.Application/Services/INotificationService.cs b/backend/src/Taskdeck.Application/Services/INotificationService.cs index 4900ecb59..55984cb8d 100644 --- a/backend/src/Taskdeck.Application/Services/INotificationService.cs +++ b/backend/src/Taskdeck.Application/Services/INotificationService.cs @@ -15,6 +15,11 @@ Task> MarkAsReadAsync( Guid notificationId, CancellationToken cancellationToken = default); + Task> MarkAllAsReadAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default); + Task> GetPreferencesAsync( Guid userId, CancellationToken cancellationToken = default); diff --git a/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs b/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs index d519a76ba..e08f130b8 100644 --- a/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs +++ b/backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs @@ -28,6 +28,14 @@ public Task> MarkAsReadAsync( return Task.FromResult(Result.Failure(ErrorCodes.NotFound, "Notification not found")); } + public Task> MarkAllAsReadAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default) + { + return Task.FromResult(Result.Success(0)); + } + public Task> GetPreferencesAsync( Guid userId, CancellationToken cancellationToken = default) diff --git a/backend/src/Taskdeck.Application/Services/NotificationService.cs b/backend/src/Taskdeck.Application/Services/NotificationService.cs index 4e121d21f..fab34cb67 100644 --- a/backend/src/Taskdeck.Application/Services/NotificationService.cs +++ b/backend/src/Taskdeck.Application/Services/NotificationService.cs @@ -84,6 +84,43 @@ public async Task> MarkAsReadAsync( return Result.Success(MapToDto(notification)); } + public async Task> MarkAllAsReadAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "User ID cannot be empty"); + + if (boardId.HasValue && _authorizationService is not null) + { + var boardPermission = await _authorizationService.CanReadBoardAsync(userId, boardId.Value); + if (!boardPermission.IsSuccess || !boardPermission.Value) + { + return Result.Failure( + ErrorCodes.Forbidden, + "You do not have access to notifications for this board"); + } + } + + var unreadNotifications = await _unitOfWork.Notifications.GetUnreadByUserIdAsync( + userId, boardId, cancellationToken); + + var count = 0; + foreach (var notification in unreadNotifications) + { + notification.MarkAsRead(); + count++; + } + + if (count > 0) + { + await _unitOfWork.SaveChangesAsync(cancellationToken); + } + + return Result.Success(count); + } + public async Task> GetPreferencesAsync( Guid userId, CancellationToken cancellationToken = default) @@ -218,6 +255,8 @@ private static bool TryResolveCadence( NotificationType.Mention => preference.MentionImmediateEnabled, NotificationType.Assignment => preference.AssignmentImmediateEnabled, NotificationType.ProposalOutcome => preference.ProposalOutcomeImmediateEnabled, + NotificationType.BoardChange => true, + NotificationType.System => true, _ => false }; @@ -226,6 +265,8 @@ private static bool TryResolveCadence( NotificationType.Mention => preference.MentionDigestEnabled, NotificationType.Assignment => preference.AssignmentDigestEnabled, NotificationType.ProposalOutcome => preference.ProposalOutcomeDigestEnabled, + NotificationType.BoardChange => false, + NotificationType.System => false, _ => false }; diff --git a/backend/src/Taskdeck.Domain/Entities/Notification.cs b/backend/src/Taskdeck.Domain/Entities/Notification.cs index ed2d9f4d1..6e1284e2f 100644 --- a/backend/src/Taskdeck.Domain/Entities/Notification.cs +++ b/backend/src/Taskdeck.Domain/Entities/Notification.cs @@ -94,7 +94,9 @@ public enum NotificationType { Mention, Assignment, - ProposalOutcome + ProposalOutcome, + BoardChange, + System } public enum NotificationCadence diff --git a/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs b/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs index 2df7a8920..2ba0d9e29 100644 --- a/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs +++ b/backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs @@ -49,4 +49,20 @@ public async Task> GetByUserIdAsync( n => n.UserId == userId && n.DeduplicationKey == deduplicationKey, cancellationToken); } + + public async Task> GetUnreadByUserIdAsync( + Guid userId, + Guid? boardId = null, + CancellationToken cancellationToken = default) + { + var query = _dbSet + .Where(n => n.UserId == userId && !n.IsRead); + + if (boardId.HasValue) + { + query = query.Where(n => n.BoardId == boardId.Value); + } + + return await query.ToListAsync(cancellationToken); + } } From 2a9666f58d573783a6a1f01560d14c04613bb572 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Tue, 31 Mar 2026 17:48:29 +0100 Subject: [PATCH 2/5] Add notification type colors, time grouping, smart collapsing, and mark-all-read - Type-specific left-border colors: amber (proposal), blue (mention), green (board change), purple (assignment), gray (system) - Type badge with accessible label so color is not sole differentiator - Time-based section headers (Today, Yesterday, This week, Older) - Smart grouping: consecutive same-type notifications collapse into expandable summary cards - "Mark all read" batch action button in header - New composable useNotificationGrouping with pure functions for type normalization, border/badge classes, time grouping, and smart notification grouping - Frontend API and store wired for mark-all-read endpoint Closes #625 --- .../taskdeck-web/src/api/notificationsApi.ts | 7 + .../composables/useNotificationGrouping.ts | 162 ++++++++++ .../src/store/notificationStore.ts | 19 ++ .../taskdeck-web/src/types/notifications.ts | 11 + .../src/views/NotificationInboxView.vue | 297 +++++++++--------- 5 files changed, 356 insertions(+), 140 deletions(-) create mode 100644 frontend/taskdeck-web/src/composables/useNotificationGrouping.ts diff --git a/frontend/taskdeck-web/src/api/notificationsApi.ts b/frontend/taskdeck-web/src/api/notificationsApi.ts index 9b770b876..d3f9c01ad 100644 --- a/frontend/taskdeck-web/src/api/notificationsApi.ts +++ b/frontend/taskdeck-web/src/api/notificationsApi.ts @@ -1,6 +1,7 @@ import http from './http' import { buildQueryString } from '../utils/queryBuilder' import type { + MarkAllReadResponse, NotificationItem, NotificationPreference, NotificationQuery, @@ -18,6 +19,12 @@ export const notificationsApi = { return data }, + async markAllRead(boardId?: string): Promise { + const qs = boardId ? `?boardId=${encodeURIComponent(boardId)}` : '' + const { data } = await http.post(`/notifications/mark-all-read${qs}`) + return data + }, + async getPreferences(): Promise { const { data } = await http.get('/notifications/preferences') return data diff --git a/frontend/taskdeck-web/src/composables/useNotificationGrouping.ts b/frontend/taskdeck-web/src/composables/useNotificationGrouping.ts new file mode 100644 index 000000000..990679d8b --- /dev/null +++ b/frontend/taskdeck-web/src/composables/useNotificationGrouping.ts @@ -0,0 +1,162 @@ +import type { NotificationItem, NotificationTypeName } from '../types/notifications' + +/** + * Normalize a notification type (number or string) to a canonical string name. + */ +export function normalizeType(value: number | string): NotificationTypeName { + const s = String(value) + if (s === '0' || s === 'Mention') return 'Mention' + if (s === '1' || s === 'Assignment') return 'Assignment' + if (s === '2' || s === 'ProposalOutcome') return 'ProposalOutcome' + if (s === '3' || s === 'BoardChange') return 'BoardChange' + if (s === '4' || s === 'System') return 'System' + return 'System' +} + +/** + * Human-readable label for a notification type. + */ +export function typeLabel(value: number | string): string { + const t = normalizeType(value) + switch (t) { + case 'Mention': return 'Mention' + case 'Assignment': return 'Assignment' + case 'ProposalOutcome': return 'Proposal' + case 'BoardChange': return 'Board Change' + case 'System': return 'System' + } +} + +/** + * Tailwind border-left color class for a notification type. + * Returns a left-border class. Also includes an aria-compatible + * label via the type badge so color is not the sole differentiator. + */ +export function typeBorderClass(value: number | string): string { + const t = normalizeType(value) + switch (t) { + case 'ProposalOutcome': return 'border-l-4 border-l-amber-500' + case 'Mention': return 'border-l-4 border-l-blue-500' + case 'BoardChange': return 'border-l-4 border-l-green-500' + case 'Assignment': return 'border-l-4 border-l-purple-500' + case 'System': return 'border-l-4 border-l-gray-400' + } +} + +/** + * Tailwind badge classes for a notification type. + */ +export function typeBadgeClass(value: number | string): string { + const t = normalizeType(value) + switch (t) { + case 'ProposalOutcome': return 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200' + case 'Mention': return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' + case 'BoardChange': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' + case 'Assignment': return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' + case 'System': return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' + } +} + +// ---------- Time-based grouping ---------- + +export type TimeGroup = 'Today' | 'Yesterday' | 'This week' | 'Older' + +/** + * Assign a notification to a time-based group relative to the given "now" date. + */ +export function timeGroup(createdAt: string, now: Date = new Date()): TimeGroup { + const created = new Date(createdAt) + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + const yesterdayStart = new Date(todayStart) + yesterdayStart.setDate(yesterdayStart.getDate() - 1) + const weekStart = new Date(todayStart) + weekStart.setDate(weekStart.getDate() - 6) + + if (created >= todayStart) return 'Today' + if (created >= yesterdayStart) return 'Yesterday' + if (created >= weekStart) return 'This week' + return 'Older' +} + +// ---------- Smart grouping ---------- + +export interface NotificationGroup { + /** Unique key for v-for */ + key: string + /** The time-based header this group belongs to */ + timeHeader: TimeGroup + /** If true, this group is a collapsed summary of multiple same-type notifications */ + isCollapsed: boolean + /** Summary label when collapsed, e.g. "3 automation proposals updated" */ + summaryLabel: string | null + /** The individual notifications in this group */ + items: NotificationItem[] +} + +/** + * Group notifications by time header and collapse consecutive same-type + * notifications into summary groups. + * + * Notifications are expected to be sorted by createdAt descending (newest first). + */ +export function groupNotifications( + notifications: NotificationItem[], + now: Date = new Date(), +): NotificationGroup[] { + if (notifications.length === 0) return [] + + const groups: NotificationGroup[] = [] + let currentTimeHeader: TimeGroup | null = null + let pendingItems: NotificationItem[] = [] + let pendingType: NotificationTypeName | null = null + + function flushPending() { + if (pendingItems.length === 0 || currentTimeHeader === null) return + + if (pendingItems.length >= 2) { + const label = `${pendingItems.length} ${typeLabel(pendingItems[0].type).toLowerCase()} notifications` + groups.push({ + key: `group-${pendingItems[0].id}`, + timeHeader: currentTimeHeader, + isCollapsed: true, + summaryLabel: label, + items: [...pendingItems], + }) + } else { + groups.push({ + key: `single-${pendingItems[0].id}`, + timeHeader: currentTimeHeader, + isCollapsed: false, + summaryLabel: null, + items: [...pendingItems], + }) + } + pendingItems = [] + pendingType = null + } + + for (const notification of notifications) { + const header = timeGroup(notification.createdAt, now) + const nType = normalizeType(notification.type) + + // Time header changed — flush and start new section + if (header !== currentTimeHeader) { + flushPending() + currentTimeHeader = header + } + + // Same type as pending — accumulate + if (nType === pendingType) { + pendingItems.push(notification) + continue + } + + // Different type — flush previous and start new pending + flushPending() + pendingType = nType + pendingItems = [notification] + } + + flushPending() + return groups +} diff --git a/frontend/taskdeck-web/src/store/notificationStore.ts b/frontend/taskdeck-web/src/store/notificationStore.ts index 3a3bf34d6..f2daffd28 100644 --- a/frontend/taskdeck-web/src/store/notificationStore.ts +++ b/frontend/taskdeck-web/src/store/notificationStore.ts @@ -64,6 +64,24 @@ export const useNotificationStore = defineStore('notifications', () => { } } + async function markAllRead(boardId?: string) { + guardDemoMutation() + try { + const result = await notificationsApi.markAllRead(boardId) + notifications.value = notifications.value.map((item) => ({ + ...item, + isRead: true, + readAt: item.readAt ?? new Date().toISOString(), + })) + return result + } catch (e: unknown) { + const msg = getErrorDisplay(e, 'Failed to mark all notifications as read').message + error.value = msg + toast.error(msg) + throw e + } + } + async function fetchPreferences() { if (isDemoMode) { loading.value = true @@ -112,6 +130,7 @@ export const useNotificationStore = defineStore('notifications', () => { error, fetchNotifications, markAsRead, + markAllRead, fetchPreferences, updatePreferences, } diff --git a/frontend/taskdeck-web/src/types/notifications.ts b/frontend/taskdeck-web/src/types/notifications.ts index 75a5d4d1a..0e687ac0a 100644 --- a/frontend/taskdeck-web/src/types/notifications.ts +++ b/frontend/taskdeck-web/src/types/notifications.ts @@ -1,3 +1,10 @@ +export type NotificationTypeName = + | 'Mention' + | 'Assignment' + | 'ProposalOutcome' + | 'BoardChange' + | 'System' + export interface NotificationItem { id: string userId: string @@ -14,6 +21,10 @@ export interface NotificationItem { updatedAt: string } +export interface MarkAllReadResponse { + markedCount: number +} + export interface NotificationQuery { unreadOnly?: boolean boardId?: string diff --git a/frontend/taskdeck-web/src/views/NotificationInboxView.vue b/frontend/taskdeck-web/src/views/NotificationInboxView.vue index c5ca45598..3733be6bf 100644 --- a/frontend/taskdeck-web/src/views/NotificationInboxView.vue +++ b/frontend/taskdeck-web/src/views/NotificationInboxView.vue @@ -3,6 +3,14 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useNotificationStore } from '../store/notificationStore' import { getErrorDisplay } from '../composables/useErrorMapper' +import { + groupNotifications, + typeBorderClass, + typeBadgeClass, + typeLabel, + type NotificationGroup, + type TimeGroup, +} from '../composables/useNotificationGrouping' import type { NotificationItem } from '../types/notifications' import { normalizeBoardIdQueryParam } from '../utils/navigation' @@ -11,17 +19,29 @@ const route = useRoute() const router = useRouter() const unreadOnly = ref(false) const inlineError = ref(null) +const expandedGroups = ref>(new Set()) const items = computed(() => notifications.notifications) const unreadCount = computed(() => items.value.filter((item) => !item.isRead).length) const activeBoardId = computed(() => normalizeBoardIdQueryParam(route.query.boardId)) -function formatType(value: number | string): string { - const normalized = String(value) - if (normalized === '0' || normalized === 'Mention') return 'Mention' - if (normalized === '1' || normalized === 'Assignment') return 'Assignment' - if (normalized === '2' || normalized === 'ProposalOutcome') return 'Proposal Outcome' - return normalized +const grouped = computed(() => groupNotifications(items.value)) + +/** Distinct time headers in display order */ +const timeHeaders = computed(() => { + const seen = new Set() + const result: TimeGroup[] = [] + for (const g of grouped.value) { + if (!seen.has(g.timeHeader)) { + seen.add(g.timeHeader) + result.push(g.timeHeader) + } + } + return result +}) + +function groupsForHeader(header: TimeGroup): NotificationGroup[] { + return grouped.value.filter((g) => g.timeHeader === header) } function formatCadence(value: number | string): string { @@ -35,6 +55,14 @@ function normalizeSourceEntityType(value: string | null): string { return value?.trim().toLowerCase() ?? '' } +function toggleGroupExpand(key: string) { + if (expandedGroups.value.has(key)) { + expandedGroups.value.delete(key) + } else { + expandedGroups.value.add(key) + } +} + async function loadNotifications() { inlineError.value = null try { @@ -57,6 +85,15 @@ async function markAsRead(notificationId: string) { } } +async function markAllRead() { + inlineError.value = null + try { + await notifications.markAllRead(activeBoardId.value ?? undefined) + } catch (e: unknown) { + inlineError.value = getErrorDisplay(e, notifications.error || 'Failed to mark all as read').message + } +} + function destinationLabel(item: NotificationItem): string | null { if (normalizeSourceEntityType(item.sourceEntityType) === 'proposal' && item.sourceEntityId) { return 'Open Proposal' @@ -97,24 +134,33 @@ watch([unreadOnly, activeBoardId], () => {