From 25c8fb8a356088a60a2e15ee42bbb4e7b1e59b6f Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:10:34 +0530 Subject: [PATCH 1/2] feat(subscriptions): add publisher/group-level updates (updatePublisher, updateTopicGroup) Adds the two broader-scope subscription writes: - updatePublisher: opt-in/out at the publisher level, optionally scoped to specific entities (folders, etc.) - updateTopicGroup: scope a named topic group to a set of entities API spec misspells the publisher key as `publisherID`; the SDK preserves the typo at send time and keeps the public surface clean (`publisherId`). Adds PublisherSubscriptionUpdate + TopicGroupSubscriptionUpdate types, response types, oauth-scopes entries, 5 new unit tests, and an updatePublisher round-trip integration test. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 2 + .../notification/subscriptions.models.ts | 55 ++++++++++ .../notification/subscriptions.types.ts | 24 +++++ src/services/notification/subscriptions.ts | 64 +++++++++++ src/utils/constants/endpoints/notification.ts | 2 + .../subscriptions.integration.test.ts | 21 +++- .../notification/subscriptions.test.ts | 100 ++++++++++++++++++ 7 files changed, 266 insertions(+), 2 deletions(-) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index ddf8f98f0..7787556ff 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -229,6 +229,8 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getSupportedChannels()` | `NotificationService` | | `updateTopic()` | `NotificationService` | | `updateCategory()` | `NotificationService` | +| `updatePublisher()` | `NotificationService` | +| `updateTopicGroup()` | `NotificationService` | ## Processes diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index b28826ef1..cda86722b 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -7,10 +7,12 @@ import type { OperationResponse } from '../common/types'; import type { CategorySubscriptionUpdate, + PublisherSubscriptionUpdate, SubscriptionGetAllOptions, SubscriptionGetPublishersOptions, SubscriptionPublisher, SupportedChannel, + TopicGroupSubscriptionUpdate, TopicSubscriptionUpdate, } from './subscriptions.types'; @@ -44,6 +46,16 @@ export type SubscriptionUpdateCategoryResponse = OperationResponse<{ subscriptions: CategorySubscriptionUpdate[]; }>; +/** Response from `updatePublisher()`. */ +export type SubscriptionUpdatePublisherResponse = OperationResponse<{ + subscriptions: PublisherSubscriptionUpdate[]; +}>; + +/** Response from `updateTopicGroup()`. */ +export type SubscriptionUpdateTopicGroupResponse = OperationResponse<{ + subscriptions: TopicGroupSubscriptionUpdate[]; +}>; + /** * Public surface of the Subscriptions service. JSDoc on this interface drives * the generated API reference documentation. @@ -175,4 +187,47 @@ export interface SubscriptionServiceModel { * ``` */ updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise; + + /** + * Updates publisher-level opt-in / opt-out. Each entry toggles the user's overall + * opt-in for a publisher and optionally scopes the change to specific entities. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Publisher subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdatePublisherResponse} + * + * @example Opt out of a publisher entirely + * ```typescript + * await subscriptions.updatePublisher('', [ + * { publisherId: '', isUserOptIn: false }, + * ]); + * ``` + */ + updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise; + + /** + * Updates topic-group subscription preferences. Each entry scopes a topic group to + * a specific set of entities. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Topic-group subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicGroupResponse} + * + * @example Subscribe a topic group to two folders + * ```typescript + * await subscriptions.updateTopicGroup('', [ + * { + * publisherId: '', + * topicGroupName: 'JobNotifications', + * entities: [ + * { id: '', type: 'Folder', isSubscribed: true }, + * { id: '', type: 'Folder', isSubscribed: true }, + * ], + * }, + * ]); + * ``` + */ + updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise; } diff --git a/src/models/notification/subscriptions.types.ts b/src/models/notification/subscriptions.types.ts index d8f1d2c11..be021d5fc 100644 --- a/src/models/notification/subscriptions.types.ts +++ b/src/models/notification/subscriptions.types.ts @@ -179,6 +179,30 @@ export interface CategorySubscriptionUpdate { notificationMode: NotificationMode; } +/** + * Update payload for a publisher-level opt-in / opt-out. + */ +export interface PublisherSubscriptionUpdate { + /** Publisher GUID. */ + publisherId: string; + /** Whether the user opts in to receive notifications from this publisher. */ + isUserOptIn: boolean; + /** Optional entity scoping. */ + entities?: SubscriptionEntity[]; +} + +/** + * Update payload for a topic-group-level subscription change. + */ +export interface TopicGroupSubscriptionUpdate { + /** Publisher GUID. */ + publisherId: string; + /** Topic group name. */ + topicGroupName: string; + /** Optional entity scoping. */ + entities?: SubscriptionEntity[]; +} + /** * Options for `Subscriptions.getAll()`. */ diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index 08c23c00c..7c7be7546 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -7,8 +7,10 @@ import { BaseService } from '../base'; import type { CategorySubscriptionUpdate, + PublisherSubscriptionUpdate, SubscriptionGetAllOptions, SubscriptionGetPublishersOptions, + TopicGroupSubscriptionUpdate, TopicSubscriptionUpdate, } from '../../models/notification/subscriptions.types'; import type { @@ -16,6 +18,8 @@ import type { SubscriptionGetSupportedChannelsResponse, SubscriptionServiceModel, SubscriptionUpdateCategoryResponse, + SubscriptionUpdatePublisherResponse, + SubscriptionUpdateTopicGroupResponse, SubscriptionUpdateTopicResponse, } from '../../models/notification/subscriptions.models'; @@ -199,4 +203,64 @@ export class SubscriptionService extends BaseService implements SubscriptionServ }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { subscriptions } }; } + + /** + * Updates publisher-level opt-in / opt-out. Each entry toggles the user's overall + * opt-in for a publisher and optionally scopes the change to specific entities. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Publisher subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdatePublisherResponse} + * + * @example Opt out of a publisher entirely + * ```typescript + * await subscriptions.updatePublisher('', [ + * { publisherId: '', isUserOptIn: false }, + * ]); + * ``` + */ + @track('Subscriptions.UpdatePublisher') + async updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise { + // API field is misspelled `publisherID` — map at send time. + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, { + publisherSubscriptions: subscriptions.map(({ publisherId, isUserOptIn, entities }) => ({ + publisherID: publisherId, + isUserOptIn, + entities, + })), + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { subscriptions } }; + } + + /** + * Updates topic-group subscription preferences. Each entry scopes a topic group to + * a specific set of entities. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Topic-group subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicGroupResponse} + * + * @example Subscribe a topic group to two folders + * ```typescript + * await subscriptions.updateTopicGroup('', [ + * { + * publisherId: '', + * topicGroupName: 'JobNotifications', + * entities: [ + * { id: '', type: 'Folder', isSubscribed: true }, + * { id: '', type: 'Folder', isSubscribed: true }, + * ], + * }, + * ]); + * ``` + */ + @track('Subscriptions.UpdateTopicGroup') + async updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, { + topicGroupSubscriptions: subscriptions, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { subscriptions } }; + } } diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index 9446202e9..91adba4c4 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -32,4 +32,6 @@ export const SUBSCRIPTION_ENDPOINTS = { // Intentional duplicate URL of GET_ALL: same path, POST vs GET differentiates the operation. UPDATE_TOPIC: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, UPDATE_CATEGORY: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/CategorySubscription`, + UPDATE_PUBLISHER: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/PublisherSubscription`, + UPDATE_TOPIC_GROUP: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/TopicGroupSubscription`, } as const; diff --git a/tests/integration/shared/notification/subscriptions.integration.test.ts b/tests/integration/shared/notification/subscriptions.integration.test.ts index 869314f5f..fbc5e3044 100644 --- a/tests/integration/shared/notification/subscriptions.integration.test.ts +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -110,6 +110,23 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { }); }); - // Note: no updateCategory integration test — needs richer tenant fixtures (multi-topic - // category coverage). Covered by unit tests for SDK shape. + describe('updatePublisher', () => { + it('should round-trip a publisher opt-in/out', async () => { + const original = firstPublisher.isUserOptin === true; + + const flip = await subscriptions.updatePublisher(tenantId, [ + { publisherId: firstPublisher.id, isUserOptIn: !original }, + ]); + expect(flip.success).toBe(true); + + const restore = await subscriptions.updatePublisher(tenantId, [ + { publisherId: firstPublisher.id, isUserOptIn: original }, + ]); + expect(restore.success).toBe(true); + }); + }); + + // Note: no updateCategory / updateTopicGroup integration tests — they need richer + // tenant fixtures (multi-topic category coverage, configured topic groups). Covered + // by unit tests for SDK shape. }); diff --git a/tests/unit/services/notification/subscriptions.test.ts b/tests/unit/services/notification/subscriptions.test.ts index 8f772dbcb..14e3d5940 100644 --- a/tests/unit/services/notification/subscriptions.test.ts +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -16,6 +16,8 @@ import { NotificationCategory, NotificationMode, type CategorySubscriptionUpdate, + type PublisherSubscriptionUpdate, + type TopicGroupSubscriptionUpdate, type TopicSubscriptionUpdate, } from '../../../../src/models/notification'; @@ -222,4 +224,102 @@ describe('SubscriptionService Unit Tests', () => { ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); + + describe('updatePublisher', () => { + it('should POST publisherSubscriptions with API-spelling publisherID + tenant header, echoing clean input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: PublisherSubscriptionUpdate[] = [ + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: false }, + ]; + + const result = await subscriptionService.updatePublisher(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, + { + publisherSubscriptions: [ + { + publisherID: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + isUserOptIn: false, + entities: undefined, + }, + ], + }, + { headers: TENANT_HEADER } + ); + // Result echoes the SDK-shape input (publisherId, not publisherID) + expect(result).toEqual({ success: true, data: { subscriptions } }); + }); + + it('should preserve entities scoping in the request body', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: PublisherSubscriptionUpdate[] = [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + isUserOptIn: true, + entities: [{ id: 'folder-1', type: 'Folder', isSubscribed: true }], + }, + ]; + + await subscriptionService.updatePublisher(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_PUBLISHER, + { + publisherSubscriptions: [ + { + publisherID: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + isUserOptIn: true, + entities: [{ id: 'folder-1', type: 'Folder', isSubscribed: true }], + }, + ], + }, + { headers: TENANT_HEADER } + ); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updatePublisher(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: true }, + ]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('updateTopicGroup', () => { + it('should POST topicGroupSubscriptions with tenant header and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: TopicGroupSubscriptionUpdate[] = [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + topicGroupName: 'JobNotifications', + }, + ]; + + const result = await subscriptionService.updateTopicGroup(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, + { topicGroupSubscriptions: subscriptions }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { subscriptions } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updateTopicGroup(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + topicGroupName: 'JobNotifications', + }, + ]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); }); From 654453681fda8be96a53ab1474ff480e12e55ec6 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:35:35 +0530 Subject: [PATCH 2/2] fix(subscriptions): tag publisher/group updates @internal + remove oauth-scopes entries Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 2 -- src/models/notification/subscriptions.models.ts | 2 ++ src/services/notification/subscriptions.ts | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 7787556ff..ddf8f98f0 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -229,8 +229,6 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getSupportedChannels()` | `NotificationService` | | `updateTopic()` | `NotificationService` | | `updateCategory()` | `NotificationService` | -| `updatePublisher()` | `NotificationService` | -| `updateTopicGroup()` | `NotificationService` | ## Processes diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index cda86722b..d0452e3e6 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -203,6 +203,7 @@ export interface SubscriptionServiceModel { * { publisherId: '', isUserOptIn: false }, * ]); * ``` + * @internal */ updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise; @@ -228,6 +229,7 @@ export interface SubscriptionServiceModel { * }, * ]); * ``` + * @internal */ updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise; } diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index 7c7be7546..166ac8e52 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -219,6 +219,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * { publisherId: '', isUserOptIn: false }, * ]); * ``` + * @internal */ @track('Subscriptions.UpdatePublisher') async updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise { @@ -255,6 +256,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * }, * ]); * ``` + * @internal */ @track('Subscriptions.UpdateTopicGroup') async updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise {