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
49 changes: 49 additions & 0 deletions src/models/notification/subscriptions.models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@

import type {
CategorySubscriptionUpdate,
PublisherSubscriptionUpdate,
SubscriptionGetAllOptions,
SubscriptionGetPublishersOptions,
SubscriptionGetPublishersResponse,
SubscriptionGetResponse,
SubscriptionGetSupportedChannelsResponse,
SubscriptionUpdateCategoriesResponse,
SubscriptionUpdatePublishersResponse,
SubscriptionUpdateTopicGroupsResponse,
SubscriptionUpdateTopicsResponse,
TopicGroupSubscriptionUpdate,
TopicSubscriptionUpdate,
} from './subscriptions.types';

Expand Down Expand Up @@ -152,4 +156,49 @@ export interface SubscriptionServiceModel {
* @internal
*/
updateCategories(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise<SubscriptionUpdateCategoriesResponse>;

/**
* 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
* @param subscriptions - Publisher subscription updates
* @returns Operation result echoing the submitted updates
* {@link SubscriptionUpdatePublishersResponse}
*
* @example Opt out of a publisher entirely
* ```typescript
* await subscriptions.updatePublishers('<tenantId>', [
* { publisherId: '<publisherId>', isUserOptIn: false },
* ]);
* ```
* @internal
Comment thread
sarthak688 marked this conversation as resolved.
*/
updatePublishers(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise<SubscriptionUpdatePublishersResponse>;

/**
* Updates topic-group subscription preferences. Each entry scopes a topic group to
* a specific set of entities.
*
* @param tenantId - Tenant GUID
* @param subscriptions - Topic-group subscription updates
* @returns Operation result echoing the submitted updates
* {@link SubscriptionUpdateTopicGroupsResponse}
*
* @example Subscribe a topic group to two folders
* ```typescript
* await subscriptions.updateTopicGroups('<tenantId>', [
* {
* publisherId: '<publisherId>',
* topicGroupName: 'JobNotifications',
* entities: [
* { id: '<folderId1>', type: 'Folder', isSubscribed: true },
* { id: '<folderId2>', type: 'Folder', isSubscribed: true },
* ],
* },
* ]);
* ```
* @internal
*/
updateTopicGroups(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise<SubscriptionUpdateTopicGroupsResponse>;
}
48 changes: 48 additions & 0 deletions src/models/notification/subscriptions.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,44 @@ export interface CategorySubscriptionUpdate {
notificationMode: NotificationMode;
}

/**
* Entity reference supplied when scoping a subscription update to specific entities
* (e.g. folders). Only the identity and desired subscription state are provided;
* discovery fields (`name`, `parentName`) are resolved server-side.
*/
export interface SubscriptionEntityUpdate {
/** Entity GUID. */
id: string;
/** Entity type (e.g. `Folder`). */
type?: string;
/** Whether the user should be subscribed to notifications for this entity. */
isSubscribed: boolean;
}

/**
* 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?: SubscriptionEntityUpdate[];
}

/**
* 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?: SubscriptionEntityUpdate[];
}

/**
* Options for `Subscriptions.getAll()`.
*/
Expand Down Expand Up @@ -277,3 +315,13 @@ export type SubscriptionUpdateTopicsResponse = OperationResponse<{
export type SubscriptionUpdateCategoriesResponse = OperationResponse<{
subscriptions: CategorySubscriptionUpdate[];
}>;

/** Response from `updatePublishers()`. */
export type SubscriptionUpdatePublishersResponse = OperationResponse<{
subscriptions: PublisherSubscriptionUpdate[];
}>;

/** Response from `updateTopicGroups()`. */
export type SubscriptionUpdateTopicGroupsResponse = OperationResponse<{
subscriptions: TopicGroupSubscriptionUpdate[];
}>;
25 changes: 25 additions & 0 deletions src/services/notification/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import { BaseService } from '../base';

import type {
CategorySubscriptionUpdate,
PublisherSubscriptionUpdate,
SubscriptionGetAllOptions,
SubscriptionGetPublishersOptions,
SubscriptionGetPublishersResponse,
SubscriptionGetResponse,
SubscriptionGetSupportedChannelsResponse,
SubscriptionUpdateCategoriesResponse,
SubscriptionUpdatePublishersResponse,
SubscriptionUpdateTopicGroupsResponse,
SubscriptionUpdateTopicsResponse,
TopicGroupSubscriptionUpdate,
TopicSubscriptionUpdate,
} from '../../models/notification/subscriptions.types';
import type { SubscriptionServiceModel } from '../../models/notification/subscriptions.models';
Expand Down Expand Up @@ -81,4 +85,25 @@ export class SubscriptionService extends BaseService implements SubscriptionServ
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
return { success: true, data: { subscriptions } };
}

@track('Subscriptions.UpdatePublishers')
async updatePublishers(tenantId: string, subscriptions: PublisherSubscriptionUpdate[]): Promise<SubscriptionUpdatePublishersResponse> {
// 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 } };
}

@track('Subscriptions.UpdateTopicGroups')
async updateTopicGroups(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise<SubscriptionUpdateTopicGroupsResponse> {
await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC_GROUP, {
topicGroupSubscriptions: subscriptions,
Comment thread
Sarath1018 marked this conversation as resolved.
}, { headers: createHeaders({ [TENANT_ID]: tenantId }) });
return { success: true, data: { subscriptions } };
}
}
4 changes: 4 additions & 0 deletions src/utils/constants/endpoints/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,8 @@ 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`,
// Publisher-level opt-in / opt-out updates.
UPDATE_PUBLISHER: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/PublisherSubscription`,
Comment thread
sarthak688 marked this conversation as resolved.
// Topic-group-level subscription updates (entity-scoped).
UPDATE_TOPIC_GROUP: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/TopicGroupSubscription`,
} as const;
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup';
import type { Subscriptions } from '../../../../src/services/notification';
import { NotificationCategory, NotificationMode, type SubscriptionPublisher } from '../../../../src/models/notification';
import { NotificationCategory, NotificationMode, type SubscriptionEntity, type SubscriptionPublisher } from '../../../../src/models/notification';

const modes: InitMode[] = ['v1'];

Expand Down Expand Up @@ -166,4 +166,60 @@ describe.skip.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => {
expect(restore.success).toBe(true);
});
});

describe('updatePublishers', () => {
it('should round-trip a publisher opt-in/out', async () => {
const original = firstPublisher.isUserOptin === true;

const flip = await subscriptions.updatePublishers(tenantId, [
{ publisherId: firstPublisher.id, isUserOptIn: !original },
]);
expect(flip.success).toBe(true);

const restore = await subscriptions.updatePublishers(tenantId, [
{ publisherId: firstPublisher.id, isUserOptIn: original },
]);
expect(restore.success).toBe(true);
});
});

describe('updateTopicGroups', () => {
it('should round-trip a topic-group entity subscription change', async () => {
// Find any publisher with a named topic group that has at least one entity to toggle
let match: { publisherId: string; topicGroupName: string; entity: SubscriptionEntity } | undefined;
for (const publisher of allPublishers) {
const group = publisher.topicGroupEntities?.find(
(g) => g.name !== null && (g.entities?.length ?? 0) > 0,
);
if (group?.name && group.entities?.length) {
match = { publisherId: publisher.id, topicGroupName: group.name, entity: group.entities[0] };
break;
}
}
if (!match) {
throw new Error('No publisher with a configured topic-group entity — cannot run updateTopicGroups round-trip.');
}
const originalState = match.entity.isSubscribed;

// Flip the entity's subscription within the topic group
const flip = await subscriptions.updateTopicGroups(tenantId, [
{
publisherId: match.publisherId,
topicGroupName: match.topicGroupName,
entities: [{ id: match.entity.id, type: match.entity.type ?? undefined, isSubscribed: !originalState }],
},
]);
expect(flip.success).toBe(true);

// Restore — leave the tenant in its original state
const restore = await subscriptions.updateTopicGroups(tenantId, [
{
publisherId: match.publisherId,
topicGroupName: match.topicGroupName,
entities: [{ id: match.entity.id, type: match.entity.type ?? undefined, isSubscribed: originalState }],
},
]);
expect(restore.success).toBe(true);
});
});
});
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 @@ -17,6 +17,8 @@ import {
NotificationCategory,
NotificationMode,
type CategorySubscriptionUpdate,
type PublisherSubscriptionUpdate,
type TopicGroupSubscriptionUpdate,
type TopicSubscriptionUpdate,
} from '../../../../src/models/notification';

Expand Down Expand Up @@ -223,4 +225,102 @@ describe('SubscriptionService Unit Tests', () => {
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID);
});
});

describe('updatePublishers', () => {
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.updatePublishers(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.updatePublishers(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(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID));

await expect(
subscriptionService.updatePublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [
{ publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, isUserOptIn: true },
])
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID);
});
});

describe('updateTopicGroups', () => {

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.

unit test lacks entities-scoping coverage

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.updateTopicGroups(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(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID));

await expect(
subscriptionService.updateTopicGroups(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [
{
publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID,
topicGroupName: 'JobNotifications',
},
])
).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID);
});
});
});
Loading