Skip to content

Commit 7105f28

Browse files
sarthak688claude
andcommitted
feat(subscriptions): add topic-level updates (updateTopic, updateCategory)
Adds the two topic/category-scoped subscription writes. Each entry in the payload sets the subscription state for a single (topic, mode) or (category, mode) pair. UPDATE_TOPIC reuses the same URL as GET_ALL — POST vs GET differentiates. Adds TopicSubscriptionUpdate and CategorySubscriptionUpdate types, response types, oauth-scopes entries, and unit + integration tests (updateTopic round-trip; updateCategory unit-only because it needs richer tenant fixtures). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6e10bfe commit 7105f28

8 files changed

Lines changed: 259 additions & 0 deletions

File tree

docs/oauth-scopes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (`
227227
| `getAll()` | `NotificationService` |
228228
| `getPublishers()` | `NotificationService` |
229229
| `getSupportedChannels()` | `NotificationService` |
230+
| `updateTopic()` | `NotificationService` |
231+
| `updateCategory()` | `NotificationService` |
230232

231233
## Processes
232234

src/models/notification/subscriptions.models.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
* that drives generated API documentation.
44
*/
55

6+
import type { OperationResponse } from '../common/types';
7+
68
import type {
9+
CategorySubscriptionUpdate,
710
SubscriptionGetAllOptions,
811
SubscriptionGetPublishersOptions,
912
SubscriptionPublisher,
1013
SupportedChannel,
14+
TopicSubscriptionUpdate,
1115
} from './subscriptions.types';
1216

1317
/**
@@ -30,6 +34,16 @@ export interface SubscriptionGetSupportedChannelsResponse {
3034
channels: SupportedChannel[];
3135
}
3236

37+
/** Response from `updateTopic()`. */
38+
export type SubscriptionUpdateTopicResponse = OperationResponse<{
39+
subscriptions: TopicSubscriptionUpdate[];
40+
}>;
41+
42+
/** Response from `updateCategory()`. */
43+
export type SubscriptionUpdateCategoryResponse = OperationResponse<{
44+
subscriptions: CategorySubscriptionUpdate[];
45+
}>;
46+
3347
/**
3448
* Public surface of the Subscriptions service. JSDoc on this interface drives
3549
* the generated API reference documentation.
@@ -116,4 +130,49 @@ export interface SubscriptionServiceModel {
116130
* ```
117131
*/
118132
getSupportedChannels(tenantId: string): Promise<SubscriptionGetSupportedChannelsResponse>;
133+
134+
/**
135+
* Updates topic-level subscription preferences. Each entry sets the subscription state
136+
* (`isSubscribed`) for a single (topic, mode) pair.
137+
*
138+
* @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`)
139+
* @param subscriptions - Topic subscription updates
140+
* @returns Operation result echoing the submitted updates
141+
* {@link SubscriptionUpdateTopicResponse}
142+
*
143+
* @example Unsubscribe a topic from a single channel
144+
* ```typescript
145+
* import { NotificationMode } from '@uipath/uipath-typescript/notifications';
146+
*
147+
* await subscriptions.updateTopic('<tenantId>', [
148+
* { topicId: '<topicId>', isSubscribed: false, notificationMode: NotificationMode.Email },
149+
* ]);
150+
* ```
151+
*/
152+
updateTopic(tenantId: string, subscriptions: TopicSubscriptionUpdate[]): Promise<SubscriptionUpdateTopicResponse>;
153+
154+
/**
155+
* Updates category-level subscription preferences for a publisher. Each entry sets
156+
* the subscription state for all topics of a given category via a given mode.
157+
*
158+
* @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`)
159+
* @param subscriptions - Category subscription updates
160+
* @returns Operation result echoing the submitted updates
161+
* {@link SubscriptionUpdateCategoryResponse}
162+
*
163+
* @example Unsubscribe from all `Error` topics via email for one publisher
164+
* ```typescript
165+
* import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications';
166+
*
167+
* await subscriptions.updateCategory('<tenantId>', [
168+
* {
169+
* publisherId: '<publisherId>',
170+
* category: NotificationCategory.Error,
171+
* isSubscribed: false,
172+
* notificationMode: NotificationMode.Email,
173+
* },
174+
* ]);
175+
* ```
176+
*/
177+
updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise<SubscriptionUpdateCategoryResponse>;
119178
}

src/models/notification/subscriptions.types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,32 @@ export interface SupportedChannel {
153153
isEnabled: boolean;
154154
}
155155

