Skip to content

Commit b3d288b

Browse files
authored
Merge pull request #646 from Chris0Jeky/feature/625-notification-type-differentiation
UX-25: Notification type differentiation, grouping, and batch actions
2 parents 7e73e9a + e67ffcf commit b3d288b

File tree

17 files changed

+1003
-142
lines changed

17 files changed

+1003
-142
lines changed

backend/src/Taskdeck.Api/Controllers/NotificationsController.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ public async Task<IActionResult> MarkAsRead(Guid id, CancellationToken cancellat
4949
return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult();
5050
}
5151

52+
[HttpPost("mark-all-read")]
53+
public async Task<IActionResult> MarkAllAsRead(
54+
[FromQuery] Guid? boardId = null,
55+
CancellationToken cancellationToken = default)
56+
{
57+
if (!TryGetCurrentUserId(out var userId, out var errorResult))
58+
return errorResult!;
59+
60+
var result = await _notificationService.MarkAllAsReadAsync(userId, boardId, cancellationToken);
61+
return result.IsSuccess ? Ok(new { markedCount = result.Value }) : result.ToErrorActionResult();
62+
}
63+
5264
[HttpGet("preferences")]
5365
public async Task<IActionResult> GetPreferences(CancellationToken cancellationToken = default)
5466
{

backend/src/Taskdeck.Application/Interfaces/INotificationRepository.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ Task<IEnumerable<Notification>> GetByUserIdAsync(
1616
Guid userId,
1717
string deduplicationKey,
1818
CancellationToken cancellationToken = default);
19+
20+
Task<IEnumerable<Notification>> GetUnreadByUserIdAsync(
21+
Guid userId,
22+
Guid? boardId = null,
23+
CancellationToken cancellationToken = default);
1924
}

backend/src/Taskdeck.Application/Services/INotificationService.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ Task<Result<NotificationDto>> MarkAsReadAsync(
1515
Guid notificationId,
1616
CancellationToken cancellationToken = default);
1717

18+
Task<Result<int>> MarkAllAsReadAsync(
19+
Guid userId,
20+
Guid? boardId = null,
21+
CancellationToken cancellationToken = default);
22+
1823
Task<Result<NotificationPreferenceDto>> GetPreferencesAsync(
1924
Guid userId,
2025
CancellationToken cancellationToken = default);

backend/src/Taskdeck.Application/Services/NoOpNotificationService.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ public Task<Result<NotificationDto>> MarkAsReadAsync(
2828
return Task.FromResult(Result.Failure<NotificationDto>(ErrorCodes.NotFound, "Notification not found"));
2929
}
3030

31+
public Task<Result<int>> MarkAllAsReadAsync(
32+
Guid userId,
33+
Guid? boardId = null,
34+
CancellationToken cancellationToken = default)
35+
{
36+
return Task.FromResult(Result.Success(0));
37+
}
38+
3139
public Task<Result<NotificationPreferenceDto>> GetPreferencesAsync(
3240
Guid userId,
3341
CancellationToken cancellationToken = default)

backend/src/Taskdeck.Application/Services/NotificationService.cs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,43 @@ public async Task<Result<NotificationDto>> MarkAsReadAsync(
8484
return Result.Success(MapToDto(notification));
8585
}
8686

87+
public async Task<Result<int>> MarkAllAsReadAsync(
88+
Guid userId,
89+
Guid? boardId = null,
90+
CancellationToken cancellationToken = default)
91+
{
92+
if (userId == Guid.Empty)
93+
return Result.Failure<int>(ErrorCodes.ValidationError, "User ID cannot be empty");
94+
95+
if (boardId.HasValue && _authorizationService is not null)
96+
{
97+
var boardPermission = await _authorizationService.CanReadBoardAsync(userId, boardId.Value);
98+
if (!boardPermission.IsSuccess || !boardPermission.Value)
99+
{
100+
return Result.Failure<int>(
101+
ErrorCodes.Forbidden,
102+
"You do not have access to notifications for this board");
103+
}
104+
}
105+
106+
var unreadNotifications = await _unitOfWork.Notifications.GetUnreadByUserIdAsync(
107+
userId, boardId, cancellationToken);
108+
109+
var count = 0;
110+
foreach (var notification in unreadNotifications)
111+
{
112+
notification.MarkAsRead();
113+
count++;
114+
}
115+
116+
if (count > 0)
117+
{
118+
await _unitOfWork.SaveChangesAsync(cancellationToken);
119+
}
120+
121+
return Result.Success(count);
122+
}
123+
87124
public async Task<Result<NotificationPreferenceDto>> GetPreferencesAsync(
88125
Guid userId,
89126
CancellationToken cancellationToken = default)
@@ -218,6 +255,8 @@ private static bool TryResolveCadence(
218255
NotificationType.Mention => preference.MentionImmediateEnabled,
219256
NotificationType.Assignment => preference.AssignmentImmediateEnabled,
220257
NotificationType.ProposalOutcome => preference.ProposalOutcomeImmediateEnabled,
258+
NotificationType.BoardChange => true,
259+
NotificationType.System => true,
221260
_ => false
222261
};
223262

@@ -226,6 +265,8 @@ private static bool TryResolveCadence(
226265
NotificationType.Mention => preference.MentionDigestEnabled,
227266
NotificationType.Assignment => preference.AssignmentDigestEnabled,
228267
NotificationType.ProposalOutcome => preference.ProposalOutcomeDigestEnabled,
268+
NotificationType.BoardChange => false,
269+
NotificationType.System => false,
229270
_ => false
230271
};
231272

