@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 +150 -81
- package/dist/server.js.map +1 -1
- package/package.json +1 -1
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(
|
|
214
|
-
return
|
|
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
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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(
|
|
1813
|
-
if (
|
|
1814
|
-
return yield*
|
|
1815
|
-
for (const left of
|
|
1816
|
-
for (const right of GenerateReduce(
|
|
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
|
-
|
|
3650
|
-
|
|
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
|
|
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
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
3985
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
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(
|
|
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
|
-
|
|
4275
|
-
|
|
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
|
|
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
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
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(
|
|
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 {
|
|
4795
|
-
import { eq as eq2, and as and2, gte as gte2, lte as lte2, count as
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
4890
|
+
const db = getDatabase2("read");
|
|
4822
4891
|
const rows = await db.select({
|
|
4823
4892
|
type: trackingEvents.type,
|
|
4824
|
-
total:
|
|
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 =
|
|
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:
|
|
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 =
|
|
4952
|
+
const db = getDatabase2("read");
|
|
4884
4953
|
const rows = await db.select({
|
|
4885
4954
|
linkUrl: trackingEvents.linkUrl,
|
|
4886
4955
|
linkIndex: trackingEvents.linkIndex,
|
|
4887
|
-
totalClicks:
|
|
4956
|
+
totalClicks: drizzleCount2(),
|
|
4888
4957
|
uniqueClicks: countDistinct(trackingEvents.ipAddress)
|
|
4889
4958
|
}).from(trackingEvents).where(
|
|
4890
4959
|
and2(
|