@stacksjs/notifications 0.75.164 → 0.75.166

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.d.ts CHANGED
@@ -11,6 +11,7 @@ export type { CreateNotificationOptions, DatabaseNotification } from './drivers/
11
11
  export type { NotificationPreferenceRow, PreferenceChannel } from './preferences';
12
12
  export type { EmailBodyPart, NotificationAction, NotificationEmailPayload, NotificationFrameLocale, NotificationUnsubscribe } from './email-body';
13
13
  export type { NotificationFrameStrings } from './frame-strings';
14
+ export type { AdvanceSequenceOptions, QuietHours, SequenceAdvance, SequenceStepLike } from './sequences';
14
15
  export type { ParsedUnsubscribeToken, UnsubscribeChannel, UnsubscribeLinkOptions } from './unsubscribe';
15
16
  export type { AccountNotificationPaths, AccountNotificationTemplateName, CommerceNotificationPaths, CommerceNotificationTemplateName, NotificationTemplateAction, NotificationTemplateCatalog, NotificationTemplateCatalogOptions, NotificationTemplateCopy, NotificationTemplateDefinition, NotificationTemplateVariables, RenderedNotificationTemplate, RenderNotificationTemplateOptions, SubscriptionNotificationPaths, SubscriptionNotificationTemplateName } from './templates';
16
17
  export declare function ensureSuccessfulEmailResult(result: EmailResult): void;
@@ -142,5 +143,6 @@ export {
142
143
  } from './preferences';
143
144
  export { DEFAULT_SMS_OPT_OUT, DEFAULT_UNSUBSCRIBE_NOTICE, emailParagraphs, listUnsubscribeHeaders, notificationEmailHtml, notificationEmailMessage, notificationEmailText, notificationSmsBody, SMS_SENDER_MAX_LENGTH, smsSenderPrefix } from './email-body';
144
145
  export { NOTIFICATION_FRAME_STRINGS, notificationFrameStrings, notificationLanguageTag } from './frame-strings';
146
+ export { advanceSequence, isQuietTime, nextSendableTime, sequenceTimeline } from './sequences';
145
147
  export { notificationUnsubscribeLinks, notificationUnsubscribeToken, parseNotificationUnsubscribeToken, unsubscribeTokenFromRequest, unsubscribeWithToken } from './unsubscribe';
