@stacksjs/notifications 0.70.86 → 0.70.88

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/notifications",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.86",
5
+ "version": "0.70.88",
6
6
  "description": "The Stacks notifications integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -59,14 +59,14 @@
59
59
  "prepublishOnly": "bun run build"
60
60
  },
61
61
  "devDependencies": {
62
- "@stacksjs/chat": "0.70.86",
63
- "@stacksjs/cli": "0.70.86",
64
- "@stacksjs/config": "0.70.86",
62
+ "@stacksjs/chat": "0.70.88",
63
+ "@stacksjs/cli": "0.70.88",
64
+ "@stacksjs/config": "0.70.88",
65
65
  "better-dx": "^0.2.16",
66
- "@stacksjs/email": "0.70.86",
67
- "@stacksjs/error-handling": "0.70.86",
68
- "@stacksjs/push": "0.70.86",
69
- "@stacksjs/sms": "0.70.86",
70
- "@stacksjs/types": "0.70.86"
66
+ "@stacksjs/email": "0.70.88",
67
+ "@stacksjs/error-handling": "0.70.88",
68
+ "@stacksjs/push": "0.70.88",
69
+ "@stacksjs/sms": "0.70.88",
70
+ "@stacksjs/types": "0.70.88"
71
71
  }
72
72
  }
