notifkit 0.1.3 → 0.1.5

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.
Files changed (96) hide show
  1. package/README.md +179 -152
  2. package/dist/index.d.mts +193 -129
  3. package/dist/index.d.mts.map +1 -1
  4. package/dist/index.mjs +1 -1
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/{main-DtHWhueo.mjs → main-40zwq6b0.mjs} +28 -3
  7. package/dist/{main-DtHWhueo.mjs.map → main-40zwq6b0.mjs.map} +1 -1
  8. package/dist/{main-DyfbnJc3.mjs → main-BFre2-HQ.mjs} +2 -2
  9. package/dist/{main-DyfbnJc3.mjs.map → main-BFre2-HQ.mjs.map} +1 -1
  10. package/dist/{main-CAH0_Q6d.mjs → main-BNJtzY61.mjs} +3 -3
  11. package/dist/main-BNJtzY61.mjs.map +1 -0
  12. package/dist/{main-B561M1d3.mjs → main-BOPMYqsW.mjs} +2 -2
  13. package/dist/{main-B561M1d3.mjs.map → main-BOPMYqsW.mjs.map} +1 -1
  14. package/dist/{main-CCfc45ev.mjs → main-CiigNpsP.mjs} +7 -4
  15. package/dist/main-CiigNpsP.mjs.map +1 -0
  16. package/dist/{main-Ce9dcrsg.mjs → main-DeNFQ-UL.mjs} +6 -3
  17. package/dist/{main-Ce9dcrsg.mjs.map → main-DeNFQ-UL.mjs.map} +1 -1
  18. package/dist/{main-B-jwm8ED.mjs → main-DmCPcxOc.mjs} +2 -2
  19. package/dist/{main-B-jwm8ED.mjs.map → main-DmCPcxOc.mjs.map} +1 -1
  20. package/dist/{main-C45e7grq.mjs → main-DvgJSm11.mjs} +2 -2
  21. package/dist/{main-C45e7grq.mjs.map → main-DvgJSm11.mjs.map} +1 -1
  22. package/dist/{src-C-PfEDMY.mjs → src-vG79L-8m.mjs} +57 -26
  23. package/dist/src-vG79L-8m.mjs.map +1 -0
  24. package/drizzle/0002_wide_colleen_wing.sql +2 -0
  25. package/drizzle/0003_skinny_daimon_hellstrom.sql +1 -0
  26. package/drizzle/0004_pretty_bruce_banner.sql +1 -0
  27. package/drizzle/meta/0002_snapshot.json +1460 -0
  28. package/drizzle/meta/0003_snapshot.json +1460 -0
  29. package/drizzle/meta/0004_snapshot.json +1470 -0
  30. package/drizzle/meta/_journal.json +21 -0
  31. package/package.json +7 -1
  32. package/scripts/create-project.mjs +61 -0
  33. package/src/client.ts +412 -0
  34. package/src/config/index.ts +107 -0
  35. package/src/contracts/common.ts +28 -0
  36. package/src/contracts/envelope.ts +31 -0
  37. package/src/contracts/events/notification-ai-pending.ts +18 -0
  38. package/src/contracts/events/notification-canceled.ts +7 -0
  39. package/src/contracts/events/notification-created.ts +14 -0
  40. package/src/contracts/events/notification-delivered.ts +17 -0
  41. package/src/contracts/events/notification-dispatched.ts +45 -0
  42. package/src/contracts/events/notification-enriched.ts +46 -0
  43. package/src/contracts/events/notification-failed.ts +19 -0
  44. package/src/contracts/events/notification-requested.ts +36 -0
  45. package/src/contracts/events/notification-scheduled.ts +9 -0
  46. package/src/contracts/events/notification-skipped.ts +9 -0
  47. package/src/contracts/helpers.ts +21 -0
  48. package/src/contracts/index.ts +46 -0
  49. package/src/contracts/metadata.ts +10 -0
  50. package/src/contracts/registry.ts +88 -0
  51. package/src/contracts/sdk.ts +242 -0
  52. package/src/contracts/streams.ts +62 -0
  53. package/src/db/index.ts +69 -0
  54. package/src/db/schema.ts +412 -0
  55. package/src/idempotency/index.ts +50 -0
  56. package/src/index.ts +19 -0
  57. package/src/logger/index.ts +60 -0
  58. package/src/metrics/index.ts +53 -0
  59. package/src/queue/index.ts +501 -0
  60. package/src/rate-limiter/index.ts +210 -0
  61. package/src/redis/index.ts +89 -0
  62. package/src/repositories/index.ts +1246 -0
  63. package/src/server.ts +277 -0
  64. package/src/services/ai/main.ts +404 -0
  65. package/src/services/api/handlers.ts +1734 -0
  66. package/src/services/api/http.ts +64 -0
  67. package/src/services/api/main.ts +693 -0
  68. package/src/services/api/router.ts +82 -0
  69. package/src/services/delivery/main.ts +842 -0
  70. package/src/services/delivery/throttle.ts +71 -0
  71. package/src/services/engine/main.ts +827 -0
  72. package/src/services/enricher/main.ts +594 -0
  73. package/src/services/events/main.ts +365 -0
  74. package/src/services/scheduler/main.ts +319 -0
  75. package/src/services/workflow/main.ts +627 -0
  76. package/src/shared/batch-processor.ts +67 -0
  77. package/src/shared/cache.ts +47 -0
  78. package/src/shared/circuit-breaker.ts +74 -0
  79. package/src/shared/dataloader.ts +41 -0
  80. package/src/shared/events.ts +3 -0
  81. package/src/shared/index.ts +39 -0
  82. package/src/shared/semaphore.ts +33 -0
  83. package/src/shared/utils.ts +64 -0
  84. package/src/templates/cache.ts +32 -0
  85. package/src/templates/index.ts +69 -0
  86. package/src/templates/render.ts +128 -0
  87. package/src/transport/index.ts +96 -0
  88. package/src/unsubscribe/index.ts +127 -0
  89. package/src/workers/health.ts +31 -0
  90. package/src/workers/index.ts +266 -0
  91. package/src/workflows/index.ts +2 -0
  92. package/src/workflows/registry.ts +21 -0
  93. package/src/workflows/sdk.ts +106 -0
  94. package/dist/main-CAH0_Q6d.mjs.map +0 -1
  95. package/dist/main-CCfc45ev.mjs.map +0 -1
  96. package/dist/src-C-PfEDMY.mjs.map +0 -1
