@devopsplaybook.io/common-utils 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,7 @@ npm install @devopsplaybook.io/common-utils
33
33
  | `pg` | PostgreSQL client (`Pool`) |
34
34
  | `fs-extra` | File system helpers |
35
35
  | `uuid` | UUID generation for JWT keys |
36
+ | `axios` | HTTP client for the notifications integration |
36
37
 
37
38
  ### Modules
38
39
 
@@ -290,6 +291,48 @@ DbUtilsNoTelemetryBatchInsert(
290
291
 
291
292
  ---
292
293
 
294
+ #### `Notifications` -- Central Notifications Client
295
+
296
+ Fail-safe client for sending notifications to the central notifications service (the `notifications` project). The client never throws when misconfigured: it is simply disabled, the integration status is logged exactly once at construction time, and follow-up `send` calls on a disabled client are silent and resolve to `null`.
297
+
298
+ ```ts
299
+ import { NotificationsClient } from "@devopsplaybook.io/common-utils";
300
+
301
+ const client = new NotificationsClient({
302
+ apiEndpoint: config.NOTIFICATIONS_API,
303
+ apiToken: config.NOTIFICATIONS_TOKEN,
304
+ logger: OTelLogger().createModuleLogger("notifications"),
305
+ });
306
+
307
+ // Optional helpers per severity
308
+ await client.info("Job started", "Nightly sync running", "my-app");
309
+ await client.success("Job finished", "Nightly sync done", "my-app");
310
+ await client.warning("Disk usage high", "85% on /data", "my-app");
311
+ await client.error("Job failed", "Nightly sync crashed", "my-app");
312
+
313
+ // Or a full payload
314
+ await client.send({
315
+ title: "Deployment finished",
316
+ body: "Version 1.2.3 deployed to production",
317
+ source: "my-app",
318
+ severity: "success",
319
+ data: JSON.stringify({ version: "1.2.3" }),
320
+ });
321
+ ```
322
+
323
+ | Export | Description |
324
+ | ---------------------- | ----------------------------------------------------------------- |
325
+ | `NotificationsClient` | HTTP client with `send` plus `info`/`success`/`warning`/`error` helpers |
326
+ | `NotificationsConfig` | `{ apiEndpoint, apiToken, logger? }` constructor configuration |
327
+ | `NotificationPayload` | `{ title, body?, source?, severity?, data? }` request payload |
328
+ | `NotificationResponse` | Shape returned by the notifications API |
329
+ | `NotificationSeverity` | `"info" \| "warning" \| "error" \| "success"` |
330
+ | `NotificationsLogger` | Minimal logger interface (`info`/`warn`/`error`), console by default |
331
+
332
+ **Behaviour when not configured**: when `apiEndpoint` or `apiToken` is empty the client logs `"Notifications integration disabled (...)"` once at construction and every `send`/helper call resolves to `null` without logging, so the parent application never fails.
333
+
334
+ ---
335
+
293
336
  #### `SystemCommand` -- Shell Command Execution
294
337
 
295
338
  ```ts
package/dist/index.d.ts CHANGED
@@ -4,5 +4,6 @@ export * from "./src/DbUtils";
4
4
  export * from "./src/DbUtilsNoTelemetry";
5
5
  export * from "./src/SqlDbUtils";
6
6
  export * from "./src/PostgresDbUtils";
7
+ export * from "./src/Notifications";
7
8
  export * from "./src/SystemCommand";
8
9
  export * from "./src/Timeout";
package/dist/index.js CHANGED
@@ -20,5 +20,6 @@ __exportStar(require("./src/DbUtils"), exports);
20
20
  __exportStar(require("./src/DbUtilsNoTelemetry"), exports);
21
21
  __exportStar(require("./src/SqlDbUtils"), exports);
22
22
  __exportStar(require("./src/PostgresDbUtils"), exports);
23
+ __exportStar(require("./src/Notifications"), exports);
23
24
  __exportStar(require("./src/SystemCommand"), exports);
24
25
  __exportStar(require("./src/Timeout"), exports);
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Severity levels for notifications.
3
+ */
4
+ export type NotificationSeverity = "info" | "warning" | "error" | "success";
5
+ /**
6
+ * Minimal logger interface expected by the notifications client.
7
+ * Matches the subset of the OTel logger used by devopsplaybook.io projects.
8
+ */
9
+ export interface NotificationsLogger {
10
+ info(message: string): void;
11
+ warn(message: string): void;
12
+ error(message: string, err?: unknown): void;
13
+ }
14
+ /**
15
+ * Configuration for {@link NotificationsClient}.
16
+ */
17
+ export interface NotificationsConfig {
18
+ /** API endpoint URL (e.g., "https://notifications.example.com/api/notifications") */
19
+ apiEndpoint: string;
20
+ /** API token used for Bearer authentication */
21
+ apiToken: string;
22
+ /** Optional logger; falls back to console when omitted */
23
+ logger?: NotificationsLogger;
24
+ }
25
+ /**
26
+ * Payload for creating a notification.
27
+ */
28
+ export interface NotificationPayload {
29
+ /** Notification title */
30
+ title: string;
31
+ /** Notification body/content */
32
+ body?: string;
33
+ /** Source identifier (defaults to "api") */
34
+ source?: string;
35
+ /** Severity level (defaults to "info") */
36
+ severity?: NotificationSeverity;
37
+ /** Additional data as a JSON string */
38
+ data?: string;
39
+ }
40
+ /**
41
+ * Response from the notifications API.
42
+ */
43
+ export interface NotificationResponse {
44
+ id: string;
45
+ title: string;
46
+ body: string;
47
+ source: string;
48
+ severity: string;
49
+ data: string;
50
+ createdAt: string;
51
+ }
52
+ /**
53
+ * Client for sending notifications to the central notifications service.
54
+ *
55
+ * The client is fail-safe by design:
56
+ *
57
+ * - It is disabled (and never throws) when `apiEndpoint` or `apiToken` is
58
+ * missing, so a partially configured parent application still starts.
59
+ * - The integration status is logged exactly once, at construction time.
60
+ * - Follow-up `send` calls on a disabled client are silent and resolve to
61
+ * `null`.
62
+ * - Sending errors are logged and resolve to `null` instead of rejecting.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const client = new NotificationsClient({
67
+ * apiEndpoint: config.NOTIFICATIONS_API,
68
+ * apiToken: config.NOTIFICATIONS_TOKEN,
69
+ * logger: OTelLogger().createModuleLogger("notifications"),
70
+ * });
71
+ *
72
+ * await client.send({
73
+ * title: "Deployment finished",
74
+ * body: "Version 1.2.3 deployed to production",
75
+ * source: "my-app",
76
+ * severity: "success",
77
+ * });
78
+ * ```
79
+ */
80
+ export declare class NotificationsClient {
81
+ private client;
82
+ private readonly enabled;
83
+ private readonly logger;
84
+ constructor(config: NotificationsConfig);
85
+ /**
86
+ * Check whether the client is properly configured.
87
+ */
88
+ isEnabled(): boolean;
89
+ /**
90
+ * Send a notification.
91
+ *
92
+ * @param payload The notification payload.
93
+ * @returns The created notification, or `null` when disabled or on failure.
94
+ */
95
+ send(payload: NotificationPayload): Promise<NotificationResponse | null>;
96
+ /**
97
+ * Send an info notification.
98
+ */
99
+ info(title: string, body?: string, source?: string): Promise<NotificationResponse | null>;
100
+ /**
101
+ * Send a success notification.
102
+ */
103
+ success(title: string, body?: string, source?: string): Promise<NotificationResponse | null>;
104
+ /**
105
+ * Send a warning notification.
106
+ */
107
+ warning(title: string, body?: string, source?: string): Promise<NotificationResponse | null>;
108
+ /**
109
+ * Send an error notification.
110
+ */
111
+ error(title: string, body?: string, source?: string): Promise<NotificationResponse | null>;
112
+ }
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.NotificationsClient = void 0;
7
+ const axios_1 = __importDefault(require("axios"));
8
+ /** Console fallback used when no logger is injected. */
9
+ const consoleLogger = {
10
+ info: (message) => console.log(message),
11
+ warn: (message) => console.warn(message),
12
+ error: (message, err) => console.error(message, err),
13
+ };
14
+ /**
15
+ * Client for sending notifications to the central notifications service.
16
+ *
17
+ * The client is fail-safe by design:
18
+ *
19
+ * - It is disabled (and never throws) when `apiEndpoint` or `apiToken` is
20
+ * missing, so a partially configured parent application still starts.
21
+ * - The integration status is logged exactly once, at construction time.
22
+ * - Follow-up `send` calls on a disabled client are silent and resolve to
23
+ * `null`.
24
+ * - Sending errors are logged and resolve to `null` instead of rejecting.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * const client = new NotificationsClient({
29
+ * apiEndpoint: config.NOTIFICATIONS_API,
30
+ * apiToken: config.NOTIFICATIONS_TOKEN,
31
+ * logger: OTelLogger().createModuleLogger("notifications"),
32
+ * });
33
+ *
34
+ * await client.send({
35
+ * title: "Deployment finished",
36
+ * body: "Version 1.2.3 deployed to production",
37
+ * source: "my-app",
38
+ * severity: "success",
39
+ * });
40
+ * ```
41
+ */
42
+ class NotificationsClient {
43
+ constructor(config) {
44
+ this.client = null;
45
+ this.enabled = !!(config.apiEndpoint && config.apiToken);
46
+ this.logger = config.logger || consoleLogger;
47
+ if (this.enabled) {
48
+ this.client = axios_1.default.create({
49
+ baseURL: config.apiEndpoint,
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ Authorization: `Bearer ${config.apiToken}`,
53
+ },
54
+ timeout: 10000,
55
+ });
56
+ this.logger.info("Notifications integration enabled");
57
+ }
58
+ else {
59
+ this.logger.info("Notifications integration disabled (apiEndpoint or apiToken not set)");
60
+ }
61
+ }
62
+ /**
63
+ * Check whether the client is properly configured.
64
+ */
65
+ isEnabled() {
66
+ return this.enabled;
67
+ }
68
+ /**
69
+ * Send a notification.
70
+ *
71
+ * @param payload The notification payload.
72
+ * @returns The created notification, or `null` when disabled or on failure.
73
+ */
74
+ async send(payload) {
75
+ if (!this.enabled || !this.client) {
76
+ return null;
77
+ }
78
+ try {
79
+ const response = await this.client.post("/", {
80
+ title: payload.title,
81
+ body: payload.body || "",
82
+ source: payload.source || "api",
83
+ severity: payload.severity || "info",
84
+ data: payload.data || "{}",
85
+ });
86
+ return response.data;
87
+ }
88
+ catch (err) {
89
+ this.logger.error("NotificationsClient: failed to send notification", err);
90
+ return null;
91
+ }
92
+ }
93
+ /**
94
+ * Send an info notification.
95
+ */
96
+ async info(title, body, source) {
97
+ return this.send({ title, body, source, severity: "info" });
98
+ }
99
+ /**
100
+ * Send a success notification.
101
+ */
102
+ async success(title, body, source) {
103
+ return this.send({ title, body, source, severity: "success" });
104
+ }
105
+ /**
106
+ * Send a warning notification.
107
+ */
108
+ async warning(title, body, source) {
109
+ return this.send({ title, body, source, severity: "warning" });
110
+ }
111
+ /**
112
+ * Send an error notification.
113
+ */
114
+ async error(title, body, source) {
115
+ return this.send({ title, body, source, severity: "error" });
116
+ }
117
+ }
118
+ exports.NotificationsClient = NotificationsClient;
package/index.ts CHANGED
@@ -4,5 +4,6 @@ export * from "./src/DbUtils";
4
4
  export * from "./src/DbUtilsNoTelemetry";
