@spfn/notification 0.1.0-beta.23 → 0.1.0-beta.25

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/server.js CHANGED
@@ -210,8 +210,8 @@ import { createHmac, createHash, timingSafeEqual } from "crypto";
210
210
  function hashClickUrl(url) {
211
211
  return createHash("sha256").update(url).digest("hex").slice(0, 16);
212
212
  }
213
- function toBase64Url(buffer) {
214
- return buffer.toString("base64url");
213
+ function toBase64Url(buffer2) {
214
+ return buffer2.toString("base64url");
215
215
  }
216
216
  function fromBase64Url(str) {
217
217
  return Buffer.from(str, "base64url").toString("utf8");
@@ -620,8 +620,8 @@ function registerBuiltinTemplates() {
620
620
  }
621
621
 
622
622
  // src/services/notification.service.ts
623
- import { create, createMany, findOne, findMany, updateOne, count } from "@spfn/core/db";
624
- import { desc, eq, and, gte, lte } from "drizzle-orm";
623
+ import { create, createMany, findOne, findMany, updateOne, count, getDatabase } from "@spfn/core/db";
624
+ import { desc, eq, and, gte, lte, inArray, sql, count as drizzleCount } from "drizzle-orm";
625
625
 
626
626
  // src/entities/schema.ts
627
627
  import { createSchema } from "@spfn/core/db";
@@ -799,6 +799,34 @@ async function markNotificationFailed(id3, errorMessage) {
799
799
  }
800
800
  );
801
801
  }
