From 8488f4b06709a13186a8f5bf7e70643f6c9d15b5 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:52:18 +0530 Subject: [PATCH 1/8] feat(notifications): add Notifications service foundation + getAll Establishes the notification service module with the inbox listing endpoint. Routes at the organization level via NOTIFICATION_BASE (URL-traversal trick to bypass the tenant segment). tenantId is taken as a positional argument and forwarded via X-UIPATH-Internal-TenantId. Foundation included for follow-up PRs that add the remaining inbox and subscription methods on top of this branch: - @uipath/uipath-typescript/notifications subpath in package.json + rollup - NOTIFICATION_BASE, NOTIFICATION_ENDPOINTS scaffold - test infra (NOTIFICATION_TEST_TENANT_ID env var, test-config, unified-setup registration, notification mock factories, constants) - oauth-scopes + pagination + mkdocs nav entries Methods: getAll (paginated OData listing with $top/$skip/$count, filter, orderby; transformFn drops 8 internal/transport fields). Tests: 4 unit + 4 integration (skipped when NOTIFICATION_TEST_TENANT_ID is not set). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 6 + docs/pagination.md | 1 + mkdocs.yml | 2 + package.json | 10 ++ rollup.config.js | 5 + src/models/notification/index.ts | 6 + .../notifications.internal-types.ts | 37 +++++ .../notification/notifications.models.ts | 70 ++++++++++ .../notification/notifications.types.ts | 107 ++++++++++++++ src/services/notification/index.ts | 29 ++++ src/services/notification/notifications.ts | 124 ++++++++++++++++ src/utils/constants/endpoints/base.ts | 11 ++ src/utils/constants/endpoints/index.ts | 3 + src/utils/constants/endpoints/notification.ts | 18 +++ tests/.env.integration.example | 7 +- tests/integration/config/test-config.ts | 3 + tests/integration/config/unified-setup.ts | 3 + .../notifications.integration.test.ts | 84 +++++++++++ .../notification/notifications.test.ts | 132 ++++++++++++++++++ tests/utils/constants/index.ts | 1 + tests/utils/constants/notification.ts | 35 +++++ tests/utils/mocks/index.ts | 1 + tests/utils/mocks/notification.ts | 47 +++++++ 23 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 src/models/notification/index.ts create mode 100644 src/models/notification/notifications.internal-types.ts create mode 100644 src/models/notification/notifications.models.ts create mode 100644 src/models/notification/notifications.types.ts create mode 100644 src/services/notification/index.ts create mode 100644 src/services/notification/notifications.ts create mode 100644 src/utils/constants/endpoints/notification.ts create mode 100644 tests/integration/shared/notification/notifications.integration.test.ts create mode 100644 tests/unit/services/notification/notifications.test.ts create mode 100644 tests/utils/constants/notification.ts create mode 100644 tests/utils/mocks/notification.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 297b6c16d..eae442704 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -209,6 +209,12 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getPolicyTraces()` | `Insights.RealTimeData Insights OR.Folders.Read` | | `getOperationSummary()` | `Insights.RealTimeData Insights OR.Folders.Read` | +## Notifications + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `NotificationService` | + ## Processes | Method | OAuth Scope | diff --git a/docs/pagination.md b/docs/pagination.md index 442ff7190..924840501 100644 --- a/docs/pagination.md +++ b/docs/pagination.md @@ -139,3 +139,4 @@ console.log(`Total count: ${allAssets.totalCount}`); | Traces | `getById()` | ❌ No | | Traces | `getSpansByIds()` | ❌ No | | Governance | `getPolicyTraces()` | ✅ Yes | +| Notifications | `getAll()` | ✅ Yes | diff --git a/mkdocs.yml b/mkdocs.yml index 36530735e..b4aad39b2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -205,6 +205,8 @@ nav: - Cases: api/interfaces/CasesServiceModel.md - Case Instances: api/interfaces/CaseInstancesServiceModel.md - Jobs: api/interfaces/JobServiceModel.md + - Notifications: + - Inbox: api/interfaces/NotificationServiceModel.md - Processes: api/interfaces/ProcessServiceModel.md - Queues: api/interfaces/QueueServiceModel.md - Tasks: api/interfaces/TaskServiceModel.md diff --git a/package.json b/package.json index f0050362b..a5fead53d 100644 --- a/package.json +++ b/package.json @@ -205,6 +205,16 @@ "types": "./dist/governance/index.d.ts", "default": "./dist/governance/index.cjs" } + }, + "./notifications": { + "import": { + "types": "./dist/notifications/index.d.ts", + "default": "./dist/notifications/index.mjs" + }, + "require": { + "types": "./dist/notifications/index.d.ts", + "default": "./dist/notifications/index.cjs" + } } }, "files": [ diff --git a/rollup.config.js b/rollup.config.js index 0ee219d1f..2a265eb2d 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -223,6 +223,11 @@ const serviceEntries = [ name: 'governance', input: 'src/services/governance/index.ts', output: 'governance/index' + }, + { + name: 'notifications', + input: 'src/services/notification/index.ts', + output: 'notifications/index' } ]; diff --git a/src/models/notification/index.ts b/src/models/notification/index.ts new file mode 100644 index 000000000..037da9f1d --- /dev/null +++ b/src/models/notification/index.ts @@ -0,0 +1,6 @@ +/** + * Notification models barrel export. + */ + +export * from './notifications.types'; +export * from './notifications.models'; diff --git a/src/models/notification/notifications.internal-types.ts b/src/models/notification/notifications.internal-types.ts new file mode 100644 index 000000000..c918f045c --- /dev/null +++ b/src/models/notification/notifications.internal-types.ts @@ -0,0 +1,37 @@ +/** + * Internal-only types for the Notification service. + * + * NOT exported from the public barrel (`src/models/notification/index.ts`). + */ + +import type { NotificationGetResponse } from './notifications.types'; + +/** + * Raw notification entry shape as returned by `/odata/v1/NotificationEntry` — includes + * the internal/transport-layer fields the SDK drops before returning to consumers. + */ +export interface RawNotificationEntry extends NotificationGetResponse { + entityOrgName?: string | null; + entityTenantName?: string | null; + serviceRegistryName?: string | null; + messageTemplateKey?: string | null; + messageVersion?: number; + publicationId?: string; + correlationId?: string | null; + partitionKey?: string; +} + +/** + * Fields stripped from each {@link RawNotificationEntry} before it is returned to the + * SDK consumer as a {@link NotificationGetResponse}. + */ +export const INTERNAL_NOTIFICATION_FIELDS: ReadonlyArray = [ + 'entityOrgName', + 'entityTenantName', + 'serviceRegistryName', + 'messageTemplateKey', + 'messageVersion', + 'publicationId', + 'correlationId', + 'partitionKey', +]; diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts new file mode 100644 index 000000000..58cf37919 --- /dev/null +++ b/src/models/notification/notifications.models.ts @@ -0,0 +1,70 @@ +/** + * Notification service model — public response shapes and the ServiceModel interface + * that drives generated API documentation. + */ + +import type { + HasPaginationOptions, + NonPaginatedResponse, + PaginatedResponse, +} from '../../utils/pagination/types'; + +import type { + NotificationGetAllOptions, + NotificationGetResponse, +} from './notifications.types'; + +/** + * Public surface of the Notifications service. JSDoc on this interface drives + * the generated API reference documentation. + * + * Every method takes the tenant GUID as the first argument — the notification + * API identifies the acting tenant via the `X-UIPATH-Internal-TenantId` header + * and the SDK forwards `tenantId` into that header on each call. + */ +export interface NotificationServiceModel { + /** + * Lists notifications from the current user's inbox. + * + * Returns the full list when no pagination params are provided, or a paginated cursor result + * when any of `pageSize`/`cursor`/`jumpToPage` is supplied. Supports OData `filter` and + * `orderby` query options. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional OData query and pagination options + * @returns Array of notifications, or a paginated result when pagination params are supplied + * {@link NotificationGetResponse} + * + * @example Basic usage + * ```typescript + * import { Notifications } from '@uipath/uipath-typescript/notifications'; + * + * const notifications = new Notifications(sdk); + * const all = await notifications.getAll(''); + * ``` + * + * @example Filter unread, most recent first + * ```typescript + * const unread = await notifications.getAll('', { + * filter: 'isRead eq false', + * orderby: 'publishedOn desc', + * }); + * ``` + * + * @example First page with pagination + * ```typescript + * const page1 = await notifications.getAll('', { pageSize: 20 }); + * if (page1.hasNextPage) { + * const page2 = await notifications.getAll('', { cursor: page1.nextCursor }); + * } + * ``` + */ + getAll( + tenantId: string, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; +} diff --git a/src/models/notification/notifications.types.ts b/src/models/notification/notifications.types.ts new file mode 100644 index 000000000..ab69f28eb --- /dev/null +++ b/src/models/notification/notifications.types.ts @@ -0,0 +1,107 @@ +/** + * Notification inbox types — raw API shapes and request/response options. + */ + +import type { PaginationOptions } from '../../utils/pagination/types'; + +/** + * Priority level assigned to a notification by the publisher. + */ +export enum NotificationPriority { + Low = 'Low', + Medium = 'Medium', + High = 'High', + Critical = 'Critical', +} + +/** + * Severity classification of a notification topic. + */ +export enum NotificationCategory { + /** Informational — no action required. */ + Info = 'Info', + /** Successful operation completed. */ + Success = 'Success', + /** Warning — degraded behaviour or non-fatal issue. */ + Warn = 'Warn', + /** Error — operation failed but the system continues. */ + Error = 'Error', + /** Fatal — unrecoverable failure. */ + Fatal = 'Fatal', +} + +/** + * Notification delivery channel. + * + * `InApp` is always implicitly enabled (it is not returned by `getSupportedChannels()`); + * the others must be checked via `Subscriptions.getSupportedChannels()`. + */ +export enum NotificationMode { + /** Real-time in-app push (SignalR WebSocket). Always available. */ + InApp = 'InApp', + /** Email delivery. */ + Email = 'Email', + /** Slack delivery via Integration Service. */ + Slack = 'Slack', + /** Microsoft Teams delivery via Integration Service. */ + Teams = 'Teams', +} + +/** + * Notification entry as returned by `GET /odata/v1/NotificationEntry`. + * + * Field selection: many internal/transport-layer fields (`partitionKey`, `correlationId`, + * `publicationId`, `messageVersion`, `messageTemplateKey`, `serviceRegistryName`, + * `entityOrgName`, `entityTenantName`) are returned by the API but dropped from the SDK + * because they have no use for an application developer. + */ +export interface NotificationGetResponse { + /** Notification GUID. */ + id: string; + /** Resolved notification message text. */ + message: string | null; + /** Whether the user has read this notification. */ + isRead: boolean; + /** Name of the publisher (e.g. `Orchestrator`, `Actions`). */ + publisherName: string; + /** Publisher GUID. */ + publisherId: string; + /** Human-readable topic name. */ + topicName: string; + /** Stable topic identifier (e.g. `Process.JobExecution.Faulted`). */ + topicKeyName: string; + /** Topic GUID. */ + topicId: string; + /** GUID of the user this notification belongs to (returned uppercase by the API). */ + userId: string; + /** Email of the user. Often `null`. */ + userEmail: string | null; + /** Tenant GUID this notification belongs to. Often `null` for org-scoped notifications. */ + tenantId: string | null; + /** Notification priority. */ + priority: NotificationPriority; + /** Notification severity category. */ + category: NotificationCategory; + /** JSON string of template parameters — parse with `JSON.parse()`. May be `null`. */ + messageParam: string | null; + /** URL to navigate to when the notification is clicked. */ + redirectionUrl: string | null; + /** Unix epoch **seconds** when the notification was published. */ + publishedOn: number; +} + +/** + * Options for `Notifications.getAll()`. + * + * Supports OData query options (`filter`, `orderby`) and SDK cursor pagination. + * + * Notes: + * - `$select` and `$expand` are not exposed because the API returns 500 on `$select` + * and there are no expandable relationships on this endpoint. + */ +export type NotificationGetAllOptions = { + /** OData `$filter` expression (e.g. `"isRead eq false"`). */ + filter?: string; + /** OData `$orderby` expression (e.g. `"publishedOn desc"`). */ + orderby?: string; +} & PaginationOptions; diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts new file mode 100644 index 000000000..25b830e26 --- /dev/null +++ b/src/services/notification/index.ts @@ -0,0 +1,29 @@ +/** + * Notification Service Module + * + * Provides access to the UiPath Notification platform from the perspective of an + * authenticated user (UserContext): + * - `Notifications` — list operations on the user's inbox (further operations land in + * follow-up PRs) + * + * Publishing (sending) notifications is a first-party service action and is NOT part of this module. + * + * @example + * ```typescript + * import { UiPath } from '@uipath/uipath-typescript/core'; + * import { Notifications } from '@uipath/uipath-typescript/notifications'; + * + * const sdk = new UiPath(config); + * await sdk.initialize(); + * + * const notifications = new Notifications(sdk); + * const unread = await notifications.getAll('', { filter: 'isRead eq false' }); + * ``` + * + * @module + */ + +export { NotificationService as Notifications } from './notifications'; + +// Models (types, enums, response shapes) +export * from '../../models/notification'; diff --git a/src/services/notification/notifications.ts b/src/services/notification/notifications.ts new file mode 100644 index 000000000..357542bea --- /dev/null +++ b/src/services/notification/notifications.ts @@ -0,0 +1,124 @@ +/** + * NotificationService — manages the current user's notification inbox. + */ + +import { track } from '../../core/telemetry'; +import { BaseService } from '../base'; + +import type { + NotificationGetAllOptions, + NotificationGetResponse, +} from '../../models/notification/notifications.types'; +import type { + NotificationServiceModel, +} from '../../models/notification/notifications.models'; +import { + INTERNAL_NOTIFICATION_FIELDS, + type RawNotificationEntry, +} from '../../models/notification/notifications.internal-types'; + +import { ODATA_OFFSET_PARAMS, ODATA_PAGINATION } from '../../utils/constants/common'; +import { NOTIFICATION_ENDPOINTS } from '../../utils/constants/endpoints'; +import { TENANT_ID } from '../../utils/constants/headers'; +import { createHeaders } from '../../utils/http/headers'; +import { + HasPaginationOptions, + NonPaginatedResponse, + PaginatedResponse, +} from '../../utils/pagination/types'; +import { PaginationHelpers } from '../../utils/pagination/helpers'; +import { PaginationType } from '../../utils/pagination/internal-types'; + +/** + * Service for interacting with the UiPath Notification inbox. + * + * Provides list operations against the current user's notifications (the + * `/odata/v1/NotificationEntry` API). Further inbox operations (mark-read, + * delete) land in follow-up PRs. + * + * Every public method takes the acting tenant GUID as the first argument — the + * notification API identifies the tenant via the `X-UIPATH-Internal-TenantId` + * header and the SDK forwards `tenantId` into that header on each call. + */ +export class NotificationService extends BaseService implements NotificationServiceModel { + /** + * Lists notifications from the current user's inbox. + * + * Returns the full list when no pagination params are provided, or a paginated cursor result + * when any of `pageSize`/`cursor`/`jumpToPage` is supplied. Supports OData `filter` and + * `orderby` query options. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional OData query and pagination options + * @returns Array of notifications, or a paginated result when pagination params are supplied + * {@link NotificationGetResponse} + * + * @example Basic usage + * ```typescript + * import { Notifications } from '@uipath/uipath-typescript/notifications'; + * + * const notifications = new Notifications(sdk); + * const all = await notifications.getAll(''); + * ``` + * + * @example Filter unread, most recent first + * ```typescript + * const unread = await notifications.getAll('', { + * filter: 'isRead eq false', + * orderby: 'publishedOn desc', + * }); + * ``` + * + * @example First page with pagination + * ```typescript + * const page1 = await notifications.getAll('', { pageSize: 20 }); + * if (page1.hasNextPage) { + * const page2 = await notifications.getAll('', { cursor: page1.nextCursor }); + * } + * ``` + */ + @track('Notifications.GetAll') + async getAll( + tenantId: string, + options?: T + ): Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + > { + return PaginationHelpers.getAll({ + serviceAccess: this.createPaginationServiceAccess(), + getEndpoint: () => NOTIFICATION_ENDPOINTS.GET_ALL, + headers: createHeaders({ [TENANT_ID]: tenantId }), + transformFn: stripInternalNotificationFields, + pagination: { + paginationType: PaginationType.OFFSET, + itemsField: ODATA_PAGINATION.ITEMS_FIELD, + totalCountField: ODATA_PAGINATION.TOTAL_COUNT_FIELD, + paginationParams: { + pageSizeParam: ODATA_OFFSET_PARAMS.PAGE_SIZE_PARAM, + offsetParam: ODATA_OFFSET_PARAMS.OFFSET_PARAM, + countParam: ODATA_OFFSET_PARAMS.COUNT_PARAM, + }, + }, + }, options) as Promise< + T extends HasPaginationOptions + ? PaginatedResponse + : NonPaginatedResponse + >; + } +} + +/** + * Drops internal/transport-layer fields from a raw notification entry before + * returning it to the SDK consumer. Exported as module-level for testability. + * + * @internal + */ +export function stripInternalNotificationFields(item: RawNotificationEntry): NotificationGetResponse { + const result: RawNotificationEntry = { ...item }; + for (const field of INTERNAL_NOTIFICATION_FIELDS) { + delete result[field]; + } + return result; +} diff --git a/src/utils/constants/endpoints/base.ts b/src/utils/constants/endpoints/base.ts index 6bfa8f393..cef473f74 100644 --- a/src/utils/constants/endpoints/base.ts +++ b/src/utils/constants/endpoints/base.ts @@ -9,3 +9,14 @@ export const IDENTITY_BASE = 'identity_'; export const AUTOPILOT_BASE = 'autopilotforeveryone_'; export const LLMOPS_BASE = 'llmopstenant_'; export const INSIGHTS_RTM_BASE = 'insightsrtm_'; +/** + * Notification service base. The notification service is routed at the **organization** + * level — its URLs do not include a tenant segment (unlike most UiPath services). + * + * The `../` prefix relies on `URL` path normalization to collapse the tenant segment + * that {@link ApiClient} unconditionally inserts (`{orgName}/{tenantName}/{path}`). Concretely, + * `{orgName}/{tenantName}/../notificationservice_/...` resolves to `{orgName}/notificationservice_/...`. + * + * Do NOT remove the leading `../`. See real-API curl confirmation in the PR description. + */ +export const NOTIFICATION_BASE = '../notificationservice_'; diff --git a/src/utils/constants/endpoints/index.ts b/src/utils/constants/endpoints/index.ts index 6b2d50313..9a8179816 100644 --- a/src/utils/constants/endpoints/index.ts +++ b/src/utils/constants/endpoints/index.ts @@ -35,3 +35,6 @@ export * from './agents'; // Governance endpoints export * from './governance'; + +// Notification endpoints +export * from './notification'; diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts new file mode 100644 index 000000000..4c97f529a --- /dev/null +++ b/src/utils/constants/endpoints/notification.ts @@ -0,0 +1,18 @@ +/** + * Notification Service Endpoints + * + * Inbox endpoints under the `notificationservice_/notificationserviceapi` prefix. + * + * URLs route at the **organization** level (no tenant segment); see {@link NOTIFICATION_BASE}. + */ + +import { NOTIFICATION_BASE } from './base'; + +const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; + +/** + * Notification inbox endpoints + */ +export const NOTIFICATION_ENDPOINTS = { + GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`, +} as const; diff --git a/tests/.env.integration.example b/tests/.env.integration.example index 1ff5fa67a..45f1fefc9 100644 --- a/tests/.env.integration.example +++ b/tests/.env.integration.example @@ -65,4 +65,9 @@ DATA_FABRIC_TEST_ATTACHMENT_FIELD= JOBS_TEST_FOLDER_ID= # Orchestrator attachment ID (UUID) for attachment getById tests -ORCHESTRATOR_ATTACHMENT_ID= \ No newline at end of file +ORCHESTRATOR_ATTACHMENT_ID= + +# Tenant GUID for Notifications/Subscriptions integration tests +# Required for any notification API call — the service identifies the tenant +# via the X-UIPATH-Internal-TenantId header, not the URL path +NOTIFICATION_TEST_TENANT_ID= \ No newline at end of file diff --git a/tests/integration/config/test-config.ts b/tests/integration/config/test-config.ts index 883502d56..521c880bb 100644 --- a/tests/integration/config/test-config.ts +++ b/tests/integration/config/test-config.ts @@ -22,6 +22,7 @@ export interface IntegrationConfig { dataFabricTestAttachmentField?: string; orchestratorAttachmentId?: string; jobsTestFolderId?: string; + notificationTenantId?: string; } function isValidUrl(value: string): boolean { @@ -75,6 +76,7 @@ function validateConfig(rawConfig: Record): IntegrationConfig { dataFabricTestAttachmentField: typeof rawConfig.dataFabricTestAttachmentField === 'string' ? rawConfig.dataFabricTestAttachmentField : undefined, orchestratorAttachmentId: typeof rawConfig.orchestratorAttachmentId === 'string' ? rawConfig.orchestratorAttachmentId : undefined, jobsTestFolderId: typeof rawConfig.jobsTestFolderId === 'string' ? rawConfig.jobsTestFolderId : undefined, + notificationTenantId: typeof rawConfig.notificationTenantId === 'string' ? rawConfig.notificationTenantId : undefined, }; } @@ -112,6 +114,7 @@ export function loadIntegrationConfig(): IntegrationConfig { dataFabricTestAttachmentField: process.env.DATA_FABRIC_TEST_ATTACHMENT_FIELD || undefined, orchestratorAttachmentId: process.env.ORCHESTRATOR_ATTACHMENT_ID || undefined, jobsTestFolderId: process.env.JOBS_TEST_FOLDER_ID || undefined, + notificationTenantId: process.env.NOTIFICATION_TEST_TENANT_ID || undefined, }; cachedConfig = validateConfig(rawConfig); diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index feed6384c..2e6221f2a 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -15,6 +15,7 @@ import { Agents } from '../../../src/services/agents'; import { AgentMemory } from '../../../src/services/agents/memory'; import { Traces } from '../../../src/services/observability/traces'; import { Governance } from '../../../src/services/governance'; +import { Notifications } from '../../../src/services/notification'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -54,6 +55,7 @@ export interface TestServices { traces?: Traces; agents?: Agents; governance?: Governance; + notifications?: Notifications; } /** @@ -138,6 +140,7 @@ function createV1Services(config: IntegrationConfig): TestServices { traces: new Traces(sdk), agents: new Agents(sdk), governance: new Governance(sdk), + notifications: new Notifications(sdk), }; } diff --git a/tests/integration/shared/notification/notifications.integration.test.ts b/tests/integration/shared/notification/notifications.integration.test.ts new file mode 100644 index 000000000..58bec652d --- /dev/null +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import type { Notifications } from '../../../../src/services/notification'; +import { INTERNAL_NOTIFICATION_FIELDS } from '../../../../src/models/notification/notifications.internal-types'; + +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let notifications!: Notifications; + let tenantId!: string; + + beforeAll(() => { + const service = getServices().notifications; + if (!service) { + throw new Error('Notifications service is not registered for this init mode'); + } + notifications = service; + + const id = getTestConfig().notificationTenantId; + if (!id) { + throw new Error('NOTIFICATION_TEST_TENANT_ID must be set in .env.integration to run notification tests.'); + } + tenantId = id; + }); + + describe('getAll', () => { + it('should retrieve notifications without pagination options as a NonPaginatedResponse', async () => { + const result = await notifications.getAll(tenantId); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + }); + + it('should retrieve notifications with pagination options as a PaginatedResponse', async () => { + const result = await notifications.getAll(tenantId, { pageSize: 5 }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + expect(result.items.length).toBeLessThanOrEqual(5); + expect(result.currentPage).toBe(1); + expect(result.supportsPageJump).toBe(true); + expect(typeof result.hasNextPage).toBe('boolean'); + }); + + it('should support OData filter and orderby', async () => { + const result = await notifications.getAll(tenantId, { + filter: 'isRead eq false', + orderby: 'publishedOn desc', + pageSize: 3, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result.items)).toBe(true); + for (const item of result.items) { + expect(item.isRead).toBe(false); + } + }); + + it('should drop internal API fields (entityOrgName, partitionKey, etc.) from each item', async () => { + const result = await notifications.getAll(tenantId, { pageSize: 1 }); + + if (result.items.length === 0) { + throw new Error( + 'Inbox is empty — cannot validate transform. Trigger at least one notification on the test tenant.' + ); + } + + const item = result.items[0]; + // Public fields present + expect(item.id).toBeDefined(); + expect(typeof item.isRead).toBe('boolean'); + expect(item.publisherName).toBeDefined(); + expect(item.topicName).toBeDefined(); + expect(typeof item.publishedOn).toBe('number'); + + // Internal fields stripped — assert against the same source-of-truth list the service uses + for (const field of INTERNAL_NOTIFICATION_FIELDS) { + expect((item as Record)[field]).toBeUndefined(); + } + }); + }); +}); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts new file mode 100644 index 000000000..f0d86b474 --- /dev/null +++ b/tests/unit/services/notification/notifications.test.ts @@ -0,0 +1,132 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + NotificationService, + stripInternalNotificationFields, +} from '../../../../src/services/notification/notifications'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { PaginationHelpers } from '../../../../src/utils/pagination/helpers'; +import { + createBasicNotificationEntry, + NOTIFICATION_TEST_CONSTANTS, + TEST_CONSTANTS, + createMockError, +} from '../../../utils/mocks'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { TENANT_ID } from '../../../../src/utils/constants/headers'; +import { NotificationCategory, NotificationPriority } from '../../../../src/models/notification'; +import type { RawNotificationEntry } from '../../../../src/models/notification/notifications.internal-types'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +const mocks = vi.hoisted(() => import('../../../utils/mocks/core')); + +vi.mock('../../../../src/utils/pagination/helpers', async () => (await mocks).mockPaginationHelpers); + +// Shorthand for asserting the tenant header is forwarded on each call +const TENANT_HEADER = { [TENANT_ID]: NOTIFICATION_TEST_CONSTANTS.TENANT_ID }; + +// ===== TEST SUITE ===== +describe('NotificationService Unit Tests', () => { + let notificationService: NotificationService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + vi.mocked(PaginationHelpers.getAll).mockReset(); + + notificationService = new NotificationService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should return a list of notifications via PaginationHelpers with OData pagination params and tenant header', async () => { + const items = [createBasicNotificationEntry()]; + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items, totalCount: 1 }); + + const result = await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(result.items.length).toBe(1); + expect(result.totalCount).toBe(1); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.objectContaining({ + serviceAccess: expect.any(Object), + getEndpoint: expect.any(Function), + headers: TENANT_HEADER, + transformFn: stripInternalNotificationFields, + pagination: expect.objectContaining({ + itemsField: 'value', + totalCountField: '@odata.count', + paginationParams: expect.objectContaining({ + pageSizeParam: '$top', + offsetParam: '$skip', + countParam: '$count', + }), + }), + }), + undefined + ); + }); + + it('should pass query/pagination options through to PaginationHelpers', async () => { + vi.mocked(PaginationHelpers.getAll).mockResolvedValue({ items: [], totalCount: 0 }); + + await notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { + filter: 'isRead eq false', + orderby: 'publishedOn desc', + pageSize: 20, + }); + + expect(PaginationHelpers.getAll).toHaveBeenCalledWith( + expect.anything(), + { filter: 'isRead eq false', orderby: 'publishedOn desc', pageSize: 20 } + ); + }); + + it('should propagate errors from PaginationHelpers', async () => { + const error = createMockError(TEST_CONSTANTS.ERROR_MESSAGE); + vi.mocked(PaginationHelpers.getAll).mockRejectedValue(error); + + await expect( + notificationService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('stripInternalNotificationFields', () => { + it('removes all 8 internal fields without mutating the original', () => { + const raw: RawNotificationEntry = createBasicNotificationEntry(); + const before = JSON.stringify(raw); + + const stripped = stripInternalNotificationFields(raw); + + // original untouched (shallow-copy semantics) + expect(JSON.stringify(raw)).toBe(before); + + // every internal field stripped + expect(stripped).not.toHaveProperty('entityOrgName'); + expect(stripped).not.toHaveProperty('entityTenantName'); + expect(stripped).not.toHaveProperty('serviceRegistryName'); + expect(stripped).not.toHaveProperty('messageTemplateKey'); + expect(stripped).not.toHaveProperty('messageVersion'); + expect(stripped).not.toHaveProperty('publicationId'); + expect(stripped).not.toHaveProperty('correlationId'); + expect(stripped).not.toHaveProperty('partitionKey'); + + // public fields preserved with exact values + expect(stripped.id).toBe(NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID); + expect(stripped.priority).toBe(NotificationPriority.High); + expect(stripped.category).toBe(NotificationCategory.Error); + expect(stripped.publishedOn).toBe(NOTIFICATION_TEST_CONSTANTS.PUBLISHED_ON); + expect(stripped.message).toBe(NOTIFICATION_TEST_CONSTANTS.MESSAGE); + expect(stripped.isRead).toBe(false); + }); + }); +}); diff --git a/tests/utils/constants/index.ts b/tests/utils/constants/index.ts index 6f88aebe8..b0f038b53 100644 --- a/tests/utils/constants/index.ts +++ b/tests/utils/constants/index.ts @@ -19,3 +19,4 @@ export * from './memory'; export * from './traces'; export * from './agents'; export * from './governance'; +export * from './notification'; diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts new file mode 100644 index 000000000..383eca1d5 --- /dev/null +++ b/tests/utils/constants/notification.ts @@ -0,0 +1,35 @@ +/** + * Notification test constants. + */ + +export const NOTIFICATION_TEST_CONSTANTS = { + // Tenant GUID — passed as the first arg to every Notifications method + // (sent to the API via the X-UIPATH-Internal-TenantId header) + TENANT_ID: '99999999-9999-4999-8999-999999999999', + + // Notification entry identifiers + NOTIFICATION_ID: '11111111-1111-4111-8111-111111111111', + NOTIFICATION_ID_2: '22222222-2222-4222-8222-222222222222', + NOTIFICATION_ID_3: '33333333-3333-4333-8333-333333333333', + + // Publisher / topic IDs used inside notification-entry fixtures + PUBLISHER_ID: '44444444-4444-4444-4444-444444444444', + PUBLISHER_NAME: 'Orchestrator', + TOPIC_ID: '55555555-5555-4555-8555-555555555555', + TOPIC_NAME: 'Process.JobExecution.Faulted', + TOPIC_KEY_NAME: 'Process.JobExecution.Faulted', + + // User identifier + USER_ID: '66666666-6666-4666-8666-666666666666', + + // Notification content (mirrors real-API field shapes captured during onboarding) + MESSAGE: 'Job XYZ failed in folder ABC', + MESSAGE_PARAM: '{"jobId":"","folderName":""}', + REDIRECTION_URL: 'https://alpha.uipath.com/orchestrator_/jobs/', + + // Unix epoch seconds (API returns seconds, not ms) + PUBLISHED_ON: 1780981200, + + // Error messages + ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found', +} as const; diff --git a/tests/utils/mocks/index.ts b/tests/utils/mocks/index.ts index 49e54fd4d..9cdfa44fb 100644 --- a/tests/utils/mocks/index.ts +++ b/tests/utils/mocks/index.ts @@ -22,6 +22,7 @@ export * from './attachments'; export * from './memory'; export * from './traces'; export * from './governance'; +export * from './notification'; // Re-export constants for convenience export * from '../constants'; \ No newline at end of file diff --git a/tests/utils/mocks/notification.ts b/tests/utils/mocks/notification.ts new file mode 100644 index 000000000..b1c6f4425 --- /dev/null +++ b/tests/utils/mocks/notification.ts @@ -0,0 +1,47 @@ +/** + * Notification mock factories. + * + * Shapes mirror the real API responses captured live during onboarding (NOT the + * Swagger spec, which omits some nullable behaviour). + */ + +import { + NotificationCategory, + NotificationPriority, +} from '../../../src/models/notification'; +import type { RawNotificationEntry } from '../../../src/models/notification/notifications.internal-types'; +import { NOTIFICATION_TEST_CONSTANTS } from '../constants/notification'; + +/** + * Builds a raw notification entry mirroring a live API response. + */ +export const createBasicNotificationEntry = ( + overrides?: Partial +): RawNotificationEntry => ({ + id: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + message: NOTIFICATION_TEST_CONSTANTS.MESSAGE, + isRead: false, + publisherName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + topicName: NOTIFICATION_TEST_CONSTANTS.TOPIC_NAME, + topicKeyName: NOTIFICATION_TEST_CONSTANTS.TOPIC_KEY_NAME, + topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + userId: NOTIFICATION_TEST_CONSTANTS.USER_ID, + userEmail: null, + tenantId: null, + priority: NotificationPriority.High, + category: NotificationCategory.Error, + messageParam: NOTIFICATION_TEST_CONSTANTS.MESSAGE_PARAM, + redirectionUrl: NOTIFICATION_TEST_CONSTANTS.REDIRECTION_URL, + publishedOn: NOTIFICATION_TEST_CONSTANTS.PUBLISHED_ON, + // Internal fields the API includes but the SDK drops: + entityOrgName: null, + entityTenantName: null, + serviceRegistryName: null, + messageTemplateKey: null, + messageVersion: 1, + publicationId: '00000000-0000-0000-0000-000000000000', + correlationId: null, + partitionKey: 'testorg|testtenant|partition-key-value', + ...overrides, +}); From c4d4fd88502c105ca08f6e4653e0df8afd6d1505 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:56:44 +0530 Subject: [PATCH 2/8] feat(notifications): add mark-read flows (markRead, markUnread, markAllRead) Adds the three read-state mutation methods on top of the foundation established in feat/notifications-sdk. All three POST to the NotificationEntry.UpdateRead OData action; markAllRead uses the server-side forceAllRead flag. Adds UPDATE_READ endpoint constant and per-method telemetry decorators. Tests: 6 additional unit tests + 2 integration tests (mark/unmark round-trip + markAllRead). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 3 + .../notification/notifications.models.ts | 65 ++++++++++++++ src/services/notification/notifications.ts | 73 ++++++++++++++++ src/utils/constants/endpoints/notification.ts | 1 + .../notifications.integration.test.ts | 28 ++++++ .../notification/notifications.test.ts | 86 +++++++++++++++++++ 6 files changed, 256 insertions(+) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index eae442704..24495b0b7 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -214,6 +214,9 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | Method | OAuth Scope | |--------|-------------| | `getAll()` | `NotificationService` | +| `markRead()` | `NotificationService` | +| `markUnread()` | `NotificationService` | +| `markAllRead()` | `NotificationService` | ## Processes diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts index 58cf37919..da7f109bb 100644 --- a/src/models/notification/notifications.models.ts +++ b/src/models/notification/notifications.models.ts @@ -3,6 +3,7 @@ * that drives generated API documentation. */ +import type { OperationResponse } from '../common/types'; import type { HasPaginationOptions, NonPaginatedResponse, @@ -14,6 +15,24 @@ import type { NotificationGetResponse, } from './notifications.types'; +/** + * Response from `markRead()` / `markUnread()`. + * + * `notificationIds` echoes the IDs that were marked; `read` reflects the new state. + */ +export type NotificationUpdateReadResponse = OperationResponse<{ + notificationIds: string[]; + read: boolean; +}>; + +/** + * Response from `markAllRead()`. + */ +export type NotificationMarkAllReadResponse = OperationResponse<{ + all: true; + read: true; +}>; + /** * Public surface of the Notifications service. JSDoc on this interface drives * the generated API reference documentation. @@ -67,4 +86,50 @@ export interface NotificationServiceModel { ? PaginatedResponse : NonPaginatedResponse >; + + /** + * Marks the given notifications as read. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param notificationIds - GUIDs of notifications to mark read + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markRead('', ['', '']); + * ``` + */ + markRead(tenantId: string, notificationIds: string[]): Promise; + + /** + * Marks the given notifications as unread. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param notificationIds - GUIDs of notifications to mark unread + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markUnread('', ['']); + * ``` + */ + markUnread(tenantId: string, notificationIds: string[]): Promise; + + /** + * Marks all notifications in the current user's inbox as read. + * + * Uses the server-side `forceAllRead` flag — no per-notification IDs are sent. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Operation result confirming the bulk update + * {@link NotificationMarkAllReadResponse} + * + * @example + * ```typescript + * await notifications.markAllRead(''); + * ``` + */ + markAllRead(tenantId: string): Promise; } diff --git a/src/services/notification/notifications.ts b/src/services/notification/notifications.ts index 357542bea..f53ab6d00 100644 --- a/src/services/notification/notifications.ts +++ b/src/services/notification/notifications.ts @@ -10,7 +10,9 @@ import type { NotificationGetResponse, } from '../../models/notification/notifications.types'; import type { + NotificationMarkAllReadResponse, NotificationServiceModel, + NotificationUpdateReadResponse, } from '../../models/notification/notifications.models'; import { INTERNAL_NOTIFICATION_FIELDS, @@ -107,6 +109,77 @@ export class NotificationService extends BaseService implements NotificationServ : NonPaginatedResponse >; } + + /** + * Marks the given notifications as read. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param notificationIds - GUIDs of notifications to mark read + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markRead('', ['', '']); + * ``` + */ + @track('Notifications.MarkRead') + async markRead(tenantId: string, notificationIds: string[]): Promise { + return this.updateRead(tenantId, notificationIds, true); + } + + /** + * Marks the given notifications as unread. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param notificationIds - GUIDs of notifications to mark unread + * @returns Operation result echoing the affected IDs and new read state + * {@link NotificationUpdateReadResponse} + * + * @example + * ```typescript + * await notifications.markUnread('', ['']); + * ``` + */ + @track('Notifications.MarkUnread') + async markUnread(tenantId: string, notificationIds: string[]): Promise { + return this.updateRead(tenantId, notificationIds, false); + } + + /** + * Marks all notifications in the current user's inbox as read. + * + * Uses the server-side `forceAllRead` flag — no per-notification IDs are sent. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Operation result confirming the bulk update + * {@link NotificationMarkAllReadResponse} + * + * @example + * ```typescript + * await notifications.markAllRead(''); + * ``` + */ + @track('Notifications.MarkAllRead') + async markAllRead(tenantId: string): Promise { + await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, { + notifications: [], + forceAllRead: true, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { all: true, read: true } }; + } + + private async updateRead( + tenantId: string, + notificationIds: string[], + read: boolean + ): Promise { + await this.post(NOTIFICATION_ENDPOINTS.UPDATE_READ, { + notifications: notificationIds.map((notificationId) => ({ notificationId, read })), + forceAllRead: false, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { notificationIds, read } }; + } } /** diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index 4c97f529a..ab5c00a39 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -15,4 +15,5 @@ const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; */ export const NOTIFICATION_ENDPOINTS = { GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`, + UPDATE_READ: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.UpdateRead`, } as const; diff --git a/tests/integration/shared/notification/notifications.integration.test.ts b/tests/integration/shared/notification/notifications.integration.test.ts index 58bec652d..c9099f92c 100644 --- a/tests/integration/shared/notification/notifications.integration.test.ts +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -81,4 +81,32 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { } }); }); + + describe('mark-read flows', () => { + it('should mark a single notification as read and reflect the change via getAll', async () => { + const unread = await notifications.getAll(tenantId, { filter: 'isRead eq false', pageSize: 1 }); + if (unread.items.length === 0) { + throw new Error( + 'No unread notifications in the inbox — cannot validate markRead. Trigger one on the test tenant.' + ); + } + const target = unread.items[0]; + + const mark = await notifications.markRead(tenantId, [target.id]); + expect(mark.success).toBe(true); + expect(mark.data.notificationIds).toEqual([target.id]); + expect(mark.data.read).toBe(true); + + // Restore so subsequent runs see the same fixture + const restore = await notifications.markUnread(tenantId, [target.id]); + expect(restore.success).toBe(true); + expect(restore.data.read).toBe(false); + }); + + it('markAllRead should succeed without per-id payload', async () => { + const result = await notifications.markAllRead(tenantId); + expect(result.success).toBe(true); + expect(result.data).toEqual({ all: true, read: true }); + }); + }); }); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts index f0d86b474..314960eb9 100644 --- a/tests/unit/services/notification/notifications.test.ts +++ b/tests/unit/services/notification/notifications.test.ts @@ -13,6 +13,7 @@ import { createMockError, } from '../../../utils/mocks'; import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { NOTIFICATION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; import { TENANT_ID } from '../../../../src/utils/constants/headers'; import { NotificationCategory, NotificationPriority } from '../../../../src/models/notification'; import type { RawNotificationEntry } from '../../../../src/models/notification/notifications.internal-types'; @@ -100,6 +101,91 @@ describe('NotificationService Unit Tests', () => { }); }); + describe('markRead', () => { + it('should POST per-id read=true entries with tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [ + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, + ]; + + const result = await notificationService.markRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { + notifications: [ + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: true }, + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, read: true }, + ], + forceAllRead: false, + }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids, read: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND)); + + await expect( + notificationService.markRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_NOTIFICATION_NOT_FOUND); + }); + }); + + describe('markUnread', () => { + it('should POST per-id read=false entries with tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]; + + const result = await notificationService.markUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { + notifications: [ + { notificationId: NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, read: false }, + ], + forceAllRead: false, + }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids, read: false } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + notificationService.markUnread(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('markAllRead', () => { + it('should POST forceAllRead=true with empty notifications array and tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + + const result = await notificationService.markAllRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.UPDATE_READ, + { notifications: [], forceAllRead: true }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { all: true, read: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + notificationService.markAllRead(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + describe('stripInternalNotificationFields', () => { it('removes all 8 internal fields without mutating the original', () => { const raw: RawNotificationEntry = createBasicNotificationEntry(); From 577dc47c941eaaa9f11635e8c62bc0a4b99bda53 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:59:08 +0530 Subject: [PATCH 3/8] feat(notifications): add delete flows (deleteNotifications, deleteAll) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two delete methods. Both POST to the NotificationEntry.DeleteBulk OData action; deleteAll uses the server-side deleteAll flag. Preserves the API spec's misspelling `notifcationIds` in the request body — the server expects that exact (mistyped) key. Tests: 4 additional unit tests. No integration tests — these destructively mutate the inbox with no SDK-level undo. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 2 + .../notification/notifications.models.ts | 48 ++++++++++++++++++ src/services/notification/notifications.ts | 50 +++++++++++++++++++ src/utils/constants/endpoints/notification.ts | 1 + .../notifications.integration.test.ts | 3 ++ .../notification/notifications.test.ts | 50 +++++++++++++++++++ 6 files changed, 154 insertions(+) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 24495b0b7..d34215e48 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -217,6 +217,8 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `markRead()` | `NotificationService` | | `markUnread()` | `NotificationService` | | `markAllRead()` | `NotificationService` | +| `deleteNotifications()` | `NotificationService` | +| `deleteAll()` | `NotificationService` | ## Processes diff --git a/src/models/notification/notifications.models.ts b/src/models/notification/notifications.models.ts index da7f109bb..ba22fe136 100644 --- a/src/models/notification/notifications.models.ts +++ b/src/models/notification/notifications.models.ts @@ -33,6 +33,22 @@ export type NotificationMarkAllReadResponse = OperationResponse<{ read: true; }>; +/** + * Response from `deleteNotifications()`. + * + * `notificationIds` echoes the IDs that were deleted. + */ +export type NotificationDeleteResponse = OperationResponse<{ + notificationIds: string[]; +}>; + +/** + * Response from `deleteAll()`. + */ +export type NotificationDeleteAllResponse = OperationResponse<{ + all: true; +}>; + /** * Public surface of the Notifications service. JSDoc on this interface drives * the generated API reference documentation. @@ -132,4 +148,36 @@ export interface NotificationServiceModel { * ``` */ markAllRead(tenantId: string): Promise; + + /** + * Deletes the given notifications. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param notificationIds - GUIDs of notifications to delete. Must be non-empty — + * the server rejects an empty array with HTTP 400. + * @returns Operation result echoing the deleted IDs + * {@link NotificationDeleteResponse} + * + * @example + * ```typescript + * await notifications.deleteNotifications('', ['', '']); + * ``` + */ + deleteNotifications(tenantId: string, notificationIds: string[]): Promise; + + /** + * Deletes all notifications from the current user's inbox. + * + * Uses the server-side `deleteAll` flag — no per-notification IDs are sent. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Operation result confirming the bulk delete + * {@link NotificationDeleteAllResponse} + * + * @example + * ```typescript + * await notifications.deleteAll(''); + * ``` + */ + deleteAll(tenantId: string): Promise; } diff --git a/src/services/notification/notifications.ts b/src/services/notification/notifications.ts index f53ab6d00..60e0201d0 100644 --- a/src/services/notification/notifications.ts +++ b/src/services/notification/notifications.ts @@ -10,6 +10,8 @@ import type { NotificationGetResponse, } from '../../models/notification/notifications.types'; import type { + NotificationDeleteAllResponse, + NotificationDeleteResponse, NotificationMarkAllReadResponse, NotificationServiceModel, NotificationUpdateReadResponse, @@ -169,6 +171,54 @@ export class NotificationService extends BaseService implements NotificationServ return { success: true, data: { all: true, read: true } }; } + /** + * Deletes the given notifications. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param notificationIds - GUIDs of notifications to delete. Must be non-empty — + * the server rejects an empty array with HTTP 400. + * @returns Operation result echoing the deleted IDs + * {@link NotificationDeleteResponse} + * + * @example + * ```typescript + * await notifications.deleteNotifications('', ['', '']); + * ``` + */ + @track('Notifications.DeleteNotifications') + async deleteNotifications(tenantId: string, notificationIds: string[]): Promise { + await this.post(NOTIFICATION_ENDPOINTS.DELETE_BULK, { + // API spec misspells the key as `notifcationIds` — preserve it. + notifcationIds: notificationIds, + deleteAll: false, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { notificationIds } }; + } + + /** + * Deletes all notifications from the current user's inbox. + * + * Uses the server-side `deleteAll` flag — no per-notification IDs are sent. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Operation result confirming the bulk delete + * {@link NotificationDeleteAllResponse} + * + * @example + * ```typescript + * await notifications.deleteAll(''); + * ``` + */ + @track('Notifications.DeleteAll') + async deleteAll(tenantId: string): Promise { + await this.post(NOTIFICATION_ENDPOINTS.DELETE_BULK, { + // API spec misspells the key as `notifcationIds` — preserve it. + notifcationIds: [], + deleteAll: true, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { all: true } }; + } + private async updateRead( tenantId: string, notificationIds: string[], diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index ab5c00a39..211bee2c6 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -16,4 +16,5 @@ const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; export const NOTIFICATION_ENDPOINTS = { GET_ALL: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry`, UPDATE_READ: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.UpdateRead`, + DELETE_BULK: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.DeleteBulk`, } as const; diff --git a/tests/integration/shared/notification/notifications.integration.test.ts b/tests/integration/shared/notification/notifications.integration.test.ts index c9099f92c..5a5c6b69a 100644 --- a/tests/integration/shared/notification/notifications.integration.test.ts +++ b/tests/integration/shared/notification/notifications.integration.test.ts @@ -109,4 +109,7 @@ describe.each(modes)('Notifications - Integration Tests [%s]', (mode) => { expect(result.data).toEqual({ all: true, read: true }); }); }); + + // Note: no deleteNotifications / deleteAll integration tests — these destructively + // mutate the inbox with no SDK-level undo. Run manually if needed. }); diff --git a/tests/unit/services/notification/notifications.test.ts b/tests/unit/services/notification/notifications.test.ts index 314960eb9..6efc230ee 100644 --- a/tests/unit/services/notification/notifications.test.ts +++ b/tests/unit/services/notification/notifications.test.ts @@ -186,6 +186,56 @@ describe('NotificationService Unit Tests', () => { }); }); + describe('deleteNotifications', () => { + it('should POST notifcationIds (preserving the API typo), deleteAll=false, and tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const ids = [ + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID, + NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID_2, + ]; + + const result = await notificationService.deleteNotifications(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, ids); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.DELETE_BULK, + { notifcationIds: ids, deleteAll: false }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { notificationIds: ids } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + notificationService.deleteNotifications(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [NOTIFICATION_TEST_CONSTANTS.NOTIFICATION_ID]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('deleteAll', () => { + it('should POST deleteAll=true with empty notifcationIds array and tenant header', async () => { + mockApiClient.post.mockResolvedValue(undefined); + + const result = await notificationService.deleteAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.post).toHaveBeenCalledWith( + NOTIFICATION_ENDPOINTS.DELETE_BULK, + { notifcationIds: [], deleteAll: true }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ success: true, data: { all: true } }); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + notificationService.deleteAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + describe('stripInternalNotificationFields', () => { it('removes all 8 internal fields without mutating the original', () => { const raw: RawNotificationEntry = createBasicNotificationEntry(); From 6e10bfe86d61c5b5326446483dea0df6127639e9 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:04:31 +0530 Subject: [PATCH 4/8] feat(subscriptions): add Subscriptions service foundation + read methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SubscriptionService class alongside the existing NotificationService. Both services live under @uipath/uipath-typescript/notifications as discrete classes (no shared state). Subscription endpoints route through the same notificationservice_ base via the URL-traversal trick. Methods: getAll, getPublishers, getSupportedChannels — the three read-side methods that return user subscription state and tenant channel availability. Mutation methods land in follow-up PRs. Adds SUBSCRIPTION_ENDPOINTS scaffold (3 read URLs), subscription model types (SubscriptionPublisher, SubscriptionTopic, SubscriptionMode, SupportedChannel, AllowedMode, options types), subscription test infrastructure (mock factories), Subscriptions section in oauth-scopes and mkdocs nav, and Subscriptions registration in unified-setup. Tests: 8 unit + 4 integration. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 8 + mkdocs.yml | 1 + src/models/notification/index.ts | 4 +- .../notification/subscriptions.models.ts | 119 ++++++++++++ .../notification/subscriptions.types.ts | 170 ++++++++++++++++++ src/services/notification/index.ts | 10 +- src/services/notification/subscriptions.ts | 141 +++++++++++++++ src/utils/constants/endpoints/notification.ts | 14 +- tests/integration/config/unified-setup.ts | 4 +- .../subscriptions.integration.test.ts | 88 +++++++++ .../notification/subscriptions.test.ts | 145 +++++++++++++++ tests/utils/constants/notification.ts | 4 + tests/utils/mocks/notification.ts | 62 +++++++ 13 files changed, 764 insertions(+), 6 deletions(-) create mode 100644 src/models/notification/subscriptions.models.ts create mode 100644 src/models/notification/subscriptions.types.ts create mode 100644 src/services/notification/subscriptions.ts create mode 100644 tests/integration/shared/notification/subscriptions.integration.test.ts create mode 100644 tests/unit/services/notification/subscriptions.test.ts diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index d34215e48..3fdf6545c 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -220,6 +220,14 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `deleteNotifications()` | `NotificationService` | | `deleteAll()` | `NotificationService` | +## Subscriptions + +| Method | OAuth Scope | +|--------|-------------| +| `getAll()` | `NotificationService` | +| `getPublishers()` | `NotificationService` | +| `getSupportedChannels()` | `NotificationService` | + ## Processes | Method | OAuth Scope | diff --git a/mkdocs.yml b/mkdocs.yml index b4aad39b2..efb3a56b8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -207,6 +207,7 @@ nav: - Jobs: api/interfaces/JobServiceModel.md - Notifications: - Inbox: api/interfaces/NotificationServiceModel.md + - Subscriptions: api/interfaces/SubscriptionServiceModel.md - Processes: api/interfaces/ProcessServiceModel.md - Queues: api/interfaces/QueueServiceModel.md - Tasks: api/interfaces/TaskServiceModel.md diff --git a/src/models/notification/index.ts b/src/models/notification/index.ts index 037da9f1d..5901cbf75 100644 --- a/src/models/notification/index.ts +++ b/src/models/notification/index.ts @@ -1,6 +1,8 @@ /** - * Notification models barrel export. + * Notification & Subscription models barrel export. */ export * from './notifications.types'; export * from './notifications.models'; +export * from './subscriptions.types'; +export * from './subscriptions.models'; diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts new file mode 100644 index 000000000..f18949d91 --- /dev/null +++ b/src/models/notification/subscriptions.models.ts @@ -0,0 +1,119 @@ +/** + * Subscription service model — public response shapes and the ServiceModel interface + * that drives generated API documentation. + */ + +import type { + SubscriptionGetAllOptions, + SubscriptionGetPublishersOptions, + SubscriptionPublisher, + SupportedChannel, +} from './subscriptions.types'; + +/** + * Response from `getAll()` and `getPublishers()` — a list of publishers + * with their topics, channels, and subscription state. + * + * Note: when returned from `getPublishers()`, publisher and topic objects only carry + * identity/discovery fields (no subscription-state). Use `getAll()` to inspect state. + */ +export interface SubscriptionGetResponse { + /** Publishers with their topics and subscription state. */ + publishers: SubscriptionPublisher[]; +} + +/** + * Response from `getSupportedChannels()`. + */ +export interface SubscriptionGetSupportedChannelsResponse { + /** Notification channels supported in the current tenant. `InApp` is not listed — it is always available. */ + channels: SupportedChannel[]; +} + +/** + * Public surface of the Subscriptions service. JSDoc on this interface drives + * the generated API reference documentation. + * + * Every method takes the tenant GUID as the first argument — the subscription + * API identifies the acting tenant via the `X-UIPATH-Internal-TenantId` header + * and the SDK forwards `tenantId` into that header on each call. + */ +export interface SubscriptionServiceModel { + /** + * Gets the current user's subscription preferences, optionally filtered to a set of + * publisher names. + * + * Returns the full list of publishers (their topics, channels, and current subscription + * state) the user has access to. Use {@link SubscriptionGetAllOptions.publishers} to + * narrow to specific publishers. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional publisher-name filter + * @returns Full subscription state for the matched publishers + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * import { Subscriptions } from '@uipath/uipath-typescript/notifications'; + * + * const subscriptions = new Subscriptions(sdk); + * const { publishers } = await subscriptions.getAll(''); + * ``` + * + * @example Filter to specific publishers + * ```typescript + * const { publishers } = await subscriptions.getAll('', { + * publishers: ['Orchestrator', 'Actions'], + * }); + * ``` + */ + getAll(tenantId: string, options?: SubscriptionGetAllOptions): Promise; + + /** + * Lists available publishers and their topics, regardless of the user's current subscription. + * + * Used for discovery — pair with {@link getAll} to inspect what's subscribable. + * + * Note: the response from this endpoint carries only identity/discovery fields on + * publishers and topics (no subscription state). Use {@link getAll} to inspect state. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional publisher-name filter + * @returns Publishers and their full topic catalogue + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * const { publishers } = await subscriptions.getPublishers(''); + * ``` + * + * @example Filter to a single publisher + * ```typescript + * const { publishers } = await subscriptions.getPublishers('', { name: 'Orchestrator' }); + * ``` + */ + getPublishers(tenantId: string, options?: SubscriptionGetPublishersOptions): Promise; + + /** + * Gets the notification channels supported in the current tenant. + * + * Check the `isEnabled` field on each {@link SupportedChannel} before attempting to subscribe to a + * channel — disabled channels will be rejected by the server. + * + * Note: `InApp` is always available and is not included in the response. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Supported channels with enabled status + * {@link SubscriptionGetSupportedChannelsResponse} + * + * @example + * ```typescript + * const { channels } = await subscriptions.getSupportedChannels(''); + * const slack = channels.find(c => c.name === 'Slack'); + * if (slack?.isEnabled) { + * // safe to subscribe to Slack + * } + * ``` + */ + getSupportedChannels(tenantId: string): Promise; +} diff --git a/src/models/notification/subscriptions.types.ts b/src/models/notification/subscriptions.types.ts new file mode 100644 index 000000000..ba36fdda8 --- /dev/null +++ b/src/models/notification/subscriptions.types.ts @@ -0,0 +1,170 @@ +/** + * Subscription service types — request/response shapes for user subscription preferences. + */ + +import { NotificationCategory, NotificationMode } from './notifications.types'; + +/** + * Status of a notification mode (channel) for a publisher — whether the user has + * activated this channel. + */ +export interface AllowedMode { + /** Notification channel. */ + name: NotificationMode; + /** Whether the user has activated this channel for the publisher. */ + isActive: boolean; +} + +/** + * Per-mode subscription state for a topic. + */ +export interface SubscriptionMode { + /** Notification channel. */ + name: NotificationMode; + /** Whether the user is subscribed to this topic via this channel. */ + isSubscribed: boolean; + /** Whether the topic is subscribed by default via this channel. */ + isSubscribedByDefault: boolean; +} + +/** + * A topic that the user can subscribe to. + * + * Note: the discovery endpoint (`getPublishers()`) returns a subset of these fields — + * only `id`, `name`, `displayName`, `description`, `group`. Subscription-state fields + * (`isSubscribed`, `modes`, etc.) appear only on `getAll()`. + */ +export interface SubscriptionTopic { + /** Topic GUID. */ + id: string; + /** Stable topic identifier (e.g. `Process.JobExecution.Faulted`). */ + name: string; + /** Human-readable topic name. */ + displayName: string | null; + /** Topic description. */ + description: string | null; + /** Severity category. Only populated on `getAll()`. */ + category?: NotificationCategory; + /** Topic group name. */ + group: string | null; + /** Parent topic group name. Often `null`. */ + parentGroup?: string | null; + /** Whether the user is currently subscribed to this topic. Only populated on `getAll()`. */ + isSubscribed?: boolean; + /** Whether the topic is mandatory — cannot be unsubscribed. Only populated on `getAll()`. */ + isMandatory?: boolean; + /** Whether the topic should be visible in the user-facing subscription UI. */ + isVisible?: boolean; + /** Whether the topic is subscribed by default for new users. */ + isDefault?: boolean; + /** Whether notifications for this topic can be batched. */ + isAllowedToBeDispatchedInBatch?: boolean; + /** Whether the topic is marked as infrequent (lower-priority delivery). */ + isInfrequent?: boolean; + /** Number of days notifications for this topic are retained. */ + retentionDays?: number; + /** Display ordering hint. */ + orderingSequence?: number; + /** Per-channel subscription state. Only populated on `getAll()`. */ + modes?: SubscriptionMode[]; +} + +/** + * An entity reference used in publisher / topic-group subscription requests. + */ +export interface SubscriptionEntity { + /** Entity GUID. */ + id?: string; + /** Entity name. */ + name?: string; + /** Entity type. */ + type?: string; + /** Parent name (e.g. folder name for a sub-folder). */ + parentName?: string; + /** Whether the user is subscribed to notifications for this entity. */ + isSubscribed?: boolean; +} + +/** + * Group of entities that belong to a topic group. + */ +export interface TopicGroupEntity { + /** Topic group name. */ + name?: string; + /** Entities belonging to this group. */ + entities?: SubscriptionEntity[]; +} + +/** + * Entity types supported by a topic group. + */ +export interface TopicGroupEntityType { + /** Topic group name. */ + name?: string; + /** Entity type names. */ + entityTypes?: string[]; +} + +/** + * A publisher with its topics, channels, and (when retrieved via `getAll()`) subscription state. + * + * The discovery endpoint (`getPublishers()`) populates only `id`, `name`, `displayName`, + * and `topics` — subscription-state fields (`isUserOptin`, `modes`, `entities`, etc.) + * appear only on `getAll()`. + */ +export interface SubscriptionPublisher { + /** Publisher GUID. */ + id: string; + /** Stable publisher name (e.g. `Orchestrator`, `Actions`). */ + name: string; + /** Human-readable publisher name. */ + displayName: string | null; + /** URL to navigate to when a publisher notification is clicked. */ + redirectionUrl?: string | null; + /** Number of days notifications from this publisher are retained. */ + retentionDays?: number; + /** Whether notifications from this publisher are included in summary digests. */ + addToSummary?: boolean; + /** Whether the user has opted in to receive notifications from this publisher. */ + isUserOptin?: boolean; + /** Channels available for this publisher and their activation state. */ + modes?: AllowedMode[]; + /** Topics published under this publisher. */ + topics: SubscriptionTopic[]; + /** Entities (e.g. folders) that the publisher exposes. */ + entities?: SubscriptionEntity[]; + /** Entity types the publisher exposes. */ + entityTypes?: string[]; + /** Topic-group entity sets. */ + topicGroupEntities?: TopicGroupEntity[]; + /** Topic-group entity types. */ + topicGroupEntityTypes?: TopicGroupEntityType[]; +} + +/** + * Channel availability returned by `getSupportedChannels()`. + * + * Note: `InApp` is never returned — it is always implicitly available. + */ +export interface SupportedChannel { + /** Notification channel. */ + name: NotificationMode; + /** Whether the channel is enabled for the current tenant. */ + isEnabled: boolean; +} + +/** + * Options for `Subscriptions.getAll()`. + */ +export interface SubscriptionGetAllOptions { + /** Filter to specific publisher names. When omitted, all publishers are returned. */ + publishers?: string[]; +} + +/** + * Options for `Subscriptions.getPublishers()`. + */ +export interface SubscriptionGetPublishersOptions { + /** Filter to a specific publisher name. When omitted, all publishers are returned. */ + name?: string; +} diff --git a/src/services/notification/index.ts b/src/services/notification/index.ts index 25b830e26..3ca907b70 100644 --- a/src/services/notification/index.ts +++ b/src/services/notification/index.ts @@ -3,27 +3,31 @@ * * Provides access to the UiPath Notification platform from the perspective of an * authenticated user (UserContext): - * - `Notifications` — list operations on the user's inbox (further operations land in - * follow-up PRs) + * - `Notifications` — list / mark-read / delete operations on the user's inbox + * - `Subscriptions` — read the user's notification preferences per publisher and topic * * Publishing (sending) notifications is a first-party service action and is NOT part of this module. * * @example * ```typescript * import { UiPath } from '@uipath/uipath-typescript/core'; - * import { Notifications } from '@uipath/uipath-typescript/notifications'; + * import { Notifications, Subscriptions } from '@uipath/uipath-typescript/notifications'; * * const sdk = new UiPath(config); * await sdk.initialize(); * * const notifications = new Notifications(sdk); * const unread = await notifications.getAll('', { filter: 'isRead eq false' }); + * + * const subscriptions = new Subscriptions(sdk); + * const { publishers } = await subscriptions.getAll(''); * ``` * * @module */ export { NotificationService as Notifications } from './notifications'; +export { SubscriptionService as Subscriptions } from './subscriptions'; // Models (types, enums, response shapes) export * from '../../models/notification'; diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts new file mode 100644 index 000000000..68a08debd --- /dev/null +++ b/src/services/notification/subscriptions.ts @@ -0,0 +1,141 @@ +/** + * SubscriptionService — manages the current user's notification preferences. + */ + +import { track } from '../../core/telemetry'; +import { BaseService } from '../base'; + +import type { + SubscriptionGetAllOptions, + SubscriptionGetPublishersOptions, +} from '../../models/notification/subscriptions.types'; +import type { + SubscriptionGetResponse, + SubscriptionGetSupportedChannelsResponse, + SubscriptionServiceModel, +} from '../../models/notification/subscriptions.models'; + +import { SUBSCRIPTION_ENDPOINTS } from '../../utils/constants/endpoints'; +import { TENANT_ID } from '../../utils/constants/headers'; +import { createHeaders } from '../../utils/http/headers'; + +/** + * Service for managing the current user's notification subscription preferences. + * + * Subscriptions are scoped to publishers (e.g. `Orchestrator`, `Actions`) and topics + * within them. Each topic can be activated per notification channel (InApp, Email, + * Slack, Teams) — see {@link NotificationMode}. + * + * Every public method takes the acting tenant GUID as the first argument — the + * subscription API identifies the tenant via the `X-UIPATH-Internal-TenantId` + * header and the SDK forwards `tenantId` into that header on each call. + * + * This PR ships the three read methods (`getAll`, `getPublishers`, + * `getSupportedChannels`). Mutation methods land in follow-up PRs. + */ +export class SubscriptionService extends BaseService implements SubscriptionServiceModel { + /** + * Gets the current user's subscription preferences, optionally filtered to a set of + * publisher names. + * + * Returns the full list of publishers (their topics, channels, and current subscription + * state) the user has access to. Use {@link SubscriptionGetAllOptions.publishers} to + * narrow to specific publishers. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional publisher-name filter + * @returns Full subscription state for the matched publishers + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * import { Subscriptions } from '@uipath/uipath-typescript/notifications'; + * + * const subscriptions = new Subscriptions(sdk); + * const { publishers } = await subscriptions.getAll(''); + * ``` + * + * @example Filter to specific publishers + * ```typescript + * const { publishers } = await subscriptions.getAll('', { + * publishers: ['Orchestrator', 'Actions'], + * }); + * ``` + */ + @track('Subscriptions.GetAll') + async getAll(tenantId: string, options?: SubscriptionGetAllOptions): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_ALL, + { + headers: createHeaders({ [TENANT_ID]: tenantId }), + ...(options?.publishers ? { params: { Publishers: options.publishers } } : {}), + } + ); + return response.data; + } + + /** + * Lists available publishers and their topics, regardless of the user's current subscription. + * + * Used for discovery — pair with {@link getAll} to inspect what's subscribable. + * + * Note: the response from this endpoint carries only identity/discovery fields on + * publishers and topics (no subscription state). Use {@link getAll} to inspect state. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param options - Optional publisher-name filter + * @returns Publishers and their full topic catalogue + * {@link SubscriptionGetResponse} + * + * @example Basic usage + * ```typescript + * const { publishers } = await subscriptions.getPublishers(''); + * ``` + * + * @example Filter to a single publisher + * ```typescript + * const { publishers } = await subscriptions.getPublishers('', { name: 'Orchestrator' }); + * ``` + */ + @track('Subscriptions.GetPublishers') + async getPublishers(tenantId: string, options?: SubscriptionGetPublishersOptions): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, + { + headers: createHeaders({ [TENANT_ID]: tenantId }), + ...(options?.name ? { params: { PublisherName: options.name } } : {}), + } + ); + return response.data; + } + + /** + * Gets the notification channels supported in the current tenant. + * + * Check {@link SupportedChannel.isEnabled} before attempting to subscribe to a channel — + * disabled channels will be rejected by the server. + * + * Note: `InApp` is always available and is not included in the response. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @returns Supported channels with enabled status + * {@link SubscriptionGetSupportedChannelsResponse} + * + * @example + * ```typescript + * const { channels } = await subscriptions.getSupportedChannels(''); + * const slack = channels.find(c => c.name === 'Slack'); + * if (slack?.isEnabled) { + * // safe to subscribe to Slack + * } + * ``` + */ + @track('Subscriptions.GetSupportedChannels') + async getSupportedChannels(tenantId: string): Promise { + const response = await this.get( + SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, + { headers: createHeaders({ [TENANT_ID]: tenantId }) } + ); + return response.data; + } +} diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index 211bee2c6..f70c364bc 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -1,7 +1,9 @@ /** * Notification Service Endpoints * - * Inbox endpoints under the `notificationservice_/notificationserviceapi` prefix. + * Covers two backend sub-services under the same `notificationservice_` prefix: + * - `notificationserviceapi` — notification inbox (OData) + * - `usersubscriptionservice` — user subscription preferences * * URLs route at the **organization** level (no tenant segment); see {@link NOTIFICATION_BASE}. */ @@ -9,6 +11,7 @@ import { NOTIFICATION_BASE } from './base'; const NOTIFICATION_API_BASE = `${NOTIFICATION_BASE}/notificationserviceapi`; +const SUBSCRIPTION_API_BASE = `${NOTIFICATION_BASE}/usersubscriptionservice`; /** * Notification inbox endpoints @@ -18,3 +21,12 @@ export const NOTIFICATION_ENDPOINTS = { UPDATE_READ: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.UpdateRead`, DELETE_BULK: `${NOTIFICATION_API_BASE}/odata/v1/NotificationEntry/UiPath.NotificationService.Api.DeleteBulk`, } as const; + +/** + * User subscription endpoints + */ +export const SUBSCRIPTION_ENDPOINTS = { + GET_ALL: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, + GET_PUBLISHERS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetPublishers`, + GET_SUPPORTED_CHANNELS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetSupportedChannelStatus`, +} as const; diff --git a/tests/integration/config/unified-setup.ts b/tests/integration/config/unified-setup.ts index 2e6221f2a..1299d276b 100644 --- a/tests/integration/config/unified-setup.ts +++ b/tests/integration/config/unified-setup.ts @@ -15,7 +15,7 @@ import { Agents } from '../../../src/services/agents'; import { AgentMemory } from '../../../src/services/agents/memory'; import { Traces } from '../../../src/services/observability/traces'; import { Governance } from '../../../src/services/governance'; -import { Notifications } from '../../../src/services/notification'; +import { Notifications, Subscriptions } from '../../../src/services/notification'; import { loadIntegrationConfig, IntegrationConfig } from './test-config'; import { UiPath as LegacyUiPath } from '../../../src/uipath'; import { afterAll, beforeAll } from 'vitest'; @@ -56,6 +56,7 @@ export interface TestServices { agents?: Agents; governance?: Governance; notifications?: Notifications; + subscriptions?: Subscriptions; } /** @@ -141,6 +142,7 @@ function createV1Services(config: IntegrationConfig): TestServices { agents: new Agents(sdk), governance: new Governance(sdk), notifications: new Notifications(sdk), + subscriptions: new Subscriptions(sdk), }; } diff --git a/tests/integration/shared/notification/subscriptions.integration.test.ts b/tests/integration/shared/notification/subscriptions.integration.test.ts new file mode 100644 index 000000000..072099be5 --- /dev/null +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { getServices, getTestConfig, setupUnifiedTests, InitMode } from '../../config/unified-setup'; +import type { Subscriptions } from '../../../../src/services/notification'; +import { NotificationMode, type SubscriptionPublisher } from '../../../../src/models/notification'; + +const modes: InitMode[] = ['v1']; + +describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { + setupUnifiedTests(mode); + + let subscriptions!: Subscriptions; + let tenantId!: string; + let firstPublisher!: SubscriptionPublisher; + + beforeAll(async () => { + const service = getServices().subscriptions; + if (!service) { + throw new Error('Subscriptions service is not registered for this init mode'); + } + subscriptions = service; + + const id = getTestConfig().notificationTenantId; + if (!id) { + throw new Error('NOTIFICATION_TEST_TENANT_ID must be set in .env.integration to run subscription tests.'); + } + tenantId = id; + + const { publishers } = await subscriptions.getAll(tenantId); + if (publishers.length === 0) { + throw new Error('No publishers visible to the test user — cannot run subscription tests.'); + } + firstPublisher = publishers[0]; + }); + + describe('getAll', () => { + it('should return the user\'s publishers + topics + subscription state', () => { + expect(firstPublisher).toBeDefined(); + expect(firstPublisher.id).toBeDefined(); + expect(firstPublisher.name).toBeDefined(); + expect(Array.isArray(firstPublisher.topics)).toBe(true); + expect(Array.isArray(firstPublisher.modes)).toBe(true); + expect(typeof firstPublisher.isUserOptin).toBe('boolean'); + }); + + it('should support filtering by publisher names', async () => { + const { publishers } = await subscriptions.getAll(tenantId, { publishers: [firstPublisher.name] }); + + expect(publishers.length).toBeGreaterThanOrEqual(1); + for (const p of publishers) { + expect(p.name).toBe(firstPublisher.name); + } + }); + }); + + describe('getPublishers', () => { + it('should list available publishers with discovery-only fields', async () => { + const { publishers } = await subscriptions.getPublishers(tenantId); + + expect(Array.isArray(publishers)).toBe(true); + expect(publishers.length).toBeGreaterThan(0); + const p = publishers[0]; + expect(p.id).toBeDefined(); + expect(p.name).toBeDefined(); + expect(Array.isArray(p.topics)).toBe(true); + }); + + it('should support filtering by publisher name', async () => { + const { publishers } = await subscriptions.getPublishers(tenantId, { name: firstPublisher.name }); + + expect(publishers.length).toBeGreaterThanOrEqual(1); + expect(publishers[0].name).toBe(firstPublisher.name); + }); + }); + + describe('getSupportedChannels', () => { + it('should return supported channels for the tenant (excluding implicit InApp)', async () => { + const { channels } = await subscriptions.getSupportedChannels(tenantId); + + expect(Array.isArray(channels)).toBe(true); + // InApp is always implicit; the endpoint shouldn't list it + expect(channels.map(c => c.name)).not.toContain(NotificationMode.InApp); + for (const channel of channels) { + expect(channel.name).toBeDefined(); + expect(typeof channel.isEnabled).toBe('boolean'); + } + }); + }); +}); diff --git a/tests/unit/services/notification/subscriptions.test.ts b/tests/unit/services/notification/subscriptions.test.ts new file mode 100644 index 000000000..326f5c51a --- /dev/null +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -0,0 +1,145 @@ +// ===== IMPORTS ===== +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { SubscriptionService } from '../../../../src/services/notification/subscriptions'; +import { ApiClient } from '../../../../src/core/http/api-client'; +import { + createBasicSubscriptionPublisher, + createBasicSupportedChannels, + NOTIFICATION_TEST_CONSTANTS, + TEST_CONSTANTS, + createMockError, +} from '../../../utils/mocks'; +import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; +import { SUBSCRIPTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; +import { TENANT_ID } from '../../../../src/utils/constants/headers'; + +// ===== MOCKING ===== +vi.mock('../../../../src/core/http/api-client'); + +// Shorthand for asserting the tenant header is forwarded on each call +const TENANT_HEADER = { [TENANT_ID]: NOTIFICATION_TEST_CONSTANTS.TENANT_ID }; + +// ===== TEST SUITE ===== +describe('SubscriptionService Unit Tests', () => { + let subscriptionService: SubscriptionService; + let mockApiClient: ReturnType; + + beforeEach(() => { + const { instance } = createServiceTestDependencies(); + mockApiClient = createMockApiClient(); + vi.mocked(ApiClient).mockImplementation(() => mockApiClient as unknown as ApiClient); + + subscriptionService = new SubscriptionService(instance); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getAll', () => { + it('should GET UserSubscription with only tenant header when no publishers filter', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_ALL, { + headers: TENANT_HEADER, + }); + expect(result).toEqual(mockData); + }); + + it('should GET UserSubscription with Publishers param + tenant header when filter supplied', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { + publishers: [NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME_ALT], + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_ALL, + { + headers: TENANT_HEADER, + params: { + Publishers: [ + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME_ALT, + ], + }, + } + ); + expect(result).toEqual(mockData); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND)); + + await expect( + subscriptionService.getAll(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND); + }); + }); + + describe('getPublishers', () => { + it('should GET GetPublishers with only tenant header when no name filter', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith(SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, { + headers: TENANT_HEADER, + }); + expect(result).toEqual(mockData); + }); + + it('should GET GetPublishers with PublisherName param + tenant header when name supplied', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.get.mockResolvedValue(mockData); + + await subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, { + name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + }); + + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_PUBLISHERS, + { headers: TENANT_HEADER, params: { PublisherName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME } } + ); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.getPublishers(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('getSupportedChannels', () => { + it('should GET GetSupportedChannelStatus with tenant header and return channels (InApp not included)', async () => { + const mockData = { channels: createBasicSupportedChannels() }; + mockApiClient.get.mockResolvedValue(mockData); + + const result = await subscriptionService.getSupportedChannels(NOTIFICATION_TEST_CONSTANTS.TENANT_ID); + + expect(mockApiClient.get).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.GET_SUPPORTED_CHANNELS, + { headers: TENANT_HEADER } + ); + expect(result).toEqual(mockData); + // Confirms InApp is intentionally omitted (it's always implicit) + expect(result.channels.length).toBeGreaterThan(0); + expect(result.channels.map(c => c.name)).not.toContain('InApp'); + }); + + it('should propagate errors', async () => { + mockApiClient.get.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.getSupportedChannels(NOTIFICATION_TEST_CONSTANTS.TENANT_ID) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); +}); diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts index 383eca1d5..86c29f666 100644 --- a/tests/utils/constants/notification.ts +++ b/tests/utils/constants/notification.ts @@ -15,9 +15,12 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Publisher / topic IDs used inside notification-entry fixtures PUBLISHER_ID: '44444444-4444-4444-4444-444444444444', PUBLISHER_NAME: 'Orchestrator', + PUBLISHER_DISPLAY_NAME: 'Orchestrator', + PUBLISHER_NAME_ALT: 'Actions', TOPIC_ID: '55555555-5555-4555-8555-555555555555', TOPIC_NAME: 'Process.JobExecution.Faulted', TOPIC_KEY_NAME: 'Process.JobExecution.Faulted', + TOPIC_DISPLAY_NAME: 'Job execution faulted', // User identifier USER_ID: '66666666-6666-4666-8666-666666666666', @@ -32,4 +35,5 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Error messages ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found', + ERROR_PUBLISHER_NOT_FOUND: 'Publisher not found', } as const; diff --git a/tests/utils/mocks/notification.ts b/tests/utils/mocks/notification.ts index b1c6f4425..aa1a61245 100644 --- a/tests/utils/mocks/notification.ts +++ b/tests/utils/mocks/notification.ts @@ -7,8 +7,15 @@ import { NotificationCategory, + NotificationMode, NotificationPriority, } from '../../../src/models/notification'; +import type { + SubscriptionMode, + SubscriptionPublisher, + SubscriptionTopic, + SupportedChannel, +} from '../../../src/models/notification/subscriptions.types'; import type { RawNotificationEntry } from '../../../src/models/notification/notifications.internal-types'; import { NOTIFICATION_TEST_CONSTANTS } from '../constants/notification'; @@ -45,3 +52,58 @@ export const createBasicNotificationEntry = ( partitionKey: 'testorg|testtenant|partition-key-value', ...overrides, }); + +const defaultSubscriptionMode = (name: NotificationMode, isSubscribed = true): SubscriptionMode => ({ + name, + isSubscribed, + isSubscribedByDefault: isSubscribed, +}); + +export const createBasicSubscriptionTopic = ( + overrides?: Partial +): SubscriptionTopic => ({ + id: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + name: NOTIFICATION_TEST_CONSTANTS.TOPIC_NAME, + displayName: NOTIFICATION_TEST_CONSTANTS.TOPIC_DISPLAY_NAME, + description: 'Job execution faulted', + group: 'Process Activities', + parentGroup: null, + category: NotificationCategory.Error, + isSubscribed: true, + isMandatory: false, + isVisible: true, + isDefault: true, + isAllowedToBeDispatchedInBatch: false, + isInfrequent: false, + retentionDays: 30, + orderingSequence: 1, + modes: [ + defaultSubscriptionMode(NotificationMode.InApp, true), + defaultSubscriptionMode(NotificationMode.Email, true), + ], + ...overrides, +}); + +export const createBasicSubscriptionPublisher = ( + overrides?: Partial +): SubscriptionPublisher => ({ + id: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + name: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_NAME, + displayName: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_DISPLAY_NAME, + redirectionUrl: 'https://alpha.uipath.com/', + retentionDays: 30, + addToSummary: false, + isUserOptin: true, + modes: [ + { name: NotificationMode.InApp, isActive: true }, + { name: NotificationMode.Email, isActive: true }, + ], + topics: [createBasicSubscriptionTopic()], + ...overrides, +}); + +export const createBasicSupportedChannels = (): SupportedChannel[] => [ + { name: NotificationMode.Email, isEnabled: true }, + { name: NotificationMode.Slack, isEnabled: false }, + { name: NotificationMode.Teams, isEnabled: false }, +]; From 7105f28c8bfdbb05601213309ce0b950692bcb50 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:07:41 +0530 Subject: [PATCH 5/8] feat(subscriptions): add topic-level updates (updateTopic, updateCategory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/oauth-scopes.md | 2 + .../notification/subscriptions.models.ts | 59 ++++++++++++++ .../notification/subscriptions.types.ts | 26 ++++++ src/services/notification/subscriptions.ts | 61 ++++++++++++++ src/utils/constants/endpoints/notification.ts | 3 + .../subscriptions.integration.test.ts | 27 +++++++ .../notification/subscriptions.test.ts | 80 +++++++++++++++++++ tests/utils/constants/notification.ts | 1 + 8 files changed, 259 insertions(+) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 3fdf6545c..ddf8f98f0 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -227,6 +227,8 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `getAll()` | `NotificationService` | | `getPublishers()` | `NotificationService` | | `getSupportedChannels()` | `NotificationService` | +| `updateTopic()` | `NotificationService` | +| `updateCategory()` | `NotificationService` | ## Processes diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index f18949d91..b28826ef1 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -3,11 +3,15 @@ * that drives generated API documentation. */ +import type { OperationResponse } from '../common/types'; + import type { + CategorySubscriptionUpdate, SubscriptionGetAllOptions, SubscriptionGetPublishersOptions, SubscriptionPublisher, SupportedChannel, + TopicSubscriptionUpdate, } from './subscriptions.types'; /** @@ -30,6 +34,16 @@ export interface SubscriptionGetSupportedChannelsResponse { channels: SupportedChannel[]; } +/** Response from `updateTopic()`. */ +export type SubscriptionUpdateTopicResponse = OperationResponse<{ + subscriptions: TopicSubscriptionUpdate[]; +}>; + +/** Response from `updateCategory()`. */ +export type SubscriptionUpdateCategoryResponse = OperationResponse<{ + subscriptions: CategorySubscriptionUpdate[]; +}>; + /** * Public surface of the Subscriptions service. JSDoc on this interface drives * the generated API reference documentation. @@ -116,4 +130,49 @@ export interface SubscriptionServiceModel { * ``` */ getSupportedChannels(tenantId: string): Promise; + + /** + * Updates topic-level subscription preferences. Each entry sets the subscription state + * (`isSubscribed`) for a single (topic, mode) pair. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Topic subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicResponse} + * + * @example Unsubscribe a topic from a single channel + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateTopic('', [ + * { topicId: '', isSubscribed: false, notificationMode: NotificationMode.Email }, + * ]); + * ``` + */ + updateTopic(tenantId: string, subscriptions: TopicSubscriptionUpdate[]): Promise; + + /** + * Updates category-level subscription preferences for a publisher. Each entry sets + * the subscription state for all topics of a given category via a given mode. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Category subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateCategoryResponse} + * + * @example Unsubscribe from all `Error` topics via email for one publisher + * ```typescript + * import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateCategory('', [ + * { + * publisherId: '', + * category: NotificationCategory.Error, + * isSubscribed: false, + * notificationMode: NotificationMode.Email, + * }, + * ]); + * ``` + */ + updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise; } diff --git a/src/models/notification/subscriptions.types.ts b/src/models/notification/subscriptions.types.ts index ba36fdda8..d8f1d2c11 100644 --- a/src/models/notification/subscriptions.types.ts +++ b/src/models/notification/subscriptions.types.ts @@ -153,6 +153,32 @@ export interface SupportedChannel { isEnabled: boolean; } +/** + * Update payload for a topic-level subscription change. + */ +export interface TopicSubscriptionUpdate { + /** Topic GUID. */ + topicId: string; + /** Whether the user should be subscribed to this topic via the chosen mode. */ + isSubscribed: boolean; + /** Notification channel this update applies to. */ + notificationMode: NotificationMode; +} + +/** + * Update payload for a category-level subscription change. + */ +export interface CategorySubscriptionUpdate { + /** Publisher GUID. */ + publisherId: string; + /** Category to update. */ + category: NotificationCategory; + /** Whether the user should be subscribed to topics of this category via the chosen mode. */ + isSubscribed: boolean; + /** Notification channel this update applies to. */ + notificationMode: NotificationMode; +} + /** * Options for `Subscriptions.getAll()`. */ diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index 68a08debd..08c23c00c 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -6,13 +6,17 @@ import { track } from '../../core/telemetry'; import { BaseService } from '../base'; import type { + CategorySubscriptionUpdate, SubscriptionGetAllOptions, SubscriptionGetPublishersOptions, + TopicSubscriptionUpdate, } from '../../models/notification/subscriptions.types'; import type { SubscriptionGetResponse, SubscriptionGetSupportedChannelsResponse, SubscriptionServiceModel, + SubscriptionUpdateCategoryResponse, + SubscriptionUpdateTopicResponse, } from '../../models/notification/subscriptions.models'; import { SUBSCRIPTION_ENDPOINTS } from '../../utils/constants/endpoints'; @@ -138,4 +142,61 @@ export class SubscriptionService extends BaseService implements SubscriptionServ ); return response.data; } + + /** + * Updates topic-level subscription preferences. Each entry sets the subscription state + * (`isSubscribed`) for a single (topic, mode) pair. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Topic subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateTopicResponse} + * + * @example Unsubscribe a topic from a single channel + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateTopic('', [ + * { topicId: '', isSubscribed: false, notificationMode: NotificationMode.Email }, + * ]); + * ``` + */ + @track('Subscriptions.UpdateTopic') + async updateTopic(tenantId: string, subscriptions: TopicSubscriptionUpdate[]): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, { + userSubscriptions: subscriptions, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { subscriptions } }; + } + + /** + * Updates category-level subscription preferences for a publisher. Each entry sets + * the subscription state for all topics of a given category via a given mode. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param subscriptions - Category subscription updates + * @returns Operation result echoing the submitted updates + * {@link SubscriptionUpdateCategoryResponse} + * + * @example Unsubscribe from all `Error` topics via email for one publisher + * ```typescript + * import { NotificationCategory, NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateCategory('', [ + * { + * publisherId: '', + * category: NotificationCategory.Error, + * isSubscribed: false, + * notificationMode: NotificationMode.Email, + * }, + * ]); + * ``` + */ + @track('Subscriptions.UpdateCategory') + async updateCategory(tenantId: string, subscriptions: CategorySubscriptionUpdate[]): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, { + categorySubscriptions: 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 f70c364bc..9446202e9 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -29,4 +29,7 @@ export const SUBSCRIPTION_ENDPOINTS = { GET_ALL: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription`, GET_PUBLISHERS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetPublishers`, GET_SUPPORTED_CHANNELS: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/GetSupportedChannelStatus`, + // 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`, } as const; diff --git a/tests/integration/shared/notification/subscriptions.integration.test.ts b/tests/integration/shared/notification/subscriptions.integration.test.ts index 072099be5..869314f5f 100644 --- a/tests/integration/shared/notification/subscriptions.integration.test.ts +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -85,4 +85,31 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { } }); }); + + describe('updateTopic', () => { + it('should round-trip a topic subscription change', async () => { + // Find a non-mandatory topic with at least one mode we can toggle + const topic = firstPublisher.topics.find((t) => t.isMandatory === false && (t.modes?.length ?? 0) > 0); + if (!topic) { + throw new Error('No non-mandatory topics with toggleable modes — cannot run updateTopic round-trip.'); + } + const mode = topic.modes![0]; + const originalState = mode.isSubscribed; + + // Flip the state + const flip = await subscriptions.updateTopic(tenantId, [ + { topicId: topic.id, isSubscribed: !originalState, notificationMode: mode.name }, + ]); + expect(flip.success).toBe(true); + + // Restore — leave the tenant in its original state + const restore = await subscriptions.updateTopic(tenantId, [ + { topicId: topic.id, isSubscribed: originalState, notificationMode: mode.name }, + ]); + expect(restore.success).toBe(true); + }); + }); + + // Note: no updateCategory integration test — needs richer tenant fixtures (multi-topic + // category coverage). 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 326f5c51a..8f772dbcb 100644 --- a/tests/unit/services/notification/subscriptions.test.ts +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -12,6 +12,12 @@ import { import { createServiceTestDependencies, createMockApiClient } from '../../../utils/setup'; import { SUBSCRIPTION_ENDPOINTS } from '../../../../src/utils/constants/endpoints'; import { TENANT_ID } from '../../../../src/utils/constants/headers'; +import { + NotificationCategory, + NotificationMode, + type CategorySubscriptionUpdate, + type TopicSubscriptionUpdate, +} from '../../../../src/models/notification'; // ===== MOCKING ===== vi.mock('../../../../src/core/http/api-client'); @@ -142,4 +148,78 @@ describe('SubscriptionService Unit Tests', () => { ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); + + describe('updateTopic', () => { + it('should POST userSubscriptions with tenant header and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: TopicSubscriptionUpdate[] = [ + { + topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + isSubscribed: false, + notificationMode: NotificationMode.Email, + }, + ]; + + const result = await subscriptionService.updateTopic(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_TOPIC, + { userSubscriptions: 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.updateTopic(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ + { + topicId: NOTIFICATION_TEST_CONSTANTS.TOPIC_ID, + isSubscribed: true, + notificationMode: NotificationMode.InApp, + }, + ]) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_SUBSCRIPTION_INVALID); + }); + }); + + describe('updateCategory', () => { + it('should POST categorySubscriptions with tenant header and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const subscriptions: CategorySubscriptionUpdate[] = [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + category: NotificationCategory.Error, + isSubscribed: false, + notificationMode: NotificationMode.Email, + }, + ]; + + const result = await subscriptionService.updateCategory(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, subscriptions); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_CATEGORY, + { categorySubscriptions: 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.updateCategory(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, [ + { + publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + category: NotificationCategory.Info, + isSubscribed: true, + notificationMode: NotificationMode.InApp, + }, + ]) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); }); diff --git a/tests/utils/constants/notification.ts b/tests/utils/constants/notification.ts index 86c29f666..b06dc7f2a 100644 --- a/tests/utils/constants/notification.ts +++ b/tests/utils/constants/notification.ts @@ -36,4 +36,5 @@ export const NOTIFICATION_TEST_CONSTANTS = { // Error messages ERROR_NOTIFICATION_NOT_FOUND: 'Notification not found', ERROR_PUBLISHER_NOT_FOUND: 'Publisher not found', + ERROR_SUBSCRIPTION_INVALID: 'Subscription request is invalid', } as const; 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 6/8] 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 21182c90e2583ee27cea3d8aae24e477375b9e79 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:14:02 +0530 Subject: [PATCH 7/8] feat(subscriptions): add channel mode + reset (updateMode, reset) Final two methods completing the Subscriptions service: - updateMode: activate/deactivate a notification channel (Email/Slack/Teams) for a publisher. Check getSupportedChannels first. - reset: restore a publisher's subscriptions to its defaults, returning the post-reset state. Adds UPDATE_MODE + RESET endpoints, SubscriptionUpdateModeResponse type, oauth-scopes entries, 5 new unit tests, and a reset integration test. This completes the 7-PR stack onboarding the Notification + Subscriptions services (15 methods total). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/oauth-scopes.md | 2 + .../notification/subscriptions.models.ts | 45 ++++++++++ src/services/notification/subscriptions.ts | 53 ++++++++++++ src/utils/constants/endpoints/notification.ts | 2 + .../subscriptions.integration.test.ts | 16 +++- .../notification/subscriptions.test.ts | 82 +++++++++++++++++++ 6 files changed, 197 insertions(+), 3 deletions(-) diff --git a/docs/oauth-scopes.md b/docs/oauth-scopes.md index 7787556ff..bdc894986 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -231,6 +231,8 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `updateCategory()` | `NotificationService` | | `updatePublisher()` | `NotificationService` | | `updateTopicGroup()` | `NotificationService` | +| `updateMode()` | `NotificationService` | +| `reset()` | `NotificationService` | ## Processes diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index cda86722b..b4131b379 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -6,6 +6,7 @@ import type { OperationResponse } from '../common/types'; import type { + AllowedMode, CategorySubscriptionUpdate, PublisherSubscriptionUpdate, SubscriptionGetAllOptions, @@ -56,6 +57,12 @@ export type SubscriptionUpdateTopicGroupResponse = OperationResponse<{ subscriptions: TopicGroupSubscriptionUpdate[]; }>; +/** Response from `updateMode()`. */ +export type SubscriptionUpdateModeResponse = OperationResponse<{ + publisherId: string; + mode: AllowedMode; +}>; + /** * Public surface of the Subscriptions service. JSDoc on this interface drives * the generated API reference documentation. @@ -230,4 +237,42 @@ export interface SubscriptionServiceModel { * ``` */ updateTopicGroup(tenantId: string, subscriptions: TopicGroupSubscriptionUpdate[]): Promise; + + /** + * Activates or deactivates a notification channel for a publisher. + * + * Use {@link getSupportedChannels} first to check whether the channel is enabled in the tenant. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param publisherId - Publisher GUID + * @param mode - Channel and target activation state + * @returns Operation result echoing the new mode state + * {@link SubscriptionUpdateModeResponse} + * + * @example Activate email delivery for a publisher + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateMode('', '', { + * name: NotificationMode.Email, + * isActive: true, + * }); + * ``` + */ + updateMode(tenantId: string, publisherId: string, mode: AllowedMode): Promise; + + /** + * Resets the current user's subscriptions for a publisher to the publisher's defaults. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param publisherId - Publisher GUID + * @returns The publisher's full subscription state after reset + * {@link SubscriptionGetResponse} + * + * @example + * ```typescript + * const { publishers } = await subscriptions.reset('', ''); + * ``` + */ + reset(tenantId: string, publisherId: string): Promise; } diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index 7c7be7546..ef08ffaff 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -6,6 +6,7 @@ import { track } from '../../core/telemetry'; import { BaseService } from '../base'; import type { + AllowedMode, CategorySubscriptionUpdate, PublisherSubscriptionUpdate, SubscriptionGetAllOptions, @@ -18,6 +19,7 @@ import type { SubscriptionGetSupportedChannelsResponse, SubscriptionServiceModel, SubscriptionUpdateCategoryResponse, + SubscriptionUpdateModeResponse, SubscriptionUpdatePublisherResponse, SubscriptionUpdateTopicGroupResponse, SubscriptionUpdateTopicResponse, @@ -263,4 +265,55 @@ export class SubscriptionService extends BaseService implements SubscriptionServ }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); return { success: true, data: { subscriptions } }; } + + /** + * Activates or deactivates a notification channel for a publisher. + * + * Use {@link getSupportedChannels} first to check whether the channel is enabled in the tenant. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param publisherId - Publisher GUID + * @param mode - Channel and target activation state + * @returns Operation result echoing the new mode state + * {@link SubscriptionUpdateModeResponse} + * + * @example Activate email delivery for a publisher + * ```typescript + * import { NotificationMode } from '@uipath/uipath-typescript/notifications'; + * + * await subscriptions.updateMode('', '', { + * name: NotificationMode.Email, + * isActive: true, + * }); + * ``` + */ + @track('Subscriptions.UpdateMode') + async updateMode(tenantId: string, publisherId: string, mode: AllowedMode): Promise { + await this.post(SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, { + publisherId, + publisherMode: mode, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return { success: true, data: { publisherId, mode } }; + } + + /** + * Resets the current user's subscriptions for a publisher to the publisher's defaults. + * + * @param tenantId - Tenant GUID (sent via `X-UIPATH-Internal-TenantId`) + * @param publisherId - Publisher GUID + * @returns The publisher's full subscription state after reset + * {@link SubscriptionGetResponse} + * + * @example + * ```typescript + * const { publishers } = await subscriptions.reset('', ''); + * ``` + */ + @track('Subscriptions.Reset') + async reset(tenantId: string, publisherId: string): Promise { + const response = await this.post(SUBSCRIPTION_ENDPOINTS.RESET, { + publisherId, + }, { headers: createHeaders({ [TENANT_ID]: tenantId }) }); + return response.data; + } } diff --git a/src/utils/constants/endpoints/notification.ts b/src/utils/constants/endpoints/notification.ts index 91adba4c4..5f93dc407 100644 --- a/src/utils/constants/endpoints/notification.ts +++ b/src/utils/constants/endpoints/notification.ts @@ -34,4 +34,6 @@ export const SUBSCRIPTION_ENDPOINTS = { 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`, + UPDATE_MODE: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/UpdateMode`, + RESET: `${SUBSCRIPTION_API_BASE}/api/v1/UserSubscription/Reset`, } as const; diff --git a/tests/integration/shared/notification/subscriptions.integration.test.ts b/tests/integration/shared/notification/subscriptions.integration.test.ts index fbc5e3044..c6610f4fb 100644 --- a/tests/integration/shared/notification/subscriptions.integration.test.ts +++ b/tests/integration/shared/notification/subscriptions.integration.test.ts @@ -126,7 +126,17 @@ describe.each(modes)('Subscriptions - Integration Tests [%s]', (mode) => { }); }); - // 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. + describe('reset', () => { + it('should reset publisher subscriptions and return updated state', async () => { + const result = await subscriptions.reset(tenantId, firstPublisher.id); + + expect(Array.isArray(result.publishers)).toBe(true); + const resetPub = result.publishers.find((p) => p.id === firstPublisher.id); + expect(resetPub).toBeDefined(); + }); + }); + + // Note: no updateCategory / updateTopicGroup / updateMode integration tests — they + // need richer tenant fixtures (multi-topic category coverage, configured topic groups, + // active Slack/Teams channels). 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 14e3d5940..5a5825087 100644 --- a/tests/unit/services/notification/subscriptions.test.ts +++ b/tests/unit/services/notification/subscriptions.test.ts @@ -322,4 +322,86 @@ describe('SubscriptionService Unit Tests', () => { ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); }); }); + + describe('updateMode', () => { + it('should POST publisherId + publisherMode with tenant header and echo input', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const mode = { name: NotificationMode.Email, isActive: true }; + + const result = await subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + mode + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, publisherMode: mode }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual({ + success: true, + data: { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, mode }, + }); + }); + + it('should support deactivating a mode', async () => { + mockApiClient.post.mockResolvedValue(undefined); + const mode = { name: NotificationMode.Slack, isActive: false }; + + await subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + mode + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.UPDATE_MODE, + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, publisherMode: mode }, + { headers: TENANT_HEADER } + ); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(TEST_CONSTANTS.ERROR_MESSAGE)); + + await expect( + subscriptionService.updateMode( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID, + { + name: NotificationMode.InApp, + isActive: true, + } + ) + ).rejects.toThrow(TEST_CONSTANTS.ERROR_MESSAGE); + }); + }); + + describe('reset', () => { + it('should POST publisherId with tenant header and return the publisher subscription state', async () => { + const mockData = { publishers: [createBasicSubscriptionPublisher()] }; + mockApiClient.post.mockResolvedValue(mockData); + + const result = await subscriptionService.reset( + NOTIFICATION_TEST_CONSTANTS.TENANT_ID, + NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID + ); + + expect(mockApiClient.post).toHaveBeenCalledWith( + SUBSCRIPTION_ENDPOINTS.RESET, + { publisherId: NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID }, + { headers: TENANT_HEADER } + ); + expect(result).toEqual(mockData); + }); + + it('should propagate errors', async () => { + mockApiClient.post.mockRejectedValue(createMockError(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND)); + + await expect( + subscriptionService.reset(NOTIFICATION_TEST_CONSTANTS.TENANT_ID, NOTIFICATION_TEST_CONSTANTS.PUBLISHER_ID) + ).rejects.toThrow(NOTIFICATION_TEST_CONSTANTS.ERROR_PUBLISHER_NOT_FOUND); + }); + }); }); From 1f7d4bf79293b60097e41e4d243f88a344adefb7 Mon Sep 17 00:00:00 2001 From: sarthak688 <107241313+sarthak688@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:36:47 +0530 Subject: [PATCH 8/8] fix(subscriptions): tag updateMode/reset @internal + remove oauth-scopes entries Final piece of the @internal treatment across the 7-PR stack. 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 bdc894986..7787556ff 100644 --- a/docs/oauth-scopes.md +++ b/docs/oauth-scopes.md @@ -231,8 +231,6 @@ The `ConversationalAgents` scope is required for real-time WebSocket sessions (` | `updateCategory()` | `NotificationService` | | `updatePublisher()` | `NotificationService` | | `updateTopicGroup()` | `NotificationService` | -| `updateMode()` | `NotificationService` | -| `reset()` | `NotificationService` | ## Processes diff --git a/src/models/notification/subscriptions.models.ts b/src/models/notification/subscriptions.models.ts index b4131b379..59f218e7c 100644 --- a/src/models/notification/subscriptions.models.ts +++ b/src/models/notification/subscriptions.models.ts @@ -258,6 +258,7 @@ export interface SubscriptionServiceModel { * isActive: true, * }); * ``` + * @internal */ updateMode(tenantId: string, publisherId: string, mode: AllowedMode): Promise; @@ -273,6 +274,7 @@ export interface SubscriptionServiceModel { * ```typescript * const { publishers } = await subscriptions.reset('', ''); * ``` + * @internal */ reset(tenantId: string, publisherId: string): Promise; } diff --git a/src/services/notification/subscriptions.ts b/src/services/notification/subscriptions.ts index ef08ffaff..43c10f2f9 100644 --- a/src/services/notification/subscriptions.ts +++ b/src/services/notification/subscriptions.ts @@ -286,6 +286,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * isActive: true, * }); * ``` + * @internal */ @track('Subscriptions.UpdateMode') async updateMode(tenantId: string, publisherId: string, mode: AllowedMode): Promise { @@ -308,6 +309,7 @@ export class SubscriptionService extends BaseService implements SubscriptionServ * ```typescript * const { publishers } = await subscriptions.reset('', ''); * ``` + * @internal */ @track('Subscriptions.Reset') async reset(tenantId: string, publisherId: string): Promise {