146
148
  export { accountNotificationTemplates, commerceNotificationTemplates, createNotificationTemplates, defineNotificationTemplates, interpolateNotificationText, notificationTemplateVariables, renderNotificationTemplate, resolveNotificationLocale, subscriptionNotificationTemplates } from './templates';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- import{log as j}from"@stacksjs/cli";import{notification as Be}from"@stacksjs/config";import{mail as le}from"@stacksjs/email";import{log as I}from"@stacksjs/cli";var g={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 i=await import("@stacksjs/realtime").catch(()=>null),r=i?.emit,a=i?.getServer;if(!r||!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 r(n,t,e.data,{private:s}),I.info(`[notifications] broadcast to '${n}' (${t})`),{delivered:!0,channel:n,event:t}}catch(i){let r=i instanceof Error?i.message:String(i);return I.warn(`[notifications] broadcast failed: ${r}`),{delivered:!1,channel:n,event:t,reason:r}}}};import*as L from"@stacksjs/chat";import{log as B}from"@stacksjs/cli";import{createHash as pe}from"crypto";import{db as u,enqueueAfterCommit as be,lockRow as ge,sqlDateTime as k}from"@stacksjs/database/runtime";import{emit as he,getServer as ye}from"@stacksjs/realtime";function M(e){return`private-user.${e}`}function P(e,t,n){let i=()=>{try{if(ye())he(M(e),t,n)}catch{B.warn("Notification inbox broadcast failed; the persisted inbox remains available")}};if(!be(i))i()}async function F(e,t){let n=k(),r=await u.insertInto("notifications").values({user_id:e.userId,...t?{uuid:t}:{},type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}).returning("id").execute(),a=Array.isArray(r)?r[0]:r,s=Number(a?.id??a?.insertId??a?.lastInsertRowid??a?.lastInsertId??0);if(!Number.isSafeInteger(s)||s<=0)throw Error("Database notification did not return a generated id");if(B.info(`Database notification sent to user ${e.userId}: ${e.type}`),e.broadcast!==!1)P(e.userId,"notifications.created",{id:s});return{id:s,user_id:e.userId,type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}}var h={async send(e){if(e.idempotencyKey!==void 0){if(!e.idempotencyKey||e.idempotencyKey.length>255)throw Error("A notification idempotency key must contain 1 to 255 characters");let t=pe("sha256").update(JSON.stringify([e.userId,e.type,e.idempotencyKey])).digest("hex"),n=`${t.slice(0,8)}-${t.slice(8,12)}-8${t.slice(13,16)}-8${t.slice(17,20)}-${t.slice(20,32)}`;return u.transaction(async(i)=>{if(!await ge(i,"users",{id:e.userId}))throw Error("Notification recipient not found");let a=await u.selectFrom("notifications").selectAll().where("user_id","=",e.userId).where("uuid","=",n).executeTakeFirst();if(a)return{...a,id:Number(a.id),user_id:Number(a.user_id)};return F(e,n)})}return F(e)},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,t){let n=u.updateTable("notifications").set({read_at:k()}).where("id","=",e).whereNull("read_at");if(t!==void 0)n=n.where("user_id","=",t);if(await n.execute(),t)P(t,"notifications.read",{id:e})},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:k()}).where("user_id","=",e).whereNull("read_at").execute(),P(e,"notifications.read",{all:!0})},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 n=u.deleteFrom("notifications").where("id","=",e);if(t!==void 0)n=n.where("user_id","=",t);await n.execute()},async deleteAllNotifications(e){await u.deleteFrom("notifications").where("user_id","=",e).execute()}};import*as z from"@stacksjs/push";import*as y from"@stacksjs/sms";import{log as Ne}from"@stacksjs/cli";import{config as K}from"@stacksjs/config";import{db as we,sqlDateTime as q}from"@stacksjs/database/runtime";function Y(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 _(e,t,n,i,r=q()){return{userId:e.userId,channel:n.channel,recipient:Y(e,n.channel),subject:t.subject,body:t.body,status:n.success?"sent":"failed",error:n.error?.message,metadata:{...t.data,...i.category?{category:i.category}:{}},sentAt:r}}function Te(e){let t=K.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 v(e){if(!Te(e.channel))return;let t=q();try{await we.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:K.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 i=n instanceof Error?n.message:String(n);Ne.warn(`[notifications] could not record ${e.channel} delivery: ${i}`)}}import{templateByName as ve}from"@stacksjs/email";function V(e={}){let t=e.resetPassword??"/forgot-password";return{password_changed:{copy:{en:{subject:"Your {{app}} password was changed",body:`The password for your {{app}} account was changed.
2
+ import{log as L}from"@stacksjs/cli";import{notification as We}from"@stacksjs/config";import{mail as be}from"@stacksjs/email";import{log as F}from"@stacksjs/cli";var h={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 i=await import("@stacksjs/realtime").catch(()=>null),r=i?.emit,o=i?.getServer;if(!r||!o)return{delivered:!1,channel:n,event:t,reason:"@stacksjs/realtime is not available"};if(!o())return{delivered:!1,channel:n,event:t,reason:"realtime server not running"};let s=e.private??e.userId!==void 0;return r(n,t,e.data,{private:s}),F.info(`[notifications] broadcast to '${n}' (${t})`),{delivered:!0,channel:n,event:t}}catch(i){let r=i instanceof Error?i.message:String(i);return F.warn(`[notifications] broadcast failed: ${r}`),{delivered:!1,channel:n,event:t,reason:r}}}};import*as B from"@stacksjs/chat";import{log as q}from"@stacksjs/cli";import{createHash as Ne}from"crypto";import{db as u,enqueueAfterCommit as we,lockRow as Te,sqlDateTime as S}from"@stacksjs/database/runtime";import{emit as xe,getServer as ke}from"@stacksjs/realtime";function z(e){return`private-user.${e}`}function v(e,t,n){let i=()=>{try{if(ke())xe(z(e),t,n)}catch{q.warn("Notification inbox broadcast failed; the persisted inbox remains available")}};if(!we(i))i()}async function M(e,t){let n=S(),r=await u.insertInto("notifications").values({user_id:e.userId,...t?{uuid:t}:{},type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}).returning("id").execute(),o=Array.isArray(r)?r[0]:r,s=Number(o?.id??o?.insertId??o?.lastInsertRowid??o?.lastInsertId??0);if(!Number.isSafeInteger(s)||s<=0)throw Error("Database notification did not return a generated id");if(q.info(`Database notification sent to user ${e.userId}: ${e.type}`),e.broadcast!==!1)v(e.userId,"notifications.created",{id:s});return{id:s,user_id:e.userId,type:e.type,data:JSON.stringify(e.data),read_at:null,created_at:n,updated_at:n}}var y={async send(e){if(e.idempotencyKey!==void 0){if(!e.idempotencyKey||e.idempotencyKey.length>255)throw Error("A notification idempotency key must contain 1 to 255 characters");let t=Ne("sha256").update(JSON.stringify([e.userId,e.type,e.idempotencyKey])).digest("hex"),n=`${t.slice(0,8)}-${t.slice(8,12)}-8${t.slice(13,16)}-8${t.slice(17,20)}-${t.slice(20,32)}`;return u.transaction(async(i)=>{if(!await Te(i,"users",{id:e.userId}))throw Error("Notification recipient not found");let o=await u.selectFrom("notifications").selectAll().where("user_id","=",e.userId).where("uuid","=",n).executeTakeFirst();if(o)return{...o,id:Number(o.id),user_id:Number(o.user_id)};return M(e,n)})}return M(e)},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,t){let n=u.updateTable("notifications").set({read_at:S()}).where("id","=",e).whereNull("read_at");if(t!==void 0)n=n.where("user_id","=",t);if(await n.execute(),t)v(t,"notifications.read",{id:e})},async markAllAsRead(e){await u.updateTable("notifications").set({read_at:S()}).where("user_id","=",e).whereNull("read_at").execute(),v(e,"notifications.read",{all:!0})},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 n=u.deleteFrom("notifications").where("id","=",e);if(t!==void 0)n=n.where("user_id","=",t);await n.execute()},async deleteAllNotifications(e){await u.deleteFrom("notifications").where("user_id","=",e).execute()}};import*as H from"@stacksjs/push";import*as N from"@stacksjs/sms";import{log as Se}from"@stacksjs/cli";import{config as K}from"@stacksjs/config";import{db as ve,sqlDateTime as Y}from"@stacksjs/database/runtime";function Q(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 _(e,t,n,i,r=Y()){return{userId:e.userId,channel:n.channel,recipient:Q(e,n.channel),subject:t.subject,body:t.body,status:n.success?"sent":"failed",error:n.error?.message,metadata:{...t.data,...i.category?{category:i.category}:{}},sentAt:r}}function _e(e){let t=K.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 P(e){if(!_e(e.channel))return;let t=Y();try{await ve.insertInto("notification_deliveries").values({user_id:e.userId??null,channel:e.channel,recipient:e.recipient,subject:e.subject??null,body:K.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 i=n instanceof Error?n.message:String(n);Se.warn(`[notifications] could not record ${e.channel} delivery: ${i}`)}}import{templateByName as Ae}from"@stacksjs/email";function V(e={}){let t=e.resetPassword??"/forgot-password";return{password_changed:{copy:{en:{subject:"Your {{app}} password was changed",body:`The password for your {{app}} account was changed.
3
3
 
4
4
  If you did this, there\u2019s nothing else to do. If you didn\u2019t, reset your password right away and let us know at {{support_email}}.`},de:{subject:"Dein {{app}}-Passwort wurde ge\xE4ndert",body:`Das Passwort f\xFCr dein {{app}}-Konto wurde ge\xE4ndert.
5
5
 
@@ -37,13 +37,13 @@ Bestellung {{order_number}}.`}},action:{path:n,label:{en:"View your sales",de:"V
37
37
 
38
38
  Your balance is now {{balance}}. You can use it on your next purchase.`},de:{subject:"{{amount}} Guthaben von {{seller}}",body:`{{seller}} hat deinem Konto {{amount}} gutgeschrieben: {{reason}}
39
39
 
40
- Dein Guthaben betr\xE4gt jetzt {{balance}}. Du kannst es bei deinem n\xE4chsten Einkauf verwenden.`}}}}}function H(e={}){let t=e.billing??"/billing";return{subscription_trial_ending:{copy:{en:{subject:"Your {{app}} trial ends on {{date}}",body:"Your {{plan}} trial ends on {{date}}. To keep using {{plan}}, check that your payment details are up to date. To stop, cancel before then and you won\u2019t be charged."},de:{subject:"Deine {{app}}-Testphase endet am {{date}}",body:"Deine Testphase f\xFCr {{plan}} endet am {{date}}. Wenn du {{plan}} weiter nutzen willst, pr\xFCfe, ob deine Zahlungsdaten aktuell sind. Wenn nicht, k\xFCndige vorher, dann wird nichts berechnet."}},action:{path:t,label:{en:"Manage billing",de:"Abrechnung verwalten"}}},subscription_payment_failed:{copy:{en:{subject:"Payment for {{plan}} failed",body:"We couldn\u2019t charge your payment method for your {{app}} {{plan}} plan. Update it to keep {{plan}} active."},de:{subject:"Zahlung f\xFCr {{plan}} fehlgeschlagen",body:"Wir konnten deine Zahlungsmethode f\xFCr deinen {{app}}-Tarif {{plan}} nicht belasten. Aktualisiere sie, damit {{plan}} aktiv bleibt."}},action:{path:t,label:{en:"Update payment method",de:"Zahlungsmethode aktualisieren"}}},subscription_ended:{copy:{en:{subject:"Your {{app}} {{plan}} plan has ended",body:"Your {{plan}} plan ended on {{date}}. Your account and data are still here; choose a plan again whenever you\u2019re ready."},de:{subject:"Dein {{app}}-Tarif {{plan}} ist beendet",body:"Dein Tarif {{plan}} endete am {{date}}. Dein Konto und deine Daten bleiben erhalten; du kannst jederzeit wieder einen Tarif w\xE4hlen."}},action:{path:t,label:{en:"Choose a plan",de:"Tarif w\xE4hlen"}}}}}var W=/\{\{\s*([a-z0-9_]+)\s*\}\}/gi;function xe(e){return e}function N(e,t={}){return e.replace(W,(n,i)=>{let r=t[i];return r==null?"":String(r)})}function J(e){return[...new Set([...e.matchAll(W)].map((t)=>t[1]))]}function w(e,t,n="en"){let i=String(t||"").trim();if(i&&e.includes(i))return i;let r=i.split(/[-_]/)[0]?.toLowerCase();if(r&&e.includes(r))return r;if(e.includes(n))return n;let a=e[0];if(!a)throw Error("A notification template needs copy in at least one language");return a}function ke(e,t){if(/^https?:\/\//i.test(t))return t;let n=String(e||"").replace(/\/+$/,"");if(!t)return n;return`${n}${t.startsWith("/")?"":"/"}${t}`}function Q(e,t={},n={}){let i=n.fallbackLocale||"en",r=w(Object.keys(e.copy),n.locale,i),a=e.copy[r],s={subject:N(a.subject,t),body:N(a.body,t)},o=e.action;if(o){let c=J(o.path).some((b)=>t[b]==null||t[b]===""),l=o.label[r]??o.label[i]??Object.values(o.label)[0];if(!c&&l)s.action={label:l,url:ke(n.baseUrl,N(o.path,t))}}return s}function Pe(e,t={}){let n=Object.keys(e),i=()=>typeof t.baseUrl==="function"?t.baseUrl():t.baseUrl;return{templates:e,names:n,has:(r)=>Object.hasOwn(e,r),render(r,a={},s){let o=e[r];if(!o)throw Error(`Unknown notification template "${String(r)}"`);return Q(o,a,{locale:s,fallbackLocale:t.fallbackLocale,baseUrl:i()})}}}var f=Object.freeze({en:Object.freeze({automatedNotice:"This is an automated message from {app}. Please do not reply to this email.",copyright:"{year} {app}. All rights reserved.",unsubscribeNotice:"You are receiving this because these emails are turned on in your settings.",unsubscribe:"Unsubscribe",preferences:"Email preferences",action:"Open",smsOptOut:"Reply STOP to opt out."}),de:Object.freeze({automatedNotice:"Diese Nachricht wurde automatisch von {app} verschickt. Bitte antworte nicht auf diese E-Mail.",copyright:"{year} {app}. Alle Rechte vorbehalten.",unsubscribeNotice:"Du erh\xE4ltst diese E-Mail, weil diese E-Mails in deinen Einstellungen aktiviert sind.",unsubscribe:"Abmelden",preferences:"E-Mail-Einstellungen",action:"\xD6ffnen",smsOptOut:"Antworte STOP zum Abbestellen."})});function p(e,t){let n=w(Object.keys(f),e,"en"),i={...f[n]};for(let[r,a]of Object.entries(t??{}))if(r in i&&typeof a==="string"&&a.trim())i[r]=a;return i}var _e=/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i;function E(e){let t=String(e??"").trim().replace(/_/g,"-");return _e.test(t)?t:"en"}var Ee=f.en.unsubscribeNotice,Se=f.en.smsOptOut,Re=/https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]]/g;function Z(e){return String(e??"").replace(/\r\n?/g,`
40
+ Dein Guthaben betr\xE4gt jetzt {{balance}}. Du kannst es bei deinem n\xE4chsten Einkauf verwenden.`}}}}}function W(e={}){let t=e.billing??"/billing";return{subscription_trial_ending:{copy:{en:{subject:"Your {{app}} trial ends on {{date}}",body:"Your {{plan}} trial ends on {{date}}. To keep using {{plan}}, check that your payment details are up to date. To stop, cancel before then and you won\u2019t be charged."},de:{subject:"Deine {{app}}-Testphase endet am {{date}}",body:"Deine Testphase f\xFCr {{plan}} endet am {{date}}. Wenn du {{plan}} weiter nutzen willst, pr\xFCfe, ob deine Zahlungsdaten aktuell sind. Wenn nicht, k\xFCndige vorher, dann wird nichts berechnet."}},action:{path:t,label:{en:"Manage billing",de:"Abrechnung verwalten"}}},subscription_payment_failed:{copy:{en:{subject:"Payment for {{plan}} failed",body:"We couldn\u2019t charge your payment method for your {{app}} {{plan}} plan. Update it to keep {{plan}} active."},de:{subject:"Zahlung f\xFCr {{plan}} fehlgeschlagen",body:"Wir konnten deine Zahlungsmethode f\xFCr deinen {{app}}-Tarif {{plan}} nicht belasten. Aktualisiere sie, damit {{plan}} aktiv bleibt."}},action:{path:t,label:{en:"Update payment method",de:"Zahlungsmethode aktualisieren"}}},subscription_ended:{copy:{en:{subject:"Your {{app}} {{plan}} plan has ended",body:"Your {{plan}} plan ended on {{date}}. Your account and data are still here; choose a plan again whenever you\u2019re ready."},de:{subject:"Dein {{app}}-Tarif {{plan}} ist beendet",body:"Dein Tarif {{plan}} endete am {{date}}. Dein Konto und deine Daten bleiben erhalten; du kannst jederzeit wieder einen Tarif w\xE4hlen."}},action:{path:t,label:{en:"Choose a plan",de:"Tarif w\xE4hlen"}}}}}var Z=/\{\{\s*([a-z0-9_]+)\s*\}\}/gi;function Pe(e){return e}function w(e,t={}){return e.replace(Z,(n,i)=>{let r=t[i];return r==null?"":String(r)})}function J(e){return[...new Set([...e.matchAll(Z)].map((t)=>t[1]))]}function T(e,t,n="en"){let i=String(t||"").trim();if(i&&e.includes(i))return i;let r=i.split(/[-_]/)[0]?.toLowerCase();if(r&&e.includes(r))return r;if(e.includes(n))return n;let o=e[0];if(!o)throw Error("A notification template needs copy in at least one language");return o}function Ee(e,t){if(/^https?:\/\//i.test(t))return t;let n=String(e||"").replace(/\/+$/,"");if(!t)return n;return`${n}${t.startsWith("/")?"":"/"}${t}`}function X(e,t={},n={}){let i=n.fallbackLocale||"en",r=T(Object.keys(e.copy),n.locale,i),o=e.copy[r],s={subject:w(o.subject,t),body:w(o.body,t)},a=e.action;if(a){let c=J(a.path).some((g)=>t[g]==null||t[g]===""),l=a.label[r]??a.label[i]??Object.values(a.label)[0];if(!c&&l)s.action={label:l,url:Ee(n.baseUrl,w(a.path,t))}}return s}function Re(e,t={}){let n=Object.keys(e),i=()=>typeof t.baseUrl==="function"?t.baseUrl():t.baseUrl;return{templates:e,names:n,has:(r)=>Object.hasOwn(e,r),render(r,o={},s){let a=e[r];if(!a)throw Error(`Unknown notification template "${String(r)}"`);return X(a,o,{locale:s,fallbackLocale:t.fallbackLocale,baseUrl:i()})}}}var f=Object.freeze({en:Object.freeze({automatedNotice:"This is an automated message from {app}. Please do not reply to this email.",copyright:"{year} {app}. All rights reserved.",unsubscribeNotice:"You are receiving this because these emails are turned on in your settings.",unsubscribe:"Unsubscribe",preferences:"Email preferences",action:"Open",smsOptOut:"Reply STOP to opt out."}),de:Object.freeze({automatedNotice:"Diese Nachricht wurde automatisch von {app} verschickt. Bitte antworte nicht auf diese E-Mail.",copyright:"{year} {app}. Alle Rechte vorbehalten.",unsubscribeNotice:"Du erh\xE4ltst diese E-Mail, weil diese E-Mails in deinen Einstellungen aktiviert sind.",unsubscribe:"Abmelden",preferences:"E-Mail-Einstellungen",action:"\xD6ffnen",smsOptOut:"Antworte STOP zum Abbestellen."})});function b(e,t){let n=T(Object.keys(f),e,"en"),i={...f[n]};for(let[r,o]of Object.entries(t??{}))if(r in i&&typeof o==="string"&&o.trim())i[r]=o;return i}var De=/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/i;function E(e){let t=String(e??"").trim().replace(/_/g,"-");return De.test(t)?t:"en"}var Ce=f.en.unsubscribeNotice,Oe=f.en.smsOptOut,Ue=/https?:\/\/[^\s<>"']+[^\s<>"'.,;:!?)\]]/g;function ee(e){return String(e??"").replace(/\r\n?/g,`
41
41
  `).split(/\n\s*\n/).map((t)=>t.trim()).filter(Boolean).map((t)=>{let n=[];return t.split(`
42
- `).forEach((i,r)=>{if(r>0)n.push({br:!0});let a=0;for(let s of i.matchAll(Re)){if(s.index>a)n.push({text:i.slice(a,s.index)});n.push({url:s[0]}),a=s.index+s[0].length}if(a<i.length)n.push({text:i.slice(a)})}),n})}function d(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t])}async function X(e){let t=e.unsubscribe?.url?e.unsubscribe:void 0,n=p(e.locale,e.strings);try{let{html:a}=await ve("notification",{subject:e.subject??"",variables:{subject:e.subject??"",paragraphs:Z(e.body),preheader:String(e.body??"").replace(/\s+/g," ").trim().slice(0,140),lang:E(e.locale),automatedNotice:n.automatedNotice,copyrightNotice:n.copyright,unsubscribeLabel:n.unsubscribe,preferencesLabel:n.preferences,...e.action?.url?{actionUrl:e.action.url,actionLabel:e.action.label||n.action}:{},...t?{unsubscribeUrl:t.url,unsubscribeNotice:t.notice||n.unsubscribeNotice,...t.preferencesUrl?{preferencesUrl:t.preferencesUrl}:{}}:{}}});if(a)return a}catch{}let i=e.action?.url?`<p><a href="${d(e.action.url)}">${d(e.action.label||e.action.url)}</a></p>`:"",r=t?`<p>${d(t.notice||n.unsubscribeNotice)} <a href="${d(t.url)}">${d(n.unsubscribe)}</a></p>`:"";return`<p>${d(e.body)}</p>${i}${r}`}function ee(e){let t=p(e.locale,e.strings),n=e.action?.url?`${e.body}
42
+ `).forEach((i,r)=>{if(r>0)n.push({br:!0});let o=0;for(let s of i.matchAll(Ue)){if(s.index>o)n.push({text:i.slice(o,s.index)});n.push({url:s[0]}),o=s.index+s[0].length}if(o<i.length)n.push({text:i.slice(o)})}),n})}function d(e){return String(e??"").replace(/[&<>"']/g,(t)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[t])}async function te(e){let t=e.unsubscribe?.url?e.unsubscribe:void 0,n=b(e.locale,e.strings);try{let{html:o}=await Ae("notification",{subject:e.subject??"",variables:{subject:e.subject??"",paragraphs:ee(e.body),preheader:String(e.body??"").replace(/\s+/g," ").trim().slice(0,140),lang:E(e.locale),automatedNotice:n.automatedNotice,copyrightNotice:n.copyright,unsubscribeLabel:n.unsubscribe,preferencesLabel:n.preferences,...e.action?.url?{actionUrl:e.action.url,actionLabel:e.action.label||n.action}:{},...t?{unsubscribeUrl:t.url,unsubscribeNotice:t.notice||n.unsubscribeNotice,...t.preferencesUrl?{preferencesUrl:t.preferencesUrl}:{}}:{}}});if(o)return o}catch{}let i=e.action?.url?`<p><a href="${d(e.action.url)}">${d(e.action.label||e.action.url)}</a></p>`:"",r=t?`<p>${d(t.notice||n.unsubscribeNotice)} <a href="${d(t.url)}">${d(n.unsubscribe)}</a></p>`:"";return`<p>${d(e.body)}</p>${i}${r}`}function ne(e){let t=b(e.locale,e.strings),n=e.action?.url?`${e.body}
43
43
 
44
44
  ${e.action.label||t.action}: ${e.action.url}`:e.body;if(e.unsubscribe?.url)n+=`
45
45
 
46
46
  ${e.unsubscribe.notice||t.unsubscribeNotice}
47
- ${t.unsubscribe}: ${e.unsubscribe.url}`;return n}function te(e){if(!e?.url)return{};return{"List-Unsubscribe":`<${e.oneClickUrl||e.url}>`,...e.oneClickUrl?{"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}:{}}}async function S(e,t){let n={...t.headers,...te(t.unsubscribe)};return{to:e,subject:t.subject??"",text:ee(t),html:await X(t),...Object.keys(n).length?{headers:n}:{}}}var ne=30;function ie(e){let t=String(e??"").replace(/\s+/g," ").trim().slice(0,ne).trim();return t?`${t}: `:""}function R(e,t){let n=String(e.body??""),i=ie(e.smsSender);if(i&&!n.trimStart().toLowerCase().startsWith(i.trim().toLowerCase()))n=`${i}${n.trimStart()}`;if(e.smsOptOut===!1)return n;let r=typeof e.smsOptOut==="string"?e.smsOptOut.trim():t==="marketing"?p(e.locale,e.strings).smsOptOut:"";if(!r||n.toLowerCase().includes(r.toLowerCase()))return n;return`${n.trimEnd()}
47
+ ${t.unsubscribe}: ${e.unsubscribe.url}`;return n}function ie(e){if(!e?.url)return{};return{"List-Unsubscribe":`<${e.oneClickUrl||e.url}>`,...e.oneClickUrl?{"List-Unsubscribe-Post":"List-Unsubscribe=One-Click"}:{}}}async function R(e,t){let n={...t.headers,...ie(t.unsubscribe)};return{to:e,subject:t.subject??"",text:ne(t),html:await te(t),...Object.keys(n).length?{headers:n}:{}}}var re=30;function oe(e){let t=String(e??"").replace(/\s+/g," ").trim().slice(0,re).trim();return t?`${t}: `:""}function D(e,t){let n=String(e.body??""),i=oe(e.smsSender);if(i&&!n.trimStart().toLowerCase().startsWith(i.trim().toLowerCase()))n=`${i}${n.trimStart()}`;if(e.smsOptOut===!1)return n;let r=typeof e.smsOptOut==="string"?e.smsOptOut.trim():t==="marketing"?b(e.locale,e.strings).smsOptOut:"";if(!r||n.toLowerCase().includes(r.toLowerCase()))return n;return`${n.trimEnd()}
48
48
 
49
- ${r}`}import{log as Ce}from"@stacksjs/cli";import{db as m,lockRow as re,sqlDateTime as De}from"@stacksjs/database/runtime";var T="notification_preferences";function D(e,t){return t===null?e.whereNull("category"):e.where("category","=",t)}async function C(e,t){let n=new Map;try{let r=await D(m.selectFrom(T).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let a of r)n.set(a.channel,a.enabled===!0||a.enabled===1)}catch(i){Ce.debug?.(`[notifications] preferences lookup failed (table missing?): ${i.message}`)}return n}async function A(e,t,n,i){await m.transaction(async(r)=>{await re(r,"users",{id:e}),await ae(e,t,n,i)})}async function ae(e,t,n,i){if(!Number.isSafeInteger(e)||e<=0||typeof n!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let r=De(),a=i??null,s=m.selectFrom(T).select(["id"]).where("user_id","=",e).where("channel","=",t),o=await D(s,a).executeTakeFirst();if(o?.id){await m.updateTable(T).set({enabled:n,updated_at:r}).where("id","=",o.id).execute();return}await m.insertInto(T).values({user_id:e,channel:t,enabled:n,category:a,created_at:r,updated_at:r}).execute()}async function Ae(e,t){await m.transaction(async(n)=>{await re(n,"users",{id:e});for(let i of t)await ae(e,i.channel,i.enabled,i.category)})}async function U(e,t,n){let i=await C(e,n);if(n!==void 0){let r=await C(e,void 0);for(let[a,s]of r)if(!s||!i.has(a))i.set(a,s)}return t.filter((r)=>{let a=i.get(r);return a===void 0?!0:a===!0})}import x from"process";import{createHmac as Ue,timingSafeEqual as Oe}from"crypto";import{db as $e}from"@stacksjs/database/runtime";var oe=["email","sms"];function je(){let e=x.env.APP_KEY;if((!e||e.length<16)&&(x.env.APP_ENV==="production"||x.env.NODE_ENV==="production"))throw Error("[notifications/unsubscribe] APP_KEY is missing or too short (need at least 16 characters). Cannot sign unsubscribe links.");return e||"stacks-default-key-dev-only-do-not-use-prod"}function se(e){return Ue("sha256",je()).update(e).digest("hex")}function ce(e,t,n="marketing"){if(!Number.isSafeInteger(e)||e<=0)throw Error("An unsubscribe token needs a positive user id");if(!oe.includes(t))throw Error(`Unsupported unsubscribe channel: ${t}`);if(!n||n.includes(":"))throw Error("An unsubscribe category must be non-empty and contain no colon");let i=`${e}:${t}:${n}`;return Buffer.from(`${i}:${se(i)}`).toString("base64url")}function ue(e){try{let t=Buffer.from(String(e??""),"base64url").toString("utf8");if(!e||Buffer.from(t).toString("base64url")!==e)return null;let n=t.split(":");if(n.length!==4)return null;let[i,r,a,s]=n;if(!i||!a||!s||!oe.includes(r))return null;let o=se(`${i}:${r}:${a}`);if(s.length!==o.length||!Oe(Buffer.from(s),Buffer.from(o)))return null;let c=Number(i);return Number.isSafeInteger(c)&&c>0&&String(c)===i?{userId:c,channel:r,category:a}:null}catch{return null}}function Ie(e,t={}){let n=String(t.baseUrl||x.env.APP_URL||"http://localhost").replace(/\/$/,""),i=ce(e,t.channel??"email",t.category??"marketing"),r=(a)=>a.startsWith("/")?a:`/${a}`;return{url:`${n}${r(t.pagePath??"/unsubscribe")}?token=${i}`,oneClickUrl:`${n}${r(t.endpointPath??"/api/notifications/unsubscribe")}?token=${i}`,...t.preferencesPath===!1?{}:{preferencesUrl:`${n}${r(t.preferencesPath??"/settings")}`},...t.notice?{notice:t.notice}:{}}}async function Le(e){let t=new URL(e.url,"http://localhost").searchParams.get("token");if(t)return t;let n=await e.json?.().catch(()=>({}));return String(n?.token||"")}async function Fe(e){let t=ue(e);if(!t)return null;if(await $e.selectFrom("users").select(["id"]).where("id","=",t.userId).executeTakeFirst())await A(t.userId,t.channel,!1,t.category);return t}var Me=Be;function ze(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function jt(e){if(!e.length)throw Error("No notification channels were delivered");let t=e.filter((n)=>!n.success);if(t.length){let n=t.map((i)=>i.error instanceof Error?i.error:Error(`Notification delivery failed through ${i.channel}`));if(n.length===1)throw n[0];throw AggregateError(n,n.map((i)=>i.message).join("; "))}}function O(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function fe(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return L[t]}function de(e){if(e)try{return le.use(e)}catch(t){j.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return le}function me(e){if(!e)return y;if(e!=="twilio"&&e!=="vonage")throw Error(`Unsupported SMS driver: ${e}`);let t=e;return{send:async(n)=>(await y.init(),y.getDriver(t).send(n))}}function Ke(){return z}function qe(){return h}function It(){return g}function Ye(e,t){let n=e||Me?.default||"email",i=t;switch(n){case"email":return de(i);case"chat":return fe(i);case"sms":return me(i);case"database":return qe();default:throw Error(`Notification type "${n}" is not supported`)}}async function Lt(e,t,n=["email"],i={}){let r=[...new Set(n)];if(e.userId&&!i.ignorePreferences)try{r=await U(e.userId,r,i.category)}catch(o){j.warn(`[notify] preferences filter failed, sending all channels: ${o.message}`)}let s=(await Promise.allSettled(r.map(async(o)=>{switch(o){case"email":{if(!e.email)throw Error("[notify] email channel requires recipient.email");let l=await de().send(await S(e.email,t));ze(l);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(i.ignorePreferences!==!0){let{isPhoneOptedOut:l}=await import("@stacksjs/sms");if(await l(e.phone))throw Error("[notify] sms recipient has opted out (STOP)")}let c=me();O(await c.send({to:e.phone,body:R(t,i.category)}));break}case"chat":{if(!e.chatRecipient||Array.isArray(e.chatRecipient)&&!e.chatRecipient.length)throw Error("[notify] chat channel requires recipient.chatRecipient");let c=fe();O(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 h.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=Ke();O(await c.send(e.pushTokens,{title:t.subject,body:t.body,data:t.data},{driver:e.pushDriver}));break}case"broadcast":{let c=await g.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 l=r[c];if(!l)throw Error(`Missing notification channel for result ${c}`);if(o.status==="rejected"){let b=o.reason instanceof Error?o.reason.message:String(o.reason);j.warn(`[notify] ${l} channel failed: ${b}`)}return{channel:l,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)=>v(_(e,t,o,i)))),s}function Ft(){return Ye()}export{g as BroadcastNotificationDriver,Se as DEFAULT_SMS_OPT_OUT,Ee as DEFAULT_UNSUBSCRIBE_NOTICE,h as DatabaseNotificationDriver,f as NOTIFICATION_FRAME_STRINGS,ne as SMS_SENDER_MAX_LENGTH,V as accountNotificationTemplates,Ae as bulkSetPreferences,G as commerceNotificationTemplates,Pe as createNotificationTemplates,xe as defineNotificationTemplates,Z as emailParagraphs,ze as ensureSuccessfulEmailResult,jt as ensureSuccessfulNotificationResults,U as filterChannelsByPreferences,C as getNotificationPreferences,N as interpolateNotificationText,te as listUnsubscribeHeaders,_ as makeDeliveryRecord,Ft as notification,M as notificationChannel,X as notificationEmailHtml,S as notificationEmailMessage,ee as notificationEmailText,p as notificationFrameStrings,E as notificationLanguageTag,R as notificationSmsBody,J as notificationTemplateVariables,Ie as notificationUnsubscribeLinks,ce as notificationUnsubscribeToken,Lt as notify,ue as parseNotificationUnsubscribeToken,v as recordNotificationDelivery,Q as renderNotificationTemplate,Y as resolveDeliveryRecipient,w as resolveNotificationLocale,A as setNotificationPreference,ie as smsSenderPrefix,H as subscriptionNotificationTemplates,Le as unsubscribeTokenFromRequest,Fe as unsubscribeWithToken,It as useBroadcast,fe as useChat,qe as useDatabase,de as useEmail,Ye as useNotification,Ke as usePush,me as useSMS,D as wherePreferenceCategory};
49
+ ${r}`}import{log as $e}from"@stacksjs/cli";import{db as m,lockRow as ae,sqlDateTime as je}from"@stacksjs/database/runtime";var x="notification_preferences";function C(e,t){return t===null?e.whereNull("category"):e.where("category","=",t)}async function A(e,t){let n=new Map;try{let r=await C(m.selectFrom(x).select(["channel","enabled","category"]).where("user_id","=",e),t??null).execute();for(let o of r)n.set(o.channel,o.enabled===!0||o.enabled===1)}catch(i){$e.debug?.(`[notifications] preferences lookup failed (table missing?): ${i.message}`)}return n}async function O(e,t,n,i){await m.transaction(async(r)=>{await ae(r,"users",{id:e}),await se(e,t,n,i)})}async function se(e,t,n,i){if(!Number.isSafeInteger(e)||e<=0||typeof n!=="boolean")throw Error("A notification preference needs a valid user id and enabled boolean");let r=je(),o=i??null,s=m.selectFrom(x).select(["id"]).where("user_id","=",e).where("channel","=",t),a=await C(s,o).executeTakeFirst();if(a?.id){await m.updateTable(x).set({enabled:n,updated_at:r}).where("id","=",a.id).execute();return}await m.insertInto(x).values({user_id:e,channel:t,enabled:n,category:o,created_at:r,updated_at:r}).execute()}async function Ie(e,t){await m.transaction(async(n)=>{await ae(n,"users",{id:e});for(let i of t)await se(e,i.channel,i.enabled,i.category)})}async function U(e,t,n){let i=await A(e,n);if(n!==void 0){let r=await A(e,void 0);for(let[o,s]of r)if(!s||!i.has(o))i.set(o,s)}return t.filter((r)=>{let o=i.get(r);return o===void 0?!0:o===!0})}import{localDateTime as ce,zonedTimeToUtc as Le}from"@stacksjs/datetime";var Fe=/^([01]\d|2[0-3]):([0-5]\d)$/;function p(e,t){let n=Fe.exec(String(e));if(!n)throw RangeError(`Quiet hours ${t} must be a time like 21:00`);return Number(n[1])*60+Number(n[2])}function Be(e,t){let n=new Date(`${e}T00:00:00Z`);return n.setUTCDate(n.getUTCDate()+t),n.toISOString().slice(0,10)}function j(e,t){if(!t)return!1;let n=p(t.start,"start"),i=p(t.end,"end");if(n===i)return!1;let r=ce(e,t.timeZone),o=p(r.time,"time");return n<i?o>=n&&o<i:o>=n||o<i}function ue(e,t){if(!t||!j(e,t))return e;let n=p(t.start,"start"),i=ce(e,t.timeZone),r=p(i.time,"time"),o=n>p(t.end,"end")&&r>=n?Be(i.date,1):i.date;return Le(`${o}T${t.end}`,i.timeZone)}function Me(e){let t=Number(e.minutes??0);if(!Number.isFinite(t)||t<0)throw RangeError("A wait step needs zero or more minutes");return t}function le(e,t,n,i={}){if(!Number.isSafeInteger(t)||t<0)throw RangeError("A sequence position must be a whole number of zero or more");let r=i.respectsQuietHours??((s)=>s.type!=="wait"),o=[];for(let s=t;s<e.length;s++){let a=e[s];if(a.type==="wait"){let c=Me(a);if(c>0)return{run:o,next:{index:s+1,at:new Date(n.getTime()+c*60000)}};continue}if(r(a)&&j(n,i.quietHours))return{run:o,next:{index:s,at:ue(n,i.quietHours)}};o.push({index:s,step:a})}return{run:o,next:null}}function qe(e,t,n={}){let i=[],r=0,o=t;for(let s=0;s<=e.length*2+1;s++){let a=le(e,r,o,n);for(let c of a.run)i.push({...c,at:o});if(!a.next)break;r=a.next.index,o=a.next.at}return i}import k from"process";import{createHmac as ze,timingSafeEqual as He}from"crypto";import{db as Ke}from"@stacksjs/database/runtime";var fe=["email","sms"];function Ye(){let e=k.env.APP_KEY;if((!e||e.length<16)&&(k.env.APP_ENV==="production"||k.env.NODE_ENV==="production"))throw Error("[notifications/unsubscribe] APP_KEY is missing or too short (need at least 16 characters). Cannot sign unsubscribe links.");return e||"stacks-default-key-dev-only-do-not-use-prod"}function de(e){return ze("sha256",Ye()).update(e).digest("hex")}function me(e,t,n="marketing"){if(!Number.isSafeInteger(e)||e<=0)throw Error("An unsubscribe token needs a positive user id");if(!fe.includes(t))throw Error(`Unsupported unsubscribe channel: ${t}`);if(!n||n.includes(":"))throw Error("An unsubscribe category must be non-empty and contain no colon");let i=`${e}:${t}:${n}`;return Buffer.from(`${i}:${de(i)}`).toString("base64url")}function pe(e){try{let t=Buffer.from(String(e??""),"base64url").toString("utf8");if(!e||Buffer.from(t).toString("base64url")!==e)return null;let n=t.split(":");if(n.length!==4)return null;let[i,r,o,s]=n;if(!i||!o||!s||!fe.includes(r))return null;let a=de(`${i}:${r}:${o}`);if(s.length!==a.length||!He(Buffer.from(s),Buffer.from(a)))return null;let c=Number(i);return Number.isSafeInteger(c)&&c>0&&String(c)===i?{userId:c,channel:r,category:o}:null}catch{return null}}function Qe(e,t={}){let n=String(t.baseUrl||k.env.APP_URL||"http://localhost").replace(/\/$/,""),i=me(e,t.channel??"email",t.category??"marketing"),r=(o)=>o.startsWith("/")?o:`/${o}`;return{url:`${n}${r(t.pagePath??"/unsubscribe")}?token=${i}`,oneClickUrl:`${n}${r(t.endpointPath??"/api/notifications/unsubscribe")}?token=${i}`,...t.preferencesPath===!1?{}:{preferencesUrl:`${n}${r(t.preferencesPath??"/settings")}`},...t.notice?{notice:t.notice}:{}}}async function Ve(e){let t=new URL(e.url,"http://localhost").searchParams.get("token");if(t)return t;let n=await e.json?.().catch(()=>({}));return String(n?.token||"")}async function Ge(e){let t=pe(e);if(!t)return null;if(await Ke.selectFrom("users").select(["id"]).where("id","=",t.userId).executeTakeFirst())await O(t.userId,t.channel,!1,t.category);return t}var Ze=We;function Je(e){if(!e.success)throw Error(e.message||`Email delivery failed through ${e.provider||"the configured provider"}.`)}function Vt(e){if(!e.length)throw Error("No notification channels were delivered");let t=e.filter((n)=>!n.success);if(t.length){let n=t.map((i)=>i.error instanceof Error?i.error:Error(`Notification delivery failed through ${i.channel}`));if(n.length===1)throw n[0];throw AggregateError(n,n.map((i)=>i.message).join("; "))}}function I(e){if(!e.success)throw Error(e.error||e.message||"Notification provider rejected delivery")}function ge(e){let t=e||"slack";if(!["slack","discord","teams"].includes(t))throw Error(`Unsupported chat driver: ${t}`);return B[t]}function he(e){if(e)try{return be.use(e)}catch(t){L.warn(`[notifications] email driver '${e}' not registered - falling back to default mail singleton (${t.message})`)}return be}function ye(e){if(!e)return N;if(e!=="twilio"&&e!=="vonage")throw Error(`Unsupported SMS driver: ${e}`);let t=e;return{send:async(n)=>(await N.init(),N.getDriver(t).send(n))}}function Xe(){return H}function et(){return y}function Gt(){return h}function tt(e,t){let n=e||Ze?.default||"email",i=t;switch(n){case"email":return he(i);case"chat":return ge(i);case"sms":return ye(i);case"database":return et();default:throw Error(`Notification type "${n}" is not supported`)}}async function Wt(e,t,n=["email"],i={}){let r=[...new Set(n)];if(e.userId&&!i.ignorePreferences)try{r=await U(e.userId,r,i.category)}catch(a){L.warn(`[notify] preferences filter failed, sending all channels: ${a.message}`)}let s=(await Promise.allSettled(r.map(async(a)=>{switch(a){case"email":{if(!e.email)throw Error("[notify] email channel requires recipient.email");let l=await he().send(await R(e.email,t));Je(l);break}case"sms":{if(!e.phone)throw Error("[notify] sms channel requires recipient.phone");if(i.ignorePreferences!==!0){let{isPhoneOptedOut:l}=await import("@stacksjs/sms");if(await l(e.phone))throw Error("[notify] sms recipient has opted out (STOP)")}let c=ye();I(await c.send({to:e.phone,body:D(t,i.category)}));break}case"chat":{if(!e.chatRecipient||Array.isArray(e.chatRecipient)&&!e.chatRecipient.length)throw Error("[notify] chat channel requires recipient.chatRecipient");let c=ge();I(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 y.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=Xe();I(await c.send(e.pushTokens,{title:t.subject,body:t.body,data:t.data},{driver:e.pushDriver}));break}case"broadcast":{let c=await h.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: ${a}`)}}))).map((a,c)=>{let l=r[c];if(!l)throw Error(`Missing notification channel for result ${c}`);if(a.status==="rejected"){let g=a.reason instanceof Error?a.reason.message:String(a.reason);L.warn(`[notify] ${l} channel failed: ${g}`)}return{channel:l,success:a.status==="fulfilled",error:a.status==="rejected"?a.reason instanceof Error?a.reason:Error(String(a.reason)):void 0}});return await Promise.all(s.map((a)=>P(_(e,t,a,i)))),s}function Zt(){return tt()}export{h as BroadcastNotificationDriver,Oe as DEFAULT_SMS_OPT_OUT,Ce as DEFAULT_UNSUBSCRIBE_NOTICE,y as DatabaseNotificationDriver,f as NOTIFICATION_FRAME_STRINGS,re as SMS_SENDER_MAX_LENGTH,V as accountNotificationTemplates,le as advanceSequence,Ie as bulkSetPreferences,G as commerceNotificationTemplates,Re as createNotificationTemplates,Pe as defineNotificationTemplates,ee as emailParagraphs,Je as ensureSuccessfulEmailResult,Vt as ensureSuccessfulNotificationResults,U as filterChannelsByPreferences,A as getNotificationPreferences,w as interpolateNotificationText,j as isQuietTime,ie as listUnsubscribeHeaders,_ as makeDeliveryRecord,ue as nextSendableTime,Zt as notification,z as notificationChannel,te as notificationEmailHtml,R as notificationEmailMessage,ne as notificationEmailText,b as notificationFrameStrings,E as notificationLanguageTag,D as notificationSmsBody,J as notificationTemplateVariables,Qe as notificationUnsubscribeLinks,me as notificationUnsubscribeToken,Wt as notify,pe as parseNotificationUnsubscribeToken,P as recordNotificationDelivery,X as renderNotificationTemplate,Q as resolveDeliveryRecipient,T as resolveNotificationLocale,qe as sequenceTimeline,O as setNotificationPreference,oe as smsSenderPrefix,W as subscriptionNotificationTemplates,Ve as unsubscribeTokenFromRequest,Ge as unsubscribeWithToken,Gt as useBroadcast,ge as useChat,et as useDatabase,he as useEmail,tt as useNotification,Xe as usePush,ye as useSMS,C as wherePreferenceCategory};
@@ -0,0 +1,60 @@
1
+ /** Whether `at` falls inside the quiet window. A window that starts and ends at the same time is no window. */
2
+ export declare function isQuietTime(at: Date, quiet: QuietHours | null | undefined): boolean;
3
+ /**
4
+ * The first instant at or after `at` that is outside the quiet window: `at`
5
+ * itself when it is not quiet, else the window's end in the zone's local
6
+ * time (the next morning, for a window that started the evening before).
7
+ */
8
+ export declare function nextSendableTime(at: Date, quiet: QuietHours | null | undefined): Date;
9
+ /**
10
+ * What to do now for someone whose next step is `index`, at `now`.
11
+ *
12
+ * Runs forward from `index`: every step that is due is returned in `run`, up
13
+ * to the first wait (which sets when the sequence continues) or the first
14
+ * step that would land in quiet hours (which is held until they end). A
15
+ * wait of zero minutes is passed straight over. When the steps run out,
16
+ * `next` is null and the sequence is finished.
17
+ */
18
+ export declare function advanceSequence<S extends SequenceStepLike>(steps: readonly S[], index: number, now: Date, options?: AdvanceSequenceOptions<S>): SequenceAdvance<S>;
19
+ /**
20
+ * When each step would run for someone who starts the sequence at `start`,
21
+ * assuming every tick comes on time: what a preview shows ("day 0, 09:00:
22
+ * welcome email; day 2: text"). Waits are not listed.
23
+ */
24
+ export declare function sequenceTimeline<S extends SequenceStepLike>(steps: readonly S[], start: Date, options?: AdvanceSequenceOptions<S>): Array<{ index: number, step: S, at: Date }>;
25
+ /**
26
+ * Sequences: a list of steps run one after another for one person, with
27
+ * waits between them. "Email now, wait two days, text, wait a week, give
28
+ * the front desk a call task" is a sequence; so is a welcome series or a
29
+ * win-back journey.
30
+ *
31
+ * This module is the timing engine only, and it is pure. It never sends
32
+ * or stores anything. The application keeps each person's place (the next
33
+ * step's index and when it is due), and on every tick asks
34
+ * {@link advanceSequence} what to do now and where to pick up afterwards.
35
+ * Sending, and remembering what was sent, stay with the caller, so the
36
+ * same engine drives email, SMS, push, or tasks for staff.
37
+ *
38
+ * Quiet hours are read in the recipient's (or the business's) time zone,
39
+ * so "not between 21:00 and 08:00" means the same thing to a gym in
40
+ * Berlin and one in Denver, across daylight saving changes.
41
+ */
42
+ /** A window, in local wall-clock time, in which messages are held back. */
43
+ export declare interface QuietHours {
44
+ start: string
45
+ end: string
46
+ timeZone: string
47
+ }
48
+ /** The smallest shape a step needs: its type, and the length of a wait. */
49
+ export declare interface SequenceStepLike {
50
+ type: string
51
+ minutes?: number
52
+ }
53
+ export declare interface SequenceAdvance<S> {
54
+ run: Array<{ index: number, step: S }>
55
+ next: { index: number, at: Date } | null
56
+ }
57
+ export declare interface AdvanceSequenceOptions<S> {
58
+ quietHours?: QuietHours | null
59
+ respectsQuietHours?: (step: S) => boolean
60
+ }
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ import{localDateTime as m,zonedTimeToUtc as S}from"@stacksjs/datetime";var l=/^([01]\d|2[0-3]):([0-5]\d)$/;function a(n,e){let t=l.exec(String(n));if(!t)throw RangeError(`Quiet hours ${e} must be a time like 21:00`);return Number(t[1])*60+Number(t[2])}function f(n,e){let t=new Date(`${n}T00:00:00Z`);return t.setUTCDate(t.getUTCDate()+e),t.toISOString().slice(0,10)}function d(n,e){if(!e)return!1;let t=a(e.start,"start"),r=a(e.end,"end");if(t===r)return!1;let u=m(n,e.timeZone),i=a(u.time,"time");return t<r?i>=t&&i<r:i>=t||i<r}function p(n,e){if(!e||!d(n,e))return n;let t=a(e.start,"start"),r=m(n,e.timeZone),u=a(r.time,"time"),i=t>a(e.end,"end")&&u>=t?f(r.date,1):r.date;return S(`${i}T${e.end}`,r.timeZone)}function x(n){let e=Number(n.minutes??0);if(!Number.isFinite(e)||e<0)throw RangeError("A wait step needs zero or more minutes");return e}function b(n,e,t,r={}){if(!Number.isSafeInteger(e)||e<0)throw RangeError("A sequence position must be a whole number of zero or more");let u=r.respectsQuietHours??((o)=>o.type!=="wait"),i=[];for(let o=e;o<n.length;o++){let s=n[o];if(s.type==="wait"){let c=x(s);if(c>0)return{run:i,next:{index:o+1,at:new Date(t.getTime()+c*60000)}};continue}if(u(s)&&d(t,r.quietHours))return{run:i,next:{index:o,at:p(t,r.quietHours)}};i.push({index:o,step:s})}return{run:i,next:null}}function D(n,e,t={}){let r=[],u=0,i=e;for(let o=0;o<=n.length*2+1;o++){let s=b(n,u,i,t);for(let c of s.run)r.push({...c,at:i});if(!s.next)break;u=s.next.index,i=s.next.at}return r}export{b as advanceSequence,d as isQuietTime,p as nextSendableTime,D as sequenceTimeline};
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.164",
5
+ "version": "0.75.166",
6
6
  "description": "The Stacks notifications integration.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -64,18 +64,19 @@
64
64
  "prepublishOnly": "bun run build"
65
65
  },
66
66
  "dependencies": {
67
- "@stacksjs/chat": "0.75.164",
68
- "@stacksjs/cli": "0.75.164",
69
- "@stacksjs/config": "0.75.164",
70
- "@stacksjs/database": "0.75.164",
71
- "@stacksjs/email": "0.75.164",
72
- "@stacksjs/push": "0.75.164",
73
- "@stacksjs/realtime": "0.75.164",
74
- "@stacksjs/sms": "0.75.164"
67
+ "@stacksjs/chat": "0.75.166",
68
+ "@stacksjs/cli": "0.75.166",
69
+ "@stacksjs/config": "0.75.166",
70
+ "@stacksjs/database": "0.75.166",
71
+ "@stacksjs/datetime": "0.75.166",
72
+ "@stacksjs/email": "0.75.166",
73
+ "@stacksjs/push": "0.75.166",
74
+ "@stacksjs/realtime": "0.75.166",
75
+ "@stacksjs/sms": "0.75.166"
75
76
  },
76
77
  "devDependencies": {
77
- "@stacksjs/error-handling": "0.75.164",
78
- "@stacksjs/types": "0.75.164",
78
+ "@stacksjs/error-handling": "0.75.166",
79
+ "@stacksjs/types": "0.75.166",
79
80
  "better-dx": "^0.2.24"
80
81
  }
81
82
  }