802
+ async function markManySent(items) {
803
+ if (items.length === 0) {
804
+ return;
805
+ }
806
+ const cases = sql.join(
807
+ items.map((it) => sql`when ${it.id} then ${it.providerMessageId ?? null}`),
808
+ sql` `
809
+ );
810
+ await getDatabase("write").update(notifications).set({
811
+ status: "sent",
812
+ sentAt: /* @__PURE__ */ new Date(),
813
+ // ELSE keeps the existing value and anchors the CASE result type to text.
814
+ providerMessageId: sql`case ${notifications.id} ${cases} else ${notifications.providerMessageId} end`
815
+ }).where(inArray(notifications.id, items.map((it) => it.id)));
816
+ }
817
+ async function markManyFailed(items) {
818
+ if (items.length === 0) {
819
+ return;
820
+ }
821
+ const cases = sql.join(
822
+ items.map((it) => sql`when ${it.id} then ${it.errorMessage}`),
823
+ sql` `
824
+ );
825
+ await getDatabase("write").update(notifications).set({
826
+ status: "failed",
827
+ errorMessage: sql`case ${notifications.id} ${cases} else ${notifications.errorMessage} end`
828
+ }).where(inArray(notifications.id, items.map((it) => it.id)));
829
+ }
802
830
  async function markNotificationPending(id3) {
803
831
  return await updateOne(
804
832
  notifications,
@@ -863,15 +891,33 @@ async function countNotifications(options = {}) {
863
891
  return await count(notifications);
864
892
  }
865
893
  async function getNotificationStats(options = {}) {
866
- const [total, scheduled, pending, sent, failed, cancelled] = await Promise.all([
867
- countNotifications(options),
868
- countNotifications({ ...options, status: "scheduled" }),
869
- countNotifications({ ...options, status: "pending" }),
870
- countNotifications({ ...options, status: "sent" }),
871
- countNotifications({ ...options, status: "failed" }),
872
- countNotifications({ ...options, status: "cancelled" })
873
- ]);
874
- return { total, scheduled, pending, sent, failed, cancelled };
894
+ const conditions = [];
895
+ if (options.channel) {
896
+ conditions.push(eq(notifications.channel, options.channel));
897
+ }
898
+ if (options.from) {
899
+ conditions.push(gte(notifications.createdAt, options.from));
900
+ }
901
+ if (options.to) {
902
+ conditions.push(lte(notifications.createdAt, options.to));
903
+ }
904
+ const rows = await getDatabase("read").select({ status: notifications.status, count: drizzleCount() }).from(notifications).where(conditions.length > 0 ? and(...conditions) : void 0).groupBy(notifications.status);
905
+ const stats = {
906
+ total: 0,
907
+ scheduled: 0,
908
+ pending: 0,
909
+ sent: 0,
910
+ failed: 0,
911
+ cancelled: 0
912
+ };
913
+ for (const row of rows) {
914
+ const n = Number(row.count);
915
+ stats.total += n;
916
+ if (row.status && row.status in stats) {
917
+ stats[row.status] = n;
918
+ }
919
+ }
920
+ return stats;
875
921
  }
876
922
  async function findScheduledNotifications(options = {}) {
877
923
  const conditions = [eq(notifications.status, "scheduled")];
@@ -1809,11 +1855,11 @@ function IsTemplateLiteralFinite(schema) {
1809
1855
  // ../../node_modules/.pnpm/@sinclair+typebox@0.34.41/node_modules/@sinclair/typebox/build/esm/type/template-literal/generate.mjs
1810
1856
  var TemplateLiteralGenerateError = class extends TypeBoxError {
1811
1857
  };
1812
- function* GenerateReduce(buffer) {
1813
- if (buffer.length === 1)
1814
- return yield* buffer[0];
1815
- for (const left of buffer[0]) {
1816
- for (const right of GenerateReduce(buffer.slice(1))) {
1858
+ function* GenerateReduce(buffer2) {
1859
+ if (buffer2.length === 1)
1860
+ return yield* buffer2[0];
1861
+ for (const left of buffer2[0]) {
1862
+ for (const right of GenerateReduce(buffer2.slice(1))) {
1817
1863
  yield `${left}${right}`;
1818
1864
  }
1819
1865
  }
@@ -3646,15 +3692,8 @@ async function sendEmail(params) {
3646
3692
  log2.error("Email send failed", { to: recipients, subject, error: result.error });
3647
3693
  }
3648
3694
  if (historyId && isHistoryEnabled()) {
3649
- try {
3650
- if (result.success) {
3651
- await markNotificationSent(historyId, result.messageId);
3652
- } else {
3653
- await markNotificationFailed(historyId, result.error || "Unknown error");
3654
- }
3655
- } catch (error) {
3656
- log2.warn("Failed to update notification history record", error);
3657
- }
3695
+ const update = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
3696
+ update.catch((error) => log2.warn("Failed to update notification history record", error));
3658
3697
  }
3659
3698
  return result;
3660
3699
  }
@@ -3794,7 +3833,8 @@ async function sendEmailBulk(items, options) {
3794
3833
  for (const { index: index3, result } of earlyFailures) {
3795
3834
  results[index3] = result;
3796
3835
  }
3797
- const historyUpdates = [];
3836
+ const sentItems = [];
3837
+ const failedItems = [];
3798
3838
  for (let i = 0; i < prepared.length; i++) {
3799
3839
  const { index: index3, recipients, subject } = prepared[i];
3800
3840
  const result = sendResults[i];
@@ -3808,13 +3848,17 @@ async function sendEmailBulk(items, options) {
3808
3848
  }
3809
3849
  const historyId = historyRecords[i]?.id;
3810
3850
  if (historyId && isHistoryEnabled()) {
3811
- const promise = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
3812
- historyUpdates.push(
3813
- promise.catch((err) => log2.warn("Failed to update notification history", err))
3814
- );
3851
+ if (result.success) {
3852
+ sentItems.push({ id: historyId, providerMessageId: result.messageId });
3853
+ } else {
3854
+ failedItems.push({ id: historyId, errorMessage: result.error || "Unknown error" });
3855
+ }
3815
3856
  }
3816
3857
  }
3817
- await Promise.all(historyUpdates);
3858
+ await Promise.all([
3859
+ markManySent(sentItems).catch((err) => log2.warn("Failed to update notification history", err)),
3860
+ markManyFailed(failedItems).catch((err) => log2.warn("Failed to update notification history", err))
3861
+ ]);
3818
3862
  return { results, successCount, failureCount, batchId };
3819
3863
  }
3820
3864
 
@@ -3951,8 +3995,7 @@ async function sendSMS(params) {
3951
3995
  };
3952
3996
  }
3953
3997
  const provider = getProvider2();
3954
- const results = [];
3955
- for (const recipient of recipients) {
3998
+ const sendOne = async (recipient) => {
3956
3999
  const normalizedPhone = normalizePhoneNumber(recipient);
3957
4000
  const internalParams = {
3958
4001
  to: normalizedPhone,
@@ -3981,18 +4024,12 @@ async function sendSMS(params) {
3981
4024
  log4.error("SMS send failed", { to: normalizedPhone, error: result.error });
3982
4025
  }
3983
4026
  if (historyId && isHistoryEnabled()) {
3984
- try {
3985
- if (result.success) {
3986
- await markNotificationSent(historyId, result.messageId);
3987
- } else {
3988
- await markNotificationFailed(historyId, result.error || "Unknown error");
3989
- }
3990
- } catch (error) {
3991
- log4.warn("Failed to update notification history record", error);
3992
- }
4027
+ const update = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
4028
+ update.catch((error) => log4.warn("Failed to update notification history record", error));
3993
4029
  }
3994
- results.push(result);
3995
- }
4030
+ return result;
4031
+ };
4032
+ const results = await runWithConcurrency(recipients, sendOne);
3996
4033
  const allSuccess = results.every((r) => r.success);
3997
4034
  const messageIds = results.filter((r) => r.messageId).map((r) => r.messageId).join(",");
3998
4035
  const errors = results.filter((r) => r.error).map((r) => r.error).join("; ");
@@ -4096,7 +4133,8 @@ async function sendSMSBulk(items, options) {
4096
4133
  concurrency
4097
4134
  );
4098
4135
  const resultsMap = /* @__PURE__ */ new Map();
4099
- const historyUpdates = [];
4136
+ const sentItems = [];
4137
+ const failedItems = [];
4100
4138
  for (let i = 0; i < prepared.length; i++) {
4101
4139
  const { index: index3, phone } = prepared[i];
4102
4140
  const result = sendResults[i];
@@ -4111,13 +4149,17 @@ async function sendSMSBulk(items, options) {
4111
4149
  }
4112
4150
  const historyId = historyRecords[i]?.id;
4113
4151
  if (historyId && isHistoryEnabled()) {
4114
- const promise = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
4115
- historyUpdates.push(
4116
- promise.catch((err) => log4.warn("Failed to update notification history", err))
4117
- );
4152
+ if (result.success) {
4153
+ sentItems.push({ id: historyId, providerMessageId: result.messageId });
4154
+ } else {
4155
+ failedItems.push({ id: historyId, errorMessage: result.error || "Unknown error" });
4156
+ }
4118
4157
  }
4119
4158
  }
4120
- await Promise.all(historyUpdates);
4159
+ await Promise.all([
4160
+ markManySent(sentItems).catch((err) => log4.warn("Failed to update notification history", err)),
4161
+ markManyFailed(failedItems).catch((err) => log4.warn("Failed to update notification history", err))
4162
+ ]);
4121
4163
  const results = new Array(items.length);
4122
4164
  let successCount = 0;
4123
4165
  let failureCount = earlyFailures.length;
@@ -4271,15 +4313,8 @@ async function sendSlack(params) {
4271
4313
  log6.error("Slack send failed", { error: result.error });
4272
4314
  }
4273
4315
  if (historyId && isHistoryEnabled()) {
4274
- try {
4275
- if (result.success) {
4276
- await markNotificationSent(historyId, result.messageId);
4277
- } else {
4278
- await markNotificationFailed(historyId, result.error || "Unknown error");
4279
- }
4280
- } catch (error) {
4281
- log6.warn("Failed to update notification history record", error);
4282
- }
4316
+ const update = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
4317
+ update.catch((error) => log6.warn("Failed to update notification history record", error));
4283
4318
  }
4284
4319
  return result;
4285
4320
  }
@@ -4384,7 +4419,8 @@ async function sendSlackBulk(items, options) {
4384
4419
  for (const { index: index3, result } of earlyFailures) {
4385
4420
  results[index3] = result;
4386
4421
  }
4387
- const historyUpdates = [];
4422
+ const sentItems = [];
4423
+ const failedItems = [];
4388
4424
  for (let i = 0; i < prepared.length; i++) {
4389
4425
  const { index: index3 } = prepared[i];
4390
4426
  const result = sendResults[i];
@@ -4398,13 +4434,17 @@ async function sendSlackBulk(items, options) {
4398
4434
  }
4399
4435
  const historyId = historyRecords[i]?.id;
4400
4436
  if (historyId && isHistoryEnabled()) {
4401
- const promise = result.success ? markNotificationSent(historyId, result.messageId) : markNotificationFailed(historyId, result.error || "Unknown error");
4402
- historyUpdates.push(
4403
- promise.catch((err) => log6.warn("Failed to update notification history", err))
4404
- );
4437
+ if (result.success) {
4438
+ sentItems.push({ id: historyId, providerMessageId: result.messageId });
4439
+ } else {
4440
+ failedItems.push({ id: historyId, errorMessage: result.error || "Unknown error" });
4441
+ }
4405
4442
  }
4406
4443
  }
4407
- await Promise.all(historyUpdates);
4444
+ await Promise.all([
4445
+ markManySent(sentItems).catch((err) => log6.warn("Failed to update notification history", err)),
4446
+ markManyFailed(failedItems).catch((err) => log6.warn("Failed to update notification history", err))
4447
+ ]);
4408
4448
  return { results, successCount, failureCount, batchId };
4409
4449
  }
4410
4450
 
@@ -4791,37 +4831,66 @@ import { route } from "@spfn/core/route";
4791
4831
  import { defineRouter } from "@spfn/core/route";
4792
4832
 
4793
4833
  // src/tracking/tracking.service.ts
4794
- import { create as create2, getDatabase } from "@spfn/core/db";
4795
- import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as drizzleCount, countDistinct } from "drizzle-orm";
4834
+ import { getDatabase as getDatabase2 } from "@spfn/core/db";
4835
+ import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as drizzleCount2, countDistinct } from "drizzle-orm";
4796
4836
  import { logger as logger7 } from "@spfn/core/logger";
4797
4837
  var log7 = logger7.child("@spfn/notification:tracking");
4838
+ var FLUSH_SIZE = 200;
4839
+ var FLUSH_INTERVAL_MS = 2e3;
4840
+ var MAX_BUFFER = 1e4;
4841
+ var buffer = [];
4842
+ var flushTimer = null;
4843
+ async function flushTrackingEvents() {
4844
+ if (flushTimer) {
4845
+ clearTimeout(flushTimer);
4846
+ flushTimer = null;
4847
+ }
4848
+ if (buffer.length === 0) {
4849
+ return;
4850
+ }
4851
+ const batch = buffer.splice(0, buffer.length);
4852
+ try {
4853
+ await getDatabase2("write").insert(trackingEvents).values(batch);
4854
+ } catch (error) {
4855
+ log7.warn(`Failed to flush ${batch.length} tracking events`, error);
4856
+ }
4857
+ }
4858
+ function enqueueTrackingEvent(event) {
4859
+ if (buffer.length >= MAX_BUFFER) {
4860
+ log7.warn("Tracking buffer full \u2014 dropping event");
4861
+ return;
4862
+ }
4863
+ buffer.push(event);
4864
+ if (buffer.length >= FLUSH_SIZE) {
4865
+ void flushTrackingEvents();
4866
+ } else if (!flushTimer) {
4867
+ flushTimer = setTimeout(() => void flushTrackingEvents(), FLUSH_INTERVAL_MS);
4868
+ flushTimer.unref?.();
4869
+ }
4870
+ }
4798
4871
  function recordOpenEvent(notificationId, meta) {
4799
- create2(trackingEvents, {
4872
+ enqueueTrackingEvent({
4800
4873
  notificationId,
4801
4874
  type: "open",
4802
4875
  ipAddress: meta?.ipAddress,
4803
4876
  userAgent: meta?.userAgent
4804
- }).catch((error) => {
4805
- log7.warn("Failed to record open event", error);
4806
4877
  });
4807
4878
  }
4808
4879
  function recordClickEvent(notificationId, linkIndex, linkUrl, meta) {
4809
- create2(trackingEvents, {
4880
+ enqueueTrackingEvent({
4810
4881
  notificationId,
4811
4882
  type: "click",
4812
4883
  linkUrl,
4813
4884
  linkIndex,
4814
4885
  ipAddress: meta?.ipAddress,
4815
4886
  userAgent: meta?.userAgent
4816
- }).catch((error) => {
4817
- log7.warn("Failed to record click event", error);
4818
4887
  });
4819
4888
  }
4820
4889
  async function getTrackingStats(notificationId) {
4821
- const db = getDatabase("read");
4890
+ const db = getDatabase2("read");
4822
4891
  const rows = await db.select({
4823
4892
  type: trackingEvents.type,
4824
- total: drizzleCount(),
4893
+ total: drizzleCount2(),
4825
4894
  unique: countDistinct(trackingEvents.ipAddress)
4826
4895
  }).from(trackingEvents).where(eq2(trackingEvents.notificationId, notificationId)).groupBy(trackingEvents.type);
4827
4896
  const openRow = rows.find((r) => r.type === "open");
@@ -4834,7 +4903,7 @@ async function getTrackingStats(notificationId) {
4834
4903
  };
4835
4904
  }
4836
4905
  async function getEngagementStats(options = {}) {
4837
- const db = getDatabase("read");
4906
+ const db = getDatabase2("read");
4838
4907
  const sentConditions = [eq2(notifications.status, "sent")];
4839
4908
  if (options.channel) {
4840
4909
  sentConditions.push(eq2(notifications.channel, options.channel));
@@ -4845,7 +4914,7 @@ async function getEngagementStats(options = {}) {
4845
4914
  if (options.to) {
4846
4915
  sentConditions.push(lte2(notifications.createdAt, options.to));
4847
4916
  }
4848
- const [sentResult] = await db.select({ count: drizzleCount() }).from(notifications).where(and2(...sentConditions));
4917
+ const [sentResult] = await db.select({ count: drizzleCount2() }).from(notifications).where(and2(...sentConditions));
4849
4918
  const sent = Number(sentResult?.count ?? 0);
4850
4919
  if (sent === 0) {
4851
4920
  return { sent: 0, opened: 0, clicked: 0, openRate: 0, clickRate: 0 };
@@ -4880,11 +4949,11 @@ async function getEngagementStats(options = {}) {
4880
4949
  };
4881
4950
  }
4882
4951
  async function getClickDetails(notificationId) {
4883
- const db = getDatabase("read");
4952
+ const db = getDatabase2("read");
4884
4953
  const rows = await db.select({
4885
4954
  linkUrl: trackingEvents.linkUrl,
4886
4955
  linkIndex: trackingEvents.linkIndex,
4887
- totalClicks: drizzleCount(),
4956
+ totalClicks: drizzleCount2(),
4888
4957
  uniqueClicks: countDistinct(trackingEvents.ipAddress)
4889
4958
  }).from(trackingEvents).where(
4890
4959
  and2(