@codixus/server 0.1.7 → 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 +1 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
// src/codixus-server.ts
|
|
2
|
+
import { createHash as createHash3 } from "crypto";
|
|
3
|
+
|
|
1
4
|
// src/db/connection.ts
|
|
2
5
|
import { MongoClient } from "mongodb";
|
|
3
6
|
var ConnectionManager = class {
|
|
@@ -179,7 +182,7 @@ function createGuard(jwt, banService) {
|
|
|
179
182
|
import { Router } from "express";
|
|
180
183
|
import { authTokenRequestSchema, refreshTokenRequestSchema } from "@codixus/shared";
|
|
181
184
|
import { CodixusError as CodixusError2 } from "@codixus/shared";
|
|
182
|
-
function createAuthRouter(jwt, banService, db, config) {
|
|
185
|
+
function createAuthRouter(jwt, banService, db, config, onAuthenticated) {
|
|
183
186
|
const router = Router();
|
|
184
187
|
const usersCol = db.collection(config.usersCollection);
|
|
185
188
|
const refreshTokensCol = db.collection("refresh_tokens");
|
|
@@ -241,6 +244,11 @@ function createAuthRouter(jwt, banService, db, config) {
|
|
|
241
244
|
);
|
|
242
245
|
}
|
|
243
246
|
}
|
|
247
|
+
await onAuthenticated?.({
|
|
248
|
+
deviceId,
|
|
249
|
+
isNewUser,
|
|
250
|
+
usersCollection: config.usersCollection
|
|
251
|
+
});
|
|
244
252
|
const tokens = await jwt.sign(deviceId);
|
|
245
253
|
const tokenHash = JwtService.hashToken(tokens.refreshToken);
|
|
246
254
|
await refreshTokensCol.insertOne({
|
|
@@ -1277,7 +1285,7 @@ function checkAuth(req, res) {
|
|
|
1277
1285
|
function isNonEmptyString(value) {
|
|
1278
1286
|
return typeof value === "string" && value.length > 0;
|
|
1279
1287
|
}
|
|
1280
|
-
function createPushRouter(db) {
|
|
1288
|
+
function createPushRouter(db, onRegistered) {
|
|
1281
1289
|
const router = Router3();
|
|
1282
1290
|
const devicesCol = db.collection("push_devices");
|
|
1283
1291
|
const deliveriesCol = db.collection("push_deliveries");
|
|
@@ -1306,6 +1314,7 @@ function createPushRouter(db) {
|
|
|
1306
1314
|
if (permissionStatus !== void 0)
|
|
1307
1315
|
update.permissionStatus = permissionStatus;
|
|
1308
1316
|
await devicesCol.updateOne({ token }, { $set: update });
|
|
1317
|
+
await onRegistered?.({ deviceId, token, platform });
|
|
1309
1318
|
res.json({ success: true });
|
|
1310
1319
|
return;
|
|
1311
1320
|
}
|
|
@@ -1343,6 +1352,7 @@ function createPushRouter(db) {
|
|
|
1343
1352
|
update.permissionStatus = permissionStatus;
|
|
1344
1353
|
await devicesCol.updateOne({ token }, { $set: update });
|
|
1345
1354
|
}
|
|
1355
|
+
await onRegistered?.({ deviceId, token, platform });
|
|
1346
1356
|
res.json({ success: true });
|
|
1347
1357
|
});
|
|
1348
1358
|
router.post("/unregister", async (req, res) => {
|
|
@@ -1382,9 +1392,462 @@ function isDuplicateKeyError(err) {
|
|
|
1382
1392
|
|
|
1383
1393
|
// src/admin/router.ts
|
|
1384
1394
|
import { Router as Router4 } from "express";
|
|
1385
|
-
import { randomUUID as
|
|
1395
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1386
1396
|
import { z } from "zod";
|
|
1387
1397
|
|
|
1398
|
+
// src/journeys/service.ts
|
|
1399
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1400
|
+
var EVENT_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
1401
|
+
var STEP_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
|
|
1402
|
+
var MAX_STEPS = 20;
|
|
1403
|
+
var MAX_OFFSET_SECONDS = 365 * 24 * 60 * 60;
|
|
1404
|
+
var MAX_ATTEMPTS = 5;
|
|
1405
|
+
var RETRY_DELAYS_MS = [6e4, 3e5, 9e5, 36e5];
|
|
1406
|
+
var PERMANENT_ERRORS = /* @__PURE__ */ new Set([
|
|
1407
|
+
"INVALID_PAYLOAD",
|
|
1408
|
+
"InvalidCredentials",
|
|
1409
|
+
"MessageTooBig"
|
|
1410
|
+
]);
|
|
1411
|
+
var JourneyError = class extends Error {
|
|
1412
|
+
constructor(code, message) {
|
|
1413
|
+
super(message);
|
|
1414
|
+
this.code = code;
|
|
1415
|
+
this.name = "JourneyError";
|
|
1416
|
+
}
|
|
1417
|
+
code;
|
|
1418
|
+
};
|
|
1419
|
+
var JourneyService = class {
|
|
1420
|
+
constructor(db, push) {
|
|
1421
|
+
this.push = push;
|
|
1422
|
+
this.journeys = db.collection("push_journeys");
|
|
1423
|
+
this.revisions = db.collection("push_journey_revisions");
|
|
1424
|
+
this.runs = db.collection("push_journey_runs");
|
|
1425
|
+
this.events = db.collection("push_events");
|
|
1426
|
+
}
|
|
1427
|
+
push;
|
|
1428
|
+
journeys;
|
|
1429
|
+
revisions;
|
|
1430
|
+
runs;
|
|
1431
|
+
events;
|
|
1432
|
+
async ensureIndexes() {
|
|
1433
|
+
await this.journeys.createIndex({ status: 1, liveEntryEvent: 1, liveSince: 1 });
|
|
1434
|
+
await this.revisions.createIndex({ journeyId: 1, revision: 1 }, { unique: true });
|
|
1435
|
+
await this.runs.createIndex(
|
|
1436
|
+
{ journeyId: 1, revision: 1, eventId: 1 },
|
|
1437
|
+
{ unique: true }
|
|
1438
|
+
);
|
|
1439
|
+
await this.runs.createIndex({ status: 1, nextRunAt: 1, lockedUntil: 1 });
|
|
1440
|
+
await this.runs.createIndex({ deviceId: 1, createdAt: -1 });
|
|
1441
|
+
}
|
|
1442
|
+
async create(input) {
|
|
1443
|
+
const name = validateName(input.name);
|
|
1444
|
+
const definition = validateJourneyDefinition(input.definition);
|
|
1445
|
+
const now = /* @__PURE__ */ new Date();
|
|
1446
|
+
const journey = {
|
|
1447
|
+
_id: randomUUID2(),
|
|
1448
|
+
name,
|
|
1449
|
+
status: "draft",
|
|
1450
|
+
draft: definition,
|
|
1451
|
+
revisionCounter: 0,
|
|
1452
|
+
createdAt: now,
|
|
1453
|
+
updatedAt: now
|
|
1454
|
+
};
|
|
1455
|
+
await this.journeys.insertOne(journey);
|
|
1456
|
+
return journey;
|
|
1457
|
+
}
|
|
1458
|
+
async list() {
|
|
1459
|
+
return this.journeys.find().sort({ updatedAt: -1 }).toArray();
|
|
1460
|
+
}
|
|
1461
|
+
async get(id) {
|
|
1462
|
+
return this.journeys.findOne({ _id: id });
|
|
1463
|
+
}
|
|
1464
|
+
async updateDraft(id, input) {
|
|
1465
|
+
const existing = await this.requireJourney(id);
|
|
1466
|
+
const name = input.name === void 0 ? existing.name : validateName(input.name);
|
|
1467
|
+
const definition = input.definition === void 0 ? existing.draft : validateJourneyDefinition(input.definition);
|
|
1468
|
+
await this.journeys.updateOne(
|
|
1469
|
+
{ _id: id },
|
|
1470
|
+
{ $set: { name, draft: definition, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1471
|
+
);
|
|
1472
|
+
return await this.requireJourney(id);
|
|
1473
|
+
}
|
|
1474
|
+
async publish(id) {
|
|
1475
|
+
const existing = await this.requireJourney(id);
|
|
1476
|
+
const publishedAt = /* @__PURE__ */ new Date();
|
|
1477
|
+
const revision = existing.revisionCounter + 1;
|
|
1478
|
+
const snapshot = cloneDefinition(existing.draft);
|
|
1479
|
+
const revisionDoc = {
|
|
1480
|
+
_id: randomUUID2(),
|
|
1481
|
+
journeyId: id,
|
|
1482
|
+
revision,
|
|
1483
|
+
name: existing.name,
|
|
1484
|
+
definition: snapshot,
|
|
1485
|
+
publishedAt
|
|
1486
|
+
};
|
|
1487
|
+
await this.revisions.insertOne(revisionDoc);
|
|
1488
|
+
const update = await this.journeys.updateOne(
|
|
1489
|
+
{ _id: id, revisionCounter: existing.revisionCounter },
|
|
1490
|
+
{
|
|
1491
|
+
$set: {
|
|
1492
|
+
status: "live",
|
|
1493
|
+
liveRevision: revision,
|
|
1494
|
+
liveEntryEvent: snapshot.entryEvent,
|
|
1495
|
+
liveSince: publishedAt,
|
|
1496
|
+
publishedAt,
|
|
1497
|
+
updatedAt: publishedAt
|
|
1498
|
+
},
|
|
1499
|
+
$inc: { revisionCounter: 1 }
|
|
1500
|
+
}
|
|
1501
|
+
);
|
|
1502
|
+
if (update.matchedCount === 0) {
|
|
1503
|
+
await this.revisions.deleteOne({ _id: revisionDoc._id });
|
|
1504
|
+
throw new JourneyError("INVALID_STATE", "Journey was published concurrently");
|
|
1505
|
+
}
|
|
1506
|
+
await this.runs.updateMany(
|
|
1507
|
+
{ journeyId: id, status: "paused" },
|
|
1508
|
+
{ $set: { status: "active", updatedAt: publishedAt } }
|
|
1509
|
+
);
|
|
1510
|
+
return this.requireJourney(id);
|
|
1511
|
+
}
|
|
1512
|
+
async pause(id) {
|
|
1513
|
+
const update = await this.journeys.updateOne(
|
|
1514
|
+
{ _id: id, status: "live" },
|
|
1515
|
+
{ $set: { status: "paused", updatedAt: /* @__PURE__ */ new Date() } }
|
|
1516
|
+
);
|
|
1517
|
+
if (update.matchedCount === 0) {
|
|
1518
|
+
await this.requireJourney(id);
|
|
1519
|
+
throw new JourneyError("INVALID_STATE", "Only live journeys can be paused");
|
|
1520
|
+
}
|
|
1521
|
+
await this.runs.updateMany(
|
|
1522
|
+
{ journeyId: id, status: "active" },
|
|
1523
|
+
{
|
|
1524
|
+
$set: { status: "paused", updatedAt: /* @__PURE__ */ new Date() },
|
|
1525
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1526
|
+
}
|
|
1527
|
+
);
|
|
1528
|
+
return this.requireJourney(id);
|
|
1529
|
+
}
|
|
1530
|
+
async resume(id) {
|
|
1531
|
+
const now = /* @__PURE__ */ new Date();
|
|
1532
|
+
const update = await this.journeys.updateOne(
|
|
1533
|
+
{ _id: id, status: "paused", liveRevision: { $exists: true } },
|
|
1534
|
+
{ $set: { status: "live", liveSince: now, updatedAt: now } }
|
|
1535
|
+
);
|
|
1536
|
+
if (update.matchedCount === 0) {
|
|
1537
|
+
await this.requireJourney(id);
|
|
1538
|
+
throw new JourneyError("INVALID_STATE", "Only paused journeys can be resumed");
|
|
1539
|
+
}
|
|
1540
|
+
await this.runs.updateMany(
|
|
1541
|
+
{ journeyId: id, status: "paused" },
|
|
1542
|
+
{ $set: { status: "active", updatedAt: now } }
|
|
1543
|
+
);
|
|
1544
|
+
return this.requireJourney(id);
|
|
1545
|
+
}
|
|
1546
|
+
async enrollEvent(event) {
|
|
1547
|
+
for (const enrollment of event.journeyEnrollments ?? []) {
|
|
1548
|
+
const revision = await this.revisions.findOne({
|
|
1549
|
+
journeyId: enrollment.journeyId,
|
|
1550
|
+
revision: enrollment.revision
|
|
1551
|
+
});
|
|
1552
|
+
const firstStep = revision?.definition.steps[0];
|
|
1553
|
+
if (!revision || !firstStep) continue;
|
|
1554
|
+
const now = /* @__PURE__ */ new Date();
|
|
1555
|
+
const run = {
|
|
1556
|
+
_id: randomUUID2(),
|
|
1557
|
+
journeyId: enrollment.journeyId,
|
|
1558
|
+
revision: enrollment.revision,
|
|
1559
|
+
eventId: event._id,
|
|
1560
|
+
deviceId: event.deviceId,
|
|
1561
|
+
eventOccurredAt: event.occurredAt,
|
|
1562
|
+
status: "active",
|
|
1563
|
+
nextStepIndex: 0,
|
|
1564
|
+
nextRunAt: addSeconds(event.occurredAt, firstStep.offsetSeconds),
|
|
1565
|
+
attempts: 0,
|
|
1566
|
+
createdAt: now,
|
|
1567
|
+
updatedAt: now
|
|
1568
|
+
};
|
|
1569
|
+
try {
|
|
1570
|
+
await this.runs.insertOne(run);
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
if (!isDuplicateKeyError2(error)) throw error;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
async resolveEnrollments(eventName) {
|
|
1577
|
+
const journeys = await this.journeys.find({ status: "live", liveEntryEvent: eventName }).project({ _id: 1, liveRevision: 1 }).toArray();
|
|
1578
|
+
return journeys.flatMap(
|
|
1579
|
+
(journey) => journey.liveRevision === void 0 ? [] : [{ journeyId: journey._id, revision: journey.liveRevision }]
|
|
1580
|
+
);
|
|
1581
|
+
}
|
|
1582
|
+
async processDue(options = {}) {
|
|
1583
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1584
|
+
const limit = Math.max(1, Math.min(options.limit ?? 100, 500));
|
|
1585
|
+
const leaseMs = Math.max(options.leaseMs ?? 6e4, 1e3);
|
|
1586
|
+
let processed = 0;
|
|
1587
|
+
for (let index = 0; index < limit; index += 1) {
|
|
1588
|
+
const lockToken = randomUUID2();
|
|
1589
|
+
const run = await this.runs.findOneAndUpdate(
|
|
1590
|
+
{
|
|
1591
|
+
status: "active",
|
|
1592
|
+
nextRunAt: { $lte: now },
|
|
1593
|
+
$or: [
|
|
1594
|
+
{ lockedUntil: { $exists: false } },
|
|
1595
|
+
{ lockedUntil: { $lte: now } }
|
|
1596
|
+
]
|
|
1597
|
+
},
|
|
1598
|
+
{
|
|
1599
|
+
$set: {
|
|
1600
|
+
lockToken,
|
|
1601
|
+
lockedUntil: new Date(now.getTime() + leaseMs),
|
|
1602
|
+
updatedAt: now
|
|
1603
|
+
}
|
|
1604
|
+
},
|
|
1605
|
+
{ sort: { nextRunAt: 1 }, returnDocument: "after" }
|
|
1606
|
+
);
|
|
1607
|
+
if (!run) break;
|
|
1608
|
+
await this.processLockedRun(run, lockToken, now);
|
|
1609
|
+
processed += 1;
|
|
1610
|
+
}
|
|
1611
|
+
return { processed };
|
|
1612
|
+
}
|
|
1613
|
+
async testSend(input) {
|
|
1614
|
+
const journey = await this.requireJourney(input.journeyId);
|
|
1615
|
+
const step = journey.draft.steps.find((candidate) => candidate.id === input.stepId);
|
|
1616
|
+
if (!step) throw new JourneyError("NOT_FOUND", "Journey step not found");
|
|
1617
|
+
return this.push.send({
|
|
1618
|
+
deviceId: input.deviceId,
|
|
1619
|
+
title: step.title,
|
|
1620
|
+
body: step.body,
|
|
1621
|
+
data: step.data,
|
|
1622
|
+
imageUrl: step.imageUrl,
|
|
1623
|
+
idempotencyKey: `journey-test:${journey._id}:${step.id}:${input.deviceId}:${randomUUID2()}`,
|
|
1624
|
+
journeyId: journey._id,
|
|
1625
|
+
journeyStepId: step.id,
|
|
1626
|
+
isTest: true
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
async processLockedRun(run, lockToken, now) {
|
|
1630
|
+
try {
|
|
1631
|
+
const journey = await this.journeys.findOne({ _id: run.journeyId });
|
|
1632
|
+
if (!journey || journey.status !== "live") {
|
|
1633
|
+
await this.runs.updateOne(
|
|
1634
|
+
{ _id: run._id, lockToken },
|
|
1635
|
+
{
|
|
1636
|
+
$set: { status: "paused", updatedAt: now },
|
|
1637
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1638
|
+
}
|
|
1639
|
+
);
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
const revision = await this.revisions.findOne({
|
|
1643
|
+
journeyId: run.journeyId,
|
|
1644
|
+
revision: run.revision
|
|
1645
|
+
});
|
|
1646
|
+
const step = revision?.definition.steps[run.nextStepIndex];
|
|
1647
|
+
if (!revision || !step) {
|
|
1648
|
+
await this.failRun(run._id, lockToken, now, "REVISION_NOT_FOUND");
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
if (!await this.matchesAudience(run.deviceId, revision.definition)) {
|
|
1652
|
+
await this.runs.updateOne(
|
|
1653
|
+
{ _id: run._id, lockToken },
|
|
1654
|
+
{
|
|
1655
|
+
$set: { status: "exited", completedAt: now, updatedAt: now },
|
|
1656
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1657
|
+
}
|
|
1658
|
+
);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
const data = step.data ? { ...step.data } : {};
|
|
1662
|
+
if (!("journeyId" in data)) data.journeyId = run.journeyId;
|
|
1663
|
+
if (!("journeyStepId" in data)) data.journeyStepId = step.id;
|
|
1664
|
+
const results = await this.push.send({
|
|
1665
|
+
deviceId: run.deviceId,
|
|
1666
|
+
title: step.title,
|
|
1667
|
+
body: step.body,
|
|
1668
|
+
imageUrl: step.imageUrl,
|
|
1669
|
+
data,
|
|
1670
|
+
idempotencyKey: `journey:${run._id}:step:${step.id}:attempt:${run.attempts}`,
|
|
1671
|
+
journeyId: run.journeyId,
|
|
1672
|
+
journeyRevision: run.revision,
|
|
1673
|
+
journeyStepId: step.id,
|
|
1674
|
+
isTest: false
|
|
1675
|
+
});
|
|
1676
|
+
if (results.some((result) => result.status === "submitted")) {
|
|
1677
|
+
const nextIndex = run.nextStepIndex + 1;
|
|
1678
|
+
const nextStep = revision.definition.steps[nextIndex];
|
|
1679
|
+
if (!nextStep) {
|
|
1680
|
+
await this.runs.updateOne(
|
|
1681
|
+
{ _id: run._id, lockToken },
|
|
1682
|
+
{
|
|
1683
|
+
$set: {
|
|
1684
|
+
status: "completed",
|
|
1685
|
+
nextStepIndex: nextIndex,
|
|
1686
|
+
attempts: 0,
|
|
1687
|
+
completedAt: now,
|
|
1688
|
+
updatedAt: now
|
|
1689
|
+
},
|
|
1690
|
+
$unset: { lockToken: "", lockedUntil: "", lastError: "" }
|
|
1691
|
+
}
|
|
1692
|
+
);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
await this.runs.updateOne(
|
|
1696
|
+
{ _id: run._id, lockToken },
|
|
1697
|
+
{
|
|
1698
|
+
$set: {
|
|
1699
|
+
nextStepIndex: nextIndex,
|
|
1700
|
+
nextRunAt: addSeconds(run.eventOccurredAt, nextStep.offsetSeconds),
|
|
1701
|
+
attempts: 0,
|
|
1702
|
+
updatedAt: now
|
|
1703
|
+
},
|
|
1704
|
+
$unset: { lockToken: "", lockedUntil: "", lastError: "" }
|
|
1705
|
+
}
|
|
1706
|
+
);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const errorCode = results.find((result) => result.errorCode)?.errorCode ?? "TRANSIENT";
|
|
1710
|
+
if (PERMANENT_ERRORS.has(errorCode)) {
|
|
1711
|
+
await this.failRun(run._id, lockToken, now, errorCode);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
await this.retryRun(run, lockToken, now, errorCode);
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
await this.retryRun(
|
|
1717
|
+
run,
|
|
1718
|
+
lockToken,
|
|
1719
|
+
now,
|
|
1720
|
+
error instanceof Error ? error.message : "TRANSIENT"
|
|
1721
|
+
);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
async matchesAudience(deviceId, definition) {
|
|
1725
|
+
if (!definition.audience) return true;
|
|
1726
|
+
const exists = await this.events.findOne({
|
|
1727
|
+
deviceId,
|
|
1728
|
+
name: definition.audience.eventName
|
|
1729
|
+
});
|
|
1730
|
+
return definition.audience.operator === "has_event" ? !!exists : !exists;
|
|
1731
|
+
}
|
|
1732
|
+
async retryRun(run, lockToken, now, errorCode) {
|
|
1733
|
+
const attempts = run.attempts + 1;
|
|
1734
|
+
if (attempts >= MAX_ATTEMPTS) {
|
|
1735
|
+
await this.failRun(run._id, lockToken, now, errorCode, attempts);
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
const delay = RETRY_DELAYS_MS[Math.min(attempts - 1, RETRY_DELAYS_MS.length - 1)];
|
|
1739
|
+
await this.runs.updateOne(
|
|
1740
|
+
{ _id: run._id, lockToken },
|
|
1741
|
+
{
|
|
1742
|
+
$set: {
|
|
1743
|
+
attempts,
|
|
1744
|
+
nextRunAt: new Date(now.getTime() + delay),
|
|
1745
|
+
lastError: errorCode,
|
|
1746
|
+
updatedAt: now
|
|
1747
|
+
},
|
|
1748
|
+
$unset: { lockToken: "", lockedUntil: "" }
|
|
1749
|
+
}
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
async failRun(id, lockToken, now, errorCode, attempts) {
|
|
1753
|
+
const set = {
|
|
1754
|
+
status: "failed",
|
|
1755
|
+
lastError: errorCode,
|
|
1756
|
+
completedAt: now,
|
|
1757
|
+
updatedAt: now
|
|
1758
|
+
};
|
|
1759
|
+
if (attempts !== void 0) set.attempts = attempts;
|
|
1760
|
+
await this.runs.updateOne(
|
|
1761
|
+
{ _id: id, lockToken },
|
|
1762
|
+
{ $set: set, $unset: { lockToken: "", lockedUntil: "" } }
|
|
1763
|
+
);
|
|
1764
|
+
}
|
|
1765
|
+
async requireJourney(id) {
|
|
1766
|
+
const journey = await this.journeys.findOne({ _id: id });
|
|
1767
|
+
if (!journey) throw new JourneyError("NOT_FOUND", "Journey not found");
|
|
1768
|
+
return journey;
|
|
1769
|
+
}
|
|
1770
|
+
};
|
|
1771
|
+
function validateJourneyDefinition(input) {
|
|
1772
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
|
1773
|
+
throw new JourneyError("INVALID_INPUT", "Invalid journey definition");
|
|
1774
|
+
}
|
|
1775
|
+
if (!EVENT_NAME_RE.test(input.entryEvent)) {
|
|
1776
|
+
throw new JourneyError("INVALID_INPUT", "Invalid entry event");
|
|
1777
|
+
}
|
|
1778
|
+
if (!Array.isArray(input.steps) || input.steps.length < 1 || input.steps.length > MAX_STEPS) {
|
|
1779
|
+
throw new JourneyError("INVALID_INPUT", "A journey needs 1-20 steps");
|
|
1780
|
+
}
|
|
1781
|
+
if (input.audience) {
|
|
1782
|
+
if (input.audience.operator !== "has_event" && input.audience.operator !== "not_has_event" || !EVENT_NAME_RE.test(input.audience.eventName)) {
|
|
1783
|
+
throw new JourneyError("INVALID_INPUT", "Invalid audience");
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
const stepIds = /* @__PURE__ */ new Set();
|
|
1787
|
+
let previousOffset = -1;
|
|
1788
|
+
for (const step of input.steps) {
|
|
1789
|
+
if (!step || typeof step !== "object" || !STEP_ID_RE.test(step.id)) {
|
|
1790
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step id");
|
|
1791
|
+
}
|
|
1792
|
+
if (stepIds.has(step.id)) {
|
|
1793
|
+
throw new JourneyError("INVALID_INPUT", "Duplicate step id");
|
|
1794
|
+
}
|
|
1795
|
+
stepIds.add(step.id);
|
|
1796
|
+
if (!Number.isInteger(step.offsetSeconds) || step.offsetSeconds < 0 || step.offsetSeconds > MAX_OFFSET_SECONDS || step.offsetSeconds < previousOffset) {
|
|
1797
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step offset");
|
|
1798
|
+
}
|
|
1799
|
+
previousOffset = step.offsetSeconds;
|
|
1800
|
+
if (typeof step.title !== "string" || step.title.length < 1 || step.title.length > 100) {
|
|
1801
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step title");
|
|
1802
|
+
}
|
|
1803
|
+
if (typeof step.body !== "string" || step.body.length < 1 || step.body.length > 1e3) {
|
|
1804
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step body");
|
|
1805
|
+
}
|
|
1806
|
+
validateHttpsUrl(step.imageUrl);
|
|
1807
|
+
if (step.data !== void 0) {
|
|
1808
|
+
if (!step.data || typeof step.data !== "object" || Array.isArray(step.data)) {
|
|
1809
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step data");
|
|
1810
|
+
}
|
|
1811
|
+
if ("deliveryId" in step.data || "journeyId" in step.data || "journeyStepId" in step.data) {
|
|
1812
|
+
throw new JourneyError("INVALID_INPUT", "Step data uses reserved keys");
|
|
1813
|
+
}
|
|
1814
|
+
try {
|
|
1815
|
+
if (Buffer.byteLength(JSON.stringify(step.data), "utf8") > 3072) {
|
|
1816
|
+
throw new JourneyError("INVALID_INPUT", "Step data is too large");
|
|
1817
|
+
}
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
if (error instanceof JourneyError) throw error;
|
|
1820
|
+
throw new JourneyError("INVALID_INPUT", "Invalid step data");
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return cloneDefinition(input);
|
|
1825
|
+
}
|
|
1826
|
+
function validateName(name) {
|
|
1827
|
+
if (typeof name !== "string" || name.trim().length < 1 || name.trim().length > 100) {
|
|
1828
|
+
throw new JourneyError("INVALID_INPUT", "Invalid journey name");
|
|
1829
|
+
}
|
|
1830
|
+
return name.trim();
|
|
1831
|
+
}
|
|
1832
|
+
function validateHttpsUrl(value) {
|
|
1833
|
+
if (value === void 0) return;
|
|
1834
|
+
try {
|
|
1835
|
+
const url = new URL(value);
|
|
1836
|
+
if (url.protocol !== "https:" || value.length > 2048) throw new Error("invalid");
|
|
1837
|
+
} catch {
|
|
1838
|
+
throw new JourneyError("INVALID_INPUT", "Invalid image URL");
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
function cloneDefinition(definition) {
|
|
1842
|
+
return structuredClone(definition);
|
|
1843
|
+
}
|
|
1844
|
+
function addSeconds(date, seconds) {
|
|
1845
|
+
return new Date(date.getTime() + seconds * 1e3);
|
|
1846
|
+
}
|
|
1847
|
+
function isDuplicateKeyError2(error) {
|
|
1848
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1388
1851
|
// src/admin/guard.ts
|
|
1389
1852
|
import { createHash, timingSafeEqual } from "crypto";
|
|
1390
1853
|
var ADMIN_HEADER = "x-codixus-admin";
|
|
@@ -1437,13 +1900,20 @@ var PUSH_DELIVERY_FIELDS = [
|
|
|
1437
1900
|
"title",
|
|
1438
1901
|
"body",
|
|
1439
1902
|
"data",
|
|
1903
|
+
"imageUrl",
|
|
1440
1904
|
"status",
|
|
1441
1905
|
"ticketId",
|
|
1442
1906
|
"errorCode",
|
|
1907
|
+
"errorMessage",
|
|
1443
1908
|
"idempotencyKey",
|
|
1444
1909
|
"createdAt",
|
|
1445
1910
|
"openedAt",
|
|
1446
|
-
"receiptedAt"
|
|
1911
|
+
"receiptedAt",
|
|
1912
|
+
"updatedAt",
|
|
1913
|
+
"journeyId",
|
|
1914
|
+
"journeyRevision",
|
|
1915
|
+
"journeyStepId",
|
|
1916
|
+
"isTest"
|
|
1447
1917
|
];
|
|
1448
1918
|
function param2(req, name) {
|
|
1449
1919
|
const val = req.params[name];
|
|
@@ -1610,6 +2080,19 @@ function notFound(res) {
|
|
|
1610
2080
|
function invalidBody(res) {
|
|
1611
2081
|
res.status(400).json({ success: false, error: "Invalid request body" });
|
|
1612
2082
|
}
|
|
2083
|
+
function journeyFailure(res, error) {
|
|
2084
|
+
if (error instanceof JourneyError) {
|
|
2085
|
+
if (error.code === "NOT_FOUND") {
|
|
2086
|
+
notFound(res);
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
if (error.code === "INVALID_STATE") {
|
|
2090
|
+
res.status(409).json({ success: false, error: "INVALID_STATE" });
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
invalidBody(res);
|
|
2095
|
+
}
|
|
1613
2096
|
function createAdminRouter(deps) {
|
|
1614
2097
|
const router = Router4();
|
|
1615
2098
|
router.use(createAdminGuard(deps.token));
|
|
@@ -1668,7 +2151,7 @@ function createAdminRouter(deps) {
|
|
|
1668
2151
|
}
|
|
1669
2152
|
const doc = { ...body };
|
|
1670
2153
|
if (!doc._id) {
|
|
1671
|
-
doc._id =
|
|
2154
|
+
doc._id = randomUUID3();
|
|
1672
2155
|
}
|
|
1673
2156
|
await deps.db.collection(name).insertOne(doc);
|
|
1674
2157
|
res.status(200).json({ success: true, data: doc });
|
|
@@ -1769,7 +2252,7 @@ function createAdminRouter(deps) {
|
|
|
1769
2252
|
notFound(res);
|
|
1770
2253
|
return;
|
|
1771
2254
|
}
|
|
1772
|
-
const { deviceId, title, body, data } = req.body ?? {};
|
|
2255
|
+
const { deviceId, title, body, data, imageUrl } = req.body ?? {};
|
|
1773
2256
|
if (!isNonEmptyString2(deviceId) || !isNonEmptyString2(title) || !isNonEmptyString2(body)) {
|
|
1774
2257
|
invalidBody(res);
|
|
1775
2258
|
return;
|
|
@@ -1784,8 +2267,100 @@ function createAdminRouter(deps) {
|
|
|
1784
2267
|
if (data !== void 0) {
|
|
1785
2268
|
input.data = data;
|
|
1786
2269
|
}
|
|
1787
|
-
|
|
1788
|
-
|
|
2270
|
+
if (imageUrl !== void 0) {
|
|
2271
|
+
if (!isNonEmptyString2(imageUrl)) {
|
|
2272
|
+
invalidBody(res);
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
input.imageUrl = imageUrl;
|
|
2276
|
+
}
|
|
2277
|
+
try {
|
|
2278
|
+
const results = await deps.pushSend(input);
|
|
2279
|
+
res.status(200).json({ success: true, data: results });
|
|
2280
|
+
} catch {
|
|
2281
|
+
invalidBody(res);
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
router.get("/push/journeys", async (_req, res) => {
|
|
2285
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2286
|
+
notFound(res);
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
res.json({ success: true, data: await deps.journeys.list() });
|
|
2290
|
+
});
|
|
2291
|
+
router.post("/push/journeys", async (req, res) => {
|
|
2292
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2293
|
+
notFound(res);
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
try {
|
|
2297
|
+
const journey = await deps.journeys.create(req.body ?? {});
|
|
2298
|
+
res.status(201).json({ success: true, data: journey });
|
|
2299
|
+
} catch (error) {
|
|
2300
|
+
journeyFailure(res, error);
|
|
2301
|
+
}
|
|
2302
|
+
});
|
|
2303
|
+
router.get("/push/journeys/:id", async (req, res) => {
|
|
2304
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2305
|
+
notFound(res);
|
|
2306
|
+
return;
|
|
2307
|
+
}
|
|
2308
|
+
const journey = await deps.journeys.get(param2(req, "id"));
|
|
2309
|
+
if (!journey) {
|
|
2310
|
+
notFound(res);
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
res.json({ success: true, data: journey });
|
|
2314
|
+
});
|
|
2315
|
+
router.patch("/push/journeys/:id", async (req, res) => {
|
|
2316
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2317
|
+
notFound(res);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
try {
|
|
2321
|
+
const journey = await deps.journeys.updateDraft(param2(req, "id"), req.body ?? {});
|
|
2322
|
+
res.json({ success: true, data: journey });
|
|
2323
|
+
} catch (error) {
|
|
2324
|
+
journeyFailure(res, error);
|
|
2325
|
+
}
|
|
2326
|
+
});
|
|
2327
|
+
for (const action of ["publish", "pause", "resume"]) {
|
|
2328
|
+
router.post(
|
|
2329
|
+
`/push/journeys/:id/${action}`,
|
|
2330
|
+
async (req, res) => {
|
|
2331
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2332
|
+
notFound(res);
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
try {
|
|
2336
|
+
const journey = await deps.journeys[action](param2(req, "id"));
|
|
2337
|
+
res.json({ success: true, data: journey });
|
|
2338
|
+
} catch (error) {
|
|
2339
|
+
journeyFailure(res, error);
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
);
|
|
2343
|
+
}
|
|
2344
|
+
router.post("/push/journeys/:id/test", async (req, res) => {
|
|
2345
|
+
if (!deps.hasPush || !deps.journeys) {
|
|
2346
|
+
notFound(res);
|
|
2347
|
+
return;
|
|
2348
|
+
}
|
|
2349
|
+
const { deviceId, stepId } = req.body ?? {};
|
|
2350
|
+
if (!isNonEmptyString2(deviceId) || !isNonEmptyString2(stepId)) {
|
|
2351
|
+
invalidBody(res);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
try {
|
|
2355
|
+
const results = await deps.journeys.testSend({
|
|
2356
|
+
journeyId: param2(req, "id"),
|
|
2357
|
+
deviceId,
|
|
2358
|
+
stepId
|
|
2359
|
+
});
|
|
2360
|
+
res.json({ success: true, data: results });
|
|
2361
|
+
} catch (error) {
|
|
2362
|
+
journeyFailure(res, error);
|
|
2363
|
+
}
|
|
1789
2364
|
});
|
|
1790
2365
|
router.get("/push/deliveries", async (req, res) => {
|
|
1791
2366
|
if (!deps.hasPush) {
|
|
@@ -1805,7 +2380,7 @@ function createAdminRouter(deps) {
|
|
|
1805
2380
|
}
|
|
1806
2381
|
|
|
1807
2382
|
// src/push/service.ts
|
|
1808
|
-
import { createHash as createHash2, randomUUID as
|
|
2383
|
+
import { createHash as createHash2, randomUUID as randomUUID4 } from "crypto";
|
|
1809
2384
|
var PERMANENT_DISABLE_CODES = /* @__PURE__ */ new Set([
|
|
1810
2385
|
"DeviceNotRegistered",
|
|
1811
2386
|
"InvalidCredentials"
|
|
@@ -1821,77 +2396,110 @@ var PushService = class {
|
|
|
1821
2396
|
devicesCol;
|
|
1822
2397
|
deliveriesCol;
|
|
1823
2398
|
async send(input) {
|
|
2399
|
+
validateImageUrl(input.imageUrl);
|
|
1824
2400
|
const deviceIds = Array.isArray(input.deviceId) ? [...new Set(input.deviceId)] : [input.deviceId];
|
|
1825
|
-
if (deviceIds.length === 0) return;
|
|
2401
|
+
if (deviceIds.length === 0) return [];
|
|
1826
2402
|
const digest = computeDigest(
|
|
1827
2403
|
input.title,
|
|
1828
2404
|
input.body,
|
|
1829
2405
|
input.idempotencyKey
|
|
1830
2406
|
);
|
|
1831
2407
|
const pending = [];
|
|
2408
|
+
const results = [];
|
|
1832
2409
|
for (const deviceId of deviceIds) {
|
|
1833
2410
|
const devices = await this.devicesCol.find({ deviceId, enabled: true }).toArray();
|
|
2411
|
+
if (devices.length === 0) {
|
|
2412
|
+
results.push({ deviceId, status: "skipped", errorCode: "NO_DEVICE" });
|
|
2413
|
+
}
|
|
1834
2414
|
for (const device of devices) {
|
|
1835
2415
|
const idempotencyKey = `${deviceId}:${device.token}:${digest}`;
|
|
1836
|
-
const
|
|
1837
|
-
if (existing) continue;
|
|
1838
|
-
const deliveryId = randomUUID3();
|
|
2416
|
+
const deliveryId = randomUUID4();
|
|
1839
2417
|
const data = input.data ? { ...input.data } : {};
|
|
1840
2418
|
if (!("deliveryId" in data)) {
|
|
1841
2419
|
data.deliveryId = deliveryId;
|
|
1842
2420
|
}
|
|
1843
|
-
|
|
1844
|
-
|
|
2421
|
+
const now = /* @__PURE__ */ new Date();
|
|
2422
|
+
const doc = {
|
|
2423
|
+
_id: deliveryId,
|
|
1845
2424
|
deviceId,
|
|
1846
2425
|
provider: device.provider,
|
|
1847
|
-
|
|
2426
|
+
token: device.token,
|
|
2427
|
+
title: input.title,
|
|
2428
|
+
body: input.body,
|
|
2429
|
+
data,
|
|
2430
|
+
status: "sending",
|
|
1848
2431
|
idempotencyKey,
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
2432
|
+
createdAt: now,
|
|
2433
|
+
updatedAt: now
|
|
2434
|
+
};
|
|
2435
|
+
if (input.imageUrl !== void 0) doc.imageUrl = input.imageUrl;
|
|
2436
|
+
if (input.journeyId !== void 0) doc.journeyId = input.journeyId;
|
|
2437
|
+
if (input.journeyRevision !== void 0) {
|
|
2438
|
+
doc.journeyRevision = input.journeyRevision;
|
|
2439
|
+
}
|
|
2440
|
+
if (input.journeyStepId !== void 0) {
|
|
2441
|
+
doc.journeyStepId = input.journeyStepId;
|
|
2442
|
+
}
|
|
2443
|
+
if (input.isTest !== void 0) doc.isTest = input.isTest;
|
|
2444
|
+
try {
|
|
2445
|
+
await this.deliveriesCol.insertOne(doc);
|
|
2446
|
+
} catch (error) {
|
|
2447
|
+
if (!isDuplicateKeyError3(error)) throw error;
|
|
2448
|
+
const existing = await this.deliveriesCol.findOne({ idempotencyKey });
|
|
2449
|
+
if (existing) results.push(resultFromDelivery(existing));
|
|
2450
|
+
continue;
|
|
2451
|
+
}
|
|
2452
|
+
const message = {
|
|
2453
|
+
token: device.token,
|
|
2454
|
+
title: input.title,
|
|
2455
|
+
body: input.body,
|
|
2456
|
+
data
|
|
2457
|
+
};
|
|
2458
|
+
if (input.imageUrl !== void 0) message.imageUrl = input.imageUrl;
|
|
2459
|
+
pending.push({ doc, message });
|
|
1856
2460
|
}
|
|
1857
2461
|
}
|
|
1858
|
-
if (pending.length === 0) return;
|
|
2462
|
+
if (pending.length === 0) return results;
|
|
1859
2463
|
const messages = pending.map((p) => p.message);
|
|
1860
|
-
|
|
2464
|
+
let tickets;
|
|
2465
|
+
try {
|
|
2466
|
+
tickets = await this.transport.send(messages);
|
|
2467
|
+
} catch (error) {
|
|
2468
|
+
tickets = messages.map((message) => ({
|
|
2469
|
+
token: message.token,
|
|
2470
|
+
status: "error",
|
|
2471
|
+
errorCode: "TRANSIENT",
|
|
2472
|
+
errorMessage: readableError(error)
|
|
2473
|
+
}));
|
|
2474
|
+
}
|
|
1861
2475
|
for (let i = 0; i < pending.length; i++) {
|
|
1862
2476
|
const item = pending[i];
|
|
1863
2477
|
const ticket = tickets[i];
|
|
1864
2478
|
if (ticket && ticket.status === "error" && ticket.errorCode && PERMANENT_DISABLE_CODES.has(ticket.errorCode)) {
|
|
1865
2479
|
await this.devicesCol.updateOne(
|
|
1866
|
-
{ token: item.token },
|
|
2480
|
+
{ token: item.doc.token },
|
|
1867
2481
|
{ $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } }
|
|
1868
2482
|
);
|
|
1869
2483
|
}
|
|
1870
|
-
const
|
|
1871
|
-
_id: item.deliveryId,
|
|
1872
|
-
deviceId: item.deviceId,
|
|
1873
|
-
token: item.token,
|
|
1874
|
-
provider: item.provider,
|
|
1875
|
-
title: input.title,
|
|
1876
|
-
body: input.body,
|
|
1877
|
-
data: item.message.data,
|
|
2484
|
+
const update = {
|
|
1878
2485
|
status: "failed",
|
|
1879
|
-
|
|
1880
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
2486
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
1881
2487
|
};
|
|
1882
2488
|
if (ticket && ticket.status === "ok") {
|
|
1883
|
-
|
|
1884
|
-
if (ticket.ticketId)
|
|
2489
|
+
update.status = "submitted";
|
|
2490
|
+
if (ticket.ticketId) update.ticketId = ticket.ticketId;
|
|
1885
2491
|
} else {
|
|
1886
|
-
|
|
1887
|
-
if (ticket?.
|
|
1888
|
-
|
|
1889
|
-
try {
|
|
1890
|
-
await this.deliveriesCol.insertOne(doc);
|
|
1891
|
-
} catch (err) {
|
|
1892
|
-
if (!isDuplicateKeyError2(err)) throw err;
|
|
2492
|
+
update.errorCode = ticket?.errorCode ?? "TRANSIENT";
|
|
2493
|
+
if (ticket?.errorMessage) update.errorMessage = ticket.errorMessage;
|
|
2494
|
+
if (ticket?.ticketId) update.ticketId = ticket.ticketId;
|
|
1893
2495
|
}
|
|
2496
|
+
await this.deliveriesCol.updateOne(
|
|
2497
|
+
{ _id: item.doc._id },
|
|
2498
|
+
{ $set: update }
|
|
2499
|
+
);
|
|
2500
|
+
results.push(resultFromDelivery({ ...item.doc, ...update }));
|
|
1894
2501
|
}
|
|
2502
|
+
return results;
|
|
1895
2503
|
}
|
|
1896
2504
|
async sendTo(input) {
|
|
1897
2505
|
const devices = await this.devicesCol.find({ $and: [input.filter, { enabled: true }] }).toArray();
|
|
@@ -1903,13 +2511,14 @@ var PushService = class {
|
|
|
1903
2511
|
deviceIds.push(device.deviceId);
|
|
1904
2512
|
}
|
|
1905
2513
|
}
|
|
1906
|
-
if (deviceIds.length === 0) return;
|
|
1907
|
-
|
|
2514
|
+
if (deviceIds.length === 0) return [];
|
|
2515
|
+
return this.send({
|
|
1908
2516
|
deviceId: deviceIds,
|
|
1909
2517
|
title: input.title,
|
|
1910
2518
|
body: input.body,
|
|
1911
2519
|
data: input.data,
|
|
1912
|
-
idempotencyKey: input.idempotencyKey
|
|
2520
|
+
idempotencyKey: input.idempotencyKey,
|
|
2521
|
+
imageUrl: input.imageUrl
|
|
1913
2522
|
});
|
|
1914
2523
|
}
|
|
1915
2524
|
async pollReceipts() {
|
|
@@ -1954,6 +2563,7 @@ var PushService = class {
|
|
|
1954
2563
|
$set: {
|
|
1955
2564
|
status: "failed",
|
|
1956
2565
|
errorCode,
|
|
2566
|
+
...receipt.errorMessage ? { errorMessage: receipt.errorMessage } : {},
|
|
1957
2567
|
receiptedAt: now
|
|
1958
2568
|
}
|
|
1959
2569
|
}
|
|
@@ -1971,16 +2581,201 @@ var PushService = class {
|
|
|
1971
2581
|
return this.devicesCol.find(filter).toArray();
|
|
1972
2582
|
}
|
|
1973
2583
|
};
|
|
2584
|
+
function resultFromDelivery(delivery) {
|
|
2585
|
+
if (delivery.status === "submitted" || delivery.status === "opened") {
|
|
2586
|
+
return {
|
|
2587
|
+
deviceId: delivery.deviceId,
|
|
2588
|
+
token: delivery.token,
|
|
2589
|
+
deliveryId: delivery._id,
|
|
2590
|
+
status: "submitted"
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
return {
|
|
2594
|
+
deviceId: delivery.deviceId,
|
|
2595
|
+
token: delivery.token,
|
|
2596
|
+
deliveryId: delivery._id,
|
|
2597
|
+
status: "failed",
|
|
2598
|
+
errorCode: delivery.errorCode ?? "TRANSIENT",
|
|
2599
|
+
...delivery.errorMessage ? { errorMessage: delivery.errorMessage } : {}
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
function readableError(error) {
|
|
2603
|
+
return error instanceof Error ? error.message : String(error);
|
|
2604
|
+
}
|
|
2605
|
+
function validateImageUrl(imageUrl) {
|
|
2606
|
+
if (imageUrl === void 0) return;
|
|
2607
|
+
try {
|
|
2608
|
+
const url = new URL(imageUrl);
|
|
2609
|
+
if (url.protocol !== "https:") throw new Error("not https");
|
|
2610
|
+
} catch {
|
|
2611
|
+
throw new Error("Invalid imageUrl");
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
1974
2614
|
function computeDigest(title, body, idempotencyKey) {
|
|
1975
2615
|
if (idempotencyKey) return idempotencyKey;
|
|
1976
2616
|
const utcDay = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1977
2617
|
const input = title + "\0" + body + "\0" + utcDay;
|
|
1978
2618
|
return createHash2("sha256").update(input, "utf8").digest("hex");
|
|
1979
2619
|
}
|
|
1980
|
-
function
|
|
2620
|
+
function isDuplicateKeyError3(err) {
|
|
1981
2621
|
return typeof err === "object" && err !== null && "code" in err && err.code === 11e3;
|
|
1982
2622
|
}
|
|
1983
2623
|
|
|
2624
|
+
// src/events/service.ts
|
|
2625
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
2626
|
+
var EVENT_NAME_RE2 = /^[a-z][a-z0-9_]{0,63}$/;
|
|
2627
|
+
var MAX_PROPERTIES_BYTES = 16 * 1024;
|
|
2628
|
+
var EventService = class {
|
|
2629
|
+
constructor(db, defaultUsersCollection, journeyHooks) {
|
|
2630
|
+
this.db = db;
|
|
2631
|
+
this.defaultUsersCollection = defaultUsersCollection;
|
|
2632
|
+
this.journeyHooks = journeyHooks;
|
|
2633
|
+
this.events = db.collection("push_events");
|
|
2634
|
+
}
|
|
2635
|
+
db;
|
|
2636
|
+
defaultUsersCollection;
|
|
2637
|
+
journeyHooks;
|
|
2638
|
+
events;
|
|
2639
|
+
async ensureIndexes() {
|
|
2640
|
+
await this.events.createIndex({ deviceId: 1, name: 1, occurredAt: -1 });
|
|
2641
|
+
await this.events.createIndex({ name: 1, createdAt: -1 });
|
|
2642
|
+
await this.events.createIndex(
|
|
2643
|
+
{ deviceId: 1, source: 1, idempotencyKey: 1 },
|
|
2644
|
+
{
|
|
2645
|
+
unique: true,
|
|
2646
|
+
partialFilterExpression: { idempotencyKey: { $type: "string" } }
|
|
2647
|
+
}
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
async track(input) {
|
|
2651
|
+
validateEventInput(input);
|
|
2652
|
+
const usersCollection = input.usersCollection ?? this.defaultUsersCollection;
|
|
2653
|
+
const user = await this.db.collection(usersCollection).findOne({
|
|
2654
|
+
$or: [{ _id: input.deviceId }, { deviceId: input.deviceId }]
|
|
2655
|
+
});
|
|
2656
|
+
if (!user) return { created: false, ignored: "USER_NOT_FOUND" };
|
|
2657
|
+
const source = input.source ?? "server";
|
|
2658
|
+
if (input.idempotencyKey !== void 0) {
|
|
2659
|
+
const existing = await this.events.findOne({
|
|
2660
|
+
deviceId: input.deviceId,
|
|
2661
|
+
source,
|
|
2662
|
+
idempotencyKey: input.idempotencyKey
|
|
2663
|
+
});
|
|
2664
|
+
if (existing) {
|
|
2665
|
+
await this.finishEnrollment(existing);
|
|
2666
|
+
return { created: false, eventId: existing._id };
|
|
2667
|
+
}
|
|
2668
|
+
}
|
|
2669
|
+
const now = /* @__PURE__ */ new Date();
|
|
2670
|
+
const event = {
|
|
2671
|
+
_id: randomUUID5(),
|
|
2672
|
+
deviceId: input.deviceId,
|
|
2673
|
+
name: input.name,
|
|
2674
|
+
source,
|
|
2675
|
+
occurredAt: input.occurredAt ?? now,
|
|
2676
|
+
createdAt: now
|
|
2677
|
+
};
|
|
2678
|
+
if (input.properties !== void 0) event.properties = input.properties;
|
|
2679
|
+
if (input.idempotencyKey !== void 0) {
|
|
2680
|
+
event.idempotencyKey = input.idempotencyKey;
|
|
2681
|
+
}
|
|
2682
|
+
if (this.journeyHooks) {
|
|
2683
|
+
event.journeyEnrollments = await this.journeyHooks.resolve(event.name);
|
|
2684
|
+
event.enrollmentStatus = "pending";
|
|
2685
|
+
} else {
|
|
2686
|
+
event.enrollmentStatus = "complete";
|
|
2687
|
+
}
|
|
2688
|
+
try {
|
|
2689
|
+
await this.events.insertOne(event);
|
|
2690
|
+
} catch (error) {
|
|
2691
|
+
if (!isDuplicateKeyError4(error) || input.idempotencyKey === void 0) {
|
|
2692
|
+
throw error;
|
|
2693
|
+
}
|
|
2694
|
+
const existing = await this.events.findOne({
|
|
2695
|
+
deviceId: input.deviceId,
|
|
2696
|
+
source,
|
|
2697
|
+
idempotencyKey: input.idempotencyKey
|
|
2698
|
+
});
|
|
2699
|
+
if (!existing) throw error;
|
|
2700
|
+
await this.finishEnrollment(existing);
|
|
2701
|
+
return { created: false, eventId: existing._id };
|
|
2702
|
+
}
|
|
2703
|
+
await this.finishEnrollment(event);
|
|
2704
|
+
return { created: true, eventId: event._id };
|
|
2705
|
+
}
|
|
2706
|
+
async find(filter) {
|
|
2707
|
+
return this.events.find(filter).sort({ createdAt: -1 }).toArray();
|
|
2708
|
+
}
|
|
2709
|
+
async finishEnrollment(event) {
|
|
2710
|
+
if (!this.journeyHooks || event.enrollmentStatus !== "pending") return;
|
|
2711
|
+
await this.journeyHooks.enroll(event);
|
|
2712
|
+
await this.events.updateOne(
|
|
2713
|
+
{ _id: event._id, enrollmentStatus: "pending" },
|
|
2714
|
+
{ $set: { enrollmentStatus: "complete" } }
|
|
2715
|
+
);
|
|
2716
|
+
}
|
|
2717
|
+
};
|
|
2718
|
+
function validateEventInput(input) {
|
|
2719
|
+
if (typeof input.deviceId !== "string" || input.deviceId.length === 0) {
|
|
2720
|
+
throw new Error("Invalid deviceId");
|
|
2721
|
+
}
|
|
2722
|
+
if (!EVENT_NAME_RE2.test(input.name)) throw new Error("Invalid event name");
|
|
2723
|
+
if (input.idempotencyKey !== void 0 && (typeof input.idempotencyKey !== "string" || input.idempotencyKey.length === 0 || input.idempotencyKey.length > 200)) {
|
|
2724
|
+
throw new Error("Invalid idempotency key");
|
|
2725
|
+
}
|
|
2726
|
+
if (input.occurredAt !== void 0 && (!(input.occurredAt instanceof Date) || Number.isNaN(input.occurredAt.getTime()))) {
|
|
2727
|
+
throw new Error("Invalid occurredAt");
|
|
2728
|
+
}
|
|
2729
|
+
if (input.properties !== void 0) {
|
|
2730
|
+
if (!input.properties || typeof input.properties !== "object" || Array.isArray(input.properties)) {
|
|
2731
|
+
throw new Error("Invalid properties");
|
|
2732
|
+
}
|
|
2733
|
+
try {
|
|
2734
|
+
if (Buffer.byteLength(JSON.stringify(input.properties), "utf8") > MAX_PROPERTIES_BYTES) {
|
|
2735
|
+
throw new Error("Invalid properties");
|
|
2736
|
+
}
|
|
2737
|
+
} catch {
|
|
2738
|
+
throw new Error("Invalid properties");
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
function isDuplicateKeyError4(error) {
|
|
2743
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === 11e3;
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
// src/events/router.ts
|
|
2747
|
+
import { Router as Router5 } from "express";
|
|
2748
|
+
function createEventsRouter(service) {
|
|
2749
|
+
const router = Router5();
|
|
2750
|
+
router.post("/", async (req, res) => {
|
|
2751
|
+
const deviceId = req.user?.deviceId;
|
|
2752
|
+
if (!deviceId) {
|
|
2753
|
+
res.status(401).json({ success: false, error: "Missing authorization header" });
|
|
2754
|
+
return;
|
|
2755
|
+
}
|
|
2756
|
+
try {
|
|
2757
|
+
const { name, properties, idempotencyKey, occurredAt } = req.body ?? {};
|
|
2758
|
+
let parsedOccurredAt;
|
|
2759
|
+
if (occurredAt !== void 0) {
|
|
2760
|
+
if (typeof occurredAt !== "string") throw new Error("Invalid occurredAt");
|
|
2761
|
+
parsedOccurredAt = new Date(occurredAt);
|
|
2762
|
+
}
|
|
2763
|
+
const result = await service.track({
|
|
2764
|
+
deviceId,
|
|
2765
|
+
name,
|
|
2766
|
+
properties,
|
|
2767
|
+
idempotencyKey,
|
|
2768
|
+
occurredAt: parsedOccurredAt,
|
|
2769
|
+
source: "client"
|
|
2770
|
+
});
|
|
2771
|
+
res.status(200).json({ success: true, data: result });
|
|
2772
|
+
} catch {
|
|
2773
|
+
res.status(400).json({ success: false, error: "Invalid request body" });
|
|
2774
|
+
}
|
|
2775
|
+
});
|
|
2776
|
+
return router;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
1984
2779
|
// src/codixus-server.ts
|
|
1985
2780
|
var CodixusServer = class {
|
|
1986
2781
|
constructor(config) {
|
|
@@ -2005,6 +2800,7 @@ var CodixusServer = class {
|
|
|
2005
2800
|
auth;
|
|
2006
2801
|
db;
|
|
2007
2802
|
push;
|
|
2803
|
+
events;
|
|
2008
2804
|
admin;
|
|
2009
2805
|
async connect() {
|
|
2010
2806
|
this._db = await this.connection.connect();
|
|
@@ -2021,6 +2817,7 @@ var CodixusServer = class {
|
|
|
2021
2817
|
{ expiresAt: 1 },
|
|
2022
2818
|
{ expireAfterSeconds: 0 }
|
|
2023
2819
|
);
|
|
2820
|
+
let journeyService;
|
|
2024
2821
|
if (this.config.push) {
|
|
2025
2822
|
const pushDevicesCol = this._db.collection("push_devices");
|
|
2026
2823
|
await pushDevicesCol.createIndex({ token: 1 }, { unique: true });
|
|
@@ -2033,15 +2830,54 @@ var CodixusServer = class {
|
|
|
2033
2830
|
);
|
|
2034
2831
|
await pushDeliveriesCol.createIndex({ ticketId: 1 });
|
|
2035
2832
|
await pushDeliveriesCol.createIndex({ deviceId: 1 });
|
|
2036
|
-
const
|
|
2833
|
+
const configuredPushService = new PushService(
|
|
2834
|
+
this._db,
|
|
2835
|
+
this.config.push.transport
|
|
2836
|
+
);
|
|
2837
|
+
journeyService = new JourneyService(this._db, configuredPushService);
|
|
2838
|
+
await journeyService.ensureIndexes();
|
|
2839
|
+
const journeys = journeyService;
|
|
2037
2840
|
this.push = {
|
|
2038
|
-
router: () => createPushRouter(this._db)
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2841
|
+
router: () => createPushRouter(this._db, async ({ deviceId, token, platform }) => {
|
|
2842
|
+
await this.events.track({
|
|
2843
|
+
deviceId,
|
|
2844
|
+
name: "push_registered",
|
|
2845
|
+
properties: { platform },
|
|
2846
|
+
idempotencyKey: `push-registered:${createHash3("sha256").update(token).digest("hex")}`,
|
|
2847
|
+
source: "system"
|
|
2848
|
+
});
|
|
2849
|
+
}),
|
|
2850
|
+
send: (input) => configuredPushService.send(input),
|
|
2851
|
+
sendTo: (input) => configuredPushService.sendTo(input),
|
|
2852
|
+
pollReceipts: () => configuredPushService.pollReceipts(),
|
|
2853
|
+
find: (filter) => configuredPushService.find(filter),
|
|
2854
|
+
journeys: {
|
|
2855
|
+
create: (input) => journeys.create(input),
|
|
2856
|
+
list: () => journeys.list(),
|
|
2857
|
+
get: (id) => journeys.get(id),
|
|
2858
|
+
updateDraft: (id, input) => journeys.updateDraft(id, input),
|
|
2859
|
+
publish: (id) => journeys.publish(id),
|
|
2860
|
+
pause: (id) => journeys.pause(id),
|
|
2861
|
+
resume: (id) => journeys.resume(id),
|
|
2862
|
+
testSend: (input) => journeys.testSend(input),
|
|
2863
|
+
processDue: (options) => journeys.processDue(options)
|
|
2864
|
+
}
|
|
2043
2865
|
};
|
|
2044
2866
|
}
|
|
2867
|
+
const eventService = new EventService(
|
|
2868
|
+
this._db,
|
|
2869
|
+
this.config.events?.usersCollection ?? "users",
|
|
2870
|
+
journeyService ? {
|
|
2871
|
+
resolve: (eventName) => journeyService.resolveEnrollments(eventName),
|
|
2872
|
+
enroll: (event) => journeyService.enrollEvent(event)
|
|
2873
|
+
} : void 0
|
|
2874
|
+
);
|
|
2875
|
+
await eventService.ensureIndexes();
|
|
2876
|
+
this.events = {
|
|
2877
|
+
router: () => createEventsRouter(eventService),
|
|
2878
|
+
track: (input) => eventService.track(input),
|
|
2879
|
+
find: (filter) => eventService.find(filter)
|
|
2880
|
+
};
|
|
2045
2881
|
this.auth = {
|
|
2046
2882
|
sign: (subject, claims) => this.jwt.sign(subject, claims),
|
|
2047
2883
|
verify: (token) => this.jwt.verify(token),
|
|
@@ -2052,7 +2888,21 @@ var CodixusServer = class {
|
|
|
2052
2888
|
return tokens;
|
|
2053
2889
|
},
|
|
2054
2890
|
guard: (options) => this.guardFn(options),
|
|
2055
|
-
router: (config) => createAuthRouter(
|
|
2891
|
+
router: (config) => createAuthRouter(
|
|
2892
|
+
this.jwt,
|
|
2893
|
+
this.banService,
|
|
2894
|
+
this._db,
|
|
2895
|
+
config,
|
|
2896
|
+
async ({ deviceId, usersCollection }) => {
|
|
2897
|
+
await eventService.track({
|
|
2898
|
+
deviceId,
|
|
2899
|
+
name: "user_created",
|
|
2900
|
+
source: "system",
|
|
2901
|
+
idempotencyKey: "user-created",
|
|
2902
|
+
usersCollection
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
),
|
|
2056
2906
|
ban: (deviceId, reason) => this.banService.ban(deviceId, reason),
|
|
2057
2907
|
unban: (deviceId) => this.banService.unban(deviceId),
|
|
2058
2908
|
isBanned: (deviceId) => this.banService.isBanned(deviceId)
|
|
@@ -2067,7 +2917,8 @@ var CodixusServer = class {
|
|
|
2067
2917
|
db: this._db,
|
|
2068
2918
|
token: this.config.admin.token,
|
|
2069
2919
|
hasPush: !!this.config.push,
|
|
2070
|
-
pushSend: this.push?.send
|
|
2920
|
+
pushSend: this.push?.send,
|
|
2921
|
+
journeys: journeyService
|
|
2071
2922
|
})
|
|
2072
2923
|
};
|
|
2073
2924
|
}
|
|
@@ -2127,12 +2978,19 @@ function validate(schema) {
|
|
|
2127
2978
|
var SEND_URL = "https://exp.host/--/api/v2/push/send";
|
|
2128
2979
|
var RECEIPTS_URL = "https://exp.host/--/api/v2/push/getReceipts";
|
|
2129
2980
|
var BATCH_SIZE = 100;
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2981
|
+
var MAX_NOTIFICATION_BYTES = 4096;
|
|
2982
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 15e3;
|
|
2983
|
+
var DEFAULT_MAX_RETRY_DELAY_MS = 5e3;
|
|
2984
|
+
function transientTickets(messages, errorMessage) {
|
|
2985
|
+
return messages.map((m) => {
|
|
2986
|
+
const ticket = {
|
|
2987
|
+
token: m.token,
|
|
2988
|
+
status: "error",
|
|
2989
|
+
errorCode: "TRANSIENT"
|
|
2990
|
+
};
|
|
2991
|
+
if (errorMessage) ticket.errorMessage = errorMessage;
|
|
2992
|
+
return ticket;
|
|
2993
|
+
});
|
|
2136
2994
|
}
|
|
2137
2995
|
function invalidPayloadTickets(messages) {
|
|
2138
2996
|
return messages.map((m) => ({
|
|
@@ -2174,6 +3032,17 @@ function mapExpoTickets(messages, data) {
|
|
|
2174
3032
|
function createExpoTransport(opts) {
|
|
2175
3033
|
const fetchImpl = opts?.fetchImpl ?? globalThis.fetch;
|
|
2176
3034
|
const accessToken = opts?.accessToken;
|
|
3035
|
+
const sleepImpl = opts?.sleepImpl ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)));
|
|
3036
|
+
const baseRetryDelayMs = Math.max(0, opts?.baseRetryDelayMs ?? 500);
|
|
3037
|
+
const maxAttempts = Math.max(1, Math.floor(opts?.maxAttempts ?? 3));
|
|
3038
|
+
const requestTimeoutMs = Math.max(
|
|
3039
|
+
1,
|
|
3040
|
+
Math.floor(opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS)
|
|
3041
|
+
);
|
|
3042
|
+
const maxRetryDelayMs = Math.max(
|
|
3043
|
+
0,
|
|
3044
|
+
Math.floor(opts?.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS)
|
|
3045
|
+
);
|
|
2177
3046
|
function headers() {
|
|
2178
3047
|
const h = {
|
|
2179
3048
|
"Content-Type": "application/json"
|
|
@@ -2183,13 +3052,36 @@ function createExpoTransport(opts) {
|
|
|
2183
3052
|
}
|
|
2184
3053
|
return h;
|
|
2185
3054
|
}
|
|
3055
|
+
async function postJson(url, body) {
|
|
3056
|
+
const controller = new AbortController();
|
|
3057
|
+
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
3058
|
+
try {
|
|
3059
|
+
const response = await fetchImpl(url, {
|
|
3060
|
+
method: "POST",
|
|
3061
|
+
headers: headers(),
|
|
3062
|
+
body: JSON.stringify(body),
|
|
3063
|
+
signal: controller.signal
|
|
3064
|
+
});
|
|
3065
|
+
const json = response.ok ? await response.json() : void 0;
|
|
3066
|
+
return { response, json };
|
|
3067
|
+
} finally {
|
|
3068
|
+
clearTimeout(timeout);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
function exponentialDelay(attempt) {
|
|
3072
|
+
return Math.min(
|
|
3073
|
+
baseRetryDelayMs * 2 ** (attempt - 1),
|
|
3074
|
+
maxRetryDelayMs
|
|
3075
|
+
);
|
|
3076
|
+
}
|
|
2186
3077
|
return {
|
|
2187
3078
|
name: "expo",
|
|
2188
3079
|
async send(messages) {
|
|
2189
3080
|
const allTickets = [];
|
|
2190
3081
|
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
|
|
2191
3082
|
const batch = messages.slice(i, i + BATCH_SIZE);
|
|
2192
|
-
const
|
|
3083
|
+
const batchTickets = new Array(batch.length);
|
|
3084
|
+
const entries = batch.map((m, batchIndex) => {
|
|
2193
3085
|
const item = {
|
|
2194
3086
|
to: m.token,
|
|
2195
3087
|
title: m.title,
|
|
@@ -2200,33 +3092,73 @@ function createExpoTransport(opts) {
|
|
|
2200
3092
|
if (m.badge !== void 0) item.badge = m.badge;
|
|
2201
3093
|
if (m.sound !== void 0) item.sound = m.sound;
|
|
2202
3094
|
if (m.channelId !== void 0) item.channelId = m.channelId;
|
|
2203
|
-
|
|
3095
|
+
if (m.imageUrl !== void 0) {
|
|
3096
|
+
item.richContent = { image: m.imageUrl };
|
|
3097
|
+
item.mutableContent = true;
|
|
3098
|
+
}
|
|
3099
|
+
return { batchIndex, message: m, item };
|
|
2204
3100
|
});
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
headers: headers(),
|
|
2209
|
-
body: JSON.stringify(body)
|
|
2210
|
-
});
|
|
2211
|
-
if (res.status === 429 || res.status >= 500) {
|
|
2212
|
-
allTickets.push(...transientTickets(batch));
|
|
2213
|
-
continue;
|
|
3101
|
+
const sendable = entries.filter((entry) => {
|
|
3102
|
+
if (Buffer.byteLength(JSON.stringify(entry.item), "utf8") <= MAX_NOTIFICATION_BYTES) {
|
|
3103
|
+
return true;
|
|
2214
3104
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
3105
|
+
batchTickets[entry.batchIndex] = {
|
|
3106
|
+
token: entry.message.token,
|
|
3107
|
+
status: "error",
|
|
3108
|
+
errorCode: "MessageTooBig",
|
|
3109
|
+
errorMessage: "Expo notification payload exceeds 4096 bytes"
|
|
3110
|
+
};
|
|
3111
|
+
return false;
|
|
3112
|
+
});
|
|
3113
|
+
const sendBatch = sendable.map((entry) => entry.message);
|
|
3114
|
+
const body = sendable.map((entry) => entry.item);
|
|
3115
|
+
if (sendBatch.length === 0) {
|
|
3116
|
+
allTickets.push(...batchTickets);
|
|
3117
|
+
continue;
|
|
3118
|
+
}
|
|
3119
|
+
const assignTickets = (tickets) => {
|
|
3120
|
+
for (let index = 0; index < tickets.length; index += 1) {
|
|
3121
|
+
const entry = sendable[index];
|
|
3122
|
+
if (entry) batchTickets[entry.batchIndex] = tickets[index];
|
|
2218
3123
|
}
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
3124
|
+
};
|
|
3125
|
+
let completed = false;
|
|
3126
|
+
let transientErrorMessage;
|
|
3127
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
3128
|
+
try {
|
|
3129
|
+
const { response: res, json } = await postJson(SEND_URL, body);
|
|
3130
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429) {
|
|
3131
|
+
assignTickets(invalidPayloadTickets(sendBatch));
|
|
3132
|
+
completed = true;
|
|
3133
|
+
break;
|
|
3134
|
+
}
|
|
3135
|
+
if (res.status === 429 || res.status >= 500 || !res.ok) {
|
|
3136
|
+
transientErrorMessage = `Expo send HTTP ${res.status}`;
|
|
3137
|
+
if (attempt === maxAttempts) break;
|
|
3138
|
+
await sleepImpl(
|
|
3139
|
+
retryDelay(res, attempt, baseRetryDelayMs, maxRetryDelayMs)
|
|
3140
|
+
);
|
|
3141
|
+
continue;
|
|
3142
|
+
}
|
|
3143
|
+
const payload = json;
|
|
3144
|
+
if (!Array.isArray(payload.data)) {
|
|
3145
|
+
if (attempt === maxAttempts) break;
|
|
3146
|
+
await sleepImpl(exponentialDelay(attempt));
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
assignTickets(mapExpoTickets(sendBatch, payload.data));
|
|
3150
|
+
completed = true;
|
|
3151
|
+
break;
|
|
3152
|
+
} catch (error) {
|
|
3153
|
+
transientErrorMessage = error instanceof Error ? error.message : String(error);
|
|
3154
|
+
if (attempt === maxAttempts) break;
|
|
3155
|
+
await sleepImpl(exponentialDelay(attempt));
|
|
2223
3156
|
}
|
|
2224
|
-
allTickets.push(
|
|
2225
|
-
...mapExpoTickets(batch, json.data)
|
|
2226
|
-
);
|
|
2227
|
-
} catch {
|
|
2228
|
-
allTickets.push(...transientTickets(batch));
|
|
2229
3157
|
}
|
|
3158
|
+
if (!completed) {
|
|
3159
|
+
assignTickets(transientTickets(sendBatch, transientErrorMessage));
|
|
3160
|
+
}
|
|
3161
|
+
allTickets.push(...batchTickets);
|
|
2230
3162
|
}
|
|
2231
3163
|
return allTickets;
|
|
2232
3164
|
},
|
|
@@ -2234,19 +3166,17 @@ function createExpoTransport(opts) {
|
|
|
2234
3166
|
const allReceipts = [];
|
|
2235
3167
|
for (let i = 0; i < ticketIds.length; i += BATCH_SIZE) {
|
|
2236
3168
|
const batch = ticketIds.slice(i, i + BATCH_SIZE);
|
|
2237
|
-
const res = await
|
|
2238
|
-
|
|
2239
|
-
headers: headers(),
|
|
2240
|
-
body: JSON.stringify({ ids: batch })
|
|
3169
|
+
const { response: res, json } = await postJson(RECEIPTS_URL, {
|
|
3170
|
+
ids: batch
|
|
2241
3171
|
});
|
|
2242
3172
|
if (!res.ok) {
|
|
2243
3173
|
throw new Error(`Expo getReceipts HTTP ${res.status}`);
|
|
2244
3174
|
}
|
|
2245
|
-
const
|
|
2246
|
-
if (typeof
|
|
3175
|
+
const payload = json;
|
|
3176
|
+
if (typeof payload.data !== "object" || payload.data === null || Array.isArray(payload.data)) {
|
|
2247
3177
|
throw new Error("Expo getReceipts invalid response");
|
|
2248
3178
|
}
|
|
2249
|
-
const data =
|
|
3179
|
+
const data = payload.data;
|
|
2250
3180
|
for (const id of batch) {
|
|
2251
3181
|
const receipt = data[id];
|
|
2252
3182
|
if (!receipt) continue;
|
|
@@ -2256,7 +3186,8 @@ function createExpoTransport(opts) {
|
|
|
2256
3186
|
allReceipts.push({
|
|
2257
3187
|
ticketId: id,
|
|
2258
3188
|
status: "error",
|
|
2259
|
-
errorCode: receipt.status === "error" ? receipt.details?.error ?? "UNKNOWN" : "UNKNOWN"
|
|
3189
|
+
errorCode: receipt.status === "error" ? receipt.details?.error ?? "UNKNOWN" : "UNKNOWN",
|
|
3190
|
+
...receipt.status === "error" && receipt.message ? { errorMessage: receipt.message } : {}
|
|
2260
3191
|
});
|
|
2261
3192
|
}
|
|
2262
3193
|
}
|
|
@@ -2265,6 +3196,16 @@ function createExpoTransport(opts) {
|
|
|
2265
3196
|
}
|
|
2266
3197
|
};
|
|
2267
3198
|
}
|
|
3199
|
+
function retryDelay(response, attempt, baseRetryDelayMs, maxRetryDelayMs) {
|
|
3200
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
3201
|
+
if (retryAfter !== null) {
|
|
3202
|
+
const seconds = Number(retryAfter);
|
|
3203
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
3204
|
+
return Math.min(seconds * 1e3, maxRetryDelayMs);
|
|
3205
|
+
}
|
|
3206
|
+
}
|
|
3207
|
+
return Math.min(baseRetryDelayMs * 2 ** (attempt - 1), maxRetryDelayMs);
|
|
3208
|
+
}
|
|
2268
3209
|
|
|
2269
3210
|
// src/index.ts
|
|
2270
3211
|
import {
|
|
@@ -2275,12 +3216,14 @@ export {
|
|
|
2275
3216
|
CodixusError3 as CodixusError,
|
|
2276
3217
|
CodixusServer,
|
|
2277
3218
|
ErrorCodes,
|
|
3219
|
+
JourneyError,
|
|
2278
3220
|
Model,
|
|
2279
3221
|
Query,
|
|
2280
3222
|
RestErrorCode,
|
|
2281
3223
|
SubCollection,
|
|
2282
3224
|
createExpoTransport,
|
|
2283
3225
|
model,
|
|
2284
|
-
validate
|
|
3226
|
+
validate,
|
|
3227
|
+
validateJourneyDefinition
|
|
2285
3228
|
};
|
|
2286
3229
|
//# sourceMappingURL=index.js.map
|