156+
/**
157+
* Update payload for a topic-level subscription change.
158+
*/
159+
export interface TopicSubscriptionUpdate {
160+
/** Topic GUID. */
161+
topicId: string;
162+
/** Whether the user should be subscribed to this topic via the chosen mode. */
163+
isSubscribed: boolean;
164+
/** Notification channel this update applies to. */
165+
notificationMode: NotificationMode;
166+
}
167+
168+
/**
169+
* Update payload for a category-level subscription change.
170+
*/
171+
export interface CategorySubscriptionUpdate {
172+
/** Publisher GUID. */
173+
publisherId: string;
174+
/** Category to update. */
175+
category: NotificationCategory;
176+
/** Whether the user should be subscribed to topics of this category via the chosen mode. */
177+
isSubscribed: boolean;
178+
/** Notification channel this update applies to. */
179+
notificationMode: NotificationMode;
180+
}
181+
156182
/**
157183
* Options for `Subscriptions.getAll()`.
158184
*/

src/services/notification/subscriptions.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,17 @@ import { track } from '../../core/telemetry';
66
import { BaseService } from '../base';
77

88
import type {
9+
CategorySubscriptionUpdate,
910
SubscriptionGetAllOptions,
1011
SubscriptionGetPublishersOptions,
12+
TopicSubscriptionUpdate,
1113
} from '../../models/notification/subscriptions.types';
1214
import type {
1315
SubscriptionGetResponse,
1416
SubscriptionGetSupportedChannelsResponse,
1517
SubscriptionServiceModel,
18+
SubscriptionUpdateCategoryResponse,
19+
SubscriptionUpdateTopicResponse,
1620
} from '../../models/notification/subscriptions.models';
1721