5
5
  export * from "./src/SqlDbUtils";
6
6
  export * from "./src/PostgresDbUtils";
7
+ export * from "./src/Notifications";
7
8
  export * from "./src/SystemCommand";
8
9
  export * from "./src/Timeout";
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/common-utils",
3
- "version": "1.2.3",
4
- "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, system helpers)",
3
+ "version": "1.3.0",
4
+ "description": "Shared utility modules for devopsplaybook.io projects (DB, Config, OTel context, notifications, system helpers)",
5
5
  "keywords": [
6
6
  "Open Telemetry",
7
7
  "OTel",
8
8
  "SQLite",
9
9
  "Postgres",
10
10
  "Config",
11
+ "Notifications",
11
12
  "Utilities"
12
13
  ],
13
14
  "license": "ISC",
@@ -24,6 +25,7 @@
24
25
  "@devopsplaybook.io/otel-utils": "^1.1.2",
25
26
  "@opentelemetry/api": "^1.9.1",
26
27
  "@opentelemetry/sdk-trace-base": "^2.10.0",
28
+ "axios": "^1.19.0",
27
29
  "better-sqlite3": "^13.0.3",
28
30
  "fs-extra": "^11.4.0",
29
31
  "pg": "^8.23.0",
@@ -0,0 +1,265 @@
1
+ jest.mock("axios", () => ({
2
+ create: jest.fn(),
3
+ }));
4
+
5
+ import axios from "axios";
6
+ import {
7
+ NotificationsClient,
8
+ NotificationsLogger,
9
+ } from "./Notifications";
10
+
11
+ const mockedCreate = axios.create as jest.MockedFunction<
12
+ typeof axios.create
13
+ >;
14
+
15
+ /** Logger double that records every call. */
16
+ function createMockLogger(): NotificationsLogger & {
17
+ info: jest.Mock;
18
+ warn: jest.Mock;
19
+ error: jest.Mock;
20
+ } {
21
+ return {
22
+ info: jest.fn(),
23
+ warn: jest.fn(),
24
+ error: jest.fn(),
25
+ };
26
+ }
27
+
28
+ describe("NotificationsClient", () => {
29
+ let mockPost: jest.Mock;
30
+ let mockLogger: ReturnType<typeof createMockLogger>;
31
+
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ mockPost = jest.fn();
35
+ mockedCreate.mockReturnValue({ post: mockPost } as never);
36
+ mockLogger = createMockLogger();
37
+ });
38
+
39
+ describe("constructor", () => {
40
+ it("should be enabled when apiEndpoint and apiToken are set", () => {
41
+ const client = new NotificationsClient({
42
+ apiEndpoint: "http://localhost/api/notifications",
43
+ apiToken: "token",
44
+ logger: mockLogger,
45
+ });
46
+
47
+ expect(client.isEnabled()).toBe(true);
48
+ expect(mockedCreate).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it("should be disabled when apiEndpoint is empty", () => {
52
+ const client = new NotificationsClient({
53
+ apiEndpoint: "",
54
+ apiToken: "token",
55
+ logger: mockLogger,
56
+ });
57
+
58
+ expect(client.isEnabled()).toBe(false);
59
+ expect(mockedCreate).not.toHaveBeenCalled();
60
+ });
61
+
62
+ it("should be disabled when apiToken is empty", () => {
63
+ const client = new NotificationsClient({
64
+ apiEndpoint: "http://localhost/api/notifications",
65
+ apiToken: "",
66
+ logger: mockLogger,
67
+ });
68
+
69
+ expect(client.isEnabled()).toBe(false);
70
+ expect(mockedCreate).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it("should log the enabled status exactly once at construction", () => {
74
+ new NotificationsClient({
75
+ apiEndpoint: "http://localhost/api/notifications",
76
+ apiToken: "token",
77
+ logger: mockLogger,
78
+ });
79
+
80
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
81
+ expect(mockLogger.info).toHaveBeenCalledWith(
82
+ "Notifications integration enabled",
83
+ );
84
+ });
85
+
86
+ it("should log the disabled status exactly once at construction", () => {
87
+ new NotificationsClient({
88
+ apiEndpoint: "",
89
+ apiToken: "",
90
+ logger: mockLogger,
91
+ });
92
+
93
+ expect(mockLogger.info).toHaveBeenCalledTimes(1);
94
+ expect(mockLogger.info).toHaveBeenCalledWith(
95
+ "Notifications integration disabled (apiEndpoint or apiToken not set)",
96
+ );
97
+ });
98
+
99
+ it("should fall back to console logging when no logger is provided", () => {
100
+ const logSpy = jest.spyOn(console, "log").mockImplementation(jest.fn());
101
+
102
+ const client = new NotificationsClient({
103
+ apiEndpoint: "http://localhost/api/notifications",
104
+ apiToken: "token",
105
+ });
106
+
107
+ expect(client.isEnabled()).toBe(true);
108
+ expect(logSpy).toHaveBeenCalledWith(
109
+ "Notifications integration enabled",
110
+ );
111
+
112
+ logSpy.mockRestore();
113
+ });
114
+ });
115
+
116
+ describe("send", () => {
117
+ it("should return null silently when disabled", async () => {
118
+ const client = new NotificationsClient({
119
+ apiEndpoint: "",
120
+ apiToken: "",
121
+ logger: mockLogger,
122
+ });
123
+ jest.clearAllMocks(); // drop the startup log
124
+
125
+ const result = await client.send({ title: "Test" });
126
+
127
+ expect(result).toBeNull();
128
+ expect(mockPost).not.toHaveBeenCalled();
129
+ expect(mockLogger.info).not.toHaveBeenCalled();
130
+ expect(mockLogger.warn).not.toHaveBeenCalled();
131
+ expect(mockLogger.error).not.toHaveBeenCalled();
132
+ });
133
+
134
+ it("should post with default values when payload fields are omitted", async () => {
135
+ const client = new NotificationsClient({
136
+ apiEndpoint: "http://localhost/api/notifications",
137
+ apiToken: "token",
138
+ logger: mockLogger,
139
+ });
140
+ const response = {
141
+ id: "id-1",
142
+ title: "Test",
143
+ body: "",
144
+ source: "api",
145
+ severity: "info",
146
+ data: "{}",
147
+ createdAt: "2026-08-26T00:00:00.000Z",
148
+ };
149
+ mockPost.mockResolvedValue({ data: response });
150
+
151
+ const result = await client.send({ title: "Test" });
152
+
153
+ expect(mockPost).toHaveBeenCalledWith("/", {
154
+ title: "Test",
155
+ body: "",
156
+ source: "api",
157
+ severity: "info",
158
+ data: "{}",
159
+ });
160
+ expect(result).toEqual(response);
161
+ });
162
+
163
+ it("should pass through explicit payload values", async () => {
164
+ const client = new NotificationsClient({
165
+ apiEndpoint: "http://localhost/api/notifications",
166
+ apiToken: "token",
167
+ logger: mockLogger,
168
+ });
169
+ mockPost.mockResolvedValue({ data: {} });
170
+
171
+ await client.send({
172
+ title: "Alert",
173
+ body: "Something happened",
174
+ source: "my-app",
175
+ severity: "error",
176
+ data: '{"key":"value"}',
177
+ });
178
+
179
+ expect(mockPost).toHaveBeenCalledWith("/", {
180
+ title: "Alert",
181
+ body: "Something happened",
182
+ source: "my-app",
183
+ severity: "error",
184
+ data: '{"key":"value"}',
185
+ });
186
+ });
187
+
188
+ it("should return null and log an error when the request fails", async () => {
189
+ const client = new NotificationsClient({
190
+ apiEndpoint: "http://localhost/api/notifications",
191
+ apiToken: "token",
192
+ logger: mockLogger,
193
+ });
194
+ const failure = new Error("network down");
195
+ mockPost.mockRejectedValue(failure);
196
+
197
+ const result = await client.send({ title: "Test" });
198
+
199
+ expect(result).toBeNull();
200
+ expect(mockLogger.error).toHaveBeenCalledWith(
201
+ "NotificationsClient: failed to send notification",
202
+ failure,
203
+ );
204
+ });
205
+
206
+ it("should not reject when the request fails", async () => {
207
+ const client = new NotificationsClient({
208
+ apiEndpoint: "http://localhost/api/notifications",
209
+ apiToken: "token",
210
+ logger: mockLogger,
211
+ });
212
+ mockPost.mockRejectedValue(new Error("network down"));
213
+
214
+ await expect(client.send({ title: "Test" })).resolves.toBeNull();
215
+ });
216
+ });
217
+
218
+ describe("severity helpers", () => {
219
+ it.each([
220
+ ["info", "info"],
221
+ ["success", "success"],
222
+ ["warning", "warning"],
223
+ ["error", "error"],
224
+ ] as const)(
225
+ "should send a %s notification",
226
+ async (method, severity) => {
227
+ const client = new NotificationsClient({
228
+ apiEndpoint: "http://localhost/api/notifications",
229
+ apiToken: "token",
230
+ logger: mockLogger,
231
+ });
232
+ mockPost.mockResolvedValue({ data: {} });
233
+
234
+ await client[method]("Title", "Body", "my-app");
235
+
236
+ expect(mockPost).toHaveBeenCalledWith(
237
+ "/",
238
+ expect.objectContaining({
239
+ title: "Title",
240
+ body: "Body",
241
+ source: "my-app",
242
+ severity,
243
+ }),
244
+ );
245
+ },
246
+ );
247
+
248
+ it("should return null silently from helpers when disabled", async () => {
249
+ const client = new NotificationsClient({
250
+ apiEndpoint: "",
251
+ apiToken: "",
252
+ logger: mockLogger,
253
+ });
254
+ jest.clearAllMocks(); // drop the startup log
255
+
256
+ expect(await client.info("Title")).toBeNull();
257
+ expect(await client.success("Title")).toBeNull();
258
+ expect(await client.warning("Title")).toBeNull();
259
+ expect(await client.error("Title")).toBeNull();
260
+ expect(mockPost).not.toHaveBeenCalled();
261
+ expect(mockLogger.info).not.toHaveBeenCalled();
262
+ expect(mockLogger.error).not.toHaveBeenCalled();
263
+ });
264
+ });
265
+ });
@@ -0,0 +1,201 @@
1
+ import axios, { AxiosInstance } from "axios";
2
+
3
+ /**
4
+ * Severity levels for notifications.
5
+ */
6
+ export type NotificationSeverity = "info" | "warning" | "error" | "success";
7
+
8
+ /**
9
+ * Minimal logger interface expected by the notifications client.
10
+ * Matches the subset of the OTel logger used by devopsplaybook.io projects.
11
+ */
12
+ export interface NotificationsLogger {
13
+ info(message: string): void;
14
+ warn(message: string): void;
15
+ error(message: string, err?: unknown): void;
16
+ }
17
+
18
+ /**
19
+ * Configuration for {@link NotificationsClient}.
20
+ */
21
+ export interface NotificationsConfig {
22
+ /** API endpoint URL (e.g., "https://notifications.example.com/api/notifications") */
23
+ apiEndpoint: string;
24
+ /** API token used for Bearer authentication */
25
+ apiToken: string;
26
+ /** Optional logger; falls back to console when omitted */
27
+ logger?: NotificationsLogger;
28
+ }
29
+
30
+ /**
31
+ * Payload for creating a notification.
32
+ */
33
+ export interface NotificationPayload {
34
+ /** Notification title */
35
+ title: string;
36
+ /** Notification body/content */
37
+ body?: string;
38
+ /** Source identifier (defaults to "api") */
39
+ source?: string;
40
+ /** Severity level (defaults to "info") */
41
+ severity?: NotificationSeverity;
42
+ /** Additional data as a JSON string */
43
+ data?: string;
44
+ }
45
+
46
+ /**
47
+ * Response from the notifications API.
48
+ */
49
+ export interface NotificationResponse {
50
+ id: string;
51
+ title: string;
52
+ body: string;
53
+ source: string;
54
+ severity: string;
55
+ data: string;
56
+ createdAt: string;
57
+ }
58
+
59
+ /** Console fallback used when no logger is injected. */
60
+ const consoleLogger: NotificationsLogger = {
61
+ info: (message: string) => console.log(message),
62
+ warn: (message: string) => console.warn(message),
63
+ error: (message: string, err?: unknown) => console.error(message, err),
64
+ };
65
+
66
+ /**
67
+ * Client for sending notifications to the central notifications service.
68
+ *
69
+ * The client is fail-safe by design:
70
+ *
71
+ * - It is disabled (and never throws) when `apiEndpoint` or `apiToken` is
72
+ * missing, so a partially configured parent application still starts.
73
+ * - The integration status is logged exactly once, at construction time.
74
+ * - Follow-up `send` calls on a disabled client are silent and resolve to
75
+ * `null`.
76
+ * - Sending errors are logged and resolve to `null` instead of rejecting.
77
+ *
78
+ * @example
79
+ * ```ts
80
+ * const client = new NotificationsClient({
81
+ * apiEndpoint: config.NOTIFICATIONS_API,
82
+ * apiToken: config.NOTIFICATIONS_TOKEN,
83
+ * logger: OTelLogger().createModuleLogger("notifications"),
84
+ * });
85
+ *
86
+ * await client.send({
87
+ * title: "Deployment finished",
88
+ * body: "Version 1.2.3 deployed to production",
89
+ * source: "my-app",
90
+ * severity: "success",
91
+ * });
92
+ * ```
93
+ */
94
+ export class NotificationsClient {
95
+ private client: AxiosInstance | null = null;
96
+ private readonly enabled: boolean;
97
+ private readonly logger: NotificationsLogger;
98
+
99
+ constructor(config: NotificationsConfig) {
100
+ this.enabled = !!(config.apiEndpoint && config.apiToken);
101
+ this.logger = config.logger || consoleLogger;
102
+
103
+ if (this.enabled) {
104
+ this.client = axios.create({
105
+ baseURL: config.apiEndpoint,
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Authorization: `Bearer ${config.apiToken}`,
109
+ },
110
+ timeout: 10000,
111
+ });
112
+ this.logger.info("Notifications integration enabled");
113
+ } else {
114
+ this.logger.info(
115
+ "Notifications integration disabled (apiEndpoint or apiToken not set)",
116
+ );
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Check whether the client is properly configured.
122
+ */
123
+ public isEnabled(): boolean {
124
+ return this.enabled;
125
+ }
126
+
127
+ /**
128
+ * Send a notification.
129
+ *
130
+ * @param payload The notification payload.
131
+ * @returns The created notification, or `null` when disabled or on failure.
132
+ */
133
+ public async send(
134
+ payload: NotificationPayload,
135
+ ): Promise<NotificationResponse | null> {
136
+ if (!this.enabled || !this.client) {
137
+ return null;
138
+ }
139
+
140
+ try {
141
+ const response = await this.client.post<NotificationResponse>("/", {
142
+ title: payload.title,
143
+ body: payload.body || "",
144
+ source: payload.source || "api",
145
+ severity: payload.severity || "info",
146
+ data: payload.data || "{}",
147
+ });
148
+ return response.data;
149
+ } catch (err) {
150
+ this.logger.error(
151
+ "NotificationsClient: failed to send notification",
152
+ err,
153
+ );
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Send an info notification.
160
+ */
161
+ public async info(
162
+ title: string,
163
+ body?: string,
164
+ source?: string,
165
+ ): Promise<NotificationResponse | null> {
166
+ return this.send({ title, body, source, severity: "info" });
167
+ }
168
+
169
+ /**
170
+ * Send a success notification.
171
+ */
172
+ public async success(
173
+ title: string,
174
+ body?: string,
175
+ source?: string,
176
+ ): Promise<NotificationResponse | null> {
177
+ return this.send({ title, body, source, severity: "success" });
178
+ }
179
+
180
+ /**
181
+ * Send a warning notification.
182
+ */
183
+ public async warning(
184
+ title: string,
185
+ body?: string,
186
+ source?: string,
187
+ ): Promise<NotificationResponse | null> {
188
+ return this.send({ title, body, source, severity: "warning" });
189
+ }
190
+
191
+ /**
192
+ * Send an error notification.
193
+ */
194
+ public async error(
195
+ title: string,
196
+ body?: string,
197
+ source?: string,
198
+ ): Promise<NotificationResponse | null> {
199
+ return this.send({ title, body, source, severity: "error" });
200
+ }
201
+ }