@buenojs/bueno 0.8.3 → 0.8.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.
- package/README.md +136 -16
- package/dist/cli/{index.js → bin.js} +3036 -1421
- package/dist/container/index.js +250 -0
- package/dist/context/index.js +219 -0
- package/dist/database/index.js +493 -0
- package/dist/frontend/index.js +7697 -0
- package/dist/health/index.js +364 -0
- package/dist/i18n/index.js +345 -0
- package/dist/index.js +11043 -6482
- package/dist/jobs/index.js +819 -0
- package/dist/lock/index.js +367 -0
- package/dist/logger/index.js +281 -0
- package/dist/metrics/index.js +289 -0
- package/dist/middleware/index.js +77 -0
- package/dist/migrations/index.js +571 -0
- package/dist/modules/index.js +3346 -0
- package/dist/notification/index.js +484 -0
- package/dist/observability/index.js +331 -0
- package/dist/openapi/index.js +776 -0
- package/dist/orm/index.js +1356 -0
- package/dist/router/index.js +886 -0
- package/dist/rpc/index.js +691 -0
- package/dist/schema/index.js +400 -0
- package/dist/telemetry/index.js +595 -0
- package/dist/template/index.js +640 -0
- package/dist/templates/index.js +640 -0
- package/dist/testing/index.js +1111 -0
- package/dist/types/index.js +60 -0
- package/package.json +121 -27
- package/src/cache/index.ts +2 -1
- package/src/cli/bin.ts +2 -2
- package/src/cli/commands/build.ts +183 -165
- package/src/cli/commands/dev.ts +96 -89
- package/src/cli/commands/generate.ts +142 -111
- package/src/cli/commands/help.ts +20 -16
- package/src/cli/commands/index.ts +3 -6
- package/src/cli/commands/migration.ts +124 -105
- package/src/cli/commands/new.ts +392 -438
- package/src/cli/commands/start.ts +81 -79
- package/src/cli/core/args.ts +68 -50
- package/src/cli/core/console.ts +89 -95
- package/src/cli/core/index.ts +4 -4
- package/src/cli/core/prompt.ts +65 -62
- package/src/cli/core/spinner.ts +23 -20
- package/src/cli/index.ts +46 -38
- package/src/cli/templates/database/index.ts +61 -0
- package/src/cli/templates/database/mysql.ts +14 -0
- package/src/cli/templates/database/none.ts +16 -0
- package/src/cli/templates/database/postgresql.ts +14 -0
- package/src/cli/templates/database/sqlite.ts +14 -0
- package/src/cli/templates/deploy.ts +29 -26
- package/src/cli/templates/docker.ts +41 -30
- package/src/cli/templates/frontend/index.ts +63 -0
- package/src/cli/templates/frontend/none.ts +17 -0
- package/src/cli/templates/frontend/react.ts +140 -0
- package/src/cli/templates/frontend/solid.ts +134 -0
- package/src/cli/templates/frontend/svelte.ts +131 -0
- package/src/cli/templates/frontend/vue.ts +130 -0
- package/src/cli/templates/generators/index.ts +339 -0
- package/src/cli/templates/generators/types.ts +56 -0
- package/src/cli/templates/index.ts +35 -2
- package/src/cli/templates/project/api.ts +81 -0
- package/src/cli/templates/project/default.ts +140 -0
- package/src/cli/templates/project/fullstack.ts +111 -0
- package/src/cli/templates/project/index.ts +95 -0
- package/src/cli/templates/project/minimal.ts +45 -0
- package/src/cli/templates/project/types.ts +94 -0
- package/src/cli/templates/project/website.ts +263 -0
- package/src/cli/utils/fs.ts +55 -41
- package/src/cli/utils/index.ts +3 -2
- package/src/cli/utils/strings.ts +47 -33
- package/src/cli/utils/version.ts +47 -0
- package/src/config/env-validation.ts +100 -0
- package/src/config/env.ts +169 -41
- package/src/config/index.ts +28 -20
- package/src/config/loader.ts +25 -16
- package/src/config/merge.ts +21 -10
- package/src/config/types.ts +545 -25
- package/src/config/validation.ts +215 -7
- package/src/container/forward-ref.ts +22 -22
- package/src/container/index.ts +34 -12
- package/src/context/index.ts +11 -1
- package/src/database/index.ts +7 -190
- package/src/database/orm/builder.ts +457 -0
- package/src/database/orm/casts/index.ts +130 -0
- package/src/database/orm/casts/types.ts +25 -0
- package/src/database/orm/compiler.ts +304 -0
- package/src/database/orm/hooks/index.ts +114 -0
- package/src/database/orm/index.ts +61 -0
- package/src/database/orm/model-registry.ts +59 -0
- package/src/database/orm/model.ts +821 -0
- package/src/database/orm/relationships/base.ts +146 -0
- package/src/database/orm/relationships/belongs-to-many.ts +179 -0
- package/src/database/orm/relationships/belongs-to.ts +56 -0
- package/src/database/orm/relationships/has-many.ts +45 -0
- package/src/database/orm/relationships/has-one.ts +41 -0
- package/src/database/orm/relationships/index.ts +11 -0
- package/src/database/orm/scopes/index.ts +55 -0
- package/src/events/__tests__/event-system.test.ts +235 -0
- package/src/events/config.ts +238 -0
- package/src/events/example-usage.ts +185 -0
- package/src/events/index.ts +278 -0
- package/src/events/manager.ts +385 -0
- package/src/events/registry.ts +182 -0
- package/src/events/types.ts +124 -0
- package/src/frontend/api-routes.ts +65 -23
- package/src/frontend/bundler.ts +76 -34
- package/src/frontend/console-client.ts +2 -2
- package/src/frontend/console-stream.ts +94 -38
- package/src/frontend/dev-server.ts +94 -46
- package/src/frontend/file-router.ts +61 -19
- package/src/frontend/frameworks/index.ts +37 -10
- package/src/frontend/frameworks/react.ts +10 -8
- package/src/frontend/frameworks/solid.ts +11 -9
- package/src/frontend/frameworks/svelte.ts +15 -9
- package/src/frontend/frameworks/vue.ts +13 -11
- package/src/frontend/hmr-client.ts +12 -10
- package/src/frontend/hmr.ts +146 -103
- package/src/frontend/index.ts +14 -5
- package/src/frontend/islands.ts +41 -22
- package/src/frontend/isr.ts +59 -37
- package/src/frontend/layout.ts +36 -21
- package/src/frontend/ssr/react.ts +74 -27
- package/src/frontend/ssr/solid.ts +54 -20
- package/src/frontend/ssr/svelte.ts +48 -14
- package/src/frontend/ssr/vue.ts +50 -18
- package/src/frontend/ssr.ts +83 -39
- package/src/frontend/types.ts +91 -56
- package/src/health/index.ts +21 -9
- package/src/i18n/engine.ts +305 -0
- package/src/i18n/index.ts +38 -0
- package/src/i18n/loader.ts +218 -0
- package/src/i18n/middleware.ts +164 -0
- package/src/i18n/negotiator.ts +162 -0
- package/src/i18n/types.ts +158 -0
- package/src/index.ts +179 -27
- package/src/jobs/drivers/memory.ts +315 -0
- package/src/jobs/drivers/redis.ts +459 -0
- package/src/jobs/index.ts +30 -0
- package/src/jobs/queue.ts +281 -0
- package/src/jobs/types.ts +295 -0
- package/src/jobs/worker.ts +380 -0
- package/src/logger/index.ts +1 -3
- package/src/logger/transports/index.ts +62 -22
- package/src/metrics/index.ts +25 -16
- package/src/migrations/index.ts +9 -0
- package/src/modules/filters.ts +13 -17
- package/src/modules/guards.ts +49 -26
- package/src/modules/index.ts +409 -298
- package/src/modules/interceptors.ts +58 -20
- package/src/modules/lazy.ts +11 -19
- package/src/modules/lifecycle.ts +15 -7
- package/src/modules/metadata.ts +15 -5
- package/src/modules/pipes.ts +94 -72
- package/src/notification/channels/base.ts +68 -0
- package/src/notification/channels/email.ts +105 -0
- package/src/notification/channels/push.ts +104 -0
- package/src/notification/channels/sms.ts +105 -0
- package/src/notification/channels/whatsapp.ts +104 -0
- package/src/notification/index.ts +48 -0
- package/src/notification/service.ts +354 -0
- package/src/notification/types.ts +344 -0
- package/src/observability/__tests__/observability.test.ts +483 -0
- package/src/observability/breadcrumbs.ts +114 -0
- package/src/observability/index.ts +136 -0
- package/src/observability/interceptor.ts +85 -0
- package/src/observability/service.ts +303 -0
- package/src/observability/trace.ts +37 -0
- package/src/observability/types.ts +196 -0
- package/src/openapi/__tests__/decorators.test.ts +335 -0
- package/src/openapi/__tests__/document-builder.test.ts +285 -0
- package/src/openapi/__tests__/route-scanner.test.ts +334 -0
- package/src/openapi/__tests__/schema-generator.test.ts +275 -0
- package/src/openapi/decorators.ts +328 -0
- package/src/openapi/document-builder.ts +274 -0
- package/src/openapi/index.ts +112 -0
- package/src/openapi/metadata.ts +112 -0
- package/src/openapi/route-scanner.ts +289 -0
- package/src/openapi/schema-generator.ts +256 -0
- package/src/openapi/swagger-module.ts +166 -0
- package/src/openapi/types.ts +398 -0
- package/src/orm/index.ts +10 -0
- package/src/rpc/index.ts +3 -1
- package/src/schema/index.ts +9 -0
- package/src/security/index.ts +15 -6
- package/src/ssg/index.ts +9 -8
- package/src/telemetry/index.ts +76 -22
- package/src/template/index.ts +7 -0
- package/src/templates/engine.ts +224 -0
- package/src/templates/index.ts +9 -0
- package/src/templates/loader.ts +331 -0
- package/src/templates/renderers/markdown.ts +212 -0
- package/src/templates/renderers/simple.ts +269 -0
- package/src/templates/types.ts +154 -0
- package/src/testing/index.ts +100 -27
- package/src/types/optional-deps.d.ts +347 -187
- package/src/validation/index.ts +92 -2
- package/src/validation/schemas.ts +536 -0
- package/tests/integration/fullstack.test.ts +4 -4
- package/tests/unit/database.test.ts +2 -72
- package/tests/unit/env-validation.test.ts +166 -0
- package/tests/unit/events.test.ts +910 -0
- package/tests/unit/i18n.test.ts +455 -0
- package/tests/unit/jobs.test.ts +493 -0
- package/tests/unit/notification.test.ts +988 -0
- package/tests/unit/observability.test.ts +453 -0
- package/tests/unit/orm/builder.test.ts +323 -0
- package/tests/unit/orm/casts.test.ts +179 -0
- package/tests/unit/orm/compiler.test.ts +220 -0
- package/tests/unit/orm/eager-loading.test.ts +285 -0
- package/tests/unit/orm/hooks.test.ts +191 -0
- package/tests/unit/orm/model.test.ts +373 -0
- package/tests/unit/orm/relationships.test.ts +303 -0
- package/tests/unit/orm/scopes.test.ts +74 -0
- package/tests/unit/templates-simple.test.ts +53 -0
- package/tests/unit/templates.test.ts +454 -0
- package/tests/unit/validation.test.ts +18 -24
- package/tsconfig.json +11 -3
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS Channel Service
|
|
3
|
+
*
|
|
4
|
+
* Sends notifications via SMS through various drivers
|
|
5
|
+
* (Twilio, AWS SNS, custom)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SMSChannelConfig, SMSMessage } from "../types";
|
|
9
|
+
import { BaseChannelService } from "./base";
|
|
10
|
+
|
|
11
|
+
// ============= SMS Channel Service =============
|
|
12
|
+
|
|
13
|
+
export class SMSChannelService extends BaseChannelService<SMSMessage> {
|
|
14
|
+
readonly name = "sms";
|
|
15
|
+
private config: SMSChannelConfig;
|
|
16
|
+
private sentCount = 0;
|
|
17
|
+
private failureCount = 0;
|
|
18
|
+
|
|
19
|
+
constructor(config: SMSChannelConfig) {
|
|
20
|
+
super();
|
|
21
|
+
this.config = config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate SMS message structure
|
|
26
|
+
*/
|
|
27
|
+
validate(message: unknown): asserts message is SMSMessage {
|
|
28
|
+
if (typeof message !== "object" || message === null) {
|
|
29
|
+
throw new Error("Invalid SMS message: must be an object");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const msg = message as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
if (msg.channel !== "sms") {
|
|
35
|
+
throw new Error('Invalid SMS message: channel must be "sms"');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof msg.recipient !== "string" || msg.recipient.length === 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Invalid SMS message: recipient (phone number) is required",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof msg.message !== "string" || msg.message.length === 0) {
|
|
45
|
+
throw new Error("Invalid SMS message: message is required");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (msg.message.length > 160) {
|
|
49
|
+
console.warn(
|
|
50
|
+
`SMS message exceeds 160 characters (${msg.message.length}), will be split`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Send SMS message
|
|
57
|
+
*/
|
|
58
|
+
async send(message: SMSMessage): Promise<string | undefined> {
|
|
59
|
+
try {
|
|
60
|
+
if (this.config.dryRun) {
|
|
61
|
+
console.log(`[SMSChannel] Would send SMS to: ${message.recipient}`);
|
|
62
|
+
console.log(` Message: ${message.message.substring(0, 50)}...`);
|
|
63
|
+
this.sentCount++;
|
|
64
|
+
return this._generateMessageId();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// In a real implementation, delegate to appropriate driver
|
|
68
|
+
// For now, simulate sending
|
|
69
|
+
const messageId = this._generateMessageId();
|
|
70
|
+
console.log(
|
|
71
|
+
`[SMSChannel] SMS sent: ${messageId} to ${message.recipient}`,
|
|
72
|
+
);
|
|
73
|
+
this.sentCount++;
|
|
74
|
+
|
|
75
|
+
return messageId;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
this.failureCount++;
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Failed to send SMS: ${error instanceof Error ? error.message : String(error)}`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get SMS channel metrics
|
|
86
|
+
*/
|
|
87
|
+
getMetrics() {
|
|
88
|
+
const total = this.sentCount + this.failureCount;
|
|
89
|
+
return {
|
|
90
|
+
sent: this.sentCount,
|
|
91
|
+
failed: this.failureCount,
|
|
92
|
+
total,
|
|
93
|
+
successRate: total > 0 ? this.sentCount / total : 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate a message ID
|
|
99
|
+
*/
|
|
100
|
+
private _generateMessageId(): string {
|
|
101
|
+
const timestamp = Date.now();
|
|
102
|
+
const random = Math.random().toString(36).substring(2, 15);
|
|
103
|
+
return `sms_${timestamp}_${random}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp Channel Service
|
|
3
|
+
*
|
|
4
|
+
* Sends notifications via WhatsApp through various drivers
|
|
5
|
+
* (Twilio, custom)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { WhatsAppChannelConfig, WhatsAppMessage } from "../types";
|
|
9
|
+
import { BaseChannelService } from "./base";
|
|
10
|
+
|
|
11
|
+
// ============= WhatsApp Channel Service =============
|
|
12
|
+
|
|
13
|
+
export class WhatsAppChannelService extends BaseChannelService<WhatsAppMessage> {
|
|
14
|
+
readonly name = "whatsapp";
|
|
15
|
+
private config: WhatsAppChannelConfig;
|
|
16
|
+
private sentCount = 0;
|
|
17
|
+
private failureCount = 0;
|
|
18
|
+
|
|
19
|
+
constructor(config: WhatsAppChannelConfig) {
|
|
20
|
+
super();
|
|
21
|
+
this.config = config;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Validate WhatsApp message structure
|
|
26
|
+
*/
|
|
27
|
+
validate(message: unknown): asserts message is WhatsAppMessage {
|
|
28
|
+
if (typeof message !== "object" || message === null) {
|
|
29
|
+
throw new Error("Invalid WhatsApp message: must be an object");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const msg = message as Record<string, unknown>;
|
|
33
|
+
|
|
34
|
+
if (msg.channel !== "whatsapp") {
|
|
35
|
+
throw new Error('Invalid WhatsApp message: channel must be "whatsapp"');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (typeof msg.recipient !== "string" || msg.recipient.length === 0) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
"Invalid WhatsApp message: recipient (phone number) is required",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (typeof msg.templateId !== "string" || msg.templateId.length === 0) {
|
|
45
|
+
throw new Error("Invalid WhatsApp message: templateId is required");
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Send WhatsApp message
|
|
51
|
+
*/
|
|
52
|
+
async send(message: WhatsAppMessage): Promise<string | undefined> {
|
|
53
|
+
try {
|
|
54
|
+
if (this.config.dryRun) {
|
|
55
|
+
console.log(
|
|
56
|
+
`[WhatsAppChannel] Would send WhatsApp to: ${message.recipient}`,
|
|
57
|
+
);
|
|
58
|
+
console.log(` Template: ${message.templateId}`);
|
|
59
|
+
console.log(
|
|
60
|
+
` Parameters: ${JSON.stringify(message.parameters || {})}`,
|
|
61
|
+
);
|
|
62
|
+
this.sentCount++;
|
|
63
|
+
return this._generateMessageId();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// In a real implementation, delegate to appropriate driver
|
|
67
|
+
// For now, simulate sending
|
|
68
|
+
const messageId = this._generateMessageId();
|
|
69
|
+
console.log(
|
|
70
|
+
`[WhatsAppChannel] WhatsApp sent: ${messageId} to ${message.recipient}`,
|
|
71
|
+
);
|
|
72
|
+
this.sentCount++;
|
|
73
|
+
|
|
74
|
+
return messageId;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.failureCount++;
|
|
77
|
+
throw new Error(
|
|
78
|
+
`Failed to send WhatsApp: ${error instanceof Error ? error.message : String(error)}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get WhatsApp channel metrics
|
|
85
|
+
*/
|
|
86
|
+
getMetrics() {
|
|
87
|
+
const total = this.sentCount + this.failureCount;
|
|
88
|
+
return {
|
|
89
|
+
sent: this.sentCount,
|
|
90
|
+
failed: this.failureCount,
|
|
91
|
+
total,
|
|
92
|
+
successRate: total > 0 ? this.sentCount / total : 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Generate a message ID
|
|
98
|
+
*/
|
|
99
|
+
private _generateMessageId(): string {
|
|
100
|
+
const timestamp = Date.now();
|
|
101
|
+
const random = Math.random().toString(36).substring(2, 15);
|
|
102
|
+
return `wa_${timestamp}_${random}`;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Module
|
|
3
|
+
*
|
|
4
|
+
* Multi-channel notification system with support for email, SMS, WhatsApp,
|
|
5
|
+
* push notifications, and custom channels. Uses registry pattern for
|
|
6
|
+
* dynamic channel registration.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Export types
|
|
10
|
+
export type {
|
|
11
|
+
NotificationChannel,
|
|
12
|
+
NotificationMessage,
|
|
13
|
+
EmailRecipient,
|
|
14
|
+
EmailRecipients,
|
|
15
|
+
EmailAttachment,
|
|
16
|
+
EmailMessage,
|
|
17
|
+
SMSMessage,
|
|
18
|
+
WhatsAppMessage,
|
|
19
|
+
PushNotificationMessage,
|
|
20
|
+
BuiltInNotification,
|
|
21
|
+
Notifiable,
|
|
22
|
+
ChannelHealth,
|
|
23
|
+
ChannelService,
|
|
24
|
+
BaseChannelConfig,
|
|
25
|
+
EmailChannelConfig,
|
|
26
|
+
SMSChannelConfig,
|
|
27
|
+
WhatsAppChannelConfig,
|
|
28
|
+
PushChannelConfig,
|
|
29
|
+
ChannelConfig,
|
|
30
|
+
NotificationServiceConfig,
|
|
31
|
+
ChannelMetrics,
|
|
32
|
+
NotificationEvent,
|
|
33
|
+
NotificationEventType,
|
|
34
|
+
NotificationValidationResult,
|
|
35
|
+
StandardSchema,
|
|
36
|
+
} from "./types";
|
|
37
|
+
|
|
38
|
+
// Export service
|
|
39
|
+
export { NotificationService, createNotificationService } from "./service";
|
|
40
|
+
|
|
41
|
+
// Export base channel
|
|
42
|
+
export { BaseChannelService } from "./channels/base";
|
|
43
|
+
|
|
44
|
+
// Export built-in channels
|
|
45
|
+
export { EmailChannelService } from "./channels/email";
|
|
46
|
+
export { SMSChannelService } from "./channels/sms";
|
|
47
|
+
export { WhatsAppChannelService } from "./channels/whatsapp";
|
|
48
|
+
export { PushNotificationChannelService } from "./channels/push";
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Service
|
|
3
|
+
*
|
|
4
|
+
* Core notification service with registry-based channel management.
|
|
5
|
+
* Supports dynamic channel registration and multi-channel notification sending.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ChannelHealth,
|
|
10
|
+
ChannelMetrics,
|
|
11
|
+
ChannelService,
|
|
12
|
+
Notifiable,
|
|
13
|
+
NotificationChannel,
|
|
14
|
+
NotificationMessage,
|
|
15
|
+
NotificationServiceConfig,
|
|
16
|
+
TemplateRef,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { isTemplateRef } from "./types";
|
|
19
|
+
|
|
20
|
+
// ============= Notification Service =============
|
|
21
|
+
|
|
22
|
+
export class NotificationService {
|
|
23
|
+
private channels: Map<NotificationChannel, ChannelService> = new Map();
|
|
24
|
+
private config: NotificationServiceConfig;
|
|
25
|
+
private metrics: Map<NotificationChannel, ChannelMetrics> = new Map();
|
|
26
|
+
|
|
27
|
+
constructor(config: NotificationServiceConfig = {}) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Register a channel service
|
|
33
|
+
* @param service Channel service to register
|
|
34
|
+
* @throws Error if channel is already registered
|
|
35
|
+
*/
|
|
36
|
+
registerChannel<T extends NotificationMessage>(
|
|
37
|
+
service: ChannelService<T>,
|
|
38
|
+
): void {
|
|
39
|
+
if (this.channels.has(service.name)) {
|
|
40
|
+
throw new Error(`Channel already registered: ${service.name}`);
|
|
41
|
+
}
|
|
42
|
+
this.channels.set(service.name, service);
|
|
43
|
+
|
|
44
|
+
// Initialize metrics
|
|
45
|
+
if (this.config.enableMetrics) {
|
|
46
|
+
this.metrics.set(service.name, {
|
|
47
|
+
sent: 0,
|
|
48
|
+
failed: 0,
|
|
49
|
+
successRate: 0,
|
|
50
|
+
avgSendTime: 0,
|
|
51
|
+
totalSendTime: 0,
|
|
52
|
+
updatedAt: new Date(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Unregister a channel service
|
|
59
|
+
* @param channelName Name of the channel to unregister
|
|
60
|
+
*/
|
|
61
|
+
unregisterChannel(channelName: NotificationChannel): void {
|
|
62
|
+
this.channels.delete(channelName);
|
|
63
|
+
this.metrics.delete(channelName);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get a registered channel service
|
|
68
|
+
* @param channelName Name of the channel
|
|
69
|
+
* @returns Channel service or null if not found
|
|
70
|
+
*/
|
|
71
|
+
getChannel(channelName: NotificationChannel): ChannelService | null {
|
|
72
|
+
return this.channels.get(channelName) || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Get all registered channel names
|
|
77
|
+
*/
|
|
78
|
+
getChannels(): NotificationChannel[] {
|
|
79
|
+
return Array.from(this.channels.keys());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if a channel is registered
|
|
84
|
+
*/
|
|
85
|
+
hasChannel(channelName: NotificationChannel): boolean {
|
|
86
|
+
return this.channels.has(channelName);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Send a notification via specified channel
|
|
91
|
+
* @param message Notification message
|
|
92
|
+
* @returns Message ID or undefined
|
|
93
|
+
* @throws Error if channel not found or message invalid
|
|
94
|
+
*/
|
|
95
|
+
async send(message: NotificationMessage): Promise<string | undefined> {
|
|
96
|
+
const startTime = Date.now();
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const service = this.channels.get(message.channel);
|
|
100
|
+
if (!service) {
|
|
101
|
+
throw new Error(`Channel not registered: ${message.channel}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve template references
|
|
105
|
+
const resolvedMessage = await this._resolveTemplates(message);
|
|
106
|
+
|
|
107
|
+
// Validate message
|
|
108
|
+
service.validate(resolvedMessage);
|
|
109
|
+
|
|
110
|
+
// Send message
|
|
111
|
+
const messageId = await service.send(resolvedMessage);
|
|
112
|
+
|
|
113
|
+
// Update metrics
|
|
114
|
+
if (this.config.enableMetrics) {
|
|
115
|
+
this._updateMetrics(message.channel, true, Date.now() - startTime);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return messageId;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
// Update metrics
|
|
121
|
+
if (this.config.enableMetrics) {
|
|
122
|
+
this._updateMetrics(message.channel, false, Date.now() - startTime);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Send multiple notifications (potentially different channels)
|
|
131
|
+
* @param messages Array of notification messages
|
|
132
|
+
* @returns Array of message IDs
|
|
133
|
+
*/
|
|
134
|
+
async sendBatch(
|
|
135
|
+
messages: NotificationMessage[],
|
|
136
|
+
): Promise<(string | undefined)[]> {
|
|
137
|
+
const results: (string | undefined)[] = [];
|
|
138
|
+
|
|
139
|
+
for (const message of messages) {
|
|
140
|
+
try {
|
|
141
|
+
const messageId = await this.send(message);
|
|
142
|
+
results.push(messageId);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
results.push(undefined);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return results;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Send a notification from a Notifiable
|
|
153
|
+
* @param notifiable Notifiable instance
|
|
154
|
+
* @param channel Optional specific channel (otherwise uses buildAll or default)
|
|
155
|
+
* @returns Message ID(s)
|
|
156
|
+
*/
|
|
157
|
+
async sendNotifiable(
|
|
158
|
+
notifiable: Notifiable,
|
|
159
|
+
channel?: NotificationChannel,
|
|
160
|
+
): Promise<string | undefined> {
|
|
161
|
+
if (channel) {
|
|
162
|
+
// Send to specific channel
|
|
163
|
+
const message = await notifiable.build(channel);
|
|
164
|
+
return this.send(message);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Try buildAll for multi-channel
|
|
168
|
+
if (notifiable.buildAll) {
|
|
169
|
+
const messages = await notifiable.buildAll();
|
|
170
|
+
const results = await this.sendBatch(
|
|
171
|
+
Object.values(messages).filter(
|
|
172
|
+
(m) => m !== undefined,
|
|
173
|
+
) as NotificationMessage[],
|
|
174
|
+
);
|
|
175
|
+
return results[0]; // Return first message ID
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Fall back to build
|
|
179
|
+
const message = await notifiable.build();
|
|
180
|
+
return this.send(message);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Queue a notification for async sending (requires job queue integration)
|
|
185
|
+
* @param message Notification message
|
|
186
|
+
* @returns Job ID or undefined
|
|
187
|
+
*/
|
|
188
|
+
async queue(message: NotificationMessage): Promise<string | undefined> {
|
|
189
|
+
// This will integrate with JobQueue in Phase 3
|
|
190
|
+
// For now, placeholder
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get health status for a channel
|
|
196
|
+
* @param channelName Name of the channel
|
|
197
|
+
*/
|
|
198
|
+
async getChannelHealth(
|
|
199
|
+
channelName: NotificationChannel,
|
|
200
|
+
): Promise<ChannelHealth | null> {
|
|
201
|
+
const service = this.channels.get(channelName);
|
|
202
|
+
if (!service) return null;
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
service.getHealth?.() || {
|
|
206
|
+
status: "healthy" as const,
|
|
207
|
+
message: "OK",
|
|
208
|
+
checkedAt: new Date(),
|
|
209
|
+
}
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Get health status for all channels
|
|
215
|
+
*/
|
|
216
|
+
async getHealthStatus(): Promise<Record<NotificationChannel, ChannelHealth>> {
|
|
217
|
+
const health: Record<NotificationChannel, ChannelHealth> = {};
|
|
218
|
+
|
|
219
|
+
for (const [name] of this.channels) {
|
|
220
|
+
const channelHealth = await this.getChannelHealth(name);
|
|
221
|
+
if (channelHealth) {
|
|
222
|
+
health[name] = channelHealth;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return health;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get metrics for a specific channel
|
|
231
|
+
* @param channelName Name of the channel
|
|
232
|
+
*/
|
|
233
|
+
getChannelMetrics(channelName: NotificationChannel): ChannelMetrics | null {
|
|
234
|
+
return this.metrics.get(channelName) || null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get metrics for all channels
|
|
239
|
+
*/
|
|
240
|
+
getAllMetrics(): Record<NotificationChannel, ChannelMetrics> {
|
|
241
|
+
const allMetrics: Record<NotificationChannel, ChannelMetrics> = {};
|
|
242
|
+
|
|
243
|
+
for (const [name, metrics] of this.metrics) {
|
|
244
|
+
allMetrics[name] = metrics;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return allMetrics;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve TemplateRef objects in message fields to rendered strings
|
|
252
|
+
*/
|
|
253
|
+
private async _resolveTemplates(
|
|
254
|
+
message: NotificationMessage,
|
|
255
|
+
): Promise<NotificationMessage> {
|
|
256
|
+
const engine = this.config.templateEngine;
|
|
257
|
+
if (!engine) {
|
|
258
|
+
// If no engine, check that no TemplateRef fields exist
|
|
259
|
+
this._assertNoTemplateRefs(message);
|
|
260
|
+
return message;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const channel = message.channel;
|
|
264
|
+
const msg = { ...message } as Record<string, unknown>;
|
|
265
|
+
|
|
266
|
+
const resolveField = async (
|
|
267
|
+
value: unknown,
|
|
268
|
+
defaultFormat: "html" | "text",
|
|
269
|
+
): Promise<string> => {
|
|
270
|
+
if (!isTemplateRef(value)) return value as string;
|
|
271
|
+
const fmt = value.outputFormat ?? defaultFormat;
|
|
272
|
+
const variant = value.variant ?? engine.getVariantForChannel(channel);
|
|
273
|
+
return engine.render(value.templateId, value.data, {
|
|
274
|
+
variant,
|
|
275
|
+
outputFormat: fmt,
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
// Email: resolve html (→ HTML) and text (→ text)
|
|
280
|
+
if (channel === "email") {
|
|
281
|
+
if (isTemplateRef(msg.html)) {
|
|
282
|
+
msg.html = await resolveField(msg.html, "html");
|
|
283
|
+
}
|
|
284
|
+
if (isTemplateRef(msg.text)) {
|
|
285
|
+
msg.text = await resolveField(msg.text, "text");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// SMS: resolve message (→ text)
|
|
289
|
+
else if (channel === "sms") {
|
|
290
|
+
if (isTemplateRef(msg.message)) {
|
|
291
|
+
msg.message = await resolveField(msg.message, "text");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// Push: resolve title and body (→ text)
|
|
295
|
+
else if (channel === "push") {
|
|
296
|
+
if (isTemplateRef(msg.title)) {
|
|
297
|
+
msg.title = await resolveField(msg.title, "text");
|
|
298
|
+
}
|
|
299
|
+
if (isTemplateRef(msg.body)) {
|
|
300
|
+
msg.body = await resolveField(msg.body, "text");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return msg as NotificationMessage;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Assert that no TemplateRef fields exist in the message
|
|
309
|
+
* Throws if a TemplateRef is found but no templateEngine is configured
|
|
310
|
+
*/
|
|
311
|
+
private _assertNoTemplateRefs(message: Record<string, unknown>): void {
|
|
312
|
+
for (const [key, value] of Object.entries(message)) {
|
|
313
|
+
if (isTemplateRef(value)) {
|
|
314
|
+
throw new Error(
|
|
315
|
+
`TemplateRef found in field "${key}" but no templateEngine is configured in NotificationService`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Update metrics for a channel
|
|
323
|
+
*/
|
|
324
|
+
private _updateMetrics(
|
|
325
|
+
channelName: NotificationChannel,
|
|
326
|
+
success: boolean,
|
|
327
|
+
duration: number,
|
|
328
|
+
): void {
|
|
329
|
+
const metrics = this.metrics.get(channelName);
|
|
330
|
+
if (!metrics) return;
|
|
331
|
+
|
|
332
|
+
if (success) {
|
|
333
|
+
metrics.sent++;
|
|
334
|
+
metrics.totalSendTime += duration;
|
|
335
|
+
} else {
|
|
336
|
+
metrics.failed++;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const total = metrics.sent + metrics.failed;
|
|
340
|
+
metrics.successRate = total > 0 ? metrics.sent / total : 0;
|
|
341
|
+
metrics.avgSendTime =
|
|
342
|
+
metrics.sent > 0 ? metrics.totalSendTime / metrics.sent : 0;
|
|
343
|
+
metrics.updatedAt = new Date();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Factory function to create notification service
|
|
349
|
+
*/
|
|
350
|
+
export function createNotificationService(
|
|
351
|
+
config?: NotificationServiceConfig,
|
|
352
|
+
): NotificationService {
|
|
353
|
+
return new NotificationService(config);
|
|
354
|
+
}
|