@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 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 import_node_crypto7 = require("crypto");
1442
+ var import_node_crypto8 = require("crypto");
1431
1443
  var import_zod = require("zod");
1432
1444
 
1433
- // src/admin/guard.ts
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, import_node_crypto6.createHash)("sha256").update(token, "utf8").digest();
1902
+ return (0, import_node_crypto7.createHash)("sha256").update(token, "utf8").digest();
1438
1903
  }
1439
1904
  function tokensMatch(provided, expected) {
1440
- return (0, import_node_crypto6.timingSafeEqual)(digestToken(provided), digestToken(expected));
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, import_node_crypto7.randomUUID)();
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
- await deps.pushSend(input);
1833
- res.status(200).json({ success: true });
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 import_node_crypto8 = require("crypto");
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 existing = await this.deliveriesCol.findOne({ idempotencyKey });
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
- pending.push({
1889
- token: device.token,
2468
+ const now = /* @__PURE__ */ new Date();
2469
+ const doc = {
2470
+ _id: deliveryId,
1890
2471
  deviceId,
1891
2472
  provider: device.provider,
1892
- deliveryId,
2473
+ token: device.token,
2474
+ title: input.title,
2475
+ body: input.body,
2476
+ data,
2477
+ status: "sending",
1893
2478
  idempotencyKey,
1894
- message: {
1895
- token: device.token,
1896
- title: input.title,
1897
- body: input.body,
1898
- data
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
- const tickets = await this.transport.send(messages);
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 doc = {
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
- idempotencyKey: item.idempotencyKey,
1925
- createdAt: /* @__PURE__ */ new Date()
2533
+ updatedAt: /* @__PURE__ */ new Date()
1926
2534
  };
1927
2535
  if (ticket && ticket.status === "ok") {
1928
- doc.status = "submitted";
1929
- if (ticket.ticketId) doc.ticketId = ticket.ticketId;
2536
+ update.status = "submitted";
2537
+ if (ticket.ticketId) update.ticketId = ticket.ticketId;
1930
2538
  } else {
1931
- doc.errorCode = ticket?.errorCode ?? "TRANSIENT";
1932
- if (ticket?.ticketId) doc.ticketId = ticket.ticketId;
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
- await this.send({
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, import_node_crypto8.createHash)("sha256").update(input, "utf8").digest("hex");
2665
+ return (0, import_node_crypto9.createHash)("sha256").update(input, "utf8").digest("hex");
2024
2666
  }
2025
- function isDuplicateKeyError2(err) {
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 pushService = new PushService(this._db, this.config.push.transport);
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
- send: (input) => pushService.send(input),
2085
- sendTo: (input) => pushService.sendTo(input),
2086
- pollReceipts: () => pushService.pollReceipts(),
2087
- find: (filter) => pushService.find(filter)
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(this.jwt, this.banService, this._db, config),
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
- function transientTickets(messages) {
2176
- return messages.map((m) => ({
2177
- token: m.token,
2178
- status: "error",
2179
- errorCode: "TRANSIENT"
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 body = batch.map((m) => {
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
- return item;
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
- try {
2251
- const res = await fetchImpl(SEND_URL, {
2252
- method: "POST",
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
- if (res.status >= 400 && res.status < 500) {
2261
- allTickets.push(...invalidPayloadTickets(batch));
2262
- continue;
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
- const json = await res.json();
2265
- if (!Array.isArray(json.data)) {
2266
- allTickets.push(...transientTickets(batch));
2267
- continue;
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 fetchImpl(RECEIPTS_URL, {
2283
- method: "POST",
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 json = await res.json();
2291
- if (typeof json.data !== "object" || json.data === null || Array.isArray(json.data)) {
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 = json.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