@@ -1,24 +0,0 @@
1
- /** @defaultValue `{ send: () => Promise<unknown> }` */
2
- export declare const BroadcastNotificationDriver: {
3
- /**
4
- * Emit the notification over the realtime channel. Resolves with
5
- * `delivered: false` (and a reason) when no realtime server is
6
- * available rather than throwing — fire-and-forget is the right shape
7
- * for a notification channel that ships transports best-effort.
8
- */
9
- send: (options: BroadcastNotificationOptions) => Promise<BroadcastNotificationResult>
10
- };
11
- /** Options accepted by the broadcast driver. */
12
- export declare interface BroadcastNotificationOptions {
13
- channel?: string
14
- userId?: number
15
- event?: string
16
- data?: Record<string, unknown>
17
- private?: boolean
18
- }
19
- export declare interface BroadcastNotificationResult {
20
- delivered: boolean
21
- channel: string
22
- event: string
23
- reason?: string
24
- }
@@ -1 +0,0 @@
1
- export * as chat from '@stacksjs/chat';
@@ -1,50 +0,0 @@
1
- export declare function useDatabase(): typeof DatabaseNotificationDriver;
2
- /**
3
- * @defaultValue
4
- * ```ts
5
- * {
6
- * send: () => unknown,
7
- * getUserNotifications: () => unknown,
8
- * getUnreadNotifications: () => unknown,
9
- * markAsRead: () => unknown,
10
- * markAllAsRead: () => unknown,
11
- * unreadCount: () => unknown,
12
- * deleteNotification: () => unknown,
13
- * deleteAllNotifications: () => unknown
14
- * }
15
- * ```
16
- */
17
- export declare const DatabaseNotificationDriver: {
18
- send: (options: CreateNotificationOptions) => Promise<DatabaseNotification>;
19
- getUserNotifications: (userId: number) => Promise<DatabaseNotification[]>;
20
- getUnreadNotifications: (userId: number) => Promise<DatabaseNotification[]>;
21
- markAsRead: (id: number) => Promise<void>;
22
- markAllAsRead: (userId: number) => Promise<void>;
23
- unreadCount: (userId: number) => Promise<number>;
24
- deleteNotification: (id: number) => Promise<void>;
25
- deleteAllNotifications: (userId: number) => Promise<void>
26
- };
27
- // `./database-schema.d.ts` augments `@stacksjs/database`'s
28
- // `DatabaseSchema` with the `notifications` + `notification_preferences`
29
- // tables so the chain calls below type-check without per-call `as any`
30
- // (Notif-3 follow-up to #1923 / #1937).
31
- export declare interface DatabaseNotification {
32
- id: number
33
- user_id: number
34
- type: string
35
- data: string
36
- read_at: string | null
37
- created_at: string
38
- updated_at: string | null
39
- }
40
- export declare interface CreateNotificationOptions {
41
- userId: number
42
- type: string
43
- data: Record<string, unknown>
44
- }
45
- /** Shape of a row-insert result across the supported drivers. */
46
- declare interface InsertResultLike {
47
- insertId?: number | bigint
48
- lastInsertRowid?: number | bigint
49
- lastInsertId?: number | bigint
50
- }
@@ -1 +0,0 @@
1
- export * as email from '@stacksjs/email';
@@ -1,6 +0,0 @@
1
- export * from './broadcast';
2
- export * from './chat';
3
- export * from './database';
4
- export * from './email';
5
- export * from './push';
6
- export * from './sms';
@@ -1 +0,0 @@
1
- export * as push from '@stacksjs/push';
@@ -1 +0,0 @@
1
- export * as sms from '@stacksjs/sms';
package/dist/index.d.ts DELETED
@@ -1,84 +0,0 @@
1
- import { BroadcastNotificationDriver } from './drivers/broadcast';
2
- import { chat, email, push, sms } from './drivers/index';
3
- import { DatabaseNotificationDriver } from './drivers/database';
4
- import { filterChannelsByPreferences } from './preferences';
5
- import type { EmailMessage, EmailResult } from '@stacksjs/types';
6
- export type { BroadcastNotificationOptions, BroadcastNotificationResult } from './drivers/broadcast';
7
- export type { CreateNotificationOptions, DatabaseNotification } from './drivers/database';
8
- export type { NotificationPreferenceRow, PreferenceChannel } from './preferences';
9
- export declare function useChat(driver?: string): typeof chat[keyof typeof chat];
10
- /**
11
- * Return the email transport — by default the @stacksjs/email Mail
12
- * singleton, which lazy-resolves its driver from `config.email.default`
13
- * (so `MAIL_MAILER=log` in dev / `ses` in prod just works). Pass an
14
- * explicit driver name to scope to a specific transport — useful in
15
- * tests that want to assert against `LogEmailDriver` specifically without
16
- * touching the global config.
17
- *
18
- * Earlier this function returned the *driver namespace* (e.g.
19
- * `{ SESDriver, default }`), which has no `send`. The `'send' in driver`
20
- * guard in `notify()` was always false, so the email channel silently
21
- * no-op'd for every booking confirmation, host alert, etc.
22
- */
23
- export declare function useEmail(driver?: string): EmailTransport;
24
- export declare function useSMS(driver?: string): typeof sms[keyof typeof sms];
25
- /**
26
- * Push transport — returns the `@stacksjs/push` namespace whose
27
- * `send(to, notification, options)` dispatches to Expo or FCM based on
28
- * `options.driver`. `notify()` calls it with the driver omitted so the
29
- * push package's own default kicks in (`expo`). Apps that need to
30
- * force FCM can call `push.send(...)` directly with `{ driver: 'fcm' }`.
31
- *
32
- * Previously the `'push'` switch arm in `notify()` threw a hardcoded
33
- * "not yet wired into a default driver" error even though both drivers
34
- * exist (stacksjs/stacks#1874 F-1) — this helper is the missing link.
35
- */
36
- export declare function usePush(): typeof push;
37
- export declare function useDatabase(): typeof DatabaseNotificationDriver;
38
- /**
39
- * Broadcast transport — returns the `BroadcastNotificationDriver` which
40
- * fans out a notification payload over the realtime WebSocket layer
41
- * (`@stacksjs/realtime` `emit()`). See {@link BroadcastNotificationDriver}
42
- * for channel-naming rules. stacksjs/stacks#669.
43
- */
44
- export declare function useBroadcast(): typeof BroadcastNotificationDriver;
45
- export declare function useNotification(typeParam?: string, driverParam?: string): ReturnType<typeof useEmail> | ReturnType<typeof useChat> | ReturnType<typeof useSMS> | typeof DatabaseNotificationDriver;
46
- export declare function notify(recipient: NotificationRecipient, payload: NotificationPayload, channels?: NotificationChannel[], options?: NotifyOptions): Promise<NotifyResult[]>;
47
- export declare function notification(): ReturnType<typeof useNotification>;
48
- /** Optional flags accepted by {@link notify}. */
49
- export declare interface NotifyOptions {
50
- ignorePreferences?: boolean
51
- category?: string
52
- }
53
- /** Minimal transport contract used by `notify()` — anything that exposes a
54
- * `send(EmailMessage)` returning an `EmailResult` works (the @stacksjs/email
55
- * Mail singleton, a per-test LogEmailDriver, a custom mock, etc.). */
56
- export declare interface EmailTransport {
57
- send: (message: EmailMessage) => Promise<EmailResult>
58
- }
59
- export declare interface NotificationPayload {
60
- subject?: string
61
- body: string
62
- data?: Record<string, unknown>
63
- }
64
- export declare interface NotificationRecipient {
65
- email?: string
66
- phone?: string
67
- userId?: number
68
- pushTokens?: string | string[]
69
- broadcastChannel?: string
70
- }
71
- export declare interface NotifyResult {
72
- channel: NotificationChannel
73
- success: boolean
74
- error?: Error
75
- }
76
- export type NotificationChannel = 'email' | 'sms' | 'chat' | 'database' | 'push' | 'broadcast';
77
- export { BroadcastNotificationDriver } from './drivers/broadcast';
78
- export { DatabaseNotificationDriver } from './drivers/database';
79
- export {
80
- bulkSetPreferences,
81
- filterChannelsByPreferences,
82
- getNotificationPreferences,
83
- setNotificationPreference,
84
- } from './preferences';
package/dist/index.js DELETED
@@ -1,2 +0,0 @@
1
- // @bun
2
- var P=import.meta.require;import{log as _}from"@stacksjs/cli";import{notification as k}from"@stacksjs/config";import{mail as T}from"@stacksjs/email";import{log as q}from"@stacksjs/cli";var V={async send(z){let F=z.event??"notification",J=z.channel;if(!J)J=z.userId!==void 0?`private-user-${z.userId}`:"notifications";try{let G=await import("@stacksjs/realtime").catch(()=>null),K=G?.emit,O=G?.getServer;if(!K||!O)return{delivered:!1,channel:J,event:F,reason:"@stacksjs/realtime is not available"};if(!O())return{delivered:!1,channel:J,event:F,reason:"realtime server not running"};let Q=z.private??z.userId!==void 0;return K(J,F,z.data,{private:Q}),q.info(`[notifications] broadcast to '${J}' (${F})`),{delivered:!0,channel:J,event:F}}catch(G){let K=G instanceof Error?G.message:String(G);return q.warn(`[notifications] broadcast failed: ${K}`),{delivered:!1,channel:J,event:F,reason:K}}}};import*as x from"@stacksjs/chat";import{log as D}from"@stacksjs/cli";import{db as U}from"@stacksjs/database";var W={async send(z){let F=new Date().toISOString(),G=await U.insertInto("notifications").values({user_id:z.userId,type:z.type,data:JSON.stringify(z.data),read_at:null,created_at:F,updated_at:F}).execute(),K=Array.isArray(G)?G[0]:G,O=Number(K?.insertId??K?.lastInsertRowid??K?.lastInsertId??0);return D.info(`Database notification sent to user ${z.userId}: ${z.type}`),{id:O,user_id:z.userId,type:z.type,data:JSON.stringify(z.data),read_at:null,created_at:F,updated_at:F}},async getUserNotifications(z){return await U.selectFrom("notifications").selectAll().where("user_id","=",z).orderBy("created_at","desc").execute()},async getUnreadNotifications(z){return await U.selectFrom("notifications").selectAll().where("user_id","=",z).where("read_at","is",null).orderBy("created_at","desc").execute()},async markAsRead(z){await U.updateTable("notifications").set({read_at:new Date().toISOString()}).where("id","=",z).execute()},async markAllAsRead(z){await U.updateTable("notifications").set({read_at:new Date().toISOString()}).where("user_id","=",z).where("read_at","is",null).execute()},async unreadCount(z){let F=await U.selectFrom("notifications").select(U.fn.countAll().as("count")).where("user_id","=",z).where("read_at","is",null).executeTakeFirst();return Number(F?.count??0)},async deleteNotification(z){await U.deleteFrom("notifications").where("id","=",z).execute()},async deleteAllNotifications(z){await U.deleteFrom("notifications").where("user_id","=",z).execute()}};import*as M from"@stacksjs/push";import*as A from"@stacksjs/sms";import{log as S}from"@stacksjs/cli";import{db as X}from"@stacksjs/database";var Y="notification_preferences";async function $(z,F){let J=new Map;try{let G=X.selectFrom(Y).select(["channel","enabled","category"]).where("user_id","=",z);if(F!==void 0)G=G.where("category","=",F);let K=await G.execute();for(let O of K)J.set(O.channel,O.enabled===!0||O.enabled===1)}catch(G){S.debug?.(`[notifications] preferences lookup failed (table missing?): ${G.message}`)}return J}async function L(z,F,J,G){let K=new Date().toISOString(),O=G??null,Q=await X.selectFrom(Y).select(["id"]).where("user_id","=",z).where("channel","=",F).where("category",O===null?"is":"=",O).executeTakeFirst();if(Q?.id){await X.updateTable(Y).set({enabled:J,updated_at:K}).where("id","=",Q.id).execute();return}await X.insertInto(Y).values({user_id:z,channel:F,enabled:J,category:O,created_at:K,updated_at:K}).execute()}async function w(z,F){for(let J of F)await L(z,J.channel,J.enabled,J.category)}async function H(z,F,J){let G=await $(z,J);if(J!==void 0){let K=await $(z,void 0);for(let[O,Q]of K)if(!G.has(O))G.set(O,Q)}return F.filter((K)=>{let O=G.get(K);return O===void 0?!0:O===!0})}var m=k;function E(z){return x[z||"slack"]}function B(z){if(z)try{return T.use(z)}catch(F){_.warn(`[notifications] email driver '${z}' not registered \u2014 falling back to default mail singleton (${F.message})`)}return T}function j(z){return A[z||"twilio"]}function I(){return M}function g(){return W}function Kz(){return V}function b(z,F){let J=z||m?.default||"email",G=F;switch(J){case"email":return B(G);case"chat":return E(G);case"sms":return j(G);case"database":return g();default:throw Error(`Notification type "${J}" is not supported`)}}function C(z){return!!z&&typeof z==="object"&&"send"in z&&typeof z.send==="function"}async function Oz(z,F,J=["email"],G={}){let K=J;if(z.userId&&!G.ignorePreferences)try{K=await H(z.userId,J,G.category)}catch(Q){_.warn(`[notify] preferences filter failed, sending all channels: ${Q.message}`)}return(await Promise.allSettled(K.map(async(Q)=>{switch(Q){case"email":{if(!z.email)throw Error("[notify] email channel requires recipient.email");await B().send({to:z.email,subject:F.subject??"",text:F.body,html:`<p>${f(F.body)}</p>`});break}case"sms":{if(!z.phone)throw Error("[notify] sms channel requires recipient.phone");let R=j();if(!C(R))throw Error("[notify] sms channel is not configured: no usable SMS driver with a send() method");await R.send({to:z.phone,body:F.body});break}case"chat":{let R=E();if(!C(R))throw Error("[notify] chat channel is not configured: no usable chat driver with a send() method");await R.send({body:F.body});break}case"database":{if(!z.userId)throw Error("[notify] database channel requires recipient.userId");await W.send({userId:z.userId,type:F.subject||"notification",data:{body:F.body,...F.data}});break}case"push":{if(!z.pushTokens||Array.isArray(z.pushTokens)&&z.pushTokens.length===0)throw Error("[notify] push channel requires recipient.pushTokens");await I().send(z.pushTokens,{title:F.subject,body:F.body,data:F.data});break}case"broadcast":{await V.send({channel:z.broadcastChannel,userId:z.userId,event:F.subject??"notification",data:{body:F.body,...F.data}});break}default:throw Error(`Unsupported notification channel: ${Q}`)}}))).map((Q,R)=>{let Z=K[R];if(!Z)throw Error(`Missing notification channel for result ${R}`);if(Q.status==="rejected"){let N=Q.reason instanceof Error?Q.reason.message:String(Q.reason);_.warn(`[notify] ${Z} channel failed: ${N}`)}return{channel:Z,success:Q.status==="fulfilled",error:Q.status==="rejected"?Q.reason:void 0}})}function Qz(){return b()}function f(z){return String(z??"").replace(/[&<>"']/g,(F)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[F])}export{j as useSMS,I as usePush,b as useNotification,B as useEmail,g as useDatabase,E as useChat,Kz as useBroadcast,L as setNotificationPreference,Oz as notify,Qz as notification,$ as getNotificationPreferences,H as filterChannelsByPreferences,w as bulkSetPreferences,W as DatabaseNotificationDriver,V as BroadcastNotificationDriver};
@@ -1,106 +0,0 @@
1
- import type { NotificationChannel } from './index';
2
- /**
3
- * Load all notification preferences for a user as a Map keyed by channel.
4
- * Returns `Map<channel, enabled>` — channels with no row recorded are
5
- * absent from the Map (callers should treat absent = enabled by default).
6
- *
7
- * If the `notification_preferences` table doesn't exist yet (apps that
8
- * haven't applied the migration) this returns an empty Map and logs a
9
- * one-line debug message rather than throwing — the user-facing notify()
10
- * flow shouldn't 500 because of an opt-in feature.
11
- *
12
- * @example
13
- * ```ts
14
- * const prefs = await getNotificationPreferences(user.id)
15
- * if (prefs.get('email') === false) console.log('user opted out of email')
16
- * ```
17
- */
18
- export declare function getNotificationPreferences(userId: number, category?: string): Promise<Map<PreferenceChannel, boolean>>;
19
- /**
20
- * Upsert a single preference row for `(user_id, channel, category)`.
21
- * The combination is treated as the natural key — calling this twice with
22
- * the same triple just toggles `enabled` on the existing row.
23
- *
24
- * @example
25
- * ```ts
26
- * // user opts out of marketing emails
27
- * await setNotificationPreference(user.id, 'email', false, 'marketing')
28
- * // ...later, opts back in
29
- * await setNotificationPreference(user.id, 'email', true, 'marketing')
30
- * ```
31
- */
32
- export declare function setNotificationPreference(userId: number, channel: PreferenceChannel, enabled: boolean, category?: string): Promise<void>;
33
- /**
34
- * Bulk upsert preferences for a user — convenient for "save preferences"
35
- * forms that submit the user's full opt-in/out matrix in one POST.
36
- *
37
- * Iterates one upsert per entry rather than one mega-query because the
38
- * underlying conflict logic is keyed on `(user_id, channel, category)`
39
- * and a single SQL statement can't express that across N rows portably.
40
- *
41
- * @example
42
- * ```ts
43
- * await bulkSetPreferences(user.id, [
44
- * { channel: 'email', enabled: false, category: 'marketing' },
45
- * { channel: 'sms', enabled: true },
46
- * { channel: 'push', enabled: false },
47
- * ])
48
- * ```
49
- */
50
- export declare function bulkSetPreferences(userId: number, prefs: Array<{ channel: PreferenceChannel, enabled: boolean, category?: string }>): Promise<void>;
51
- /**
52
- * Filter a list of channels through a user's preferences.
53
- *
54
- * Returns the subset the user is opted *in* for. Channels with no row
55
- * recorded fall through as enabled (default-allow) so that introducing
56
- * the preferences feature doesn't suddenly break notifications for users
57
- * who never visited the preferences page.
58
- *
59
- * @example
60
- * ```ts
61
- * const allowed = await filterChannelsByPreferences(user.id, ['email', 'sms', 'push'])
62
- * // -> ['email', 'push'] (user disabled sms)
63
- * ```
64
- */
65
- export declare function filterChannelsByPreferences<T extends PreferenceChannel>(userId: number, channels: T[], category?: string): Promise<T[]>;
66
- /**
67
- * `notification_preferences` table — user-level opt-out per channel
68
- * (and optionally per category, e.g. `marketing` vs `system`).
69
- *
70
- * The migration is intentionally NOT shipped here — apps maintain their
71
- * own migration directory and column conventions. Generate the migration
72
- * yourself; the schema below is what the runtime expects.
73
- *
74
- * ```sql
75
- * CREATE TABLE notification_preferences (
76
- * id INTEGER PRIMARY KEY AUTOINCREMENT,
77
- * user_id INTEGER NOT NULL,
78
- * channel TEXT NOT NULL, -- email | sms | chat | database | push
79
- * enabled INTEGER NOT NULL DEFAULT 1, -- boolean (0/1)
80
- * category TEXT, -- optional, e.g. 'marketing'
81
- * created_at TEXT NOT NULL,
82
- * updated_at TEXT NOT NULL,
83
- * UNIQUE (user_id, channel, category)
84
- * );
85
- *
86
- * CREATE INDEX idx_notification_preferences_user
87
- * ON notification_preferences (user_id);
88
- * ```
89
- *
90
- * Postgres / MySQL equivalents: use `BOOLEAN` for `enabled`, `BIGINT` for
91
- * IDs, and `TIMESTAMP` for the timestamp columns. The `UNIQUE (user_id,
92
- * channel, category)` constraint is what makes the upsert below safe; if
93
- * your DB doesn't allow `NULL` to participate in `UNIQUE`, treat
94
- * `category IS NULL` as a sentinel "global" category instead.
95
- */
96
- export declare interface NotificationPreferenceRow {
97
- id: number
98
- user_id: number
99
- channel: NotificationChannel | 'push'
100
- enabled: boolean | 0 | 1
101
- category?: string | null
102
- created_at: string
103
- updated_at: string
104
- }
105
- /** All channels that can be filtered through preferences. */
106
- export type PreferenceChannel = NotificationChannel | 'push';