Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/models/notification/subscriptions.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import type { OperationResponse } from '../common/types';

import type {
CategorySubscriptionUpdate,
PublisherSubscriptionUpdate,
SubscriptionGetAllOptions,
SubscriptionGetPublishersOptions,
SubscriptionPublisher,
SupportedChannel,
TopicGroupSubscriptionUpdate,
TopicSubscriptionUpdate,
} from './subscriptions.types';

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -175,4 +187,49 @@ export interface SubscriptionServiceModel {
* ```
*/
updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise<SubscriptionUpdateCategoryResponse>;

/**
* 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('<tenantId>', [
* { publisherId: '<publisherId>', isUserOptIn: false },
* ]);
* ```
* @internal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if these methods are public dont mark them as internal. Check for other similar methods as well

*/
updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise<SubscriptionUpdatePublisherResponse>;

/**
* 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('<tenantId>', [
* {
* publisherId: '<publisherId>',
* topicGroupName: 'JobNotifications',
* entities: [
* { id: '<folderId1>', type: 'Folder', isSubscribed: true },
* { id: '<folderId2>', type: 'Folder', isSubscribed: true },
* ],
* },
* ]);
* ```
* @internal
*/
updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise<SubscriptionUpdateTopicGroupResponse>;
}
24 changes: 24 additions & 0 deletions src/models/notification/subscriptions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
*/
Expand Down
66 changes: 66 additions & 0 deletions src/services/notification/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,19 @@ import { BaseService } from '../base';

import type {
CategorySubscriptionUpdate,
PublisherSubscriptionUpdate,
SubscriptionGetAllOptions,
SubscriptionGetPublishersOptions,
TopicGroupSubscriptionUpdate,
TopicSubscriptionUpdate,
} from '../../models/notification/subscriptions.types';
import type {
SubscriptionGetResponse,
SubscriptionGetSupportedChannelsResponse,
SubscriptionServiceModel,
SubscriptionUpdateCategoryResponse,
SubscriptionUpdatePublisherResponse,
SubscriptionUpdateTopicGroupResponse,
SubscriptionUpdateTopicResponse,
} from '../../models/notification/subscriptions.models';

Expand Down Expand Up @@ -199,4 +203,66 @@ 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('<tenantId>', [
* { publisherId: '<publisherId>', isUserOptIn: false },
* ]);
* ```
* @internal
*/
@track('Subscriptions.UpdatePublisher')
async updatePublisher(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise<SubscriptionUpdatePublisherResponse> {
// 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('<tenantId>', [
* {
* publisherId: '<publisherId>',
* topicGroupName: 'JobNotifications',
* entities: [
* { id: '<folderId1>', type: 'Folder', isSubscribed: true },
* { id: '<folderId2>', type: 'Folder', isSubscribed: true },
* ],
* },
* ]);
* ```
* @internal
*/
@track('Subscriptions.UpdateTopicGroup')
async updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise<SubscriptionUpdateTopicGroupResponse> {
await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, {
topicGroupSubscriptions: subscriptions,
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
return { success: true, data: { subscriptions } };
}
}
2 changes: 2 additions & 0 deletions src/utils/constants/endpoints/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add js doc for remaining constants as well like u have done for UPDATE_TOPIC

UPDATE_TOPIC_GROUP: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/TopicGroupSubscription`,
} as const;
Original file line number Diff line number Diff line change
Expand Up @@ -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.
});
100 changes: 100 additions & 0 deletions tests/unit/services/notification/subscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
NotificationCategory,
NotificationMode,
type CategorySubscriptionUpdate,
type PublisherSubscriptionUpdate,
type TopicGroupSubscriptionUpdate,
type TopicSubscriptionUpdate,
} from '../../../../src/models/notification';

Expand Down Expand Up @@ -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);
});
});
});
Loading