@@ -15,6 +15,27 @@
15
15
  "when": 1786643990078,
16
16
  "tag": "0001_stale_shotgun",
17
17
  "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1788339340939,
23
+ "tag": "0002_wide_colleen_wing",
24
+ "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1788779801992,
30
+ "tag": "0003_skinny_daimon_hellstrom",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "7",
36
+ "when": 1788780502388,
37
+ "tag": "0004_pretty_bruce_banner",
38
+ "breakpoints": true
18
39
  }
19
40
  ]
20
41
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notifkit",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Self-hosted notification infrastructure. One call delivers to email, SMS, push, and webhook — routed by preference, quiet hours, and consent.",
5
5
  "license": "MIT",
6
6
  "author": "devkitshq",
@@ -46,13 +46,19 @@
46
46
  },
47
47
  "files": [
48
48
  "dist",
49
+ "src",
49
50
  "drizzle",
51
+ "scripts/create-project.mjs",
50
52
  "README.md",
51
53
  "LICENSE"
52
54
  ],
55
+ "bin": {
56
+ "notifkit-create-project": "./scripts/create-project.mjs"
57
+ },
53
58
  "scripts": {
54
59
  "build": "tsdown",
55
60
  "dev": "tsdown --watch",
61
+ "create-project": "node scripts/create-project.mjs",
56
62
  "test": "vitest run --coverage",
57
63
  "test:watch": "vitest",
58
64
  "lint": "eslint . --fix",
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Create a project and print its API key.
4
+ *
5
+ * Every `/v1/*` route needs a project API key, and a project API key can only
6
+ * be minted with the admin credential, so this is the one bootstrap step
7
+ * between a running server and the first notification.
8
+ *
9
+ * ADMIN_API_KEY=supersecretkey npm run create-project
10
+ * ADMIN_API_KEY=supersecretkey npx notifkit-create-project "my-app"
11
+ *
12
+ * ADMIN_API_KEY is the same variable the server reads, so pass the value the
13
+ * server was started with.
14
+ */
15
+
16
+ const adminKey = process.env.ADMIN_API_KEY;
17
+ const baseUrl = (process.env.NOTIFKIT_URL || "http://localhost:3000").replace(/\/$/, "");
18
+ const name = process.argv[2] || process.env.PROJECT_NAME || "my-app";
19
+
20
+ function fail(message) {
21
+ console.error(`create-project: ${message}`);
22
+ process.exit(1);
23
+ }
24
+
25
+ if (!adminKey) {
26
+ fail(
27
+ "no admin credential. Set ADMIN_API_KEY to the same value the server was started with:\n" +
28
+ " ADMIN_API_KEY=supersecretkey npm run create-project",
29
+ );
30
+ }
31
+
32
+ let res;
33
+ try {
34
+ res = await fetch(`${baseUrl}/v1/projects`, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${adminKey}` },
37
+ body: JSON.stringify({ name }),
38
+ });
39
+ } catch (err) {
40
+ fail(`could not reach the server at ${baseUrl} — is it running? (${err.message})`);
41
+ }
42
+
43
+ if (res.status === 401) {
44
+ fail("admin credential rejected. It must match the server's ADMIN_API_KEY exactly.");
45
+ }
46
+
47
+ if (res.status === 403) {
48
+ fail("project management is disabled. Start the server with ADMIN_API_KEY set.");
49
+ }
50
+
51
+ if (!res.ok) {
52
+ fail(`server returned ${res.status}: ${await res.text()}`);
53
+ }
54
+
55
+ const { id, apiKey } = await res.json();
56
+
57
+ // The server keeps only a hash of the key, so this is the only time the value
58
+ // itself exists anywhere. Print it in .env form to make that the obvious move.
59
+ console.log(`\nProject "${name}" created. Save the API key now — it is not recoverable.\n`);
60
+ console.log(`NOTIFKIT_PROJECT_ID=${id}`);
61
+ console.log(`NOTIFKIT_API_KEY=${apiKey}\n`);
package/src/client.ts ADDED
@@ -0,0 +1,412 @@
1
+ import type {
2
+ AddUserInput,
3
+ UpdateUserInput,
4
+ AddContactInput,
5
+ SyncTemplatesInput,
6
+ NotifyRequestInput,
7
+ TriggerWorkflowInput,
8
+ CreateWorkflowInput,
9
+ IngestEventInput,
10
+ UpdateProjectInput,
11
+ } from "./contracts/sdk.js";
12
+
13
+ export interface NotifkitClientOptions {
14
+ baseUrl: string;
15
+ headers?: Record<string, string>;
16
+ templates?: SyncTemplatesInput["templates"];
17
+ apiKey?: string;
18
+ }
19
+
20
+ export class NotifkitClient {
21
+ private readonly options: NotifkitClientOptions;
22
+ private readonly baseUrl: string;
23
+ private readonly headers: Record<string, string>;
24
+
25
+ constructor(options: NotifkitClientOptions) {
26
+ this.options = options;
27
+ this.baseUrl = options.baseUrl.replace(/\/$/, "");
28
+ this.headers = {
29
+ "Content-Type": "application/json",
30
+ ...(options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}),
31
+ ...options.headers,
32
+ };
33
+ }
34
+
35
+ private async request<T>(path: string, method: string, body?: unknown): Promise<T> {
36
+ const url = `${this.baseUrl}${path}`;
37
+ const res = await fetch(url, {
38
+ method,
39
+ headers: this.headers,
40
+ body: body ? JSON.stringify(body) : undefined,
41
+ });
42
+
43
+ if (res.status === 204) {
44
+ return undefined as T;
45
+ }
46
+
47
+ const data = await res.json();
48
+ if (!res.ok) {
49
+ const errorMsg =
50
+ (data as any).message || (data as any).error || `Request failed with status ${res.status}`;
51
+ throw new Error(errorMsg);
52
+ }
53
+ return data as T;
54
+ }
55
+
56
+ /** Sync templates with the server. */
57
+ async syncTemplates(input: SyncTemplatesInput): Promise<{ synced: number }> {
58
+ return this.request("/v1/templates", "PUT", input);
59
+ }
60
+
61
+ /** Create/upsert a user profile and contacts. */
62
+ async addUser(input: AddUserInput): Promise<{ id: string }> {
63
+ return this.request("/v1/users", "POST", input);
64
+ }
65
+
66
+ /** Update user profile. */
67
+ async updateUser(id: string, input: UpdateUserInput): Promise<{ id: string }> {
68
+ return this.request(`/v1/users/${id}`, "PATCH", input);
69
+ }
70
+
71
+ /** Delete user profile. */
72
+ async deleteUser(id: string): Promise<void> {
73
+ return this.request(`/v1/users/${id}`, "DELETE");
74
+ }
75
+
76
+ /** Add contact targets to user profile. */
77
+ async addContact(
78
+ userId: string,
79
+ input: AddContactInput,
80
+ ): Promise<{ userId: string; channel: string; target: string }> {
81
+ return this.request(`/v1/users/${userId}/contacts`, "POST", input);
82
+ }
83
+
84
+ /** Delete a specific contact channel target. */
85
+ async deleteContact(userId: string, channel: string, target: string): Promise<void> {
86
+ return this.request(`/v1/users/${userId}/contacts/${channel}/${target}`, "DELETE");
87
+ }
88
+
89
+ /** Request a notification dispatch. */
90
+ async notify(
91
+ input: NotifyRequestInput,
92
+ ): Promise<{ messageId: string; notificationId: string; target: unknown }> {
93
+ return this.request("/v1/notify", "POST", input);
94
+ }
95
+
96
+ /** Trigger a registered background workflow. */
97
+ async triggerWorkflow(
98
+ input: TriggerWorkflowInput,
99
+ ): Promise<{ messageId: string; instanceId: string }> {
100
+ return this.request("/v1/workflows/trigger", "POST", input);
101
+ }
102
+
103
+ /** Create a dynamic JSON workflow definition. */
104
+ async createWorkflow(input: CreateWorkflowInput): Promise<{ name: string }> {
105
+ return this.request("/v1/workflows", "POST", input);
106
+ }
107
+
108
+ /** Ingest an external event into the system to resume workflows or trigger automations. */
109
+ async ingestEvent(input: IngestEventInput): Promise<{ messageId: string; eventId: string }> {
110
+ return this.request("/v1/events", "POST", input);
111
+ }
112
+
113
+ /** Sync templates configured on the client options to the server. */
114
+ async sync(): Promise<{ synced: number }> {
115
+ if (!this.options.templates || this.options.templates.length === 0) {
116
+ return { synced: 0 };
117
+ }
118
+ return this.syncTemplates({ templates: this.options.templates });
119
+ }
120
+
121
+ // ─── Missing Endpoints additions ─────────────────────────────────────────────
122
+
123
+ /** List registered workflow definitions. */
124
+ async listWorkflows(options?: {
125
+ limit?: number;
126
+ search?: string;
127
+ }): Promise<{ workflows: any[] }> {
128
+ const params = new URLSearchParams();
129
+ if (options?.limit) params.set("limit", options.limit.toString());
130
+ if (options?.search) params.set("search", options.search);
131
+ const qs = params.toString();
132
+ return this.request(`/v1/workflows${qs ? `?${qs}` : ""}`, "GET");
133
+ }
134
+
135
+ /** Get a workflow instance by ID. */
136
+ async getWorkflow(instanceId: string): Promise<any> {
137
+ return this.request(`/v1/workflows/instances/${instanceId}`, "GET");
138
+ }
139
+
140
+ /** Cancel a running/suspended workflow instance. */
141
+ async cancelWorkflow(instanceId: string): Promise<void> {
142
+ return this.request(`/v1/workflows/instances/${instanceId}`, "DELETE");
143
+ }
144
+
145
+ /** Get notification logs for the project. */
146
+ async getNotificationLogs(options?: {
147
+ limit?: number;
148
+ cursor?: string;
149
+ templateId?: string;
150
+ workflowInstanceId?: string;
151
+ channel?: string;
152
+ status?: string;
153
+ taskId?: string;
154
+ campaign?: string;
155
+ search?: string;
156
+ }): Promise<{ logs: any[]; nextCursor: string | null }> {
157
+ let url = "/v1/notifications/logs";
158
+ if (options) {
159
+ const params = new URLSearchParams();
160
+ if (options.limit !== undefined) params.append("limit", options.limit.toString());
161
+ if (options.cursor) params.append("cursor", options.cursor);
162
+ if (options.templateId) params.append("templateId", options.templateId);
163
+ if (options.workflowInstanceId)
164
+ params.append("workflowInstanceId", options.workflowInstanceId);
165
+ if (options.channel) params.append("channel", options.channel);
166
+ if (options.status) params.append("status", options.status);
167
+ if (options.taskId) params.append("taskId", options.taskId);
168
+ if (options.campaign) params.append("campaign", options.campaign);
169
+ if (options.search) params.append("search", options.search);
170
+ const str = params.toString();
171
+ if (str) url += `?${str}`;
172
+ }
173
+ return this.request(url, "GET");
174
+ }
175
+
176
+ /** List/paginate users. */
177
+ async listUsers(options?: {
178
+ limit?: number;
179
+ cursor?: string;
180
+ search?: string;
181
+ segment?: string;
182
+ language?: string;
183
+ timezone?: string;
184
+ channel?: string;
185
+ }): Promise<{ users: any[]; nextCursor: string | null }> {
186
+ const params = new URLSearchParams();
187
+ if (options?.limit) params.set("limit", options.limit.toString());
188
+ if (options?.cursor) params.set("cursor", options.cursor);
189
+ if (options?.search) params.set("search", options.search);
190
+ if (options?.segment) params.set("segment", options.segment);
191
+ if (options?.language) params.set("language", options.language);
192
+ if (options?.timezone) params.set("timezone", options.timezone);
193
+ if (options?.channel) params.set("channel", options.channel);
194
+ const qs = params.toString();
195
+ return this.request(`/v1/users${qs ? `?${qs}` : ""}`, "GET");
196
+ }
197
+
198
+ /** Delete a template. */
199
+ async deleteTemplate(id: string): Promise<void> {
200
+ return this.request(`/v1/templates/${id}`, "DELETE");
201
+ }
202
+
203
+ /** Get a user's contacts. */
204
+ async getUserContacts(userId: string): Promise<{ contacts: any[] }> {
205
+ return this.request(`/v1/users/${userId}/contacts`, "GET");
206
+ }
207
+
208
+ /** List projects (Admin only). */
209
+ async listProjects(): Promise<{ projects: any[] }> {
210
+ return this.request("/v1/projects", "GET");
211
+ }
212
+
213
+ /** Delete a project (Admin only). */
214
+ async deleteProject(id: string): Promise<void> {
215
+ return this.request(`/v1/projects/${id}`, "DELETE");
216
+ }
217
+
218
+ /** Create a new project API key (Admin only). */
219
+ async createProjectKey(
220
+ id: string,
221
+ input?: { role?: "admin" | "read_only" },
222
+ ): Promise<{ id: string; apiKey: string; role: string }> {
223
+ return this.request(`/v1/projects/${id}/keys`, "POST", input || {});
224
+ }
225
+
226
+ /** List project API keys (Admin only). */
227
+ async listProjectKeys(id: string): Promise<{ keys: any[] }> {
228
+ return this.request(`/v1/projects/${id}/keys`, "GET");
229
+ }
230
+
231
+ /** Delete a project API key (Admin only). */
232
+ async deleteProjectKey(id: string, keyId: string): Promise<void> {
233
+ return this.request(`/v1/projects/${id}/keys/${keyId}`, "DELETE");
234
+ }
235
+
236
+ /** Update project settings (Admin only). */
237
+ async updateProject(id: string, input: UpdateProjectInput): Promise<{ id: string }> {
238
+ return this.request(`/v1/projects/${id}`, "PATCH", input);
239
+ }
240
+
241
+ /** List unique segment tags. */
242
+ async listSegments(): Promise<{ segments: string[] }> {
243
+ return this.request("/v1/segments", "GET");
244
+ }
245
+
246
+ // ─── Campaigns ───────────────────────────────────────────────────────────────
247
+
248
+ /** List campaign labels seen in the delivery log, most recent activity first. */
249
+ async listCampaigns(options?: {
250
+ limit?: number;
251
+ search?: string;
252
+ channel?: string;
253
+ since?: string | Date;
254
+ until?: string | Date;
255
+ minMessages?: number;
256
+ }): Promise<{
257
+ campaigns: {
258
+ campaign: string;
259
+ messages: number;
260
+ firstSentAt: string;
261
+ lastActivityAt: string;
262
+ }[];
263
+ }> {
264
+ const params = new URLSearchParams();
265
+ if (options?.limit) params.set("limit", String(options.limit));
266
+ if (options?.search) params.set("search", options.search);
267
+ if (options?.channel) params.set("channel", options.channel);
268
+ if (options?.since) {
269
+ params.set(
270
+ "since",
271
+ options.since instanceof Date ? options.since.toISOString() : options.since,
272
+ );
273
+ }
274
+ if (options?.until) {
275
+ params.set(
276
+ "until",
277
+ options.until instanceof Date ? options.until.toISOString() : options.until,
278
+ );
279
+ }
280
+ if (options?.minMessages) params.set("minMessages", String(options.minMessages));
281
+
282
+ const qs = params.toString();
283
+ return this.request(`/v1/campaigns${qs ? `?${qs}` : ""}`, "GET");
284
+ }
285
+
286
+ /** Delivery and engagement funnel for one campaign. */
287
+ async getCampaignStats(campaign: string): Promise<{
288
+ campaign: string;
289
+ totals: Record<string, number | null>;
290
+ byChannel: Record<string, Record<string, number>>;
291
+ engagementTracked: boolean;
292
+ warnings: string[];
293
+ }> {
294
+ return this.request(`/v1/campaigns/${encodeURIComponent(campaign)}/stats`, "GET");
295
+ }
296
+
297
+ // ─── Suppressions ────────────────────────────────────────────────────────────
298
+
299
+ /** List suppressed destinations. */
300
+ async listSuppressions(options?: {
301
+ limit?: number;
302
+ channel?: string;
303
+ reason?: string;
304
+ target?: string;
305
+ }): Promise<{ suppressions: any[] }> {
306
+ const params = new URLSearchParams();
307
+ if (options?.limit) params.set("limit", options.limit.toString());
308
+ if (options?.channel) params.set("channel", options.channel);
309
+ if (options?.reason) params.set("reason", options.reason);
310
+ if (options?.target) params.set("target", options.target);
311
+ const qs = params.toString();
312
+ return this.request(`/v1/suppressions${qs ? `?${qs}` : ""}`, "GET");
313
+ }
314
+
315
+ /** Suppress a destination by hand. */
316
+ async createSuppression(input: {
317
+ channel: string;
318
+ target: string;
319
+ reason?: "unsubscribed" | "complained" | "bounced" | "manual";
320
+ }): Promise<{ channel: string; target: string; reason: string }> {
321
+ return this.request("/v1/suppressions", "POST", input);
322
+ }
323
+
324
+ /** Remove a suppression, re-enabling sends to that destination. */
325
+ async deleteSuppression(channel: string, target: string): Promise<void> {
326
+ return this.request(
327
+ `/v1/suppressions/${encodeURIComponent(channel)}/${encodeURIComponent(target)}`,
328
+ "DELETE",
329
+ );
330
+ }
331
+
332
+ // ─── Notification Status & Cancellation ─────────────────────────────────────
333
+
334
+ /** Get real-time status and delivery logs for a specific notification task. */
335
+ async getNotificationStatus(taskId: string): Promise<{ status: string; logs: any[] }> {
336
+ return this.request(`/v1/notifications/${encodeURIComponent(taskId)}`, "GET");
337
+ }
338
+
339
+ /** Cancel a scheduled notification task. */
340
+ async cancelNotification(taskId: string): Promise<{ success: boolean }> {
341
+ return this.request(`/v1/notifications/${encodeURIComponent(taskId)}`, "DELETE");
342
+ }
343
+
344
+ /** List pending scheduled messages. */
345
+ async getScheduledMessages(): Promise<{ scheduled: any[] }> {
346
+ return this.request("/v1/notifications/scheduled", "GET");
347
+ }
348
+
349
+ // ─── User Profile & Preferences ─────────────────────────────────────────────
350
+
351
+ /** Get user profile and contacts by ID. */
352
+ async getUser(id: string): Promise<any> {
353
+ return this.request(`/v1/users/${encodeURIComponent(id)}`, "GET");
354
+ }
355
+
356
+ /** Get user details including contacts and recent message logs. */
357
+ async getUserDetails(id: string): Promise<any> {
358
+ return this.request(`/v1/users/${encodeURIComponent(id)}/details`, "GET");
359
+ }
360
+
361
+ /** Get user preferences. */
362
+ async getUserPreferences(id: string): Promise<any> {
363
+ return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "GET");
364
+ }
365
+
366
+ /** Update user preferences. */
367
+ async updateUserPreferences(
368
+ id: string,
369
+ preferences: Record<string, any>,
370
+ ): Promise<{ id: string; preferences: any }> {
371
+ return this.request(`/v1/users/${encodeURIComponent(id)}/preferences`, "PATCH", preferences);
372
+ }
373
+
374
+ // ─── Templates Querying ─────────────────────────────────────────────────────
375
+
376
+ /** List all templates for the project. */
377
+ async listTemplates(): Promise<{ templates: any[] }> {
378
+ return this.request("/v1/templates", "GET");
379
+ }
380
+
381
+ /** Get a template by ID. */
382
+ async getTemplate(id: string): Promise<any> {
383
+ return this.request(`/v1/templates/${encodeURIComponent(id)}`, "GET");
384
+ }
385
+
386
+ // ─── System Health, Metrics & DLQ ───────────────────────────────────────────
387
+
388
+ /** Get system health and worker status. */
389
+ async getSystemHealth(): Promise<any> {
390
+ return this.request("/v1/system/health", "GET");
391
+ }
392
+
393
+ /** Get system metrics and queue lengths. */
394
+ async getSystemMetrics(): Promise<any> {
395
+ return this.request("/v1/system/metrics", "GET");
396
+ }
397
+
398
+ /** Get dead-letter queue messages. */
399
+ async getDLQMessages(): Promise<{ messages: any[] }> {
400
+ return this.request("/v1/dlq", "GET");
401
+ }
402
+
403
+ /** Replay a dead-letter queue message. */
404
+ async replayDLQMessage(id: string): Promise<{ success: boolean; replayedId: string }> {
405
+ return this.request("/v1/dlq/replay", "POST", { id });
406
+ }
407
+
408
+ /** Delete a dead-letter queue message. */
409
+ async deleteDLQMessage(id: string): Promise<{ success: boolean }> {
410
+ return this.request(`/v1/dlq/${encodeURIComponent(id)}`, "DELETE");
411
+ }
412
+ }
@@ -0,0 +1,107 @@
1
+ import { config } from "dotenv";
2
+ import { resolve } from "node:path";
3
+ import { z, type ZodTypeAny } from "zod";
4
+ import type { LanguageModel } from "ai";
5
+
6
+ import { ValidationError } from "@/index.js";
7
+
8
+ export function loadEnv(path?: string): void {
9
+ const envPath = path ?? resolve(process.cwd(), ".env");
10
+ config({ path: envPath, override: false });
11
+ }
12
+
13
+ export function parseConfig<TSchema extends ZodTypeAny>(
14
+ schema: TSchema,
15
+ data: unknown,
16
+ ): z.output<TSchema> {
17
+ const result = schema.safeParse(data);
18
+
19
+ if (!result.success) {
20
+ const fields: Record<string, string[]> = {};
21
+
22
+ for (const issue of result.error.issues) {
23
+ const key = issue.path.join(".");
24
+ fields[key] ??= [];
25
+ fields[key].push(issue.message);
26
+ }
27
+
28
+ throw new ValidationError("Configuration validation failed", fields);
29
+ }
30
+
31
+ return result.data;
32
+ }
33
+
34
+ export const baseConfigSchema = z.object({
35
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
36
+ LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
37
+ PORT: z.coerce.number().int().min(1).max(65_535).default(3000),
38
+ HOST: z.string().default("127.0.0.1"),
39
+ REDIS_URL: z.string().url().default("redis://localhost:6379"),
40
+ DATABASE_URL: z.string().url().default("postgres://platform:platform@localhost:5432/notifkit"),
41
+ ADMIN_API_KEY: z.string().optional(),
42
+ WORKER_CONCURRENCY: z.coerce.number().int().min(1).default(10),
43
+ QUEUE_MAX_LEN: z.coerce.number().int().min(1).default(10000000),
44
+ DB_MAX_CONNECTIONS: z.coerce.number().int().min(1).default(2),
45
+ LOG_FLUSH_INTERVAL_MS: z.coerce.number().int().min(50).default(500),
46
+ LOG_BUFFER_MAX_SIZE: z.coerce.number().int().min(100).default(5000),
47
+ SEGMENT_MAX_USERS: z.coerce.number().int().min(1).default(10000),
48
+ /**
49
+ * Externally reachable base URL of this API. Unsubscribe links are built from
50
+ * it, so it must be what an inbox can actually reach — not `HOST`/`PORT`,
51
+ * which describe the bind address behind your proxy.
52
+ */
53
+ PUBLIC_URL: z.string().url().optional(),
54
+ /**
55
+ * Signing key for unsubscribe tokens. Rotating it invalidates every
56
+ * unsubscribe link already sitting in someone's inbox, so treat it as
57
+ * permanent: a dead link means the recipient reaches for the spam button
58
+ * instead, which costs far more than the key ever protected.
59
+ */
60
+ UNSUBSCRIBE_SECRET: z.string().min(16).optional(),
61
+ });
62
+
63
+ export type BaseConfig = z.infer<typeof baseConfigSchema>;
64
+
65
+ let globalConfig: BaseConfig | null = null;
66
+
67
+ export function setGlobalConfig(config: BaseConfig) {
68
+ globalConfig = config;
69
+ }
70
+
71
+ export function readBaseConfig(data: NodeJS.ProcessEnv = process.env): BaseConfig {
72
+ if (globalConfig) {
73
+ return globalConfig;
74
+ }
75
+ return parseConfig(baseConfigSchema, data);
76
+ }
77
+
78
+ export interface RateLimitConfig {
79
+ limit: number;
80
+ windowSeconds: number;
81
+ }
82
+
83
+ export interface AiConfig {
84
+ aiModel?: LanguageModel;
85
+ /** Hard cap on generated tokens per prompt. Bounds cost and email size. */
86
+ maxOutputTokens?: number;
87
+ /** Wall-clock budget for a single generation before it is aborted. */
88
+ timeoutMs?: number;
89
+ /** Max prompts executed for one notification. */
90
+ maxPromptsPerNotification?: number;
91
+ }
92
+
93
+ export const AI_DEFAULTS = {
94
+ maxOutputTokens: 1_000,
95
+ timeoutMs: 30_000,
96
+ maxPromptsPerNotification: 5,
97
+ } as const;
98
+
99
+ let globalAiConfig: AiConfig = {};
100
+
101
+ export function setAiConfig(config: AiConfig) {
102
+ globalAiConfig = config;
103
+ }
104
+
105
+ export function getAiConfig(): AiConfig {
106
+ return globalAiConfig;
107
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+
3
+ export const NotificationChannelSchema = z.enum([
4
+ "email",
5
+ "sms",
6
+ "push",
7
+ "webhook",
8
+ "in-app",
9
+ "telegram",
10
+ "discord",
11
+ "whatsapp",
12
+ "slack",
13
+ ]);
14
+ export type NotificationChannel = z.infer<typeof NotificationChannelSchema>;
15
+
16
+ export const NotificationPrioritySchema = z.enum(["low", "normal", "high", "critical"]);
17
+ export type NotificationPriority = z.infer<typeof NotificationPrioritySchema>;
18
+
19
+ export const NotificationStatusSchema = z.enum([
20
+ "pending",
21
+ "queued",
22
+ "processing",
23
+ "delivered",
24
+ "failed",
25
+ "bounced",
26
+ "suppressed",
27
+ ]);
28
+ export type NotificationStatus = z.infer<typeof NotificationStatusSchema>;