@stacksjs/notifications 0.75.112 → 0.75.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,271 +1,79 @@
1
1
  # Stacks Notifications
2
2
 
3
- Stacks Notifications is a unified driver system for sending messages/notifications. It supports sending emails, SMS messages, and chat messages.
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 { notification } from '@stacksjs/notifications'
34
-
35
- notification.send(options)
36
- ```
37
-
38
- ## 🏎️ Drivers
39
-
40
- There are different option types for Chat, Email, and SMS drivers. To use any driver, simply configure the notification `options` object.
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
- interface ChatOptions {
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
- ### SMS
154
-
155
- SMS drivers are configured with the following environment variables:
156
-
157
- #### Twilio
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
- ### Chat
219
-
220
- Chat drivers are configured with the following environment variables:
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
- The MIT License (MIT). Please see [LICENSE](https://github.com/stacksjs/stacks/tree/main/LICENSE.md) for more information.
73
+ ## Verification
270
74
 
271
- Made with 💙
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
- markAsRead: (id: number) => Promise<void>;
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
- deleteNotification: (id: number) => Promise<void>;
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
- export declare function useChat(driver?: string): typeof chat[keyof typeof chat];
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): typeof sms[keyof typeof sms];
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 j}from"@stacksjs/email";import{log as D}from"@stacksjs/cli";var d={async send(e){let t=e.event??"notification",n=e.channel;if(!n)n=e.userId!==void 0?`private-user.${e.userId}`:"notifications";try{let r=await import("@stacksjs/realtime").catch(()=>null),i=r?.emit,a=r?.getServer;if(!i||!a)return{delivered:!1,channel:n,event:t,reason:"@stacksjs/realtime is not available"};if(!a())return{delivered:!1,channel:n,event:t,reason:"realtime server not running"};let s=e.private??e.userId!==void 0;return i(n,t,e.data,{private:s}),D.info(`[notifications] broadcast to '${n}' (${t})`),{delivered:!0,channel:n,event:t}}catch(r){let i=r instanceof Error?r.message:String(r);return D.warn(`[notifications] broadcast failed: ${i}`),{delivered:!1,channel:n,event:t,reason:i}}}};import*as R from"@stacksjs/chat";import{log as U}from"@stacksjs/cli";import{db as u,sqlDateTime as h}from"@stacksjs/database/runtime";var m={async send(e){let t=h(),r=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(r)?r[0]:r,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 U.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((n)=>({...n,id:Number(n.id),user_id:Number(n.user_id)}))},async getUnreadNotifications(e){return(await u.selectFrom("notifications").selectAll().where("user_id","=",e).whereNull("read_at").orderBy("created_at","desc").execute()).map((n)=>({...n,id:Number(n.id),user_id:Number(n.user_id)}))},async markAsRead(e){await u.updateTable("notifications").set({read_at:h()}).where("id","=",e).whereNull("read_at").execute()},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:h()}).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){await u.deleteFrom("notifications").where("id","=",e).execute()},async deleteAllNotifications(e){await u.deleteFrom("notifications").where("user_id","=",e).execute()}};import*as E from"@stacksjs/push";import*as _ from"@stacksjs/sms";import{log as H}from"@stacksjs/cli";import{config as T}from"@stacksjs/config";import{db as J,sqlDateTime as C}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"Configured chat destination"}}function b(e,t,n,r,i=C()){return{userId:e.userId,channel:n.channel,recipient:I(e,n.channel),subject:t.subject,body:t.body,status:n.success?"sent":"failed",error:n.error?.message,metadata:{...t.data,...r.category?{category:r.category}:{}},sentAt:i}}function Q(e){let t=T.notification?.tracking;if(t?.enabled===!1)return!1;let n=t?.channels;if(!n||n.length===0)return!0;return n.includes(e)}async function g(e){if(!Q(e.channel))return;let t=C();try{await J.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:T.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(n){let r=n instanceof Error?n.message:String(n);H.warn(`[notifications] could not record ${e.channel} delivery: ${r}`)}}import{templateByName as z}from"@stacksjs/email";var G=/https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]]/g;function S(e){return String(e??"").replace(/\r\n?/g,`
3
- `).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let n=[];return t.split(`
4
- `).forEach((r,i)=>{if(i>0)n.push({br:!0});let a=0;for(let s of r.matchAll(G)){if(s.index>a)n.push({text:r.slice(a,s.index)});n.push({url:s[0]}),a=s.index+s[0].length}if(a<r.length)n.push({text:r.slice(a)})}),n})}function y(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t])}async function w(e){try{let{html:n}=await z("notification",{subject:e.subject??"",variables:{subject:e.subject??"",paragraphs:S(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(n)return n}catch{}let t=e.action?.url?`<p><a href="${y(e.action.url)}">${y(e.action.label||e.action.url)}</a></p>`:"";return`<p>${y(e.body)}</p>${t}`}function N(e){return e.action?.url?`${e.body}
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)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[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 A,sqlDateTime as V}from"@stacksjs/database/runtime";var p="notification_preferences";function x(e,t){return t===null?e.whereNull("category"):e.where("category","=",t)}async function v(e,t){let n=new Map;try{let i=await x(l.selectFrom(p).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let a of i)n.set(a.channel,a.enabled===!0||a.enabled===1)}catch(r){K.debug?.(`[notifications] preferences lookup failed (table missing?): ${r.message}`)}return n}async function W(e,t,n,r){await l.transaction(async(i)=>{await A(i,"users",{id:e}),await B(e,t,n,r)})}async function B(e,t,n,r){if(!Number.isSafeInteger(e)||e<=0||typeof n!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let i=V(),a=r??null,s=l.selectFrom(p).select(["id"]).where("user_id","=",e).where("channel","=",t),o=await x(s,a).executeTakeFirst();if(o?.id){await l.updateTable(p).set({enabled:n,updated_at:i}).where("id","=",o.id).execute();return}await l.insertInto(p).values({user_id:e,channel:t,enabled:n,category:a,created_at:i,updated_at:i}).execute()}async function X(e,t){await l.transaction(async(n)=>{await A(n,"users",{id:e});for(let r of t)await B(e,r.channel,r.enabled,r.category)})}async function P(e,t,n){let r=await v(e,n);if(n!==void 0){let i=await v(e,void 0);for(let[a,s]of i)if(!s||!r.has(a))r.set(a,s)}return t.filter((i)=>{let a=r.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 q(e){return R[e||"slack"]}function F(e){if(e)try{return j.use(e)}catch(t){k.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return j}function M(e){return _[e||"twilio"]}function te(){return E}function ne(){return m}function Ie(){return d}function re(e,t){let n=e||Z?.default||"email",r=t;switch(n){case"email":return F(r);case"chat":return q(r);case"sms":return M(r);case"database":return ne();default:throw Error(`Notification type "${n}" is not supported`)}}function O(e){return!!e&&typeof e==="object"&&"send"in e&&typeof e.send==="function"}async function Se(e,t,n=["email"],r={}){let i=n;if(e.userId&&!r.ignorePreferences)try{i=await P(e.userId,n,r.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 F().send({to:e.email,subject:t.subject??"",text:N(t),html:await w(t)});ee(f);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(r.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=M();if(!O(c))throw Error("[notify] sms channel is not configured: no usable SMS driver with a send() method");await c.send({to:e.phone,body:t.body});break}case"chat":{let c=q();if(!O(c))throw Error("[notify] chat channel is not configured: no usable chat driver with a send() method");await c.send({body:t.body});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");await te().send(e.pushTokens,{title:t.subject,body:t.body,data:t.data});break}case"broadcast":{await d.send({channel:e.broadcastChannel,userId:e.userId,event:t.subject??"notification",data:{body:t.body,...t.data}});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 L=o.reason instanceof Error?o.reason.message:String(o.reason);k.warn(`[notify] ${f} channel failed: ${L}`)}return{channel:f,success:o.status==="fulfilled",error:o.status==="rejected"?o.reason:void 0}});return await Promise.all(s.map((o)=>g(b(e,t,o,r)))),s}function Ae(){return re()}export{d as BroadcastNotificationDriver,m as DatabaseNotificationDriver,X as bulkSetPreferences,S as emailParagraphs,ee as ensureSuccessfulEmailResult,P as filterChannelsByPreferences,v as getNotificationPreferences,b as makeDeliveryRecord,Ae as notification,w as notificationEmailHtml,N as notificationEmailText,Se as notify,g as recordNotificationDelivery,I as resolveDeliveryRecipient,W as setNotificationPreference,Ie as useBroadcast,q as useChat,ne as useDatabase,F as useEmail,re as useNotification,te as usePush,M as useSMS,x as wherePreferenceCategory};
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.112",
5
+ "version": "0.75.114",
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.112",
68
- "@stacksjs/cli": "0.75.112",
69
- "@stacksjs/config": "0.75.112",
70
- "@stacksjs/database": "0.75.112",
71
- "@stacksjs/email": "0.75.112",
72
- "@stacksjs/push": "0.75.112",
73
- "@stacksjs/realtime": "0.75.112",
74
- "@stacksjs/sms": "0.75.112"
67
+ "@stacksjs/chat": "0.75.114",
68
+ "@stacksjs/cli": "0.75.114",
69
+ "@stacksjs/config": "0.75.114",
70
+ "@stacksjs/database": "0.75.114",
71
+ "@stacksjs/email": "0.75.114",
72
+ "@stacksjs/push": "0.75.114",
73
+ "@stacksjs/realtime": "0.75.114",
74
+ "@stacksjs/sms": "0.75.114"
75
75
  },
76
76
  "devDependencies": {
77
- "@stacksjs/error-handling": "0.75.112",
78
- "@stacksjs/types": "0.75.112",
77
+ "@stacksjs/error-handling": "0.75.114",
78
+ "@stacksjs/types": "0.75.114",
79
79
  "better-dx": "^0.2.24"
80
80
  }
81
81
  }