@stacksjs/notifications 0.70.23 → 0.70.26

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/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- import m from"node:process";import{log as s}from"@stacksjs/cli";import{notification as r}from"@stacksjs/config";import{err as p}from"@stacksjs/error-handling";import{ExitCode as u}from"@stacksjs/types";import*as i from"@stacksjs/chat";import*as a from"@stacksjs/email";import*as k from"@stacksjs/push";import*as n from"@stacksjs/sms";function c(t="slack"){return i[t]}function l(t="mailtrap"){return a[t]}function x(t="twilio"){return n[t]}function y(t="email",f="mailtrap"){if(!r.default)return s.error("No default notification type set in config/notification.ts"),m.exit(u.InvalidArgument);let e=t||r.default,o=f||r.default;switch(e){case"email":return l(o);case"chat":return c(o);case"sms":return x(o);default:return p(`Type ${e} not supported`)}}function j(){return y()}export{x as useSMS,y as useNotification,l as useEmail,c as useChat,j as notification};
2
+ import{log as Y}from"@stacksjs/cli";import{notification as A}from"@stacksjs/config";import{mail as M}from"@stacksjs/email";import*as $ from"@stacksjs/chat";import{log as C}from"@stacksjs/cli";import{db as R}from"@stacksjs/database";var U={async send(x){let z=new Date().toISOString(),F=await R.insertInto("notifications").values({user_id:x.userId,type:x.type,data:JSON.stringify(x.data),read_at:null,created_at:z,updated_at:z}).execute(),K=Number(F?.[0]?.insertId??F?.insertId??F?.lastInsertRowid??F?.lastInsertId??0);return C.info(`Database notification sent to user ${x.userId}: ${x.type}`),{id:K,user_id:x.userId,type:x.type,data:JSON.stringify(x.data),read_at:null,created_at:z,updated_at:z}},async getUserNotifications(x){return await R.selectFrom("notifications").selectAll().where("user_id","=",x).orderBy("created_at","desc").execute()},async getUnreadNotifications(x){return await R.selectFrom("notifications").selectAll().where("user_id","=",x).where("read_at","is",null).orderBy("created_at","desc").execute()},async markAsRead(x){await R.updateTable("notifications").set({read_at:new Date().toISOString()}).where("id","=",x).execute()},async markAllAsRead(x){await R.updateTable("notifications").set({read_at:new Date().toISOString()}).where("user_id","=",x).where("read_at","is",null).execute()},async unreadCount(x){let z=await R.selectFrom("notifications").select(R.fn.countAll().as("count")).where("user_id","=",x).where("read_at","is",null).executeTakeFirst();return Number(z?.count??0)},async deleteNotification(x){await R.deleteFrom("notifications").where("id","=",x).execute()},async deleteAllNotifications(x){await R.deleteFrom("notifications").where("user_id","=",x).execute()}};import*as g from"@stacksjs/email";import*as v from"@stacksjs/push";import*as H from"@stacksjs/sms";import{log as E}from"@stacksjs/cli";import{db as _}from"@stacksjs/database";var V="notification_preferences";async function W(x,z){let G=new Map;try{let F=_.selectFrom(V).select(["channel","enabled","category"]).where("user_id","=",x);if(z!==void 0)F=F.where("category","=",z);let K=await F.execute();for(let Q of K)G.set(Q.channel,Q.enabled===!0||Q.enabled===1)}catch(F){E.debug?.(`[notifications] preferences lookup failed (table missing?): ${F.message}`)}return G}async function q(x,z,G,F){let K=new Date().toISOString(),Q=F??null,J=_,O=await J.selectFrom(V).select(["id"]).where("user_id","=",x).where("channel","=",z).where("category",Q===null?"is":"=",Q).executeTakeFirst();if(O?.id){await J.updateTable(V).set({enabled:G,updated_at:K}).where("id","=",O.id).execute();return}await J.insertInto(V).values({user_id:x,channel:z,enabled:G,category:Q,created_at:K,updated_at:K}).execute()}async function j(x,z){for(let G of z)await q(x,G.channel,G.enabled,G.category)}async function X(x,z,G){let F=await W(x,G);if(G!==void 0){let K=await W(x,void 0);for(let[Q,J]of K)if(!F.has(Q))F.set(Q,J)}return z.filter((K)=>{let Q=F.get(K);return Q===void 0?!0:Q===!0})}var D=A;function L(x){return $[x||"slack"]}function T(x){if(x)try{return M.use(x)}catch(z){Y.warn(`[notifications] email driver '${x}' not registered \u2014 falling back to default mail singleton (${z.message})`)}return M}function m(x){return H[x||"twilio"]}function N(){return U}function S(x,z){let G=x||D?.default||"email",F=z;switch(G){case"email":return T(F);case"chat":return L(F);case"sms":return m(F);case"database":return N();default:throw Error(`Notification type "${G}" is not supported`)}}async function Gx(x,z,G=["email"],F={}){let K=G;if(x.userId&&!F.ignorePreferences)try{K=await X(x.userId,G,F.category)}catch(J){Y.warn(`[notify] preferences filter failed, sending all channels: ${J.message}`)}return(await Promise.allSettled(K.map(async(J)=>{switch(J){case"email":{if(!x.email)throw Error("[notify] email channel requires recipient.email");await T().send({to:x.email,subject:z.subject,text:z.body,html:`<p>${P(z.body)}</p>`});break}case"sms":{let O=m();if(O&&typeof O==="object"&&"send"in O){if(!x.phone)throw Error("[notify] sms channel requires recipient.phone");await O.send({to:x.phone,body:z.body})}break}case"chat":{let O=L();if(O&&typeof O==="object"&&"send"in O)await O.send({body:z.body});break}case"database":{if(!x.userId)throw Error("[notify] database channel requires recipient.userId");await U.send({userId:x.userId,type:z.subject||"notification",data:{body:z.body,...z.data}});break}case"push":throw Error("[notify] push channel is filtered by preferences but not yet wired into a default driver");default:throw Error(`Unsupported notification channel: ${J}`)}}))).map((J,O)=>{let Z=K[O];if(J.status==="rejected"){let B=J.reason instanceof Error?J.reason.message:String(J.reason);Y.warn(`[notify] ${Z} channel failed: ${B}`)}return{channel:Z,success:J.status==="fulfilled",error:J.status==="rejected"?J.reason:void 0}})}function Jx(){return S()}function P(x){return String(x??"").replace(/[&<>"']/g,(z)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[z])}export{m as useSMS,S as useNotification,T as useEmail,N as useDatabase,L as useChat,q as setNotificationPreference,Gx as notify,Jx as notification,W as getNotificationPreferences,X as filterChannelsByPreferences,j as bulkSetPreferences,U as DatabaseNotificationDriver};
@@ -0,0 +1,40 @@
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
+ export declare interface DatabaseNotification {
28
+ id: number
29
+ user_id: number
30
+ type: string
31
+ data: string
32
+ read_at: string | null
33
+ created_at: string
34
+ updated_at: string | null
35
+ }
36
+ export declare interface CreateNotificationOptions {
37
+ userId: number
38
+ type: string
39
+ data: Record<string, any>
40
+ }
@@ -0,0 +1,87 @@
1
+ import { chat, email, sms } from './drivers';
2
+ import { DatabaseNotificationDriver } from './drivers/database';
3
+ import { filterChannelsByPreferences } from './preferences';
4
+ import type { EmailMessage, EmailResult } from '@stacksjs/types';
5
+ export type { CreateNotificationOptions, DatabaseNotification } from './drivers/database';
6
+ export type { NotificationPreferenceRow, PreferenceChannel } from './preferences';
7
+ export declare function useChat(driver?: string): typeof chat[keyof typeof chat];
8
+ /**
9
+ * Return the email transport — by default the @stacksjs/email Mail
10
+ * singleton, which lazy-resolves its driver from `config.email.default`
11
+ * (so `MAIL_MAILER=log` in dev / `ses` in prod just works). Pass an
12
+ * explicit driver name to scope to a specific transport — useful in
13
+ * tests that want to assert against `LogEmailDriver` specifically without
14
+ * touching the global config.
15
+ *
16
+ * Earlier this function returned the *driver namespace* (e.g.
17
+ * `{ SESDriver, default }`), which has no `send`. The `'send' in driver`
18
+ * guard in `notify()` was always false, so the email channel silently
19
+ * no-op'd for every booking confirmation, host alert, etc.
20
+ */
21
+ export declare function useEmail(driver?: string): EmailTransport;
22
+ export declare function useSMS(driver?: string): typeof sms[keyof typeof sms];
23
+ export declare function useDatabase(): typeof DatabaseNotificationDriver;
24
+ export declare function useNotification(typeParam?: string, driverParam?: string): typeof chat[keyof typeof chat] | typeof email[keyof typeof email] | typeof sms[keyof typeof sms] | typeof DatabaseNotificationDriver;
25
+ /**
26
+ * Send a notification through multiple channels simultaneously.
27
+ * Uses Promise.allSettled so one channel failure doesn't block others.
28
+ *
29
+ * When `recipient.userId` is set and `options.ignorePreferences` is not
30
+ * `true`, the channel list is first filtered through the user's
31
+ * `notification_preferences` rows — any channel they've explicitly
32
+ * disabled is silently skipped. Channels with no preference recorded fall
33
+ * through as enabled (default-allow), so introducing this feature doesn't
34
+ * regress notifications for users who haven't visited the preferences UI.
35
+ *
36
+ * For transactional notifications that must always go out (password
37
+ * resets, security alerts, billing failures), pass
38
+ * `{ ignorePreferences: true }`.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * // respects user preferences
43
+ * await notify({ userId: 7, email: 'a@x' }, { body: 'New message' }, ['email', 'sms'])
44
+ *
45
+ * // bypasses preferences (security alert)
46
+ * await notify({ userId: 7, email: 'a@x' }, { body: 'New login' }, ['email'], { ignorePreferences: true })
47
+ *
48
+ * // category-specific opt-out
49
+ * await notify({ userId: 7, email: 'a@x' }, { body: 'Sale!' }, ['email'], { category: 'marketing' })
50
+ * ```
51
+ */
52
+ export declare function notify(recipient: NotificationRecipient, payload: NotificationPayload, channels?: NotificationChannel[], options?: NotifyOptions): Promise<NotifyResult[]>;
53
+ export declare function notification(): ReturnType<typeof useNotification>;
54
+ /** Optional flags accepted by {@link notify}. */
55
+ export declare interface NotifyOptions {
56
+ ignorePreferences?: boolean
57
+ category?: string
58
+ }
59
+ /** Minimal transport contract used by `notify()` — anything that exposes a
60
+ * `send(EmailMessage)` returning an `EmailResult` works (the @stacksjs/email
61
+ * Mail singleton, a per-test LogEmailDriver, a custom mock, etc.). */
62
+ export declare interface EmailTransport {
63
+ send: (message: EmailMessage) => Promise<EmailResult>
64
+ }
65
+ export declare interface NotificationPayload {
66
+ subject?: string
67
+ body: string
68
+ data?: Record<string, unknown>
69
+ }
70
+ export declare interface NotificationRecipient {
71
+ email?: string
72
+ phone?: string
73
+ userId?: number
74
+ }
75
+ export declare interface NotifyResult {
76
+ channel: NotificationChannel
77
+ success: boolean
78
+ error?: Error
79
+ }
80
+ export type NotificationChannel = 'email' | 'sms' | 'chat' | 'database' | 'push';
81
+ export { DatabaseNotificationDriver } from './drivers/database';
82
+ export {
83
+ bulkSetPreferences,
84
+ filterChannelsByPreferences,
85
+ getNotificationPreferences,
86
+ setNotificationPreference,
87
+ } from './preferences';
@@ -0,0 +1,106 @@
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';
package/package.json CHANGED
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "name": "@stacksjs/notifications",
3
3
  "type": "module",
4
- "version": "0.70.23",
4
+ "version": "0.70.26",
5
5
  "description": "The Stacks notifications integration.",
6
6
  "author": "Chris Breuer",
7
- "contributors": ["Chris Breuer <chris@stacksjs.org>"],
7
+ "contributors": [
8
+ "Chris Breuer <chris@stacksjs.com>"
9
+ ],
8
10
  "license": "MIT",
9
11
  "funding": "https://github.com/sponsors/chrisbbreuer",
10
12
  "homepage": "https://github.com/stacksjs/stacks/tree/main/storage/framework/core/notifications#readme",
@@ -31,29 +33,35 @@
31
33
  ],
32
34
  "exports": {
33
35
  ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "bun": "./src/index.ts",
34
38
  "import": "./dist/index.js"
35
39
  },
36
40
  "./*": {
41
+ "bun": "./src/*",
37
42
  "import": "./dist/*"
38
43
  }
39
44
  },
40
45
  "module": "dist/index.js",
41
46
  "types": "dist/index.d.ts",
42
- "files": ["README.md", "dist"],
47
+ "files": [
48
+ "README.md",
49
+ "dist"
50
+ ],
43
51
  "scripts": {
44
52
  "build": "bun build.ts",
45
53
  "typecheck": "bun tsc --noEmit",
46
54
  "prepublishOnly": "bun run build"
47
55
  },
48
56
  "devDependencies": {
49
- "@stacksjs/chat": "0.70.22",
50
- "@stacksjs/cli": "0.70.22",
51
- "@stacksjs/config": "0.70.22",
52
- "@stacksjs/development": "0.70.22",
53
- "@stacksjs/email": "0.70.22",
54
- "@stacksjs/error-handling": "0.70.22",
55
- "@stacksjs/push": "0.70.22",
56
- "@stacksjs/sms": "0.70.22",
57
- "@stacksjs/types": "0.70.22"
57
+ "@stacksjs/chat": "0.70.23",
58
+ "@stacksjs/cli": "0.70.23",
59
+ "@stacksjs/config": "0.70.23",
60
+ "better-dx": "^0.2.12",
61
+ "@stacksjs/email": "0.70.23",
62
+ "@stacksjs/error-handling": "0.70.23",
63
+ "@stacksjs/push": "0.70.23",
64
+ "@stacksjs/sms": "0.70.23",
65
+ "@stacksjs/types": "0.70.23"
58
66
  }
59
67
  }
