Skip to content

Commit 323f6b1

Browse files
sarthak688claude
andcommitted
feat(notifications): add mark-read flows (markRead, markUnread, markAllRead) [internal]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7f13da9 commit 323f6b1

7 files changed

Lines changed: 269 additions & 2 deletions

File tree

src/models/notification/notifications.models.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import type {
1212
import type {
1313
NotificationGetAllOptions,
1414
NotificationGetResponse,
15+
NotificationMarkAllReadResponse,
16+
NotificationUpdateReadResponse,
1517
} from './notifications.types';
1618

1719
/**
@@ -67,4 +69,51 @@ export interface NotificationServiceModel {
6769
? PaginatedResponse<NotificationGetResponse>
6870
: NonPaginatedResponse<NotificationGetResponse>
6971
>;
72+
73+
/**
74+
* Marks the given notifications as read.
75+
*
76+
* @param tenantId - Tenant GUID
77+
* @param notificationIds - GUIDs of notifications to mark read
78+
* @returns Operation result echoing the affected IDs and new read state
79+
* {@link NotificationUpdateReadResponse}
80+
*
81+
* @example
82+
* ```typescript
83+
* await notifications.markAsRead('<tenantId>', ['<notificationId-1>', '<notificationId-2>']);
84+
* ```
85+
* @internal
86+
*/
87+
markAsRead(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse>;
88+
89+
/**
90+
* Marks the given notifications as unread.
91+
*
92+
* @param tenantId - Tenant GUID
93+
* @param notificationIds - GUIDs of notifications to mark unread
94+
* @returns Operation result echoing the affected IDs and new read state
95+
* {@link NotificationUpdateReadResponse}
96+
*
97+
* @example
98+
* ```typescript
99+
* await notifications.markAsUnread('<tenantId>', ['<notificationId>']);
100+
* ```
101+
* @internal
102+
*/
103+
markAsUnread(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse>;
104+
105+
/**
106+
* Marks all notifications in the current user's inbox as read.
107+
*
108+
* @param tenantId - Tenant GUID
109+
* @returns Operation result confirming the bulk update
110+
* {@link NotificationMarkAllReadResponse}
111+
*
112+
* @example
113+
* ```typescript
114+
* await notifications.markAllAsRead('<tenantId>');
115+
* ```
116+
* @internal
117+
*/
118+
markAllAsRead(tenantId: string): Promise<NotificationMarkAllReadResponse>;
70119
}

src/models/notification/notifications.types.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import type { PaginationOptions } from '../../utils/pagination/types';
6+
import type { OperationResponse } from '../common/types';
67

78
/**
89
* Priority level assigned to a notification by the publisher.
@@ -86,3 +87,21 @@ export type NotificationGetAllOptions = PaginationOptions & {
8687
filter?: string;
8788
orderby?: string;
8889
};
90+
91+
/**
92+
* Response from `markAsRead()` / `markAsUnread()`.
93+
*
94+
* `notificationIds` echoes the IDs that were marked; `read` reflects the new state.
95+
*/
96+
export type NotificationUpdateReadResponse = OperationResponse<{
97+
notificationIds: string[];
98+
read: boolean;
99+
}>;
100+
101+
/**
102+
* Response from `markAllAsRead()`.
103+
*/
104+
export type NotificationMarkAllReadResponse = OperationResponse<{
105+
all: true;
106+
read: true;
107+
}>;

src/services/notification/notifications.ts

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { BaseService } from '../base';
88
import type {
99
NotificationGetAllOptions,
1010
NotificationGetResponse,
11+
NotificationMarkAllReadResponse,
12+
NotificationUpdateReadResponse,
1113
} from '../../models/notification/notifications.types';
1214
import type {
1315
NotificationServiceModel,
@@ -35,8 +37,7 @@ import { PaginationType } from '../../utils/pagination/internal-types';
3537
* Service for interacting with the UiPath Notification inbox.
3638
*
3739
* Provides list operations against the current user's notifications (the
38-
* `/odata/v1/NotificationEntry` API). Further inbox operations (mark-read,
39-
* delete) land in follow-up PRs.
40+
* `/odata/v1/NotificationEntry` API).
4041
*
4142
* Every public method takes the acting tenant GUID as the first argument — the
4243
* notification API identifies the tenant via the `X-UIPATH-Internal-TenantId`
@@ -125,4 +126,76 @@ export class NotificationService extends BaseService implements NotificationServ
125126
: NonPaginatedResponse<NotificationGetResponse>
126127
>;
127128
}
129+
130+
/**
131+
* Marks the given notifications as read.
132+
*
133+
* @param tenantId - Tenant GUID
134+
* @param notificationIds - GUIDs of notifications to mark read
135+
* @returns Operation result echoing the affected IDs and new read state
136+
* {@link NotificationUpdateReadResponse}
137+
*
138+
* @example
139+
* ```typescript
140+
* await notifications.markAsRead('<tenantId>', ['<notificationId-1>', '<notificationId-2>']);
141+
* ```
142+
* @internal
143+
*/
144+
@track('Notifications.MarkAsRead')
145+
async markAsRead(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse> {
146+
return this.updateRead(tenantId, notificationIds, true);
147+
}
148+
149+
/**
150+
* Marks the given notifications as unread.
151+
*
152+
* @param tenantId - Tenant GUID
153+
* @param notificationIds - GUIDs of notifications to mark unread
154+
* @returns Operation result echoing the affected IDs and new read state
155+
* {@link NotificationUpdateReadResponse}
156+
*
157+
* @example
158+
* ```typescript
159+
* await notifications.markAsUnread('<tenantId>', ['<notificationId>']);
160+
* ```
161+
* @internal
162+
*/
163+
@track('Notifications.MarkAsUnread')
164+
async markAsUnread(tenantId: string, notificationIds: string[]): Promise<NotificationUpdateReadResponse> {
165+
return this.updateRead(tenantId, notificationIds, false);
166+
}
167+
168+
/**
169+
* Marks all notifications in the current user's inbox as read.
170+
*
171+
* @param tenantId - Tenant GUID
172+
* @returns Operation result confirming the bulk update
173+
* {@link NotificationMarkAllReadResponse}
174+
*
175+
* @example
176+
* ```typescript
177+
* await notifications.markAllAsRead('<tenantId>');
178+
* ```
179+
* @internal
180+
*/
181+
@track('Notifications.MarkAllAsRead')
182+
async markAllAsRead(tenantId: string): Promise<NotificationMarkAllReadResponse> {
183+
await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, {
184+
notifications: [],
185+
forceAllRead: true,
186+
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
187+
return { success: true, data: { all: true, read: true } };
188+
}
189+
190+
private async updateRead(
191+
tenantId: string,
192+
notificationIds: string[],
193+
read: boolean
194+
): Promise<NotificationUpdateReadResponse> {
195+
await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, {
196+
notifications: notificationIds.map((notificationId) => ({ notificationId, read })),
197+
forceAllRead: false,
198+
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
199+
return { success: true, data: { notificationIds, read } };
200+
}
128201
}

src/utils/constants/endpoints/notification.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`;
1515
*/
1616
export const NOTIFICATION_ENDPOINTS = {
1717
GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`,
18+
UPDATE_READ: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.UpdateRead`,
1819
} as const;

tests/integration/shared/notification/notifications.integration.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,4 +121,40 @@ describe.skip.each(modes)('Notifications - Integration Tests [%s]', (mode) => {
121121
expect(entry).not.toHaveProperty('partitionKey');
122122
});
123123
});
124+
125+
describe('mark-read flows', () => {
126+
it('should mark a single notification as read and reflect the change via getAll', async () => {
127+
const unread = await notifications.getAll(tenantId, { filter: 'hasRead eq false', pageSize: 1 });
128+
if (unread.items.length === 0) {
129+
throw new Error(
130+
'No unread notifications in the inbox — cannot validate markAsRead. Trigger one on the test tenant.'
131+
);
132+
}
133+
const target = unread.items[0];
134+
135+
const mark = await notifications.markAsRead(tenantId, [target.id]);
136+
expect(mark.success).toBe(true);
137+
expect(mark.data.notificationIds).toEqual([target.id]);
138+
expect(mark.data.read).toBe(true);
139+
140+
// Restore so subsequent runs see the same fixture
141+
const restore = await notifications.markAsUnread(tenantId, [target.id]);
142+
expect(restore.success).toBe(true);
143+
expect(restore.data.read).toBe(false);
144+
});
145+
146+
it('markAllAsRead should succeed without per-id payload', async () => {
147+
// Snapshot one unread notification before the bulk operation so we can restore inbox state.
148+
const preSnapshot = await notifications.getAll(tenantId, { filter: 'hasRead eq false', pageSize: 1 });
149+
150+
const result = await notifications.markAllAsRead(tenantId);
151+
expect(result.success).toBe(true);
152+
expect(result.data).toEqual({ all: true, read: true });
153+
154+
// Restore so subsequent runs can still find unread notifications for the markAsRead test.
155+
if (preSnapshot.items.length > 0) {
156+
await notifications.markAsUnread(tenantId, [preSnapshot.items[0].id]);
157+
}
158+
});
159+
});
124160
});

tests/unit/services/notification/notifications.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,91 @@ describe('NotificationService Unit Tests', () => {
101101
});
102102
});
103103

104+
describe('markAsRead', () => {
105+
it('should POST per-id read=true entries with tenant header', async () => {
106+
mockApiClient.post.mockResolvedValue(undefined);
107+
const ids = [
108+
NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID,
109+
NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2,
110+
];
111+
112+
const result = await notificationService.markAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids);
113+
114+
expect(mockApiClient.post).toHaveBeenCalledWith(
115+
NOTIFICATION_ENDPOINTS.UPDATE_READ,
116+
{
117+
notifications: [
118+
{ notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: true },
119+
{ notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, read: true },
120+
],
121+
forceAllRead: false,
122+
},
123+
{ headers: TENANT_HEADER }
124+
);
125+
expect(result).toEqual({ success: true, data: { notificationIds: ids, read: true } });
126+
});
127+
128+
it('should propagate errors', async () => {
129+
mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND));
130+
131+
await expect(
132+
notificationService.markAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID])
133+
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND);
134+
});
135+
});
136+
137+
describe('markAsUnread', () => {
138+
it('should POST per-id read=false entries with tenant header', async () => {
139+
mockApiClient.post.mockResolvedValue(undefined);
140+
const ids = [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID];
141+
142+
const result = await notificationService.markAsUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids);
143+
144+
expect(mockApiClient.post).toHaveBeenCalledWith(
145+
NOTIFICATION_ENDPOINTS.UPDATE_READ,
146+
{
147+
notifications: [
148+
{ notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: false },
149+
],
150+
forceAllRead: false,
151+
},
152+
{ headers: TENANT_HEADER }
153+
);
154+
expect(result).toEqual({ success: true, data: { notificationIds: ids, read: false } });
155+
});
156+
157+
it('should propagate errors', async () => {
158+
mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND));
159+
160+
await expect(
161+
notificationService.markAsUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID])
162+
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND);
163+
});
164+
});
165+
166+
describe('markAllAsRead', () => {
167+
it('should POST forceAllRead=true with empty notifications array and tenant header', async () => {
168+
mockApiClient.post.mockResolvedValue(undefined);
169+
170+
const result = await notificationService.markAllAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID);
171+
172+
expect(mockApiClient.post).toHaveBeenCalledWith(
173+
NOTIFICATION_ENDPOINTS.UPDATE_READ,
174+
{ notifications: [], forceAllRead: true },
175+
{ headers: TENANT_HEADER }
176+
);
177+
expect(result).toEqual({ success: true, data: { all: true, read: true } });
178+
});
179+
180+
it('should propagate errors', async () => {
181+
mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND));
182+
183+
await expect(
184+
notificationService.markAllAsRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID)
185+
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND);
186+
});
187+
});
188+
104189
describe('response transformation', () => {
105190
it('strips internal fields and renames isRead → hasRead in the returned notifications', async () => {
106191
const raw: RawNotificationEntry = createBasicNotificationEntry();

tests/utils/constants/notification.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const NOTIFICATION_TEST_CONSTANTS = {
99

1010
// Notification entry identifiers
1111
NOTIFICATION_ID: '11111111-1111-4111-8111-111111111111',
12+
NOTIFICATION_ID_2: '22222222-2222-4222-8222-222222222222',
1213

1314
// Publisher / topic IDs used inside notification-entry fixtures
1415
PUBLISHER_ID: '44444444-4444-4444-4444-444444444444',
@@ -27,4 +28,7 @@ export const NOTIFICATION_TEST_CONSTANTS = {
2728

2829
// Unix epoch seconds (API returns seconds, not ms)
2930
PUBLISHED_ON: 1780981200,
31+
32+
// Error messages
33+
ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found',
3034
} as const;

0 commit comments

Comments
 (0)