1822
import { SUBSCRIPTION_ENDPOINTS } from '../../utils/constants/endpoints';
@@ -138,4 +142,61 @@ export class SubscriptionService extends BaseService implements SubscriptionServ
138142
);
139143
return response.data;
140144
}
145+
146+
/**
147+
* Updates topic-level subscription preferences. Each entry sets the subscription state
148+
* (`isSubscribed`) for a single (topic, mode) pair.
149+
*
150+
* @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`)
151+
* @param subscriptions - Topic subscription updates
152+
* @returns Operation result echoing the submitted updates
153+
* {@link SubscriptionUpdateTopicResponse}
154+
*
155+
* @example Unsubscribe a topic from a single channel
156+
* ```typescript
157+
* import { NotificationMode } from '@uipath/uipath-typescript/notifications';
158+
*
159+
* await subscriptions.updateTopic('<tenantId>', [
160+
* { topicId: '<topicId>', isSubscribed: false, notificationMode: NotificationMode.Email },
161+
* ]);
162+
* ```
163+
*/
164+
@track('Subscriptions.UpdateTopic')
165+
async updateTopic(tenantId: string, subscriptions: TopicSubscriptionUpdate[]): Promise<SubscriptionUpdateTopicResponse> {
166+
await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, {
167+
userSubscriptions: subscriptions,
168+
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
169+
return { success: true, data: { subscriptions } };
170+
}
171+
172+
/**
173+
* Updates category-level subscription preferences for a publisher. Each entry sets
174+
* the subscription state for all topics of a given category via a given mode.
175+
*
176+
* @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`)
177+
* @param subscriptions - Category subscription updates
178+
* @returns Operation result echoing the submitted updates
179+
* {@link SubscriptionUpdateCategoryResponse}
180+
*
181+
* @example Unsubscribe from all `Error` topics via email for one publisher
182+
* ```typescript
183+
* import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications';
184+
*
185+
* await subscriptions.updateCategory('<tenantId>', [
186+
* {
187+
* publisherId: '<publisherId>',
188+
* category: NotificationCategory.Error,
189+
* isSubscribed: false,
190+
* notificationMode: NotificationMode.Email,
191+
* },
192+
* ]);
193+
* ```
194+
*/
195+
@track('Subscriptions.UpdateCategory')
196+
async updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise<SubscriptionUpdateCategoryResponse> {
197+
await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, {
198+
categorySubscriptions: subscriptions,
199+
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
200+
return { success: true, data: { subscriptions } };
201+
}
141202
}

src/utils/constants/endpoints/notification.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,7 @@ export const SUBSCRIPTION_ENDPOINTS = {
2929
GET_ALL: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`,
3030
GET_PUBLISHERS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetPublishers`,
3131
GET_SUPPORTED_CHANNELS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetSupportedChannelStatus`,
32+
// Intentional duplicate URL of GET_ALL: same path, POST vs GET differentiates the operation.
33+
UPDATE_TOPIC: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`,
34+
UPDATE_CATEGORY: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/CategorySubscription`,
3235
} as const;

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,31 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => {
8585
}
8686
});
8787
});
88+
89+
describe('updateTopic', () => {
90+
it('should round-trip a topic subscription change', async () => {
91+
// Find a non-mandatory topic with at least one mode we can toggle
92+
const topic = firstPublisher.topics.find((t) => t.isMandatory === false && (t.modes?.length ?? 0) > 0);
93+
if (!topic) {
94+
throw new Error('No non-mandatory topics with toggleable modes — cannot run updateTopic round-trip.');
95+
}
96+
const mode = topic.modes![0];
97+
const originalState = mode.isSubscribed;
98+
99+
// Flip the state
100+
const flip = await subscriptions.updateTopic(tenantId, [
101+
{ topicId: topic.id, isSubscribed: !originalState, notificationMode: mode.name },
102+
]);
103+
expect(flip.success).toBe(true);
104+
105+
// Restore — leave the tenant in its original state
106+
const restore = await subscriptions.updateTopic(tenantId, [
107+
{ topicId: topic.id, isSubscribed: originalState, notificationMode: mode.name },
108+
]);
109+
expect(restore.success).toBe(true);
110+
});
111+
});
112+
113+
// Note: no updateCategory integration test — needs richer tenant fixtures (multi-topic
114+
// category coverage). Covered by unit tests for SDK shape.
88115
});

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import {
1212
import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup';
1313
import { SUBSCRIPTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints';
1414
import { TENANT_ID } from '../../../../src/utils/constants/headers';
15+
import {
16+
NotificationCategory,
17+
NotificationMode,
18+
type CategorySubscriptionUpdate,
19+
type TopicSubscriptionUpdate,
20+
} from '../../../../src/models/notification';
1521

1622
// ===== MOCKING =====
1723
vi.mock('../../../../src/core/http/api-client');
@@ -142,4 +148,78 @@ describe('SubscriptionService Unit Tests', () => {
142148
).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE);
143149
});
144150
});
151+
152+
describe('updateTopic', () => {
153+
it('should POST userSubscriptions with tenant header and echo input', async () => {
154+
mockApiClient.post.mockResolvedValue(undefined);
155+
const subscriptions: TopicSubscriptionUpdate[] = [
156+
{
157+
topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID,
158+
isSubscribed: false,
159+
notificationMode: NotificationMode.Email,
160+
},
161+
];
162+
163+
const result = await subscriptionService.updateTopic(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions);
164+
165+
expect(mockApiClient.post).toHaveBeenCalledWith(
166+
SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC,
167+
{ userSubscriptions: subscriptions },
168+
{ headers: TENANT_HEADER }
169+
);
170+
expect(result).toEqual({ success: true, data: { subscriptions } });
171+
});
172+
173+
it('should propagate errors', async () => {
174+
mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID));
175+
176+
await expect(
177+
subscriptionService.updateTopic(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [
178+
{
179+
topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID,
180+
isSubscribed: true,
181+
notificationMode: NotificationMode.InApp,
182+
},
183+
])
184+
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID);
185+
});
186+
});
187+
188+
describe('updateCategory', () => {
189+
it('should POST categorySubscriptions with tenant header and echo input', async () => {
190+
mockApiClient.post.mockResolvedValue(undefined);
191+
const subscriptions: CategorySubscriptionUpdate[] = [
192+
{
193+
publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID,
194+
category: NotificationCategory.Error,
195+
isSubscribed: false,
196+
notificationMode: NotificationMode.Email,
197+
},
198+
];
199+
200+
const result = await subscriptionService.updateCategory(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions);
201+
202+
expect(mockApiClient.post).toHaveBeenCalledWith(
203+
SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY,
204+
{ categorySubscriptions: subscriptions },
205+
{ headers: TENANT_HEADER }
206+
);
207+
expect(result).toEqual({ success: true, data: { subscriptions } });
208+
});
209+
210+
it('should propagate errors', async () => {
211+
mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE));
212+
213+
await expect(
214+
subscriptionService.updateCategory(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [
215+
{
216+
publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID,
217+
category: NotificationCategory.Info,
218+
isSubscribed: true,
219+
notificationMode: NotificationMode.InApp,
220+
},
221+
])
222+
).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE);
223+
});
224+
});
145225
});

tests/utils/constants/notification.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ export const NOTIFICATION_TEST_CONSTANTS = {
3636
// Error messages
3737
ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found',
3838
ERROR_PUBLISHER_NOT_FOUND: 'Publisher not found',
39+
ERROR_SUBSCRIPTION_INVALID: 'Subscription request is invalid',
3940
} as const;

0 commit comments

Comments
 (0)