package/dist/chat.d.ts DELETED
@@ -1 +0,0 @@
1
- export * as chat from '@stacksjs/chat'
package/dist/config.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export default {
2
- purgeCSS: {},
3
- };
package/dist/email.d.ts DELETED
@@ -1 +0,0 @@
1
- export * as email from '@stacksjs/email'
package/dist/index.d.ts DELETED
@@ -1,5 +0,0 @@
1
- export declare function useChat(driver): any;
2
- export declare function useEmail(driver): any;
3
- export declare function useSMS(driver): any;
4
- export declare function useNotification(typeParam, driverParam): any;
5
- export declare function notification(): any;
package/dist/push.d.ts DELETED
@@ -1 +0,0 @@
1
- export * as push from '@stacksjs/push'
package/dist/sms.d.ts DELETED
@@ -1 +0,0 @@
1
- export * as sms from '@stacksjs/sms'
@@ -1,34 +0,0 @@
1
- export default {
2
- theme: {
3
- screens: {
4
- sm: '480px',
5
- md: '768px',
6
- lg: '976px',
7
- xl: '1440px',
8
- },
9
- colors: {
10
- 'blue': '#1fb6ff',
11
- 'purple': '#7e5bef',
12
- 'pink': '#ff49db',
13
- 'orange': '#ff7849',
14
- 'green': '#13ce66',
15
- 'yellow': '#ffc82c',
16
- 'gray-dark': '#273444',
17
- 'gray': '#8492a6',
18
- 'gray-light': '#d3dce6',
19
- },
20
- fontFamily: {
21
- sans: ['Graphik', 'sans-serif'],
22
- serif: ['Merriweather', 'serif'],
23
- },
24
- extend: {
25
- spacing: {
26
- 128: '32rem',
27
- 144: '36rem',
28
- },
29
- borderRadius: {
30
- '4xl': '2rem',
31
- },
32
- },
33
- },
34
- };