backend/src/Taskdeck.Domain/Entities/Notification.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ public enum NotificationType
9494
{
9595
Mention,
9696
Assignment,
97-
ProposalOutcome
97+
ProposalOutcome,
98+
BoardChange,
99+
System
98100
}
99101

100102
public enum NotificationCadence

backend/src/Taskdeck.Infrastructure/Repositories/NotificationRepository.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,20 @@ public async Task<IEnumerable<Notification>> GetByUserIdAsync(
4949
n => n.UserId == userId && n.DeduplicationKey == deduplicationKey,
5050
cancellationToken);
5151
}
52+
53+
public async Task<IEnumerable<Notification>> GetUnreadByUserIdAsync(
54+
Guid userId,
55+
Guid? boardId = null,
56+
CancellationToken cancellationToken = default)
57+
{
58+
var query = _dbSet
59+
.Where(n => n.UserId == userId && !n.IsRead);
60+
61+
if (boardId.HasValue)
62+
{
63+
query = query.Where(n => n.BoardId == boardId.Value);
64+
}
65+
66+
return await query.ToListAsync(cancellationToken);
67+
}
5268
}

backend/tests/Taskdeck.Application.Tests/Services/NotificationServiceTests.cs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,66 @@ public async Task GetNotificationsAsync_ShouldReturnForbidden_WhenBoardLookupFai
164164
result.ErrorMessage.Should().Be("You do not have access to notifications for this board");
165165
}
166166

167+
[Fact]
168+
public async Task MarkAllAsReadAsync_ShouldMarkAllUnread_WhenNotificationsExist()
169+
{
170+
var userId = Guid.NewGuid();
171+
var n1 = new Notification(userId, NotificationType.Mention, NotificationCadence.Immediate, "N1", "Message 1");
172+
var n2 = new Notification(userId, NotificationType.Assignment, NotificationCadence.Immediate, "N2", "Message 2");
173+
174+
_notificationRepositoryMock
175+
.Setup(r => r.GetUnreadByUserIdAsync(userId, null, default))
176+
.ReturnsAsync(new[] { n1, n2 });
177+
178+
var result = await _service.MarkAllAsReadAsync(userId);
179+
180+
result.IsSuccess.Should().BeTrue();
181+
result.Value.Should().Be(2);
182+
n1.IsRead.Should().BeTrue();
183+
n2.IsRead.Should().BeTrue();
184+
_unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Once);
185+
}
186+
187+
[Fact]
188+
public async Task MarkAllAsReadAsync_ShouldReturnZero_WhenNoUnreadNotifications()
189+
{
190+
var userId = Guid.NewGuid();
191+
_notificationRepositoryMock
192+
.Setup(r => r.GetUnreadByUserIdAsync(userId, null, default))
193+
.ReturnsAsync(Array.Empty<Notification>());
194+
195+
var result = await _service.MarkAllAsReadAsync(userId);
196+
197+
result.IsSuccess.Should().BeTrue();
198+
result.Value.Should().Be(0);
199+
_unitOfWorkMock.Verify(u => u.SaveChangesAsync(default), Times.Never);
200+
}
201+
202+
[Fact]
203+
public async Task MarkAllAsReadAsync_ShouldReturnForbidden_WhenBoardAccessDenied()
204+
{
205+
var userId = Guid.NewGuid();
206+
var boardId = Guid.NewGuid();
207+
208+
_authorizationServiceMock
209+
.Setup(s => s.CanReadBoardAsync(userId, boardId))
210+
.ReturnsAsync(Result.Success(false));
211+
212+
var result = await _service.MarkAllAsReadAsync(userId, boardId);
213+
214+
result.IsSuccess.Should().BeFalse();
215+
result.ErrorCode.Should().Be(ErrorCodes.Forbidden);
216+
}
217+
218+
[Fact]
219+
public async Task MarkAllAsReadAsync_ShouldReturnValidationError_WhenUserIdEmpty()
220+
{
221+
var result = await _service.MarkAllAsReadAsync(Guid.Empty);
222+
223+
result.IsSuccess.Should().BeFalse();
224+
result.ErrorCode.Should().Be(ErrorCodes.ValidationError);
225+
}
226+
167227
[Fact]
168228
public async Task PublishAsync_ShouldAvoidDuplicatesWithinSameUnitOfWork_WhenPreferenceIsNotPersistedYet()
169229
{

frontend/taskdeck-web/src/api/notificationsApi.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import http from './http'
22
import { buildQueryString } from '../utils/queryBuilder'
33
import type {
4+
MarkAllReadResponse,
45
NotificationItem,
56
NotificationPreference,
67
NotificationQuery,
@@ -18,6 +19,12 @@ export const notificationsApi = {
1819
return data
1920
},
2021

22+
async markAllRead(boardId?: string): Promise<MarkAllReadResponse> {
23+
const qs = boardId ? `?boardId=${encodeURIComponent(boardId)}` : ''
24+
const { data } = await http.post<MarkAllReadResponse>(`/notifications/mark-all-read${qs}`)
25+
return data
26+
},
27+
2128
async getPreferences(): Promise<NotificationPreference> {
2229
const { data } = await http.get<NotificationPreference>('/notifications/preferences')
2330
return data

0 commit comments

Comments
 (0)