@codixus/server 0.1.6 → 0.1.8
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.cjs +1045 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +202 -4
- package/dist/index.d.ts +202 -4
- package/dist/index.js +1038 -95
- package/dist/index.js.map +1 -1
- package/package.json +15 -4
package/dist/index.cjs
CHANGED
|
@@ -33,16 +33,21 @@ __export(index_exports, {
|
|
|
33
33
|
CodixusError: () => import_shared4.CodixusError,
|
|
34
34
|
CodixusServer: () => CodixusServer,
|
|
35
35
|
ErrorCodes: () => import_shared4.ErrorCodes,
|
|
36
|
+
JourneyError: () => JourneyError,
|
|
36
37
|
Model: () => Model,
|
|
37
38
|
Query: () => Query,
|
|
38
39
|
RestErrorCode: () => RestErrorCode,
|
|
39
40
|
SubCollection: () => SubCollection,
|
|
40
41
|
createExpoTransport: () => createExpoTransport,
|
|
41
42
|
model: () => model,
|
|
42
|
-
validate: () => validate
|
|
43
|
+
validate: () => validate,
|
|
44
|
+
validateJourneyDefinition: () => validateJourneyDefinition
|
|
43
45
|
});
|
|
44
46
|
module.exports = __toCommonJS(index_exports);
|
|
45
47
|
|
|
48
|
+
// src/codixus-server.ts
|
|
49
|
+
var import_node_crypto11 = require("crypto");
|
|
50
|
+
|
|
46
51
|
// src/db/connection.ts
|
|
47
52
|
var import_mongodb = require("mongodb");
|
|
48
53
|
var ConnectionManager = class {
|
|
@@ -224,7 +229,7 @@ function createGuard(jwt, banService) {
|
|
|
224
229
|
var import_express = require("express");
|
|
225
230
|
var import_shared2 = require("@codixus/shared");
|
|
226
231
|
var import_shared3 = require("@codixus/shared");
|
|
227
|
-
function createAuthRouter(jwt, banService, db, config) {
|
|
232
|
+
function createAuthRouter(jwt, banService, db, config, onAuthenticated) {
|
|
228
233
|
const router = (0, import_express.Router)();
|
|
229
234
|
const usersCol = db.collection(config.usersCollection);
|
|
230
235
|
const refreshTokensCol = db.collection("refresh_tokens");
|
|
@@ -286,6 +291,11 @@ function createAuthRouter(jwt, banService, db, config) {
|
|
|
286
291
|
);
|
|
287
292
|
}
|
|
288
293
|
}
|
|
294
|
+
await onAuthenticated?.({
|
|
295
|
+
deviceId,
|
|
296
|
+
isNewUser,
|
|
297
|
+
usersCollection: config.usersCollection
|
|
298
|
+
});
|
|
289
299
|
const tokens = await jwt.sign(deviceId);
|
|
290
300
|
const tokenHash = JwtService.hashToken(tokens.refreshToken);
|
|
291
301
|
await refreshTokensCol.insertOne({
|
|
@@ -1322,7 +1332,7 @@ function checkAuth(req, res) {
|
|
|
1322
1332
|
function isNonEmptyString(value) {
|
|
1323
1333
|
return typeof value === "string" && value.length > 0;
|
|
1324
1334
|
}
|
|
1325
|
-
function createPushRouter(db) {
|
|
1335
|
+
function createPushRouter(db, onRegistered) {
|
|
1326
1336
|
const router = (0, import_express3.Router)();
|
|
1327
1337
|
const devicesCol = db.collection("push_devices");
|
|
1328
1338
|
const deliveriesCol = db.collection("push_deliveries");
|
|
@@ -1351,6 +1361,7 @@ function createPushRouter(db) {
|
|
|
1351
1361
|
if (permissionStatus !== void 0)
|
|
1352
1362
|
update.permissionStatus = permissionStatus;
|
|
1353
1363
|
await devicesCol.updateOne({ token }, { $set: update });
|
|
1364
|
+
await onRegistered?.({ deviceId, token, platform });
|
|
1354
1365
|
res.json({ success: true });
|
|
1355
1366
|
return;
|
|
1356
1367
|
}
|
|
@@ -1388,6 +1399,7 @@ function createPushRouter(db) {
|
|
|
1388
1399
|
update.permissionStatus = permissionStatus;
|
|
1389
1400
|
await devicesCol.updateOne({ token }, { $set: update });
|
|
1390
1401
|
}
|
|
1402
|
+
await onRegistered?.({ deviceId, token, platform });
|
|
1391
1403
|
res.json({ success: true });
|
|
1392
1404
|
});
|
|
1393
1405
|
router.post("/unregister", async (req, res) => {
|
|
@@ -1427,17 +1439,470 @@ function isDuplicateKeyError(err) {
|
|
|
1427
1439
|
|
|
1428
1440
|
// src/admin/router.ts
|
|
1429
1441
|
var import_express4 = require("express");
|
|
1430
|
-
var
|
|
1442
|
+
var import_node_crypto8 = require("crypto");
|
|
1431
1443
|
var import_zod = require("zod");
|
|
1432
1444
|
|
|
1433
|
-
// src/
|
|
1445
|
+
// src/journeys/service.ts
|
|
1434
1446
|
var import_node_crypto6 = require("crypto");
|
|
1447
|
+
var EVENT_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
1448
|
+
var STEP_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
1449
|
+
var MAX_STEPS = 20;
|
|
1450
|
+
var MAX_OFFSET_SECONDS = 365 * 24 * 60 * 60;
|
|
1451
|
+
var MAX_ATTEMPTS = 5;
|
|
1452
|
+
var RETRY_DELAYS_MS = [6e4, 3e5, 9e5, 36e5];
|
|
1453
|
+
var PERMANENT_ERRORS = /* @__PURE__ */ new Set([
|
|
1454
|
+
"INVALID_PAYLOAD",
|
|
1455
|
+
"InvalidCredentials",
|
|
1456
|
+
"MessageTooBig"
|
|
1457
|
+
]);
|
|
1458
|
+
var JourneyError = class extends Error {
|
|
1459
|
+
constructor(code, message) {
|
|
1460
|
+
super(message);
|
|
1461
|
+
this.code = code;
|
|
1462
|
+
this.name = "JourneyError";
|
|
1463
|
+
}
|
|
1464
|
+
code;
|
|
1465
|
+
};
|
|
1466
|
+
var JourneyService = class {
|
|
1467
|
+
constructor(db, push) {
|
|
1468
|
+
this.push = push;
|
|
1469
|
+
this.journeys = db.collection("push_journeys");
|
|
1470
|
+
this.revisions = db.collection("push_journey_revisions");
|
|
1471
|
+
this.runs = db.collection("push_journey_runs");
|
|
1472
|
+
this.events = db.collection("push_events");
|
|
1473
|
+
}
|
|
1474
|
+
push;
|
|
1475
|
+
journeys;
|
|
1476
|
+
revisions;
|
|
1477
|
+
runs;
|
|
1478
|
+
events;
|
|
1479
|
+
async ensureIndexes() {
|
|
1480
|
+
await this.journeys.createIndex({ status: 1, liveEntryEvent: 1, liveSince: 1 });
|
|
1481
|
+
await this.revisions.createIndex({ journeyId: 1, revision: 1 }, { unique: true });
|
|
1482
|
+
await this.runs.createIndex(
|
|
1483
|
+
{ journeyId: 1, revision: 1, eventId: 1 },
|
|
1484
|
+
{ unique: true }
|
|
1485
|
+
);
|
|
1486
|
+
await this.runs.createIndex({ status: 1, nextRunAt: 1, lockedUntil: 1 });
|
|
1487
|
+
await this.runs.createIndex({ deviceId: 1, createdAt: -1 });
|
|
1488
|
+
}
|
|
1489
|
+
async create(input) {
|
|
1490
|
+
const name = validateName(input.name);
|
|
1491
|
+
const definition = validateJourneyDefinition(input.definition);
|
|
1492
|
+
const now = /* @__PURE__ */ new Date();
|
|
1493
|
+
const journey = {
|
|
1494
|
+
_id: (0, import_node_crypto6.randomUUID)(),
|
|
1495
|
+
name,
|
|
1496
|
+
status: "draft",
|
|
1497
|
+
draft: definition,
|
|
1498
|
+
revisionCounter: 0,
|
|
1499
|
+
createdAt: now,
|
|
1500
|
+
updatedAt: now
|
|
1501
|
+
};
|
|
1502
|
+
await this.journeys.insertOne(journey);
|
|
1503
|
+
return journey;
|
|
1504
|
+
}
|
|
1505
|
+
async list() {
|
|
1506
|
+
return this.journeys.find().sort({ updatedAt: -1 }).toArray();
|
|
1507
|
+
}
|
|
1508
|
+
async get(id) {
|
|
1509
|
+
return this.journeys.findOne({ _id: id });
|
|
1510
|
+
}
|
|
1511
|
+
async updateDraft(id, input) {
|
|
1512
|
+
const existing = await this.requireJourney(id);
|
|
1513
|
+
const name = input.name === void 0 ? existing.name : validateName(input.name);
|
|
1514
|
+
const definition = input.definition === void 0 ? existing.draft : validateJourneyDefinition(input.definition);
|
|
1515
|
+
await this.journeys.updateOne(
|
|
1516
|
+
{ _id: id },
|
|
1517
|
+
{ $set: { name, draft: definition, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1518
|
+
);
|
|
1519
|
+
return await this.requireJourney(id);
|
|
1520
|
+
}
|
|
1521
|
+
async publish(id) {
|
|
1522
|
+
const existing = await this.requireJourney(id);
|
|
1523
|
+
const publishedAt = /* @__PURE__ */ new Date();
|
|
1524
|
+
const revision = existing.revisionCounter + 1;
|
|
1525
|
+
const snapshot = cloneDefinition(existing.draft);
|
|
1526
|
+
const revisionDoc = {
|
|
1527
|
+
_id: (0, import_node_crypto6.randomUUID)(),
|
|
1528
|
+
journeyId: id,
|
|
1529
|
+
revision,
|
|
1530
|
+
name: existing.name,
|
|
1531
|
+
definition: snapshot,
|
|
1532
|
+
publishedAt
|
|
1533
|
+
};
|
|
1534
|
+
await this.revisions.insertOne(revisionDoc);
|
|
1535
|
+
const update = await this.journeys.updateOne(
|
|
1536
|
+
{ _id: id, revisionCounter: existing.revisionCounter },
|
|
1537
|
+
{
|
|
1538
|
+
$set: {
|
|
1539
|
+
status: "live",
|
|
1540
|
+
liveRevision: revision,
|
|
1541
|
+
liveEntryEvent: snapshot.entryEvent,
|
|
1542
|
+
liveSince: publishedAt,
|
|
1543
|
+
publishedAt,
|
|
1544
|
+
updatedAt: publishedAt
|
|
1545
|
+
},
|
|
1546
|
+
$inc: { revisionCounter: 1 }
|
|
1547
|
+
}
|
|
1548
|
+
);
|
|
1549
|
+
if (update.matchedCount === 0) {
|
|
1550
|
+
await this.revisions.deleteOne({ _id: revisionDoc._id });
|
|
1551
|
+
throw new JourneyError("INVALID_STATE", "Journey was published concurrently");
|
|
1552
|
+
}
|
|
1553
|
+
await this.runs.updateMany(
|
|
1554
|
+
{ journeyId: id, status: "paused" },
|
|
1555
|
+
{ $set: { status: "active", updatedAt: publishedAt } }
|
|
1556
|
+
);
|
|
1557
|
+
return this.requireJourney(id);
|
|
1558
|
+
}
|
|
1559
|
+
async pause(id) {
|
|
1560
|
+
const update = await this.journeys.updateOne(
|
|
1561
|
+
{ _id: id, status: "live" },
|
|
1562
|
+
{ $set: { status: "paused", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1563
|
+
);
|
|
1564
|
+
if (update.matchedCount === 0) {
|
|
1565
|
+
await this.requireJourney(id);
|
|
1566
|
+
throw new JourneyError("INVALID_STATE", "Only live journeys can be paused");
|
|
1567
|
+
}
|
|
1568
|
+
await this.runs.updateMany(
|
|
1569
|
+
{ journeyId: id, status: "active" },
|
|
1570
|
+
{
|
|
1571
|
+
$set: { status: "paused", updatedAt: /* @__PURE__ */ new Date() },
|
|
1572
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1573
|
+
}
|
|
1574
|
+
);
|
|
1575
|
+
return this.requireJourney(id);
|
|
1576
|
+
}
|
|
1577
|
+
async resume(id) {
|
|
1578
|
+
const now = /* @__PURE__ */ new Date();
|
|
1579
|
+
const update = await this.journeys.updateOne(
|
|
1580
|
+
{ _id: id, status: "paused", liveRevision: { $exists: true } },
|
|
1581
|
+
{ $set: { status: "live", liveSince: now, updatedAt: now } }
|
|
1582
|
+
);
|
|
1583
|
+
if (update.matchedCount === 0) {
|
|
1584
|
+
await this.requireJourney(id);
|
|
1585
|
+
throw new JourneyError("INVALID_STATE", "Only paused journeys can be resumed");
|
|
1586
|
+
}
|
|
1587
|
+
await this.runs.updateMany(
|
|
1588
|
+
{ journeyId: id, status: "paused" },
|
|
1589
|
+
{ $set: { status: "active", updatedAt: now } }
|
|
1590
|
+
);
|
|
1591
|
+
return this.requireJourney(id);
|
|
1592
|
+
}
|
|
1593
|
+
async enrollEvent(event) {
|
|
1594
|
+
for (const enrollment of event.journeyEnrollments ?? []) {
|
|
1595
|
+
const revision = await this.revisions.findOne({
|
|
1596
|
+
journeyId: enrollment.journeyId,
|
|
1597
|
+
revision: enrollment.revision
|
|
1598
|
+
});
|
|
1599
|
+
const firstStep = revision?.definition.steps[0];
|
|
1600
|
+
if (!revision || !firstStep) continue;
|
|
1601
|
+
const now = /* @__PURE__ */ new Date();
|
|
1602
|
+
const run = {
|
|
1603
|
+
_id: (0, import_node_crypto6.randomUUID)(),
|
|
1604
|
+
journeyId: enrollment.journeyId,
|
|
1605
|
+
revision: enrollment.revision,
|
|
1606
|
+
eventId: event._id,
|
|
1607
|
+
deviceId: event.deviceId,
|
|
1608
|
+
eventOccurredAt: event.occurredAt,
|
|
1609
|
+
status: "active",
|
|
1610
|
+
nextStepIndex: 0,
|
|
1611
|
+
nextRunAt: addSeconds(event.occurredAt, firstStep.offsetSeconds),
|
|
1612
|
+
attempts: 0,
|
|
1613
|
+
createdAt: now,
|
|
1614
|
+
updatedAt: now
|
|
1615
|
+
};
|
|
1616
|
+
try {
|
|
1617
|
+
await this.runs.insertOne(run);
|
|
1618
|
+
} catch (error) {
|
|
1619
|
+
if (!isDuplicateKeyError2(error)) throw error;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async resolveEnrollments(eventName) {
|
|
1624
|
+
const journeys = await this.journeys.find({ status: "live", liveEntryEvent: eventName }).project({ _id: 1, liveRevision: 1 }).toArray();
|
|
1625
|
+
return journeys.flatMap(
|
|
1626
|
+
(journey) => journey.liveRevision === void 0 ? [] : [{ journeyId: journey._id, revision: journey.liveRevision }]
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
async processDue(options = {}) {
|
|
1630
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1631
|
+
const limit = Math.max(1, Math.min(options.limit ?? 100, 500));
|
|
1632
|
+
const leaseMs = Math.max(options.leaseMs ?? 6e4, 1e3);
|
|
1633
|
+
let processed = 0;
|
|
1634
|
+
for (let index = 0; index < limit; index += 1) {
|
|
1635
|
+
const lockToken = (0, import_node_crypto6.randomUUID)();
|
|
1636
|
+
const run = await this.runs.findOneAndUpdate(
|
|
1637
|
+
{
|
|
1638
|
+
status: "active",
|
|
1639
|
+
nextRunAt: { $lte: now },
|
|
1640
|
+
$or: [
|
|
1641
|
+
{ lockedUntil: { $exists: false } },
|
|
1642
|
+
{ lockedUntil: { $lte: now } }
|
|
1643
|
+
]
|
|
1644
|
+
},
|
|
1645
|
+
{
|
|
1646
|
+
$set: {
|
|
1647
|
+
lockToken,
|
|
1648
|
+
lockedUntil: new Date(now.getTime() + leaseMs),
|
|
1649
|
+
updatedAt: now
|
|
1650
|
+
}
|
|
1651
|
+
},
|
|
1652
|
+
{ sort: { nextRunAt: 1 }, returnDocument: "after" }
|
|
1653
|
+
);
|
|
1654
|
+
if (!run) break;
|
|
1655
|
+
await this.processLockedRun(run, lockToken, now);
|
|
1656
|
+
processed += 1;
|
|
1657
|
+
}
|
|
1658
|
+
return { processed };
|
|
1659
|
+
}
|
|
1660
|
+
async testSend(input) {
|
|
1661
|
+
const journey = await this.requireJourney(input.journeyId);
|
|
1662
|
+
const step = journey.draft.steps.find((candidate) => candidate.id === input.stepId);
|
|
1663
|
+
if (!step) throw new JourneyError("NOT_FOUND", "Journey step not found");
|
|
1664
|
+
return this.push.send({
|
|
1665
|
+
deviceId: input.deviceId,
|
|
1666
|
+
title: step.title,
|
|
1667
|
+
body: step.body,
|
|
1668
|
+
data: step.data,
|
|
1669
|
+
imageUrl: step.imageUrl,
|
|
1670
|
+
idempotencyKey: `journey-test:${journey._id}:${step.id}:${input.deviceId}:${(0, import_node_crypto6.randomUUID)()}`,
|
|
1671
|
+
journeyId: journey._id,
|
|
1672
|
+
journeyStepId: step.id,
|
|
1673
|
+
isTest: true
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
async processLockedRun(run, lockToken, now) {
|
|
1677
|
+
try {
|
|
1678
|
+
const journey = await this.journeys.findOne({ _id: run.journeyId });
|
|
1679
|
+
if (!journey || journey.status !== "live") {
|
|
1680
|
+
await this.runs.updateOne(
|
|
1681
|
+
{ _id: run._id, lockToken },
|
|
1682
|
+
{
|
|
1683
|
+
$set: { status: "paused", updatedAt: now },
|
|
1684
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1685
|
+
}
|
|
1686
|
+
);
|
|
1687
|
+
return;
|
|
1688
|
+
}
|
|
1689
|
+
const revision = await this.revisions.findOne({
|
|
1690
|
+
journeyId: run.journeyId,
|
|
1691
|
+
revision: run.revision
|
|
1692
|
+
});
|
|
1693
|
+
const step = revision?.definition.steps[run.nextStepIndex];
|
|
1694
|
+
if (!revision || !step) {
|
|
1695
|
+
await this.failRun(run._id, lockToken, now, "REVISION_NOT_FOUND");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (!await this.matchesAudience(run.deviceId, revision.definition)) {
|
|
1699
|
+
await this.runs.updateOne(
|
|
1700
|
+
{ _id: run._id, lockToken },
|
|
1701
|
+
{
|
|
1702
|
+
$set: { status: "exited", completedAt: now, updatedAt: now },
|
|
1703
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1704
|
+
}
|
|
1705
|
+
);
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
const data = step.data ? { ...step.data } : {};
|
|
1709
|
+
if (!("journeyId" in data)) data.journeyId = run.journeyId;
|
|
1710
|
+
if (!("journeyStepId" in data)) data.journeyStepId = step.id;
|
|
1711
|
+
const results = await this.push.send({
|
|
1712
|
+
deviceId: run.deviceId,
|
|
1713
|
+
title: step.title,
|
|
1714
|
+
body: step.body,
|
|
1715
|
+
imageUrl: step.imageUrl,
|
|
1716
|
+
data,
|
|
1717
|
+
idempotencyKey: `journey:${run._id}:step:${step.id}:attempt:${run.attempts}`,
|
|
1718
|
+
journeyId: run.journeyId,
|
|
1719
|
+
journeyRevision: run.revision,
|
|
1720
|
+
journeyStepId: step.id,
|
|
1721
|
+
isTest: false
|
|
1722
|
+
});
|
|
1723
|
+
if (results.some((result) => result.status === "submitted")) {
|
|
1724
|
+
const nextIndex = run.nextStepIndex + 1;
|
|
1725
|
+
const nextStep = revision.definition.steps[nextIndex];
|
|
1726
|
+
if (!nextStep) {
|
|
1727
|
+
await this.runs.updateOne(
|
|
1728
|
+
{ _id: run._id, lockToken },
|
|
1729
|
+
{
|
|
1730
|
+
$set: {
|
|
1731
|
+
status: "completed",
|
|
1732
|
+
nextStepIndex: nextIndex,
|
|
1733
|
+
attempts: 0,
|
|
1734
|
+
completedAt: now,
|
|
1735
|
+
updatedAt: now
|
|
1736
|
+
},
|
|
1737
|
+
$unset: { lockToken: "", lockedUntil: "", lastError: "" }
|
|
1738
|
+
}
|
|
1739
|
+
);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
await this.runs.updateOne(
|
|
1743
|
+
{ _id: run._id, lockToken },
|
|
1744
|
+
{
|
|
1745
|
+
$set: {
|
|
1746
|
+
nextStepIndex: nextIndex,
|
|
1747
|
+
nextRunAt: addSeconds(run.eventOccurredAt, nextStep.offsetSeconds),
|
|
1748
|
+
attempts: 0,
|
|
1749
|
+
updatedAt: now
|
|
1750
|
+
},
|
|
1751
|
+
$unset: { lockToken: "", lockedUntil: "", lastError: "" }
|
|
1752
|
+
}
|
|
1753
|
+
);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const errorCode = results.find((result) => result.errorCode)?.errorCode ?? "TRANSIENT";
|
|
1757
|
+
if (PERMANENT_ERRORS.has(errorCode)) {
|
|
1758
|
+
await this.failRun(run._id, lockToken, now, errorCode);
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
await this.retryRun(run, lockToken, now, errorCode);
|
|
1762
|
+
} catch (error) {
|
|
1763
|
+
await this.retryRun(
|
|
1764
|
+
run,
|
|
1765
|
+
lockToken,
|
|
1766
|
+
now,
|
|
1767
|
+
error instanceof Error ? error.message : "TRANSIENT"
|
|
1768
|
+
);
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
async matchesAudience(deviceId, definition) {
|
|
1772
|
+
if (!definition.audience) return true;
|
|
1773
|
+
const exists = await this.events.findOne({
|
|
1774
|
+
deviceId,
|
|
1775
|
+
name: definition.audience.eventName
|
|
1776
|
+
});
|
|
1777
|
+
return definition.audience.operator === "has_event" ? !!exists : !exists;
|
|
1778
|
+
}
|
|
1779
|
+
async retryRun(run, lockToken, now, errorCode) {
|
|
1780
|
+
const attempts = run.attempts + 1;
|
|
1781
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
1782
|
+
await this.failRun(run._id, lockToken, now, errorCode, attempts);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
const delay = RETRY_DELAYS_MS[Math.min(attempts - 1, RETRY_DELAYS_MS.length - 1)];
|
|
1786
|
+
await this.runs.updateOne(
|
|
1787
|
+
{ _id: run._id, lockToken },
|
|
1788
|
+
{
|
|
1789
|
+
$set: {
|
|
1790
|
+
attempts,
|
|
1791
|
+
nextRunAt: new Date(now.getTime() + delay),
|
|
1792
|
+
lastError: errorCode,
|
|
1793
|
+
updatedAt: now
|
|
1794
|
+
},
|
|
1795
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1796
|
+
}
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
async failRun(id, lockToken, now, errorCode, attempts) {
|
|
1800
|
+
const set = {
|
|
1801
|
+
status: "failed",
|
|
1802
|
+
lastError: errorCode,
|
|
1803
|
+
completedAt: now,
|
|
1804
|
+
updatedAt: now
|
|
1805
|
+
};
|
|
1806
|
+
if (attempts !== void 0) set.attempts = attempts;
|
|
1807
|
+
await this.runs.updateOne(
|
|
1808
|
+
{ _id: id, lockToken },
|
|
1809
|
+
{ $set: set, $unset: { lockToken: "", lockedUntil: "" } }
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
async requireJourney(id) {
|
|
1813
|
+
const journey = await this.journeys.findOne({ _id: id });
|
|
1814
|
+
if (!journey) throw new JourneyError("NOT_FOUND", "Journey not found");
|
|
1815
|
+
return journey;
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
function validateJourneyDefinition(input) {
|
|
1819
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
1820
|
+
throw new JourneyError("INVALID_INPUT", "Invalid journey definition");
|
|
1821
|
+
}
|
|
1822
|
+
if (!EVENT_NAME_RE.test(input.entryEvent)) {
|
|
1823
|
+
throw new JourneyError("INVALID_INPUT", "Invalid entry event");
|
|
1824
|
+
}
|
|
1825
|
+
if (!Array.isArray(input.steps) || input.steps.length < 1 || input.steps.length > MAX_STEPS) {
|
|
1826
|
+
throw new JourneyError("INVALID_INPUT", "A journey needs 1-20 steps");
|
|
1827
|
+
}
|
|
1828
|
+
if (input.audience) {
|
|
1829
|
+
if (input.audience.operator !== "has_event" && input.audience.operator !== "not_has_event" || !EVENT_NAME_RE.test(input.audience.eventName)) {
|
|
1830
|
+
throw new JourneyError("INVALID_INPUT", "Invalid audience");
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
const stepIds = /* @__PURE__ */ new Set();
|
|
1834
|
+
let previousOffset = -1;
|
|
1835
|
+
for (const step of input.steps) {
|
|
1836
|
+
if (!step || typeof step !== "object" || !STEP_ID_RE.test(step.id)) {
|
|
1837
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step id");
|
|
1838
|
+
}
|
|
1839
|
+
if (stepIds.has(step.id)) {
|
|
1840
|
+
throw new JourneyError("INVALID_INPUT", "Duplicate step id");
|
|
1841
|
+
}
|
|
1842
|
+
stepIds.add(step.id);
|
|
1843
|
+
if (!Number.isInteger(step.offsetSeconds) || step.offsetSeconds < 0 || step.offsetSeconds > MAX_OFFSET_SECONDS || step.offsetSeconds < previousOffset) {
|
|
1844
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step offset");
|
|
1845
|
+
}
|
|
1846
|
+
previousOffset = step.offsetSeconds;
|
|
1847
|
+
if (typeof step.title !== "string" || step.title.length < 1 || step.title.length > 100) {
|
|
1848
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step title");
|
|
1849
|
+
}
|
|
1850
|
+
if (typeof step.body !== "string" || step.body.length < 1 || step.body.length > 1e3) {
|
|
1851
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step body");
|
|
1852
|
+
}
|
|
1853
|
+
validateHttpsUrl(step.imageUrl);
|
|
1854
|
+
if (step.data !== void 0) {
|
|
1855
|
+
if (!step.data || typeof step.data !== "object" || Array.isArray(step.data)) {
|
|
1856
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step data");
|
|
1857
|
+
}
|
|
1858
|
+
if ("deliveryId" in step.data || "journeyId" in step.data || "journeyStepId" in step.data) {
|
|
1859
|
+
throw new JourneyError("INVALID_INPUT", "Step data uses reserved keys");
|
|
1860
|
+
}
|
|
1861
|
+
try {
|
|
1862
|
+
if (Buffer.byteLength(JSON.stringify(step.data), "utf8") > 3072) {
|
|
1863
|
+
throw new JourneyError("INVALID_INPUT", "Step data is too large");
|
|
1864
|
+
}
|
|
1865
|
+
} catch (error) {
|
|
1866
|
+
if (error instanceof JourneyError) throw error;
|
|
1867
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step data");
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
return cloneDefinition(input);
|
|
1872
|
+
}
|
|
1873
|
+
function validateName(name) {
|
|
1874
|
+
if (typeof name !== "string" || name.trim().length < 1 || name.trim().length > 100) {
|
|
1875
|
+
throw new JourneyError("INVALID_INPUT", "Invalid journey name");
|
|
1876
|
+
}
|
|
1877
|
+
return name.trim();
|
|
1878
|
+
}
|
|
1879
|
+
function validateHttpsUrl(value) {
|
|
1880
|
+
if (value === void 0) return;
|
|
1881
|
+
try {
|
|
1882
|
+
const url = new URL(value);
|
|
1883
|
+
if (url.protocol !== "https:" || value.length > 2048) throw new Error("invalid");
|
|
1884
|
+
} catch {
|
|
1885
|
+
throw new JourneyError("INVALID_INPUT", "Invalid image URL");
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
function cloneDefinition(definition) {
|
|
1889
|
+
return structuredClone(definition);
|
|
1890
|
+
}
|
|
1891
|
+
function addSeconds(date, seconds) {
|
|
1892
|
+
return new Date(date.getTime() + seconds * 1e3);
|
|
1893
|
+
}
|
|
1894
|
+
function isDuplicateKeyError2(error) {
|
|
1895
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// src/admin/guard.ts
|
|
1899
|
+
var import_node_crypto7 = require("crypto");
|
|
1435
1900
|
var ADMIN_HEADER = "x-codixus-admin";
|
|
1436
1901
|
function digestToken(token) {
|
|
1437
|
-
return (0,
|
|
1902
|
+
return (0, import_node_crypto7.createHash)("sha256").update(token, "utf8").digest();
|
|
1438
1903
|
}
|
|
1439
1904
|
function tokensMatch(provided, expected) {
|
|
1440
|
-
return (0,
|
|
1905
|
+
return (0, import_node_crypto7.timingSafeEqual)(digestToken(provided), digestToken(expected));
|
|
1441
1906
|
}
|
|
1442
1907
|
function readHeader(req) {
|
|
1443
1908
|
const value = req.headers[ADMIN_HEADER];
|
|
@@ -1482,13 +1947,20 @@ var PUSH_DELIVERY_FIELDS = [
|
|
|
1482
1947
|
"title",
|
|
1483
1948
|
"body",
|
|
1484
1949
|
"data",
|
|
1950
|
+
"imageUrl",
|
|
1485
1951
|
"status",
|
|
1486
1952
|
"ticketId",
|
|
1487
1953
|
"errorCode",
|
|
1954
|
+
"errorMessage",
|
|
1488
1955
|
"idempotencyKey",
|
|
1489
1956
|
"createdAt",
|
|
1490
1957
|
"openedAt",
|
|
1491
|
-
"receiptedAt"
|
|
1958
|
+
"receiptedAt",
|
|
1959
|
+
"updatedAt",
|
|
1960
|
+
"journeyId",
|
|
1961
|
+
"journeyRevision",
|
|
1962
|
+
"journeyStepId",
|
|
1963
|
+
"isTest"
|
|
1492
1964
|
];
|
|
1493
1965
|
function param2(req, name) {
|
|
1494
1966
|
const val = req.params[name];
|
|
@@ -1655,6 +2127,19 @@ function notFound(res) {
|
|
|
1655
2127
|
function invalidBody(res) {
|
|
1656
2128
|
res.status(400).json({ success: false, error: "Invalid request body" });
|
|
1657
2129
|
}
|
|
2130
|
+
function journeyFailure(res, error) {
|
|
2131
|
+
if (error instanceof JourneyError) {
|
|
2132
|
+
if (error.code === "NOT_FOUND") {
|
|
2133
|
+
notFound(res);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (error.code === "INVALID_STATE") {
|
|
2137
|
+
res.status(409).json({ success: false, error: "INVALID_STATE" });
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
invalidBody(res);
|
|
2142
|
+
}
|
|
1658
2143
|
function createAdminRouter(deps) {
|
|
1659
2144
|
const router = (0, import_express4.Router)();
|
|
1660
2145
|
router.use(createAdminGuard(deps.token));
|
|
@@ -1713,7 +2198,7 @@ function createAdminRouter(deps) {
|
|
|
1713
2198
|
}
|
|
1714
2199
|
const doc = { ...body };
|
|
1715
2200
|
if (!doc._id) {
|
|
1716
|
-
doc._id = (0,
|
|
2201
|
+
doc._id = (0, import_node_crypto8.randomUUID)();
|
|
1717
2202
|
}
|
|
1718
2203
|
await deps.db.collection(name).insertOne(doc);
|
|
1719
2204
|
res.status(200).json({ success: true, data: doc });
|
|
@@ -1814,7 +2299,7 @@ function createAdminRouter(deps) {
|
|
|
1814
2299
|
notFound(res);
|
|
1815
2300
|
return;
|
|
1816
2301
|
}
|
|
1817
|
-
const { deviceId, title, body, data } = req.body ?? {};
|
|
2302
|
+
const { deviceId, title, body, data, imageUrl } = req.body ?? {};
|
|
1818
2303
|
if (!isNonEmptyString2(deviceId) || !isNonEmptyString2(title) || !isNonEmptyString2(body)) {
|
|
1819
2304
|
invalidBody(res);
|
|
1820
2305
|
return;
|
|
@@ -1829,8 +2314,100 @@ function createAdminRouter(deps) {
|
|
|
1829
2314
|
if (data !== void 0) {
|
|
1830
2315
|
input.data = data;
|
|
1831
2316
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
2317
|
+
if (imageUrl !== void 0) {
|
|
2318
|
+
if (!isNonEmptyString2(imageUrl)) {
|
|
2319
|
+
invalidBody(res);
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
input.imageUrl = imageUrl;
|
|
2323
|
+
}
|
|
2324
|
+
try {
|
|
2325
|
+
const results = await deps.pushSend(input);
|
|
2326
|
+
res.status(200).json({ success: true, data: results });
|
|
2327
|
+
} catch {
|
|
2328
|
+
invalidBody(res);
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
router.get("/push/journeys", async (_req, res) => {
|
|
2332
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2333
|
+
notFound(res);
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
res.json({ success: true, data: await deps.journeys.list() });
|
|
2337
|
+
});
|
|
2338
|
+
router.post("/push/journeys", async (req, res) => {
|
|
2339
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2340
|
+
notFound(res);
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
try {
|
|
2344
|
+
const journey = await deps.journeys.create(req.body ?? {});
|
|
2345
|
+
res.status(201).json({ success: true, data: journey });
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
journeyFailure(res, error);
|
|
2348
|
+
}
|
|
2349
|
+
});
|
|
2350
|
+
router.get("/push/journeys/:id", async (req, res) => {
|
|
2351
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2352
|
+
notFound(res);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
const journey = await deps.journeys.get(param2(req, "id"));
|
|
2356
|
+
if (!journey) {
|
|
2357
|
+
notFound(res);
|
|
2358
|
+
return;
|
|
2359
|
+
}
|
|
2360
|
+
res.json({ success: true, data: journey });
|
|
2361
|
+
});
|
|
2362
|
+
router.patch("/push/journeys/:id", async (req, res) => {
|
|
2363
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2364
|
+
notFound(res);
|
|
2365
|
+
return;
|
|
2366
|
+
}
|
|
2367
|
+
try {
|
|
2368
|
+
const journey = await deps.journeys.updateDraft(param2(req, "id"), req.body ?? {});
|
|
2369
|
+
res.json({ success: true, data: journey });
|
|
2370
|
+
} catch (error) {
|
|
2371
|
+
journeyFailure(res, error);
|
|
2372
|
+
}
|
|
2373
|
+
});
|
|
2374
|
+
for (const action of ["publish", "pause", "resume"]) {
|
|
2375
|
+
router.post(
|
|
2376
|
+
`/push/journeys/:id/${action}`,
|
|
2377
|
+
async (req, res) => {
|
|
2378
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2379
|
+
notFound(res);
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
try {
|
|
2383
|
+
const journey = await deps.journeys[action](param2(req, "id"));
|
|
2384
|
+
res.json({ success: true, data: journey });
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
journeyFailure(res, error);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
);
|
|
2390
|
+
}
|
|
2391
|
+
router.post("/push/journeys/:id/test", async (req, res) => {
|
|
2392
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2393
|
+
notFound(res);
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const { deviceId, stepId } = req.body ?? {};
|
|
2397
|
+
if (!isNonEmptyString2(deviceId) || !isNonEmptyString2(stepId)) {
|
|
2398
|
+
invalidBody(res);
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
try {
|
|
2402
|
+
const results = await deps.journeys.testSend({
|
|
2403
|
+
journeyId: param2(req, "id"),
|
|
2404
|
+
deviceId,
|
|
2405
|
+
stepId
|
|
2406
|
+
});
|
|
2407
|
+
res.json({ success: true, data: results });
|
|
2408
|
+
} catch (error) {
|
|
2409
|
+
journeyFailure(res, error);
|
|
2410
|
+
}
|
|
1834
2411
|
});
|
|
1835
2412
|
router.get("/push/deliveries", async (req, res) => {
|
|
1836
2413
|
if (!deps.hasPush) {
|
|
@@ -1850,7 +2427,7 @@ function createAdminRouter(deps) {
|
|
|
1850
2427
|
}
|
|
1851
2428
|
|
|
1852
2429
|
// src/push/service.ts
|
|
1853
|
-
var
|
|
2430
|
+
var import_node_crypto9 = require("crypto");
|
|
1854
2431
|
var PERMANENT_DISABLE_CODES = /* @__PURE__ */ new Set([
|
|
1855
2432
|
"DeviceNotRegistered",
|
|
1856
2433
|
"InvalidCredentials"
|
|
@@ -1866,77 +2443,110 @@ var PushService = class {
|
|
|
1866
2443
|
devicesCol;
|
|
1867
2444
|
deliveriesCol;
|
|
1868
2445
|
async send(input) {
|
|
2446
|
+
validateImageUrl(input.imageUrl);
|
|
1869
2447
|
const deviceIds = Array.isArray(input.deviceId) ? [...new Set(input.deviceId)] : [input.deviceId];
|
|
1870
|
-
if (deviceIds.length === 0) return;
|
|
2448
|
+
if (deviceIds.length === 0) return [];
|
|
1871
2449
|
const digest = computeDigest(
|
|
1872
2450
|
input.title,
|
|
1873
2451
|
input.body,
|
|
1874
2452
|
input.idempotencyKey
|
|
1875
2453
|
);
|
|
1876
2454
|
const pending = [];
|
|
2455
|
+
const results = [];
|
|
1877
2456
|
for (const deviceId of deviceIds) {
|
|
1878
2457
|
const devices = await this.devicesCol.find({ deviceId, enabled: true }).toArray();
|
|
2458
|
+
if (devices.length === 0) {
|
|
2459
|
+
results.push({ deviceId, status: "skipped", errorCode: "NO_DEVICE" });
|
|
2460
|
+
}
|
|
1879
2461
|
for (const device of devices) {
|
|
1880
2462
|
const idempotencyKey = `${deviceId}:${device.token}:${digest}`;
|
|
1881
|
-
const
|
|
1882
|
-
if (existing) continue;
|
|
1883
|
-
const deliveryId = (0, import_node_crypto8.randomUUID)();
|
|
2463
|
+
const deliveryId = (0, import_node_crypto9.randomUUID)();
|
|
1884
2464
|
const data = input.data ? { ...input.data } : {};
|
|
1885
2465
|
if (!("deliveryId" in data)) {
|
|
1886
2466
|
data.deliveryId = deliveryId;
|
|
1887
2467
|
}
|
|
1888
|
-
|
|
1889
|
-
|
|
2468
|
+
const now = /* @__PURE__ */ new Date();
|
|
2469
|
+
const doc = {
|
|
2470
|
+
_id: deliveryId,
|
|
1890
2471
|
deviceId,
|
|
1891
2472
|
provider: device.provider,
|
|
1892
|
-
|
|
2473
|
+
token: device.token,
|
|
2474
|
+
title: input.title,
|
|
2475
|
+
body: input.body,
|
|
2476
|
+
data,
|
|
2477
|
+
status: "sending",
|
|
1893
2478
|
idempotencyKey,
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
2479
|
+
createdAt: now,
|
|
2480
|
+
updatedAt: now
|
|
2481
|
+
};
|
|
2482
|
+
if (input.imageUrl !== void 0) doc.imageUrl = input.imageUrl;
|
|
2483
|
+
if (input.journeyId !== void 0) doc.journeyId = input.journeyId;
|
|
2484
|
+
if (input.journeyRevision !== void 0) {
|
|
2485
|
+
doc.journeyRevision = input.journeyRevision;
|
|
2486
|
+
}
|
|
2487
|
+
if (input.journeyStepId !== void 0) {
|
|
2488
|
+
doc.journeyStepId = input.journeyStepId;
|
|
2489
|
+
}
|
|
2490
|
+
if (input.isTest !== void 0) doc.isTest = input.isTest;
|
|
2491
|
+
try {
|
|
2492
|
+
await this.deliveriesCol.insertOne(doc);
|
|
2493
|
+
} catch (error) {
|
|
2494
|
+
if (!isDuplicateKeyError3(error)) throw error;
|
|
2495
|
+
const existing = await this.deliveriesCol.findOne({ idempotencyKey });
|
|
2496
|
+
if (existing) results.push(resultFromDelivery(existing));
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
const message = {
|
|
2500
|
+
token: device.token,
|
|
2501
|
+
title: input.title,
|
|
2502
|
+
body: input.body,
|
|
2503
|
+
data
|
|
2504
|
+
};
|
|
2505
|
+
if (input.imageUrl !== void 0) message.imageUrl = input.imageUrl;
|
|
2506
|
+
pending.push({ doc, message });
|
|
1901
2507
|
}
|
|
1902
2508
|
}
|
|
1903
|
-
if (pending.length === 0) return;
|
|
2509
|
+
if (pending.length === 0) return results;
|
|
1904
2510
|
const messages = pending.map((p) => p.message);
|
|
1905
|
-
|
|
2511
|
+
let tickets;
|
|
2512
|
+
try {
|
|
2513
|
+
tickets = await this.transport.send(messages);
|
|
2514
|
+
} catch (error) {
|
|
2515
|
+
tickets = messages.map((message) => ({
|
|
2516
|
+
token: message.token,
|
|
2517
|
+
status: "error",
|
|
2518
|
+
errorCode: "TRANSIENT",
|
|
2519
|
+
errorMessage: readableError(error)
|
|
2520
|
+
}));
|
|
2521
|
+
}
|
|
1906
2522
|
for (let i = 0; i < pending.length; i++) {
|
|
1907
2523
|
const item = pending[i];
|
|
1908
2524
|
const ticket = tickets[i];
|
|
1909
2525
|
if (ticket && ticket.status === "error" && ticket.errorCode && PERMANENT_DISABLE_CODES.has(ticket.errorCode)) {
|
|
1910
2526
|
await this.devicesCol.updateOne(
|
|
1911
|
-
{ token: item.token },
|
|
2527
|
+
{ token: item.doc.token },
|
|
1912
2528
|
{ $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1913
2529
|
);
|
|
1914
2530
|
}
|
|
1915
|
-
const
|
|
1916
|
-
_id: item.deliveryId,
|
|
1917
|
-
deviceId: item.deviceId,
|
|
1918
|
-
token: item.token,
|
|
1919
|
-
provider: item.provider,
|
|
1920
|
-
title: input.title,
|
|
1921
|
-
body: input.body,
|
|
1922
|
-
data: item.message.data,
|
|
2531
|
+
const update = {
|
|
1923
2532
|
status: "failed",
|
|
1924
|
-
|
|
1925
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
2533
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1926
2534
|
};
|
|
1927
2535
|
if (ticket && ticket.status === "ok") {
|
|
1928
|
-
|
|
1929
|
-
if (ticket.ticketId)
|
|
2536
|
+
update.status = "submitted";
|
|
2537
|
+
if (ticket.ticketId) update.ticketId = ticket.ticketId;
|
|
1930
2538
|
} else {
|
|
1931
|
-
|
|
1932
|
-
if (ticket?.
|
|
1933
|
-
|
|
1934
|
-
try {
|
|
1935
|
-
await this.deliveriesCol.insertOne(doc);
|
|
1936
|
-
} catch (err) {
|
|
1937
|
-
if (!isDuplicateKeyError2(err)) throw err;
|
|
2539
|
+
update.errorCode = ticket?.errorCode ?? "TRANSIENT";
|
|
2540
|
+
if (ticket?.errorMessage) update.errorMessage = ticket.errorMessage;
|
|
2541
|
+
if (ticket?.ticketId) update.ticketId = ticket.ticketId;
|
|
1938
2542
|
}
|
|
2543
|
+
await this.deliveriesCol.updateOne(
|
|
2544
|
+
{ _id: item.doc._id },
|
|
2545
|
+
{ $set: update }
|
|
2546
|
+
);
|
|
2547
|
+
results.push(resultFromDelivery({ ...item.doc, ...update }));
|
|
1939
2548
|
}
|
|
2549
|
+
return results;
|
|
1940
2550
|
}
|
|
1941
2551
|
async sendTo(input) {
|
|
1942
2552
|
const devices = await this.devicesCol.find({ $and: [input.filter, { enabled: true }] }).toArray();
|
|
@@ -1948,13 +2558,14 @@ var PushService = class {
|
|
|
1948
2558
|
deviceIds.push(device.deviceId);
|
|
1949
2559
|
}
|
|
1950
2560
|
}
|
|
1951
|
-
if (deviceIds.length === 0) return;
|
|
1952
|
-
|
|
2561
|
+
if (deviceIds.length === 0) return [];
|
|
2562
|
+
return this.send({
|
|
1953
2563
|
deviceId: deviceIds,
|
|
1954
2564
|
title: input.title,
|
|
1955
2565
|
body: input.body,
|
|
1956
2566
|
data: input.data,
|
|
1957
|
-
idempotencyKey: input.idempotencyKey
|
|
2567
|
+
idempotencyKey: input.idempotencyKey,
|
|
2568
|
+
imageUrl: input.imageUrl
|
|
1958
2569
|
});
|
|
1959
2570
|
}
|
|
1960
2571
|
async pollReceipts() {
|
|
@@ -1999,6 +2610,7 @@ var PushService = class {
|
|
|
1999
2610
|
$set: {
|
|
2000
2611
|
status: "failed",
|
|
2001
2612
|
errorCode,
|
|
2613
|
+
...receipt.errorMessage ? { errorMessage: receipt.errorMessage } : {},
|
|
2002
2614
|
receiptedAt: now
|
|
2003
2615
|
}
|
|
2004
2616
|
}
|
|
@@ -2016,16 +2628,201 @@ var PushService = class {
|
|
|
2016
2628
|
return this.devicesCol.find(filter).toArray();
|
|
2017
2629
|
}
|
|
2018
2630
|
};
|
|
2631
|
+
function resultFromDelivery(delivery) {
|
|
2632
|
+
if (delivery.status === "submitted" || delivery.status === "opened") {
|
|
2633
|
+
return {
|
|
2634
|
+
deviceId: delivery.deviceId,
|
|
2635
|
+
token: delivery.token,
|
|
2636
|
+
deliveryId: delivery._id,
|
|
2637
|
+
status: "submitted"
|
|
2638
|
+
};
|
|
2639
|
+
}
|
|
2640
|
+
return {
|
|
2641
|
+
deviceId: delivery.deviceId,
|
|
2642
|
+
token: delivery.token,
|
|
2643
|
+
deliveryId: delivery._id,
|
|
2644
|
+
status: "failed",
|
|
2645
|
+
errorCode: delivery.errorCode ?? "TRANSIENT",
|
|
2646
|
+
...delivery.errorMessage ? { errorMessage: delivery.errorMessage } : {}
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
function readableError(error) {
|
|
2650
|
+
return error instanceof Error ? error.message : String(error);
|
|
2651
|
+
}
|
|
2652
|
+
function validateImageUrl(imageUrl) {
|
|
2653
|
+
if (imageUrl === void 0) return;
|
|
2654
|
+
try {
|
|
2655
|
+
const url = new URL(imageUrl);
|
|
2656
|
+
if (url.protocol !== "https:") throw new Error("not https");
|
|
2657
|
+
} catch {
|
|
2658
|
+
throw new Error("Invalid imageUrl");
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2019
2661
|
function computeDigest(title, body, idempotencyKey) {
|
|
2020
2662
|
if (idempotencyKey) return idempotencyKey;
|
|
2021
2663
|
const utcDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2022
2664
|
const input = title + "\0" + body + "\0" + utcDay;
|
|
2023
|
-
return (0,
|
|
2665
|
+
return (0, import_node_crypto9.createHash)("sha256").update(input, "utf8").digest("hex");
|
|
2024
2666
|
}
|
|
2025
|
-
function
|
|
2667
|
+
function isDuplicateKeyError3(err) {
|
|
2026
2668
|
return typeof err === "object" && err !== null && "code" in err && err.code === 11e3;
|
|
2027
2669
|
}
|
|
2028
2670
|
|
|
2671
|
+
// src/events/service.ts
|
|
2672
|
+
var import_node_crypto10 = require("crypto");
|
|
2673
|
+
var EVENT_NAME_RE2 = /^[a-z][a-z0-9_]{0,63}$/;
|
|
2674
|
+
var MAX_PROPERTIES_BYTES = 16 * 1024;
|
|
2675
|
+
var EventService = class {
|
|
2676
|
+
constructor(db, defaultUsersCollection, journeyHooks) {
|
|
2677
|
+
this.db = db;
|
|
2678
|
+
this.defaultUsersCollection = defaultUsersCollection;
|
|
2679
|
+
this.journeyHooks = journeyHooks;
|
|
2680
|
+
this.events = db.collection("push_events");
|
|
2681
|
+
}
|
|
2682
|
+
db;
|
|
2683
|
+
defaultUsersCollection;
|
|
2684
|
+
journeyHooks;
|
|
2685
|
+
events;
|
|
2686
|
+
async ensureIndexes() {
|
|
2687
|
+
await this.events.createIndex({ deviceId: 1, name: 1, occurredAt: -1 });
|
|
2688
|
+
await this.events.createIndex({ name: 1, createdAt: -1 });
|
|
2689
|
+
await this.events.createIndex(
|
|
2690
|
+
{ deviceId: 1, source: 1, idempotencyKey: 1 },
|
|
2691
|
+
{
|
|
2692
|
+
unique: true,
|
|
2693
|
+
partialFilterExpression: { idempotencyKey: { $type: "string" } }
|
|
2694
|
+
}
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
async track(input) {
|
|
2698
|
+
validateEventInput(input);
|
|
2699
|
+
const usersCollection = input.usersCollection ?? this.defaultUsersCollection;
|
|
2700
|
+
const user = await this.db.collection(usersCollection).findOne({
|
|
2701
|
+
$or: [{ _id: input.deviceId }, { deviceId: input.deviceId }]
|
|
2702
|
+
});
|
|
2703
|
+
if (!user) return { created: false, ignored: "USER_NOT_FOUND" };
|
|
2704
|
+
const source = input.source ?? "server";
|
|
2705
|
+
if (input.idempotencyKey !== void 0) {
|
|
2706
|
+
const existing = await this.events.findOne({
|
|
2707
|
+
deviceId: input.deviceId,
|
|
2708
|
+
source,
|
|
2709
|
+
idempotencyKey: input.idempotencyKey
|
|
2710
|
+
});
|
|
2711
|
+
if (existing) {
|
|
2712
|
+
await this.finishEnrollment(existing);
|
|
2713
|
+
return { created: false, eventId: existing._id };
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
const now = /* @__PURE__ */ new Date();
|
|
2717
|
+
const event = {
|
|
2718
|
+
_id: (0, import_node_crypto10.randomUUID)(),
|
|
2719
|
+
deviceId: input.deviceId,
|
|
2720
|
+
name: input.name,
|
|
2721
|
+
source,
|
|
2722
|
+
occurredAt: input.occurredAt ?? now,
|
|
2723
|
+
createdAt: now
|
|
2724
|
+
};
|
|
2725
|
+
if (input.properties !== void 0) event.properties = input.properties;
|
|
2726
|
+
if (input.idempotencyKey !== void 0) {
|
|
2727
|
+
event.idempotencyKey = input.idempotencyKey;
|
|
2728
|
+
}
|
|
2729
|
+
if (this.journeyHooks) {
|
|
2730
|
+
event.journeyEnrollments = await this.journeyHooks.resolve(event.name);
|
|
2731
|
+
event.enrollmentStatus = "pending";
|
|
2732
|
+
} else {
|
|
2733
|
+
event.enrollmentStatus = "complete";
|
|
2734
|
+
}
|
|
2735
|
+
try {
|
|
2736
|
+
await this.events.insertOne(event);
|
|
2737
|
+
} catch (error) {
|
|
2738
|
+
if (!isDuplicateKeyError4(error) || input.idempotencyKey === void 0) {
|
|
2739
|
+
throw error;
|
|
2740
|
+
}
|
|
2741
|
+
const existing = await this.events.findOne({
|
|
2742
|
+
deviceId: input.deviceId,
|
|
2743
|
+
source,
|
|
2744
|
+
idempotencyKey: input.idempotencyKey
|
|
2745
|
+
});
|
|
2746
|
+
if (!existing) throw error;
|
|
2747
|
+
await this.finishEnrollment(existing);
|
|
2748
|
+
return { created: false, eventId: existing._id };
|
|
2749
|
+
}
|
|
2750
|
+
await this.finishEnrollment(event);
|
|
2751
|
+
return { created: true, eventId: event._id };
|
|
2752
|
+
}
|
|
2753
|
+
async find(filter) {
|
|
2754
|
+
return this.events.find(filter).sort({ createdAt: -1 }).toArray();
|
|
2755
|
+
}
|
|
2756
|
+
async finishEnrollment(event) {
|
|
2757
|
+
if (!this.journeyHooks || event.enrollmentStatus !== "pending") return;
|
|
2758
|
+
await this.journeyHooks.enroll(event);
|
|
2759
|
+
await this.events.updateOne(
|
|
2760
|
+
{ _id: event._id, enrollmentStatus: "pending" },
|
|
2761
|
+
{ $set: { enrollmentStatus: "complete" } }
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
};
|
|
2765
|
+
function validateEventInput(input) {
|
|
2766
|
+
if (typeof input.deviceId !== "string" || input.deviceId.length === 0) {
|
|
2767
|
+
throw new Error("Invalid deviceId");
|
|
2768
|
+
}
|
|
2769
|
+
if (!EVENT_NAME_RE2.test(input.name)) throw new Error("Invalid event name");
|
|
2770
|
+
if (input.idempotencyKey !== void 0 && (typeof input.idempotencyKey !== "string" || input.idempotencyKey.length === 0 || input.idempotencyKey.length > 200)) {
|
|
2771
|
+
throw new Error("Invalid idempotency key");
|
|
2772
|
+
}
|
|
2773
|
+
if (input.occurredAt !== void 0 && (!(input.occurredAt instanceof Date) || Number.isNaN(input.occurredAt.getTime()))) {
|
|
2774
|
+
throw new Error("Invalid occurredAt");
|
|
2775
|
+
}
|
|
2776
|
+
if (input.properties !== void 0) {
|
|
2777
|
+
if (!input.properties || typeof input.properties !== "object" || Array.isArray(input.properties)) {
|
|
2778
|
+
throw new Error("Invalid properties");
|
|
2779
|
+
}
|
|
2780
|
+
try {
|
|
2781
|
+
if (Buffer.byteLength(JSON.stringify(input.properties), "utf8") > MAX_PROPERTIES_BYTES) {
|
|
2782
|
+
throw new Error("Invalid properties");
|
|
2783
|
+
}
|
|
2784
|
+
} catch {
|
|
2785
|
+
throw new Error("Invalid properties");
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
2788
|
+
}
|
|
2789
|
+
function isDuplicateKeyError4(error) {
|
|
2790
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// src/events/router.ts
|
|
2794
|
+
var import_express5 = require("express");
|
|
2795
|
+
function createEventsRouter(service) {
|
|
2796
|
+
const router = (0, import_express5.Router)();
|
|
2797
|
+
router.post("/", async (req, res) => {
|
|
2798
|
+
const deviceId = req.user?.deviceId;
|
|
2799
|
+
if (!deviceId) {
|
|
2800
|
+
res.status(401).json({ success: false, error: "Missing authorization header" });
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
try {
|
|
2804
|
+
const { name, properties, idempotencyKey, occurredAt } = req.body ?? {};
|
|
2805
|
+
let parsedOccurredAt;
|
|
2806
|
+
if (occurredAt !== void 0) {
|
|
2807
|
+
if (typeof occurredAt !== "string") throw new Error("Invalid occurredAt");
|
|
2808
|
+
parsedOccurredAt = new Date(occurredAt);
|
|
2809
|
+
}
|
|
2810
|
+
const result = await service.track({
|
|
2811
|
+
deviceId,
|
|
2812
|
+
name,
|
|
2813
|
+
properties,
|
|
2814
|
+
idempotencyKey,
|
|
2815
|
+
occurredAt: parsedOccurredAt,
|
|
2816
|
+
source: "client"
|
|
2817
|
+
});
|
|
2818
|
+
res.status(200).json({ success: true, data: result });
|
|
2819
|
+
} catch {
|
|
2820
|
+
res.status(400).json({ success: false, error: "Invalid request body" });
|
|
2821
|
+
}
|
|
2822
|
+
});
|
|
2823
|
+
return router;
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2029
2826
|
// src/codixus-server.ts
|
|
2030
2827
|
var CodixusServer = class {
|
|
2031
2828
|
constructor(config) {
|
|
@@ -2050,6 +2847,7 @@ var CodixusServer = class {
|
|
|
2050
2847
|
auth;
|
|
2051
2848
|
db;
|
|
2052
2849
|
push;
|
|
2850
|
+
events;
|
|
2053
2851
|
admin;
|
|
2054
2852
|
async connect() {
|
|
2055
2853
|
this._db = await this.connection.connect();
|
|
@@ -2066,6 +2864,7 @@ var CodixusServer = class {
|
|
|
2066
2864
|
{ expiresAt: 1 },
|
|
2067
2865
|
{ expireAfterSeconds: 0 }
|
|
2068
2866
|
);
|
|
2867
|
+
let journeyService;
|
|
2069
2868
|
if (this.config.push) {
|
|
2070
2869
|
const pushDevicesCol = this._db.collection("push_devices");
|
|
2071
2870
|
await pushDevicesCol.createIndex({ token: 1 }, { unique: true });
|
|
@@ -2078,15 +2877,54 @@ var CodixusServer = class {
|
|
|
2078
2877
|
);
|
|
2079
2878
|
await pushDeliveriesCol.createIndex({ ticketId: 1 });
|
|
2080
2879
|
await pushDeliveriesCol.createIndex({ deviceId: 1 });
|
|
2081
|
-
const
|
|
2880
|
+
const configuredPushService = new PushService(
|
|
2881
|
+
this._db,
|
|
2882
|
+
this.config.push.transport
|
|
2883
|
+
);
|
|
2884
|
+
journeyService = new JourneyService(this._db, configuredPushService);
|
|
2885
|
+
await journeyService.ensureIndexes();
|
|
2886
|
+
const journeys = journeyService;
|
|
2082
2887
|
this.push = {
|
|
2083
|
-
router: () => createPushRouter(this._db)
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2888
|
+
router: () => createPushRouter(this._db, async ({ deviceId, token, platform }) => {
|
|
2889
|
+
await this.events.track({
|
|
2890
|
+
deviceId,
|
|
2891
|
+
name: "push_registered",
|
|
2892
|
+
properties: { platform },
|
|
2893
|
+
idempotencyKey: `push-registered:${(0, import_node_crypto11.createHash)("sha256").update(token).digest("hex")}`,
|
|
2894
|
+
source: "system"
|
|
2895
|
+
});
|
|
2896
|
+
}),
|
|
2897
|
+
send: (input) => configuredPushService.send(input),
|
|
2898
|
+
sendTo: (input) => configuredPushService.sendTo(input),
|
|
2899
|
+
pollReceipts: () => configuredPushService.pollReceipts(),
|
|
2900
|
+
find: (filter) => configuredPushService.find(filter),
|
|
2901
|
+
journeys: {
|
|
2902
|
+
create: (input) => journeys.create(input),
|
|
2903
|
+
list: () => journeys.list(),
|
|
2904
|
+
get: (id) => journeys.get(id),
|
|
2905
|
+
updateDraft: (id, input) => journeys.updateDraft(id, input),
|
|
2906
|
+
publish: (id) => journeys.publish(id),
|
|
2907
|
+
pause: (id) => journeys.pause(id),
|
|
2908
|
+
resume: (id) => journeys.resume(id),
|
|
2909
|
+
testSend: (input) => journeys.testSend(input),
|
|
2910
|
+
processDue: (options) => journeys.processDue(options)
|
|
2911
|
+
}
|
|
2088
2912
|
};
|
|
2089
2913
|
}
|
|
2914
|
+
const eventService = new EventService(
|
|
2915
|
+
this._db,
|
|
2916
|
+
this.config.events?.usersCollection ?? "users",
|
|
2917
|
+
journeyService ? {
|
|
2918
|
+
resolve: (eventName) => journeyService.resolveEnrollments(eventName),
|
|
2919
|
+
enroll: (event) => journeyService.enrollEvent(event)
|
|
2920
|
+
} : void 0
|
|
2921
|
+
);
|
|
2922
|
+
await eventService.ensureIndexes();
|
|
2923
|
+
this.events = {
|
|
2924
|
+
router: () => createEventsRouter(eventService),
|
|
2925
|
+
track: (input) => eventService.track(input),
|
|
2926
|
+
find: (filter) => eventService.find(filter)
|
|
2927
|
+
};
|
|
2090
2928
|
this.auth = {
|
|
2091
2929
|
sign: (subject, claims) => this.jwt.sign(subject, claims),
|
|
2092
2930
|
verify: (token) => this.jwt.verify(token),
|
|
@@ -2097,7 +2935,21 @@ var CodixusServer = class {
|
|
|
2097
2935
|
return tokens;
|
|
2098
2936
|
},
|
|
2099
2937
|
guard: (options) => this.guardFn(options),
|
|
2100
|
-
router: (config) => createAuthRouter(
|
|
2938
|
+
router: (config) => createAuthRouter(
|
|
2939
|
+
this.jwt,
|
|
2940
|
+
this.banService,
|
|
2941
|
+
this._db,
|
|
2942
|
+
config,
|
|
2943
|
+
async ({ deviceId, usersCollection }) => {
|
|
2944
|
+
await eventService.track({
|
|
2945
|
+
deviceId,
|
|
2946
|
+
name: "user_created",
|
|
2947
|
+
source: "system",
|
|
2948
|
+
idempotencyKey: "user-created",
|
|
2949
|
+
usersCollection
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
),
|
|
2101
2953
|
ban: (deviceId, reason) => this.banService.ban(deviceId, reason),
|
|
2102
2954
|
unban: (deviceId) => this.banService.unban(deviceId),
|
|
2103
2955
|
isBanned: (deviceId) => this.banService.isBanned(deviceId)
|
|
@@ -2112,7 +2964,8 @@ var CodixusServer = class {
|
|
|
2112
2964
|
db: this._db,
|
|
2113
2965
|
token: this.config.admin.token,
|
|
2114
2966
|
hasPush: !!this.config.push,
|
|
2115
|
-
pushSend: this.push?.send
|
|
2967
|
+
pushSend: this.push?.send,
|
|
2968
|
+
journeys: journeyService
|
|
2116
2969
|
})
|
|
2117
2970
|
};
|
|
2118
2971
|
}
|
|
@@ -2172,12 +3025,19 @@ function validate(schema) {
|
|
|
2172
3025
|
var SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
2173
3026
|
var RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
2174
3027
|
var BATCH_SIZE = 100;
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
3028
|
+
var MAX_NOTIFICATION_BYTES = 4096;
|
|
3029
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
|
|
3030
|
+
var DEFAULT_MAX_RETRY_DELAY_MS = 5e3;
|
|
3031
|
+
function transientTickets(messages, errorMessage) {
|
|
3032
|
+
return messages.map((m) => {
|
|
3033
|
+
const ticket = {
|
|
3034
|
+
token: m.token,
|
|
3035
|
+
status: "error",
|
|
3036
|
+
errorCode: "TRANSIENT"
|
|
3037
|
+
};
|
|
3038
|
+
if (errorMessage) ticket.errorMessage = errorMessage;
|
|
3039
|
+
return ticket;
|
|
3040
|
+
});
|
|
2181
3041
|
}
|
|
2182
3042
|
function invalidPayloadTickets(messages) {
|
|
2183
3043
|
return messages.map((m) => ({
|
|
@@ -2219,6 +3079,17 @@ function mapExpoTickets(messages, data) {
|
|
|
2219
3079
|
function createExpoTransport(opts) {
|
|
2220
3080
|
const fetchImpl = opts?.fetchImpl ?? globalThis.fetch;
|
|
2221
3081
|
const accessToken = opts?.accessToken;
|
|
3082
|
+
const sleepImpl = opts?.sleepImpl ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
|
|
3083
|
+
const baseRetryDelayMs = Math.max(0, opts?.baseRetryDelayMs ?? 500);
|
|
3084
|
+
const maxAttempts = Math.max(1, Math.floor(opts?.maxAttempts ?? 3));
|
|
3085
|
+
const requestTimeoutMs = Math.max(
|
|
3086
|
+
1,
|
|
3087
|
+
Math.floor(opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS)
|
|
3088
|
+
);
|
|
3089
|
+
const maxRetryDelayMs = Math.max(
|
|
3090
|
+
0,
|
|
3091
|
+
Math.floor(opts?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS)
|
|
3092
|
+
);
|
|
2222
3093
|
function headers() {
|
|
2223
3094
|
const h = {
|
|
2224
3095
|
"Content-Type": "application/json"
|
|
@@ -2228,13 +3099,36 @@ function createExpoTransport(opts) {
|
|
|
2228
3099
|
}
|
|
2229
3100
|
return h;
|
|
2230
3101
|
}
|
|
3102
|
+
async function postJson(url, body) {
|
|
3103
|
+
const controller = new AbortController();
|
|
3104
|
+
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
3105
|
+
try {
|
|
3106
|
+
const response = await fetchImpl(url, {
|
|
3107
|
+
method: "POST",
|
|
3108
|
+
headers: headers(),
|
|
3109
|
+
body: JSON.stringify(body),
|
|
3110
|
+
signal: controller.signal
|
|
3111
|
+
});
|
|
3112
|
+
const json = response.ok ? await response.json() : void 0;
|
|
3113
|
+
return { response, json };
|
|
3114
|
+
} finally {
|
|
3115
|
+
clearTimeout(timeout);
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
function exponentialDelay(attempt) {
|
|
3119
|
+
return Math.min(
|
|
3120
|
+
baseRetryDelayMs * 2 ** (attempt - 1),
|
|
3121
|
+
maxRetryDelayMs
|
|
3122
|
+
);
|
|
3123
|
+
}
|
|
2231
3124
|
return {
|
|
2232
3125
|
name: "expo",
|
|
2233
3126
|
async send(messages) {
|
|
2234
3127
|
const allTickets = [];
|
|
2235
3128
|
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
|
|
2236
3129
|
const batch = messages.slice(i, i + BATCH_SIZE);
|
|
2237
|
-
const
|
|
3130
|
+
const batchTickets = new Array(batch.length);
|
|
3131
|
+
const entries = batch.map((m, batchIndex) => {
|
|
2238
3132
|
const item = {
|
|
2239
3133
|
to: m.token,
|
|
2240
3134
|
title: m.title,
|
|
@@ -2245,33 +3139,73 @@ function createExpoTransport(opts) {
|
|
|
2245
3139
|
if (m.badge !== void 0) item.badge = m.badge;
|
|
2246
3140
|
if (m.sound !== void 0) item.sound = m.sound;
|
|
2247
3141
|
if (m.channelId !== void 0) item.channelId = m.channelId;
|
|
2248
|
-
|
|
3142
|
+
if (m.imageUrl !== void 0) {
|
|
3143
|
+
item.richContent = { image: m.imageUrl };
|
|
3144
|
+
item.mutableContent = true;
|
|
3145
|
+
}
|
|
3146
|
+
return { batchIndex, message: m, item };
|
|
2249
3147
|
});
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
headers: headers(),
|
|
2254
|
-
body: JSON.stringify(body)
|
|
2255
|
-
});
|
|
2256
|
-
if (res.status === 429 || res.status >= 500) {
|
|
2257
|
-
allTickets.push(...transientTickets(batch));
|
|
2258
|
-
continue;
|
|
3148
|
+
const sendable = entries.filter((entry) => {
|
|
3149
|
+
if (Buffer.byteLength(JSON.stringify(entry.item), "utf8") <= MAX_NOTIFICATION_BYTES) {
|
|
3150
|
+
return true;
|
|
2259
3151
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
3152
|
+
batchTickets[entry.batchIndex] = {
|
|
3153
|
+
token: entry.message.token,
|
|
3154
|
+
status: "error",
|
|
3155
|
+
errorCode: "MessageTooBig",
|
|
3156
|
+
errorMessage: "Expo notification payload exceeds 4096 bytes"
|
|
3157
|
+
};
|
|
3158
|
+
return false;
|
|
3159
|
+
});
|
|
3160
|
+
const sendBatch = sendable.map((entry) => entry.message);
|
|
3161
|
+
const body = sendable.map((entry) => entry.item);
|
|
3162
|
+
if (sendBatch.length === 0) {
|
|
3163
|
+
allTickets.push(...batchTickets);
|
|
3164
|
+
continue;
|
|
3165
|
+
}
|
|
3166
|
+
const assignTickets = (tickets) => {
|
|
3167
|
+
for (let index = 0; index < tickets.length; index += 1) {
|
|
3168
|
+
const entry = sendable[index];
|
|
3169
|
+
if (entry) batchTickets[entry.batchIndex] = tickets[index];
|
|
2263
3170
|
}
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
3171
|
+
};
|
|
3172
|
+
let completed = false;
|
|
3173
|
+
let transientErrorMessage;
|
|
3174
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
3175
|
+
try {
|
|
3176
|
+
const { response: res, json } = await postJson(SEND_URL, body);
|
|
3177
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
3178
|
+
assignTickets(invalidPayloadTickets(sendBatch));
|
|
3179
|
+
completed = true;
|
|
3180
|
+
break;
|
|
3181
|
+
}
|
|
3182
|
+
if (res.status === 429 || res.status >= 500 || !res.ok) {
|
|
3183
|
+
transientErrorMessage = `Expo send HTTP ${res.status}`;
|
|
3184
|
+
if (attempt === maxAttempts) break;
|
|
3185
|
+
await sleepImpl(
|
|
3186
|
+
retryDelay(res, attempt, baseRetryDelayMs, maxRetryDelayMs)
|
|
3187
|
+
);
|
|
3188
|
+
continue;
|
|
3189
|
+
}
|
|
3190
|
+
const payload = json;
|
|
3191
|
+
if (!Array.isArray(payload.data)) {
|
|
3192
|
+
if (attempt === maxAttempts) break;
|
|
3193
|
+
await sleepImpl(exponentialDelay(attempt));
|
|
3194
|
+
continue;
|
|
3195
|
+
}
|
|
3196
|
+
assignTickets(mapExpoTickets(sendBatch, payload.data));
|
|
3197
|
+
completed = true;
|
|
3198
|
+
break;
|
|
3199
|
+
} catch (error) {
|
|
3200
|
+
transientErrorMessage = error instanceof Error ? error.message : String(error);
|
|
3201
|
+
if (attempt === maxAttempts) break;
|
|
3202
|
+
await sleepImpl(exponentialDelay(attempt));
|
|
2268
3203
|
}
|
|
2269
|
-
allTickets.push(
|
|
2270
|
-
...mapExpoTickets(batch, json.data)
|
|
2271
|
-
);
|
|
2272
|
-
} catch {
|
|
2273
|
-
allTickets.push(...transientTickets(batch));
|
|
2274
3204
|
}
|
|
3205
|
+
if (!completed) {
|
|
3206
|
+
assignTickets(transientTickets(sendBatch, transientErrorMessage));
|
|
3207
|
+
}
|
|
3208
|
+
allTickets.push(...batchTickets);
|
|
2275
3209
|
}
|
|
2276
3210
|
return allTickets;
|
|
2277
3211
|
},
|
|
@@ -2279,19 +3213,17 @@ function createExpoTransport(opts) {
|
|
|
2279
3213
|
const allReceipts = [];
|
|
2280
3214
|
for (let i = 0; i < ticketIds.length; i += BATCH_SIZE) {
|
|
2281
3215
|
const batch = ticketIds.slice(i, i + BATCH_SIZE);
|
|
2282
|
-
const res = await
|
|
2283
|
-
|
|
2284
|
-
headers: headers(),
|
|
2285
|
-
body: JSON.stringify({ ids: batch })
|
|
3216
|
+
const { response: res, json } = await postJson(RECEIPTS_URL, {
|
|
3217
|
+
ids: batch
|
|
2286
3218
|
});
|
|
2287
3219
|
if (!res.ok) {
|
|
2288
3220
|
throw new Error(`Expo getReceipts HTTP ${res.status}`);
|
|
2289
3221
|
}
|
|
2290
|
-
const
|
|
2291
|
-
if (typeof
|
|
3222
|
+
const payload = json;
|
|
3223
|
+
if (typeof payload.data !== "object" || payload.data === null || Array.isArray(payload.data)) {
|
|
2292
3224
|
throw new Error("Expo getReceipts invalid response");
|
|
2293
3225
|
}
|
|
2294
|
-
const data =
|
|
3226
|
+
const data = payload.data;
|
|
2295
3227
|
for (const id of batch) {
|
|
2296
3228
|
const receipt = data[id];
|
|
2297
3229
|
if (!receipt) continue;
|
|
@@ -2301,7 +3233,8 @@ function createExpoTransport(opts) {
|
|
|
2301
3233
|
allReceipts.push({
|
|
2302
3234
|
ticketId: id,
|
|
2303
3235
|
status: "error",
|
|
2304
|
-
errorCode: receipt.status === "error" ? receipt.details?.error ?? "UNKNOWN" : "UNKNOWN"
|
|
3236
|
+
errorCode: receipt.status === "error" ? receipt.details?.error ?? "UNKNOWN" : "UNKNOWN",
|
|
3237
|
+
...receipt.status === "error" && receipt.message ? { errorMessage: receipt.message } : {}
|
|
2305
3238
|
});
|
|
2306
3239
|
}
|
|
2307
3240
|
}
|
|
@@ -2310,6 +3243,16 @@ function createExpoTransport(opts) {
|
|
|
2310
3243
|
}
|
|
2311
3244
|
};
|
|
2312
3245
|
}
|
|
3246
|
+
function retryDelay(response, attempt, baseRetryDelayMs, maxRetryDelayMs) {
|
|
3247
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
3248
|
+
if (retryAfter !== null) {
|
|
3249
|
+
const seconds = Number(retryAfter);
|
|
3250
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
3251
|
+
return Math.min(seconds * 1e3, maxRetryDelayMs);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
return Math.min(baseRetryDelayMs * 2 ** (attempt - 1), maxRetryDelayMs);
|
|
3255
|
+
}
|
|
2313
3256
|
|
|
2314
3257
|
// src/index.ts
|
|
2315
3258
|
var import_shared4 = require("@codixus/shared");
|
|
@@ -2318,12 +3261,14 @@ var import_shared4 = require("@codixus/shared");
|
|
|
2318
3261
|
CodixusError,
|
|
2319
3262
|
CodixusServer,
|
|
2320
3263
|
ErrorCodes,
|
|
3264
|
+
JourneyError,
|
|
2321
3265
|
Model,
|
|
2322
3266
|
Query,
|
|
2323
3267
|
RestErrorCode,
|
|
2324
3268
|
SubCollection,
|
|
2325
3269
|
createExpoTransport,
|
|
2326
3270
|
model,
|
|
2327
|
-
validate
|
|
3271
|
+
validate,
|
|
3272
|
+
validateJourneyDefinition
|
|
2328
3273
|
});
|
|
2329
3274
|
//# sourceMappingURL=index.cjs.map
|