From 71115d66fe2dd6ea58534909cc94e6ea024643a5 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:06:58 +0530 Subject: [PATCH] feat(subscriptions): add publisher/group-level updates (updatePublishers, updateTopicGroups) [internal] Adds publisher-level opt-in/out and topic-group subscription writes. Each method takes an array of update entries, batching many toggles into one request. - updatePublishers: POST notificationservice_/.../UserSubscription/PublisherSubscription - updateTopicGroups: POST notificationservice_/.../UserSubscription/TopicGroupSubscription Both methods are @internal (NotificationService is an internal scope), so no oauth-scopes.md entries. Response types live in subscriptions.types.ts and JSDoc lives only on the ServiceModel interface (inherited by the service class via `implements`, per the repo convention). Entity scoping uses a dedicated SubscriptionEntityUpdate input type so callers only supply id/type/isSubscribed rather than server-resolved discovery fields. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../notification/subscriptions.models.ts | 49 +++++++++ .../notification/subscriptions.types.ts | 48 +++++++++ src/services/notification/subscriptions.ts | 25 +++++ src/utils/constants/endpoints/notification.ts | 4 + .../subscriptions.integration.test.ts | 58 +++++++++- .../notification/subscriptions.test.ts | 100 ++++++++++++++++++ 6 files changed, 283 insertions(+), 1 deletion(-) diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index 5a9495e2b..f39d85fbf 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -5,13 +5,17 @@ import type { CategorySubscriptionUpdate, + PublisherSubscriptionUpdate, SubscriptionGetAllOptions, SubscriptionGetPublishersOptions, SubscriptionGetPublishersResponse, SubscriptionGetResponse, SubscriptionGetSupportedChannelsResponse, SubscriptionUpdateCategoriesResponse, + SubscriptionUpdatePublishersResponse, + SubscriptionUpdateTopicGroupsResponse, SubscriptionUpdateTopicsResponse, + TopicGroupSubscriptionUpdate, TopicSubscriptionUpdate, } from './subscriptions.types'; @@ -152,4 +156,49 @@ export interface SubscriptionServiceModel { * @internal */ updateCategories(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 + * @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('', [ + * { publisherId: '', isUserOptIn: false }, + * ]); + * ``` + * @internal + */ + updatePublishers(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 + * @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('', [ + * { + * publisherId: '', + * topicGroupName: 'JobNotifications', + * entities: [ + * { id: '', type: 'Folder', isSubscribed: true }, + * { id: '', type: 'Folder', isSubscribed: true }, + * ], + * }, + * ]); + * ``` + * @internal + */ + updateTopicGroups(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise; } diff --git a/src/models/notification/subscriptions.types.ts b/src/models/notification/subscriptions.types.ts index 001fcdeb4..ad6ceb800 100644 --- a/src/models/notification/subscriptions.types.ts +++ b/src/models/notification/subscriptions.types.ts @@ -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()`. */ @@ -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[]; +}>; diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index a74c34685..963ed0cd5 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -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'; @@ -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 { + // 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 { + 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..ab4e62fbd 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -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`, + // Topic-group-level subscription updates (entity-scoped). + 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 65d1eddf8..81e8d90be 100644 --- a/tests/integration/shared/notification/subscriptions.integration.test.ts +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -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']; @@ -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); + }); + }); }); diff --git a/tests/unit/services/notification/subscriptions.test.ts b/tests/unit/services/notification/subscriptions.test.ts index 7b063abf1..9b3d1ff56 100644 --- a/tests/unit/services/notification/subscriptions.test.ts +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -17,6 +17,8 @@ import { NotificationCategory, NotificationMode, type CategorySubscriptionUpdate, + type PublisherSubscriptionUpdate, + type TopicGroupSubscriptionUpdate, type TopicSubscriptionUpdate, } from '../../../../src/models/notification'; @@ -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', () => { + 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); + }); + }); });