@stacksjs/notifications 0.75.111 → 0.75.113
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 +68 -260
- package/dist/drivers/database.d.ts +6 -4
- package/dist/index.d.ts +39 -3
- package/dist/index.js +4 -4
- package/package.json +11 -11
package/README.md
CHANGED
|
@@ -1,271 +1,79 @@
|
|
|
1
1
|
# Stacks Notifications
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## ☘️ Features
|
|
6
|
-
|
|
7
|
-
- 📦 Send Emails
|
|
8
|
-
- 🎨 Email Styling
|
|
9
|
-
- 📱 Send SMS messages
|
|
10
|
-
- 💬 Send Chat messages
|
|
11
|
-
|
|
12
|
-
## TODO
|
|
13
|
-
|
|
14
|
-
- [ ] Driver: SNS (Push)
|
|
15
|
-
- [ ] Driver: Pushwoosh
|
|
16
|
-
|
|
17
|
-
## 🤖 Usage
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
bun install -d @stacksjs/notifications
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Set these variables in your env:
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
NOTIFICATION_TYPE=email
|
|
27
|
-
NOTIFICATION_DRIVER=sendgrid
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
You may now use it in your project:
|
|
3
|
+
Send email, SMS, chat, push, database inbox messages and realtime broadcasts
|
|
4
|
+
through `@stacksjs/notifications`.
|
|
31
5
|
|
|
32
6
|
```ts
|
|
33
|
-
import {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
7
|
+
import { ensureSuccessfulNotificationResults, notify } from '@stacksjs/notifications'
|
|
8
|
+
|
|
9
|
+
const results = await notify(
|
|
10
|
+
{ userId: 7, email: 'athlete@example.com', phone: '+15555550100' },
|
|
11
|
+
{ subject: 'Training reminder', body: 'Your session starts soon.' },
|
|
12
|
+
['email', 'sms'],
|
|
13
|
+
{ category: 'training' },
|
|
14
|
+
)
|
|
15
|
+
ensureSuccessfulNotificationResults(results)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Channels run independently. A provider's structured failure is returned as
|
|
19
|
+
`success: false` even when its send promise resolves. Broadcast also reports
|
|
20
|
+
failure when no realtime server is running. Success means provider acceptance,
|
|
21
|
+
not a final device or mailbox receipt. The result checker rejects empty results
|
|
22
|
+
and partial failure; with several failures it throws an `AggregateError`.
|
|
23
|
+
|
|
24
|
+
## Transports
|
|
25
|
+
|
|
26
|
+
| Channel | Recipient | Configuration |
|
|
27
|
+
| --- | --- | --- |
|
|
28
|
+
| email | `email` | `config/email.ts`, Mail singleton |
|
|
29
|
+
| sms | `phone` | `config/sms.ts`, Twilio or Vonage |
|
|
30
|
+
| chat | `chatRecipient` (channel/user id or array) | `config/services.ts`, Slack by default |
|
|
31
|
+
| push | `pushTokens` (token or array) | Expo by default, `config/services.ts` |
|
|
32
|
+
| database | `userId` | Apply notification model migrations |
|
|
33
|
+
| broadcast | `broadcastChannel` or `userId` | Running realtime server |
|
|
34
|
+
|
|
35
|
+
`useEmail()`, `useSMS()`, `useChat()`, `usePush()`, `useDatabase()` and
|
|
36
|
+
`useBroadcast()` expose native transports. `useSMS()` honors the configured
|
|
37
|
+
provider; `useSMS('twilio')` or `useSMS('vonage')` overrides it. Runtime SMS
|
|
38
|
+
configuration survives lazy initialization. Chat maps `body` to the transport's
|
|
39
|
+
`content` and `chatRecipient` to `to`; delivery tracking records that destination.
|
|
40
|
+
Use `useChat('discord')` or `useChat('teams')` for direct alternative transports.
|
|
41
|
+
Unknown SMS/chat driver names throw.
|
|
42
|
+
|
|
43
|
+
## Preferences and tracking
|
|
44
|
+
|
|
45
|
+
Absent preferences allow a channel. A global opt-out wins over a category
|
|
46
|
+
opt-in. `getNotificationPreferences`, `setNotificationPreference` and
|
|
47
|
+
`bulkSetPreferences` provide persistent preferences; writes serialize on the
|
|
48
|
+
user row, and bulk changes are atomic. `options.ignorePreferences: true`
|
|
49
|
+
explicitly bypasses preferences and SMS opt-outs. Preference lookup failures
|
|
50
|
+
currently log and allow sends, so this filter is not a fail-closed consent gate.
|
|
51
|
+
|
|
52
|
+
The database inbox and `notification_deliveries` tracking log are separate.
|
|
53
|
+
Apply their model-driven migrations before relying on persistence. Inspect the
|
|
54
|
+
returned channel results; the tracking log is not a final receipt.
|
|
55
|
+
|
|
56
|
+
## Inbox ownership
|
|
41
57
|
|
|
42
58
|
```ts
|
|
43
|
-
|
|
44
|
-
providerName?: 'discord' | 'slack'
|
|
45
|
-
webhookUrl: string
|
|
46
|
-
content: string
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface EmailOptions {
|
|
50
|
-
providerName?: 'sendgrid' | 'emailjs' | 'mailjet' | 'mandrill' | 'netcore' | 'nodemailer' | 'postmark' | 'ses'
|
|
51
|
-
to: string | string[]
|
|
52
|
-
subject: string
|
|
53
|
-
html: string
|
|
54
|
-
from?: string
|
|
55
|
-
text?: string
|
|
56
|
-
attachments?: AttachmentOptions[]
|
|
57
|
-
id?: string
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface SMSOptions {
|
|
61
|
-
providerName?: 'gupshup' | 'nexmo' | 'plivo' | 'sms77' | 'sns' | 'telnyx' | 'termii' | 'twilio'
|
|
62
|
-
to: string
|
|
63
|
-
content: string
|
|
64
|
-
from?: string
|
|
65
|
-
attachments?: AttachmentOptions[]
|
|
66
|
-
id?: string
|
|
67
|
-
}
|
|
68
|
-
```
|
|
69
|
-
|
|
70
|
-
Available drivers are listed below, with the proper variables needed to get started.
|
|
71
|
-
|
|
72
|
-
### Email
|
|
73
|
-
|
|
74
|
-
Email drivers are configured with the following environment variables:
|
|
75
|
-
|
|
76
|
-
#### Sendgrid
|
|
77
|
-
|
|
78
|
-
```bash
|
|
79
|
-
SENDGRID_API_KEY=SG123
|
|
80
|
-
SENDGRID_FROM=from@example.com
|
|
81
|
-
SENDGRID_SENDER_NAME=Sender
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
#### Mailgun
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
MAILGUN_API_KEY=MG123
|
|
88
|
-
MAILGUN_DOMAIN=example.com
|
|
89
|
-
MAILGUN_USERNAME=username
|
|
90
|
-
MAILGUN_FROM=from@example.com
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
#### Mailjet
|
|
94
|
-
|
|
95
|
-
```bash
|
|
96
|
-
MAILJET_API_KEY=MJ123
|
|
97
|
-
MAILJET_API_SECRET=MJTESTSECRET
|
|
98
|
-
MAILJET_FROM_EMAIL=from@example.com
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
#### Netcore
|
|
102
|
-
|
|
103
|
-
```bash
|
|
104
|
-
NETCORE_API_KEY=NC123
|
|
105
|
-
NETCORE_FROM=from@example.com
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
#### Nodemailer
|
|
109
|
-
|
|
110
|
-
```bash
|
|
111
|
-
NODEMAILER_FROM_EMAIL=from@example.com
|
|
112
|
-
NODEMAILER_HOST=example.com
|
|
113
|
-
NODEMAILER_USERNAME=username
|
|
114
|
-
NODEMAILER_PASSWORD=password
|
|
115
|
-
NODEMAILER_PORT=25
|
|
116
|
-
NODEMAILER_SECURE=true
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
#### Postmark
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
POSTMARK_API_KEY=PM123
|
|
123
|
-
POSTMARK_FROM=from@example.com
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
#### AWS SES
|
|
127
|
-
|
|
128
|
-
```bash
|
|
129
|
-
SES_REGION=US
|
|
130
|
-
SES_ACCESS_KEY_ID=testkey123
|
|
131
|
-
SES_SECRET_ACCESS_KEY=testaccesskey123
|
|
132
|
-
SES_FROM=from@example.com
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
#### Mandrill
|
|
136
|
-
|
|
137
|
-
```bash
|
|
138
|
-
MANDRILL_API_KEY=Ma123
|
|
139
|
-
MANDRILL_EMAIL=from@example.com
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
#### EmailJS
|
|
143
|
-
|
|
144
|
-
```bash
|
|
145
|
-
MAIL_FROM_ADDRESS=from@example.com
|
|
146
|
-
EMAILJS_HOST=example.com
|
|
147
|
-
EMAILJS_USERNAME=username
|
|
148
|
-
EMAILJS_PASSWORD=password
|
|
149
|
-
EMAILJS_PORT=25
|
|
150
|
-
EMAILJS_SECURE=true
|
|
151
|
-
```
|
|
59
|
+
import { useDatabase } from '@stacksjs/notifications'
|
|
152
60
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
```bash
|
|
160
|
-
TWILIO_ACCOUNT_SID=ACtest
|
|
161
|
-
TWILIO_AUTH_TOKEN=testtoken
|
|
162
|
-
TWILIO_FROM_NUMBER=+112345
|
|
163
|
-
TWILIO_TO_NUMBER=+145678
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
#### Nexmo
|
|
167
|
-
|
|
168
|
-
```bash
|
|
169
|
-
VONAGE_API_KEY=VN123
|
|
170
|
-
VONAGE_API_SECRET=testkey
|
|
171
|
-
VONAGE_FROM_NUMBER=+112345
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
#### Gupshup
|
|
175
|
-
|
|
176
|
-
```bash
|
|
177
|
-
GUPSHUP_USER_ID=GU123
|
|
178
|
-
GUPSHUP_PASSWORD=password
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
#### Plivo
|
|
182
|
-
|
|
183
|
-
```bash
|
|
184
|
-
PLIVO_ACCOUNT_ID=PA123
|
|
185
|
-
PLIVO_AUTH_TOKEN=testtoken
|
|
186
|
-
PLIVO_FROM_NUMBER=+112345
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
#### SMS77
|
|
190
|
-
|
|
191
|
-
```bash
|
|
192
|
-
SMS77_API_KEY=SA123
|
|
193
|
-
SMS77_FROM=from@example.com
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
#### SNS
|
|
197
|
-
|
|
198
|
-
```bash
|
|
199
|
-
SMS77_API_KEY=SA123
|
|
200
|
-
SMS77_FROM=from@example.com
|
|
201
|
-
```
|
|
202
|
-
|
|
203
|
-
#### Telnyx
|
|
204
|
-
|
|
205
|
-
```bash
|
|
206
|
-
TELNYX_API_KEY=TA123
|
|
207
|
-
TELNYX_MESSAGE_PROFILE_ID=testprofileid
|
|
208
|
-
TELNYX_FROM=from@example.com
|
|
209
|
-
```
|
|
210
|
-
|
|
211
|
-
#### Termii
|
|
212
|
-
|
|
213
|
-
```bash
|
|
214
|
-
TERMII_API_KEY=TermA123
|
|
215
|
-
TERMII_SENDER=from@example.com
|
|
61
|
+
const inbox = useDatabase()
|
|
62
|
+
await inbox.send({ userId: 7, type: 'training.reminder', data: { body: 'See you soon' } })
|
|
63
|
+
const messages = await inbox.getUnreadNotifications(7)
|
|
64
|
+
await inbox.markAsRead(messages[0]!.id, 7)
|
|
65
|
+
await inbox.deleteNotification(messages[0]!.id, 7)
|
|
216
66
|
```
|
|
217
67
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
#### Discord
|
|
223
|
-
|
|
224
|
-
- None
|
|
225
|
-
|
|
226
|
-
#### Slack
|
|
227
|
-
|
|
228
|
-
```
|
|
229
|
-
SLACK_APPLICATION_ID=SAID123
|
|
230
|
-
SLACK_CLIENT_ID=SCID123
|
|
231
|
-
SLACK_SECRET_KEY=SSK123
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
Learn more in the docs.
|
|
235
|
-
|
|
236
|
-
## 🧪 Testing
|
|
237
|
-
|
|
238
|
-
```bash
|
|
239
|
-
bun test
|
|
240
|
-
```
|
|
241
|
-
|
|
242
|
-
## 📈 Changelog
|
|
243
|
-
|
|
244
|
-
Please see our [releases](https://github.com/stacksjs/stacks/releases) page for more information on what has changed recently.
|
|
245
|
-
|
|
246
|
-
## 🚜 Contributing
|
|
247
|
-
|
|
248
|
-
Please review the [Contributing Guide](https://github.com/stacksjs/contributing) for details.
|
|
249
|
-
|
|
250
|
-
## 🏝 Community
|
|
251
|
-
|
|
252
|
-
For help, discussion about best practices, or any other conversation that would benefit from being searchable:
|
|
253
|
-
|
|
254
|
-
[Discussions on GitHub](https://github.com/stacksjs/stacks/discussions)
|
|
255
|
-
|
|
256
|
-
For casual chit-chat with others using this package:
|
|
257
|
-
|
|
258
|
-
[Join the Stacks Discord Server](https://stacksjs.com/discord)
|
|
259
|
-
|
|
260
|
-
## 🙏🏼 Credits
|
|
261
|
-
|
|
262
|
-
Many thanks to the following core technologies & people who have contributed to this package:
|
|
263
|
-
|
|
264
|
-
- [Chris Breuer](https://github.com/chrisbbreuer)
|
|
265
|
-
- [All Contributors](../../contributors)
|
|
266
|
-
|
|
267
|
-
## 📄 License
|
|
68
|
+
Supply the authenticated user ID to single-row mutations to enforce ownership in
|
|
69
|
+
the update/delete statement. Id-only calls remain available for trusted internal
|
|
70
|
+
use. `markAllAsRead(userId)`, `deleteAllNotifications(userId)` and
|
|
71
|
+
`unreadCount(userId)` are already scoped. Repeated reads preserve `read_at`.
|
|
268
72
|
|
|
269
|
-
|
|
73
|
+
## Verification
|
|
270
74
|
|
|
271
|
-
|
|
75
|
+
`tests/provider-outcomes.test.ts` exercises the real SMS, chat and push drivers
|
|
76
|
+
with only external HTTP replaced, including acceptance and rejection.
|
|
77
|
+
`tests/preferences-runtime.test.ts` verifies persistence, atomic preference
|
|
78
|
+
changes, ownership and read timestamp preservation on SQLite and optional local
|
|
79
|
+
PostgreSQL (`STACKS_PREFERENCE_TEST_POSTGRES_URL`).
|
|
@@ -6,10 +6,10 @@ export declare function useDatabase(): typeof DatabaseNotificationDriver;
|
|
|
6
6
|
* send: (options: CreateNotificationOptions) => Promise<DatabaseNotification>,
|
|
7
7
|
* getUserNotifications: (userId: number) => Promise<DatabaseNotification[]>,
|
|
8
8
|
* getUnreadNotifications: (userId: number) => Promise<DatabaseNotification[]>,
|
|
9
|
-
* markAsRead: (id: number) => Promise<void>,
|
|
9
|
+
* markAsRead: (id: number, userId?: number) => Promise<void>,
|
|
10
10
|
* markAllAsRead: (userId: number) => Promise<void>,
|
|
11
11
|
* unreadCount: (userId: number) => Promise<number>,
|
|
12
|
-
* deleteNotification: (id: number) => Promise<void>,
|
|
12
|
+
* deleteNotification: (id: number, userId?: number) => Promise<void>,
|
|
13
13
|
* deleteAllNotifications: (userId: number) => Promise<void>
|
|
14
14
|
* }
|
|
15
15
|
* ```
|
|
@@ -18,10 +18,12 @@ export declare const DatabaseNotificationDriver: {
|
|
|
18
18
|
send: (options: CreateNotificationOptions) => Promise<DatabaseNotification>;
|
|
19
19
|
getUserNotifications: (userId: number) => Promise<DatabaseNotification[]>;
|
|
20
20
|
getUnreadNotifications: (userId: number) => Promise<DatabaseNotification[]>;
|
|
21
|
-
|
|
21
|
+
/** Supply the authenticated user id to scope the mutation in the same statement. */
|
|
22
|
+
markAsRead: (id: number, userId?: number) => Promise<void>;
|
|
22
23
|
markAllAsRead: (userId: number) => Promise<void>;
|
|
23
24
|
unreadCount: (userId: number) => Promise<number>;
|
|
24
|
-
|
|
25
|
+
/** Supply the authenticated user id to scope the mutation in the same statement. */
|
|
26
|
+
deleteNotification: (id: number, userId?: number) => Promise<void>;
|
|
25
27
|
deleteAllNotifications: (userId: number) => Promise<void>
|
|
26
28
|
};
|
|
27
29
|
// `./database-schema.d.ts` augments `@stacksjs/database`'s
|
package/dist/index.d.ts
CHANGED
|
@@ -4,14 +4,16 @@ import { DatabaseNotificationDriver } from './drivers/database';
|
|
|
4
4
|
import { filterChannelsByPreferences } from './preferences';
|
|
5
5
|
import { makeDeliveryRecord, recordNotificationDelivery } from './delivery';
|
|
6
6
|
import { type NotificationAction, notificationEmailHtml, notificationEmailText } from './email-body';
|
|
7
|
-
import type { EmailMessage, EmailResult } from '@stacksjs/types';
|
|
7
|
+
import type { ChatMessage, ChatResult, EmailMessage, EmailResult, SmsMessage, SmsSendResult } from '@stacksjs/types';
|
|
8
8
|
export type { BroadcastNotificationOptions, BroadcastNotificationResult } from './drivers/broadcast';
|
|
9
9
|
export type { NotificationDeliveryRecord } from './delivery';
|
|
10
10
|
export type { CreateNotificationOptions, DatabaseNotification } from './drivers/database';
|
|
11
11
|
export type { NotificationPreferenceRow, PreferenceChannel } from './preferences';
|
|
12
12
|
export type { EmailBodyPart, NotificationAction } from './email-body';
|
|
13
13
|
export declare function ensureSuccessfulEmailResult(result: EmailResult): void;
|
|
14
|
-
|
|
14
|
+
/** Require at least one delivered channel and surface partial fan-out failure. */
|
|
15
|
+
export declare function ensureSuccessfulNotificationResults(results: readonly NotifyResult[]): void;
|
|
16
|
+
export declare function useChat(driver?: string): ChatTransport;
|
|
15
17
|
/**
|
|
16
18
|
* Return the email transport — by default the @stacksjs/email Mail
|
|
17
19
|
* singleton, which lazy-resolves its driver from `config.email.default`
|
|
@@ -26,7 +28,7 @@ export declare function useChat(driver?: string): typeof chat[keyof typeof chat]
|
|
|
26
28
|
* no-op'd for every booking confirmation, host alert, etc.
|
|
27
29
|
*/
|
|
28
30
|
export declare function useEmail(driver?: string): EmailTransport;
|
|
29
|
-
export declare function useSMS(driver?: string):
|
|
31
|
+
export declare function useSMS(driver?: string): SmsTransport;
|
|
30
32
|
/**
|
|
31
33
|
* Push transport — returns the `@stacksjs/push` namespace whose
|
|
32
34
|
* `send(to, notification, options)` dispatches to Expo or FCM based on
|
|
@@ -48,6 +50,33 @@ export declare function useDatabase(): typeof DatabaseNotificationDriver;
|
|
|
48
50
|
*/
|
|
49
51
|
export declare function useBroadcast(): typeof BroadcastNotificationDriver;
|
|
50
52
|
export declare function useNotification(typeParam?: string, driverParam?: string): ReturnType<typeof useEmail> | ReturnType<typeof useChat> | ReturnType<typeof useSMS> | typeof DatabaseNotificationDriver;
|
|
53
|
+
/**
|
|
54
|
+
* Send a notification through multiple channels simultaneously.
|
|
55
|
+
* Uses Promise.allSettled so one channel failure doesn't block others.
|
|
56
|
+
*
|
|
57
|
+
* When `recipient.userId` is set and `options.ignorePreferences` is not
|
|
58
|
+
* `true`, the channel list is first filtered through the user's
|
|
59
|
+
* `notification_preferences` rows — any channel they've explicitly
|
|
60
|
+
* disabled is silently skipped. Channels with no preference recorded fall
|
|
61
|
+
* through as enabled (default-allow), so introducing this feature doesn't
|
|
62
|
+
* regress notifications for users who haven't visited the preferences UI.
|
|
63
|
+
*
|
|
64
|
+
* For transactional notifications that must always go out (password
|
|
65
|
+
* resets, security alerts, billing failures), pass
|
|
66
|
+
* `{ ignorePreferences: true }`.
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```ts
|
|
70
|
+
* // respects user preferences
|
|
71
|
+
* await notify({ userId: 7, email: 'a@x' }, { body: 'New message' }, ['email', 'sms'])
|
|
72
|
+
*
|
|
73
|
+
* // bypasses preferences (security alert)
|
|
74
|
+
* await notify({ userId: 7, email: 'a@x' }, { body: 'New login' }, ['email'], { ignorePreferences: true })
|
|
75
|
+
*
|
|
76
|
+
* // category-specific opt-out
|
|
77
|
+
* await notify({ userId: 7, email: 'a@x' }, { body: 'Sale!' }, ['email'], { category: 'marketing' })
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
51
80
|
export declare function notify(recipient: NotificationRecipient, payload: NotificationPayload, channels?: NotificationChannel[], options?: NotifyOptions): Promise<NotifyResult[]>;
|
|
52
81
|
export declare function notification(): ReturnType<typeof useNotification>;
|
|
53
82
|
/** Optional flags accepted by {@link notify}. */
|
|
@@ -70,6 +99,7 @@ export declare interface NotificationPayload {
|
|
|
70
99
|
export declare interface NotificationRecipient {
|
|
71
100
|
email?: string
|
|
72
101
|
phone?: string
|
|
102
|
+
chatRecipient?: string | string[]
|
|
73
103
|
userId?: number
|
|
74
104
|
pushTokens?: string | string[]
|
|
75
105
|
broadcastChannel?: string
|
|
@@ -79,6 +109,12 @@ export declare interface NotifyResult {
|
|
|
79
109
|
success: boolean
|
|
80
110
|
error?: Error
|
|
81
111
|
}
|
|
112
|
+
export declare interface ChatTransport {
|
|
113
|
+
send: (message: ChatMessage) => Promise<ChatResult>
|
|
114
|
+
}
|
|
115
|
+
export declare interface SmsTransport {
|
|
116
|
+
send: (message: SmsMessage) => Promise<SmsSendResult>
|
|
117
|
+
}
|
|
82
118
|
export type NotificationChannel = 'email' | 'sms' | 'chat' | 'database' | 'push' | 'broadcast';
|
|
83
119
|
export { BroadcastNotificationDriver } from './drivers/broadcast';
|
|
84
120
|
export { makeDeliveryRecord, recordNotificationDelivery, resolveDeliveryRecipient } from './delivery';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
import{log as k}from"@stacksjs/cli";import{notification as Y}from"@stacksjs/config";import{mail as
|
|
3
|
-
`).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let
|
|
4
|
-
`).forEach((
|
|
2
|
+
import{log as k}from"@stacksjs/cli";import{notification as Y}from"@stacksjs/config";import{mail as O}from"@stacksjs/email";import{log as _}from"@stacksjs/cli";var d={async send(e){let t=e.event??"notification",r=e.channel;if(!r)r=e.userId!==void 0?`private-user.${e.userId}`:"notifications";try{let n=await import("@stacksjs/realtime").catch(()=>null),i=n?.emit,a=n?.getServer;if(!i||!a)return{delivered:!1,channel:r,event:t,reason:"@stacksjs/realtime is not available"};if(!a())return{delivered:!1,channel:r,event:t,reason:"realtime server not running"};let s=e.private??e.userId!==void 0;return i(r,t,e.data,{private:s}),_.info(`[notifications] broadcast to '${r}' (${t})`),{delivered:!0,channel:r,event:t}}catch(n){let i=n instanceof Error?n.message:String(n);return _.warn(`[notifications] broadcast failed: ${i}`),{delivered:!1,channel:r,event:t,reason:i}}}};import*as T from"@stacksjs/chat";import{log as L}from"@stacksjs/cli";import{db as u,sqlDateTime as g}from"@stacksjs/database/runtime";var m={async send(e){let t=g(),n=await u.insertInto("notifications").values({user_id:e.userId,type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:t,updated_at:t}).returning("id").execute(),i=Array.isArray(n)?n[0]:n,a=Number(i?.id??i?.insertId??i?.lastInsertRowid??i?.lastInsertId??0);if(!Number.isSafeInteger(a)||a<=0)throw Error("Database notification did not return a generated id");return L.info(`Database notification sent to user ${e.userId}: ${e.type}`),{id:a,user_id:e.userId,type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:t,updated_at:t}},async getUserNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).orderBy("created_at","desc").execute()).map((r)=>({...r,id:Number(r.id),user_id:Number(r.user_id)}))},async getUnreadNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).whereNull("read_at").orderBy("created_at","desc").execute()).map((r)=>({...r,id:Number(r.id),user_id:Number(r.user_id)}))},async markAsRead(e,t){let r=u.updateTable("notifications").set({read_at:g()}).where("id","=",e).whereNull("read_at");if(t!==void 0)r=r.where("user_id","=",t);await r.execute()},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:g()}).where("user_id","=",e).whereNull("read_at").execute()},async unreadCount(e){let t=await u.selectFrom("notifications").select(u.fn.countAll().as("count")).where("user_id","=",e).whereNull("read_at").executeTakeFirst();return Number(t?.count??0)},async deleteNotification(e,t){let r=u.deleteFrom("notifications").where("id","=",e);if(t!==void 0)r=r.where("user_id","=",t);await r.execute()},async deleteAllNotifications(e){await u.deleteFrom("notifications").where("user_id","=",e).execute()}};import*as D from"@stacksjs/push";import*as h from"@stacksjs/sms";import{log as H}from"@stacksjs/cli";import{config as C}from"@stacksjs/config";import{db as J,sqlDateTime as S}from"@stacksjs/database/runtime";function I(e,t){switch(t){case"email":return e.email||"";case"sms":return e.phone||"";case"push":return Array.isArray(e.pushTokens)?e.pushTokens.join(", "):e.pushTokens||"";case"broadcast":return e.broadcastChannel||(e.userId?`private-user.${e.userId}`:"notifications");case"database":return e.userId?`User #${e.userId}`:"";case"chat":return Array.isArray(e.chatRecipient)?e.chatRecipient.join(", "):e.chatRecipient||""}}function b(e,t,r,n,i=S()){return{userId:e.userId,channel:r.channel,recipient:I(e,r.channel),subject:t.subject,body:t.body,status:r.success?"sent":"failed",error:r.error?.message,metadata:{...t.data,...n.category?{category:n.category}:{}},sentAt:i}}function Q(e){let t=C.notification?.tracking;if(t?.enabled===!1)return!1;let r=t?.channels;if(!r||r.length===0)return!0;return r.includes(e)}async function y(e){if(!Q(e.channel))return;let t=S();try{await J.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:C.notification?.tracking?.body===!1?"":e.body,status:e.status,error:e.error??null,metadata:e.metadata?JSON.stringify(e.metadata):null,sent_at:e.sentAt,created_at:t,updated_at:t}).execute()}catch(r){let n=r instanceof Error?r.message:String(r);H.warn(`[notifications] could not record ${e.channel} delivery: ${n}`)}}import{templateByName as z}from"@stacksjs/email";var G=/https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]]/g;function A(e){return String(e??"").replace(/\r\n?/g,`
|
|
3
|
+
`).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let r=[];return t.split(`
|
|
4
|
+
`).forEach((n,i)=>{if(i>0)r.push({br:!0});let a=0;for(let s of n.matchAll(G)){if(s.index>a)r.push({text:n.slice(a,s.index)});r.push({url:s[0]}),a=s.index+s[0].length}if(a<n.length)r.push({text:n.slice(a)})}),r})}function w(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t])}async function N(e){try{let{html:r}=await z("notification",{subject:e.subject??"",variables:{subject:e.subject??"",paragraphs:A(e.body),preheader:String(e.body??"").replace(/\s+/g," ").trim().slice(0,140),...e.action?.url?{actionUrl:e.action.url,actionLabel:e.action.label||"Open"}:{}}});if(r)return r}catch{}let t=e.action?.url?`<p><a href="${w(e.action.url)}">${w(e.action.label||e.action.url)}</a></p>`:"";return`<p>${w(e.body)}</p>${t}`}function v(e){return e.action?.url?`${e.body}
|
|
5
5
|
|
|
6
|
-
${e.action.label||"Open"}: ${e.action.url}`:e.body}import{log as K}from"@stacksjs/cli";import{db as l,lockRow as
|
|
6
|
+
${e.action.label||"Open"}: ${e.action.url}`:e.body}import{log as K}from"@stacksjs/cli";import{db as l,lockRow as j,sqlDateTime as V}from"@stacksjs/database/runtime";var p="notification_preferences";function P(e,t){return t===null?e.whereNull("category"):e.where("category","=",t)}async function x(e,t){let r=new Map;try{let i=await P(l.selectFrom(p).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let a of i)r.set(a.channel,a.enabled===!0||a.enabled===1)}catch(n){K.debug?.(`[notifications] preferences lookup failed (table missing?): ${n.message}`)}return r}async function W(e,t,r,n){await l.transaction(async(i)=>{await j(i,"users",{id:e}),await B(e,t,r,n)})}async function B(e,t,r,n){if(!Number.isSafeInteger(e)||e<=0||typeof r!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let i=V(),a=n??null,s=l.selectFrom(p).select(["id"]).where("user_id","=",e).where("channel","=",t),o=await P(s,a).executeTakeFirst();if(o?.id){await l.updateTable(p).set({enabled:r,updated_at:i}).where("id","=",o.id).execute();return}await l.insertInto(p).values({user_id:e,channel:t,enabled:r,category:a,created_at:i,updated_at:i}).execute()}async function X(e,t){await l.transaction(async(r)=>{await j(r,"users",{id:e});for(let n of t)await B(e,n.channel,n.enabled,n.category)})}async function R(e,t,r){let n=await x(e,r);if(r!==void 0){let i=await x(e,void 0);for(let[a,s]of i)if(!s||!n.has(a))n.set(a,s)}return t.filter((i)=>{let a=n.get(i);return a===void 0?!0:a===!0})}var Z=Y;function ee(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function Se(e){if(!e.length)throw Error("No notification channels were delivered");let t=e.filter((r)=>!r.success);if(t.length){let r=t.map((n)=>n.error instanceof Error?n.error:Error(`Notification delivery failed through ${n.channel}`));if(r.length===1)throw r[0];throw AggregateError(r,r.map((n)=>n.message).join("; "))}}function E(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function q(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return T[t]}function M(e){if(e)try{return O.use(e)}catch(t){k.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return O}function F(e){if(!e)return h;if(e!=="twilio"&&e!=="vonage")throw Error(`Unsupported SMS driver: ${e}`);let t=e;return{send:async(r)=>(await h.init(),h.getDriver(t).send(r))}}function te(){return D}function re(){return m}function Ie(){return d}function ne(e,t){let r=e||Z?.default||"email",n=t;switch(r){case"email":return M(n);case"chat":return q(n);case"sms":return F(n);case"database":return re();default:throw Error(`Notification type "${r}" is not supported`)}}async function Ae(e,t,r=["email"],n={}){let i=r;if(e.userId&&!n.ignorePreferences)try{i=await R(e.userId,r,n.category)}catch(o){k.warn(`[notify] preferences filter failed, sending all channels: ${o.message}`)}let s=(await Promise.allSettled(i.map(async(o)=>{switch(o){case"email":{if(!e.email)throw Error("[notify] email channel requires recipient.email");let f=await M().send({to:e.email,subject:t.subject??"",text:v(t),html:await N(t)});ee(f);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(n.ignorePreferences!==!0){let{isPhoneOptedOut:f}=await import("@stacksjs/sms");if(await f(e.phone))throw Error("[notify] sms recipient has opted out (STOP)")}let c=F();E(await c.send({to:e.phone,body:t.body}));break}case"chat":{if(!e.chatRecipient||Array.isArray(e.chatRecipient)&&!e.chatRecipient.length)throw Error("[notify] chat channel requires recipient.chatRecipient");let c=q();E(await c.send({to:e.chatRecipient,content:t.body,subject:t.subject,data:t.data}));break}case"database":{if(!e.userId)throw Error("[notify] database channel requires recipient.userId");await m.send({userId:e.userId,type:t.subject||"notification",data:{body:t.body,...t.data}});break}case"push":{if(!e.pushTokens||Array.isArray(e.pushTokens)&&e.pushTokens.length===0)throw Error("[notify] push channel requires recipient.pushTokens");let c=te();E(await c.send(e.pushTokens,{title:t.subject,body:t.body,data:t.data}));break}case"broadcast":{let c=await d.send({channel:e.broadcastChannel,userId:e.userId,event:t.subject??"notification",data:{body:t.body,...t.data}});if(!c.delivered)throw Error(c.reason||"Broadcast notification was not delivered");break}default:throw Error(`Unsupported notification channel: ${o}`)}}))).map((o,c)=>{let f=i[c];if(!f)throw Error(`Missing notification channel for result ${c}`);if(o.status==="rejected"){let U=o.reason instanceof Error?o.reason.message:String(o.reason);k.warn(`[notify] ${f} channel failed: ${U}`)}return{channel:f,success:o.status==="fulfilled",error:o.status==="rejected"?o.reason instanceof Error?o.reason:Error(String(o.reason)):void 0}});return await Promise.all(s.map((o)=>y(b(e,t,o,n)))),s}function $e(){return ne()}export{d as BroadcastNotificationDriver,m as DatabaseNotificationDriver,X as bulkSetPreferences,A as emailParagraphs,ee as ensureSuccessfulEmailResult,Se as ensureSuccessfulNotificationResults,R as filterChannelsByPreferences,x as getNotificationPreferences,b as makeDeliveryRecord,$e as notification,N as notificationEmailHtml,v as notificationEmailText,Ae as notify,y as recordNotificationDelivery,I as resolveDeliveryRecipient,W as setNotificationPreference,Ie as useBroadcast,q as useChat,re as useDatabase,M as useEmail,ne as useNotification,te as usePush,F as useSMS,P as wherePreferenceCategory};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@stacksjs/notifications",
|
|
3
3
|
"type": "module",
|
|
4
4
|
"sideEffects": false,
|
|
5
|
-
"version": "0.75.
|
|
5
|
+
"version": "0.75.113",
|
|
6
6
|
"description": "The Stacks notifications integration.",
|
|
7
7
|
"author": "Chris Breuer",
|
|
8
8
|
"contributors": [
|
|
@@ -64,18 +64,18 @@
|
|
|
64
64
|
"prepublishOnly": "bun run build"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@stacksjs/chat": "0.75.
|
|
68
|
-
"@stacksjs/cli": "0.75.
|
|
69
|
-
"@stacksjs/config": "0.75.
|
|
70
|
-
"@stacksjs/database": "0.75.
|
|
71
|
-
"@stacksjs/email": "0.75.
|
|
72
|
-
"@stacksjs/push": "0.75.
|
|
73
|
-
"@stacksjs/realtime": "0.75.
|
|
74
|
-
"@stacksjs/sms": "0.75.
|
|
67
|
+
"@stacksjs/chat": "0.75.113",
|
|
68
|
+
"@stacksjs/cli": "0.75.113",
|
|
69
|
+
"@stacksjs/config": "0.75.113",
|
|
70
|
+
"@stacksjs/database": "0.75.113",
|
|
71
|
+
"@stacksjs/email": "0.75.113",
|
|
72
|
+
"@stacksjs/push": "0.75.113",
|
|
73
|
+
"@stacksjs/realtime": "0.75.113",
|
|
74
|
+
"@stacksjs/sms": "0.75.113"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@stacksjs/error-handling": "0.75.
|
|
78
|
-
"@stacksjs/types": "0.75.
|
|
77
|
+
"@stacksjs/error-handling": "0.75.113",
|
|
78
|
+
"@stacksjs/types": "0.75.113",
|
|
79
79
|
"better-dx": "^0.2.24"
|
|
80
80
|
}
|
|
81
81
|
}
|