diff --git a/.changeset/notifications-client.md b/.changeset/notifications-client.md new file mode 100644 index 0000000..b3b1d78 --- /dev/null +++ b/.changeset/notifications-client.md @@ -0,0 +1,12 @@ +--- +'@thatopen/services': minor +--- + +Add notification methods to `PlatformClient`: `getNotifications`, +`getUnreadNotificationCount`, `markNotificationsRead`, +`markAllNotificationsRead`, `getNotificationSubscriptions` and +`unsubscribeFromAutomation`. All scoped to the signed-in user via the +bearer token an app already has. + +The notification types mirror the backend's wire DTOs in `src/types`, the +same as every other type here. diff --git a/.changeset/notifications-subscribe-live.md b/.changeset/notifications-subscribe-live.md new file mode 100644 index 0000000..24688ea --- /dev/null +++ b/.changeset/notifications-subscribe-live.md @@ -0,0 +1,10 @@ +--- +'@thatopen/services': minor +--- + +Add `subscribeToAutomation` and `updateAutomationSubscription`, so an app can +create a subscription rather than only listing and cancelling one. + +Add `onNotification`, a live socket subscription for the signed-in user. +Unlike `onExecutionProgress` it stays connected for the session rather than +closing on a terminal event, and it returns a function that disconnects. diff --git a/src/core/client.ts b/src/core/client.ts index 6daf2cf..1835771 100644 --- a/src/core/client.ts +++ b/src/core/client.ts @@ -299,6 +299,15 @@ export class EngineServicesClient { * the new token is picked up on every request — expired tokens no * longer stick around. */ + /** + * Socket origin without namespace or query, for gateways other than the + * execution one. `wsUrl` already carries a token that may be stale when a + * provider is in play, so callers append their own. + */ + protected get socketOrigin(): string { + return this.wsUrl.split('?')[0]; + } + protected async resolveAccessToken(): Promise { return this.accessToken; } diff --git a/src/core/platform-client.live.test.ts b/src/core/platform-client.live.test.ts new file mode 100644 index 0000000..108d50d --- /dev/null +++ b/src/core/platform-client.live.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +const handlers = new Map void>(); +const disconnect = vi.fn(); +const ioMock = vi.fn(() => ({ + on: (event: string, handler: (payload: unknown) => void) => { + handlers.set(event, handler); + }, + disconnect, +})); + +vi.mock('socket.io-client', () => ({ io: (...args: unknown[]) => ioMock(...(args as [])) })); + +const { PlatformClient } = await import('./platform-client'); + +const API = 'https://api.example.com'; + +describe('PlatformClient — live notifications', () => { + let client: InstanceType; + + beforeEach(() => { + handlers.clear(); + ioMock.mockClear(); + disconnect.mockClear(); + client = new PlatformClient('jwt-1', API); + }); + + it('connects to the notifications namespace with the token', async () => { + await client.onNotification(() => {}); + + const url = (ioMock.mock.calls[0] as unknown as string[])[0]; + expect(url).toContain('/notifications'); + expect(url).toContain('accessToken=jwt-1'); + // No /api on a socket URL; that prefix is for REST only. + expect(url).not.toContain('/api/'); + }); + + // A provider-backed client must open the socket with a current token, not + // the one it happened to be constructed with. + it('resolves the token per connection when a provider is used', async () => { + const provider = vi.fn().mockResolvedValue('fresh-token'); + const providerClient = new PlatformClient(provider, API); + + await providerClient.onNotification(() => {}); + + expect(provider).toHaveBeenCalled(); + expect((ioMock.mock.calls[0] as unknown as string[])[0]).toContain( + 'accessToken=fresh-token', + ); + }); + + it('maps each server event onto one callback shape', async () => { + const seen: unknown[] = []; + await client.onNotification((event) => seen.push(event)); + + handlers.get('notification.created')?.({ id: 'n1' }); + handlers.get('notification.read')?.({ id: 'n2' }); + handlers.get('notifications.allRead')?.({ batch: 42 }); + + expect(seen).toEqual([ + { type: 'created', id: 'n1' }, + { type: 'read', id: 'n2' }, + { type: 'allRead', batch: 42 }, + ]); + }); + + it('returns a disconnect function', async () => { + const stop = await client.onNotification(() => {}); + + expect(disconnect).not.toHaveBeenCalled(); + stop(); + expect(disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/platform-client.notifications.test.ts b/src/core/platform-client.notifications.test.ts new file mode 100644 index 0000000..ca41e97 --- /dev/null +++ b/src/core/platform-client.notifications.test.ts @@ -0,0 +1,214 @@ +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, +} from 'vitest'; +import { PlatformClient } from './platform-client'; + +const API = 'https://api.example.com'; +const JWT = 'test-jwt'; + +function okResponse(data: unknown): Response { + return { + ok: true, + status: 200, + statusText: 'OK', + text: async () => JSON.stringify(data), + json: async () => data, + } as unknown as Response; +} + +function callUrl(fetchMock: Mock, index = 0): URL { + return new URL(fetchMock.mock.calls[index][0] as string); +} + +function callInit(fetchMock: Mock, index = 0): RequestInit { + return fetchMock.mock.calls[index][1] as RequestInit; +} + +const emptyPage = { items: [], nextCursor: null }; + +describe('PlatformClient — notifications', () => { + let fetchMock: Mock; + let client: PlatformClient; + + beforeEach(() => { + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + client = new PlatformClient(JWT, API); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getNotifications', () => { + it('reads the account notifications with no query when unpaged', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + + await client.getNotifications(); + + const url = callUrl(fetchMock); + expect(url.pathname).toContain('/notifications'); + expect(url.searchParams.get('cursor')).toBeNull(); + expect(url.searchParams.get('limit')).toBeNull(); + }); + + it('passes cursor and limit through', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + + await client.getNotifications({ cursor: 'abc_123', limit: 50 }); + + const url = callUrl(fetchMock); + expect(url.searchParams.get('cursor')).toBe('abc_123'); + expect(url.searchParams.get('limit')).toBe('50'); + }); + + // The cursor is opaque and round-trips verbatim; anything that mangles it + // silently breaks pagination rather than erroring. + it('does not mangle a cursor containing an ISO timestamp', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + const cursor = '2026-08-12T09:30:00.000Z_6a7c95b780c5fd7e84758c32'; + + await client.getNotifications({ cursor }); + + expect(callUrl(fetchMock).searchParams.get('cursor')).toBe(cursor); + }); + + it('returns the page as sent, ids and timestamps as strings', async () => { + const page = { + items: [ + { + _id: '6a7c95b780c5fd7e84758c32', + accountId: '6a3bb8b0f32c03c0f86897f2', + type: 'automation.run.finished', + category: 'automation', + title: 'Nightly report failed', + body: 'IFC Converter ended with ERROR.', + link: '/dashboard/projects/p1/automation-runs', + muted: false, + readAt: null, + createdAt: '2026-08-12T09:30:00.000Z', + }, + ], + nextCursor: null, + }; + fetchMock.mockResolvedValue(okResponse(page)); + + const result = await client.getNotifications(); + + expect(result).toEqual(page); + expect(typeof result.items[0]._id).toBe('string'); + expect(typeof result.items[0].createdAt).toBe('string'); + }); + }); + + it('unwraps the unread count to a number', async () => { + fetchMock.mockResolvedValue(okResponse({ count: 7 })); + + await expect(client.getUnreadNotificationCount()).resolves.toBe(7); + expect(callUrl(fetchMock).pathname).toContain('/notifications/unread-count'); + }); + + it('marks specific notifications read as a JSON body', async () => { + fetchMock.mockResolvedValue(okResponse({ updated: 2 })); + + const result = await client.markNotificationsRead(['id-1', 'id-2']); + + const init = callInit(fetchMock); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ ids: ['id-1', 'id-2'] }); + expect(result.updated).toBe(2); + }); + + it('marks all read without a body', async () => { + fetchMock.mockResolvedValue(okResponse({ updated: 9 })); + + await client.markAllNotificationsRead(); + + expect(callUrl(fetchMock).pathname).toContain( + '/notifications/mark-all-read', + ); + expect(callInit(fetchMock).method).toBe('POST'); + }); + + it('lists subscriptions', async () => { + fetchMock.mockResolvedValue(okResponse([])); + + await expect(client.getNotificationSubscriptions()).resolves.toEqual([]); + expect(callUrl(fetchMock).pathname).toContain('/notifications/subscriptions'); + }); + + it('unsubscribes by hook id', async () => { + fetchMock.mockResolvedValue(okResponse({ unsubscribed: true })); + + const result = await client.unsubscribeFromAutomation('hook-1'); + + expect(callInit(fetchMock).method).toBe('DELETE'); + expect(callUrl(fetchMock).pathname).toContain( + '/notifications/subscriptions/hook-1', + ); + expect(result.unsubscribed).toBe(true); + }); + + it('sends the bearer token on notification routes', async () => { + fetchMock.mockResolvedValue(okResponse(emptyPage)); + + await client.getNotifications(); + + const headers = callInit(fetchMock).headers as Record; + expect(headers.Authorization).toBe(`Bearer ${JWT}`); + }); + + describe('subscribing to an automation', () => { + const PROJECT = 'proj-1'; + const HOOK = 'hook-1'; + + it('subscribes through the project route', async () => { + fetchMock.mockResolvedValue(okResponse({ subscribed: true })); + + await client.subscribeToAutomation(PROJECT, HOOK, { + filter: 'failures', + channels: { email: true }, + }); + + const init = callInit(fetchMock); + expect(init.method).toBe('POST'); + expect(callUrl(fetchMock).pathname).toContain( + `/project/${PROJECT}/events/hooks/${HOOK}/subscription`, + ); + expect(JSON.parse(init.body as string)).toEqual({ + filter: 'failures', + channels: { email: true }, + }); + }); + + it('sends an empty body when no options are given', async () => { + fetchMock.mockResolvedValue(okResponse({ subscribed: true })); + + await client.subscribeToAutomation(PROJECT, HOOK); + + expect(JSON.parse(callInit(fetchMock).body as string)).toEqual({}); + }); + + // PATCH is a merge server-side, so sending channels alone must not carry + // a filter along with it and reset one that was already set. + it('patches only what it is given', async () => { + fetchMock.mockResolvedValue(okResponse({ updated: true })); + + await client.updateAutomationSubscription(PROJECT, HOOK, { + channels: { email: false }, + }); + + const init = callInit(fetchMock); + expect(init.method).toBe('PATCH'); + expect(JSON.parse(init.body as string)).toEqual({ + channels: { email: false }, + }); + }); + }); +}); \ No newline at end of file diff --git a/src/core/platform-client.ts b/src/core/platform-client.ts index f72d893..e1a3123 100644 --- a/src/core/platform-client.ts +++ b/src/core/platform-client.ts @@ -1,11 +1,40 @@ +import { io } from 'socket.io-client'; import { EngineServicesClient, EngineServicesClientProps, } from './client'; import { Project, ProjectData } from '../types/projects'; import { ThatOpenContext } from '../types/context'; +import { + MarkNotificationsReadResultDto, + NotificationPageDto, + NotificationSubscriptionView, + UnreadCountDto, +} from '../types/notifications'; const PROJECT_PATH = 'project'; +const NOTIFICATION_PATH = 'notifications'; + +/** + * What arrived on the socket. One callback for all three because a bell + * reacts the same way to each: refresh the badge, and the list if open. + * + * `allRead` is one event for the whole sweep rather than one per + * notification, so do not expect an id on it. + */ +export type LiveNotificationEvent = + | { type: 'created'; id: string } + | { type: 'read'; id: string } + | { type: 'allRead'; batch: number }; + +/** Per-automation opt-in. `failures` skips started events entirely. */ +export type NotificationSubscriptionFilterInput = 'all' | 'failures'; + +export interface NotificationSubscriptionInput { + filter?: NotificationSubscriptionFilterInput; + /** Overrides the account-level channel setting for this automation only. */ + channels?: { email?: boolean }; +} /** Scope by which a permission was granted (or `'none'` if denied). */ export type PermissionScope = 'global' | 'project' | 'entity' | 'none'; @@ -187,4 +216,203 @@ export class PlatformClient extends EngineServicesClient { }); return response.results; } + + // ─── Notifications ──────────────────────────────────────────────── + + /** + * Lists the signed-in user's notifications, newest first. + * + * Scoped to whoever the bearer token belongs to — an app cannot read + * anyone else's. Muted notifications are included: muting silences the + * badge and the delivery channels, it does not hide the record. + * + * Paginate by passing the previous response's `nextCursor` back in; it is + * opaque, so do not build one by hand. A null `nextCursor` means the last + * page. + * + * @example Walk every page: + * ```ts + * let cursor: string | undefined; + * do { + * const page = await client.getNotifications({ cursor }); + * render(page.items); + * cursor = page.nextCursor ?? undefined; + * } while (cursor); + * ``` + */ + async getNotifications(params?: { cursor?: string; limit?: number }) { + return await this.request('GET', NOTIFICATION_PATH, { + query: { + ...(params?.cursor !== undefined && { cursor: params.cursor }), + ...(params?.limit !== undefined && { limit: String(params.limit) }), + }, + }); + } + + /** + * Number of unread notifications, excluding muted ones. This is the bell + * badge count, so it is cheap to poll. + */ + async getUnreadNotificationCount() { + const response = await this.request( + 'GET', + `${NOTIFICATION_PATH}/unread-count`, + ); + return response.count; + } + + /** + * Marks specific notifications as read. Ids the caller does not own are + * ignored rather than rejected, so `updated` can be lower than the number + * passed in. + */ + async markNotificationsRead(notificationIds: string[]) { + return await this.request( + 'POST', + `${NOTIFICATION_PATH}/mark-read`, + { + body: JSON.stringify({ ids: notificationIds }), + contentType: 'application/json', + }, + ); + } + + /** Marks every unread notification as read in one call. */ + async markAllNotificationsRead() { + return await this.request( + 'POST', + `${NOTIFICATION_PATH}/mark-all-read`, + ); + } + + /** + * The automations this user has subscribed to. Nobody is subscribed by + * default, so an empty list is the normal state. + */ + async getNotificationSubscriptions() { + return await this.request( + 'GET', + `${NOTIFICATION_PATH}/subscriptions`, + ); + } + + /** + * Removes this user's subscription to one automation. + * + * Unlike subscribing, which happens through the project routes, this works + * for any automation the user is subscribed to and keeps working after the + * automation itself is gone — opting out must never be the thing that + * fails. + */ + async unsubscribeFromAutomation(hookId: string) { + return await this.request<{ unsubscribed: boolean }>( + 'DELETE', + `${NOTIFICATION_PATH}/subscriptions/${hookId}`, + ); + } + + /** + * Subscribes the signed-in user to one project automation's runs. + * + * Nobody is subscribed by default, so this is what makes an automation + * produce notifications for this user at all. Subscribing again is + * harmless: it updates the existing subscription rather than duplicating. + * + * Read-level access to the project is enough. Someone who can see an + * automation can follow it without being able to change it. + * + * @example Follow only the failures, and email me about them: + * ```ts + * await client.subscribeToAutomation(projectId, hookId, { + * filter: 'failures', + * channels: { email: true }, + * }); + * ``` + */ + async subscribeToAutomation( + projectId: string, + hookId: string, + input?: NotificationSubscriptionInput, + ) { + return await this.request<{ subscribed: true }>( + 'POST', + `${PROJECT_PATH}/${projectId}/events/hooks/${hookId}/subscription`, + { + body: JSON.stringify(input ?? {}), + contentType: 'application/json', + }, + ); + } + + /** + * Changes an existing subscription. Only the fields passed are touched, so + * sending `channels` alone leaves the filter as it was. + * + * Throws if the user is not subscribed; use {@link subscribeToAutomation} + * to create one. + */ + async updateAutomationSubscription( + projectId: string, + hookId: string, + changes: NotificationSubscriptionInput, + ) { + return await this.request<{ updated: true }>( + 'PATCH', + `${PROJECT_PATH}/${projectId}/events/hooks/${hookId}/subscription`, + { + body: JSON.stringify(changes), + contentType: 'application/json', + }, + ); + } + + /** + * Listens for this user's notifications in real time. + * + * Unlike {@link EngineServicesClient.onExecutionProgress}, which follows one + * execution and closes when it ends, this stays connected for the session: + * the server puts the socket in a room for the signed-in account and pushes + * anything addressed to them. + * + * The events carry ids rather than the notifications themselves, so treat + * them as a signal to refresh. That keeps a burst cheap and means the + * server is never the source of a stale render. + * + * Requires a user JWT. An API access token is rejected by the gateway, + * because it identifies a token rather than a person. + * + * @returns a function that disconnects. Call it on unmount. + * + * @example + * ```ts + * const stop = await client.onNotification((event) => { + * if (event.type === 'created') refreshBell(); + * }); + * // later + * stop(); + * ``` + */ + async onNotification( + onEvent: (event: LiveNotificationEvent) => void, + ): Promise<() => void> { + // Resolved per connection rather than reused from construction, so a + // provider-backed client opens the socket with a current token. + const token = await this.resolveAccessToken(); + const socket = io( + `${this.socketOrigin}/notifications?accessToken=${encodeURIComponent(token)}`, + { transports: ['websocket'] }, + ); + + socket.on('notification.created', (data: { id: string }) => + onEvent({ type: 'created', id: data?.id }), + ); + socket.on('notification.read', (data: { id: string }) => + onEvent({ type: 'read', id: data?.id }), + ); + socket.on('notifications.allRead', (data: { batch: number }) => + onEvent({ type: 'allRead', batch: data?.batch }), + ); + + return () => socket.disconnect(); + } } diff --git a/src/index.ts b/src/index.ts index ef82edf..6f6bbf0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,4 +10,5 @@ export * from './types/item.dto'; export * from './types/projects'; export * from './types/context'; export * from './types/npm'; +export * from './types/notifications'; export * from './built-in'; diff --git a/src/types/notifications.ts b/src/types/notifications.ts new file mode 100644 index 0000000..8fd2106 --- /dev/null +++ b/src/types/notifications.ts @@ -0,0 +1,76 @@ +// Declared here rather than imported from the backend. +// +// The obvious move is to vendor `src/common/dto` from the backend repo so the +// contract has one definition. It does not work for this package: this repo +// is public and the backend is private, so a clean clone and any fork PR +// could not build, and reaching outside `src` moves tsc's root and reshuffles +// the published `dist` layout, breaking deep imports. +// +// Sharing these properly means publishing the backend's wire DTOs as their +// own package and depending on it normally. Until then this mirrors +// `src/common/dto/notifications.dto.ts` and has to be updated alongside it. + +export type NotificationCategoryDto = 'automation' | 'invitation'; + +export type NotificationTypeDto = + | 'automation.run.started' + | 'automation.run.finished' + | 'invitation.added' + | 'invitation.accepted' + | 'project.role_changed'; + +/** + * One notification as the API returns it. + * + * Ids are strings and timestamps are ISO-8601 strings, because that is what + * JSON carries. The producer payload is deliberately absent: `title`, `body` + * and `link` are built server-side, so the data behind them is an internal + * detail rather than part of this contract. + */ +export interface NotificationDto { + _id: string; + accountId: string; + type: NotificationTypeDto; + category: NotificationCategoryDto; + title: string; + body: string; + link?: string; + /** Silenced by the recipient: still listed, but no badge and no channel. */ + muted: boolean; + readAt: string | null; + createdAt: string; +} + +/** `nextCursor` is opaque — pass it back verbatim. Null means the last page. */ +export interface NotificationPageDto { + items: NotificationDto[]; + nextCursor: string | null; +} + +/** Excludes muted and already-read notifications: this is the bell badge. */ +export interface UnreadCountDto { + count: number; +} + +export interface MarkNotificationsReadResultDto { + updated: number; +} + +/** A user's opt-in to one automation's runs, as the API returns it. */ +export interface NotificationSubscriptionView { + _id: string; + accountId: string; + hookId: string; + /** Absent for personal automations, which belong to an account. */ + projectId?: string; + filter: NotificationSubscriptionFilter; + channels?: { email?: boolean }; + createdAt: string; + updatedAt?: string; +} + +/** + * `failures` suppresses started events entirely and only passes a finished + * run that did not succeed. + */ +export type NotificationSubscriptionFilter = 'all' | 'failures';