@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.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 randomUUID2 } from "crypto";
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 = randomUUID2();
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
- await deps.pushSend(input);
1788
- res.status(200).json({ success: true });
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 randomUUID3 } from "crypto";
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 existing = await this.deliveriesCol.findOne({ idempotencyKey });
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
- pending.push({
1844
- token: device.token,
2421
+ const now = /* @__PURE__ */ new Date();
2422
+ const doc = {
2423
+ _id: deliveryId,
1845
2424
  deviceId,
1846
2425
  provider: device.provider,
1847
- deliveryId,
2426
+ token: device.token,
2427
+ title: input.title,
2428
+ body: input.body,
2429
+ data,
2430
+ status: "sending",
1848
2431
  idempotencyKey,
1849
- message: {
1850
- token: device.token,
1851
- title: input.title,
1852
- body: input.body,
1853
- data
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
- const tickets = await this.transport.send(messages);
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 doc = {
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
- idempotencyKey: item.idempotencyKey,
1880
- createdAt: /* @__PURE__ */ new Date()
2486
+ updatedAt: /* @__PURE__ */ new Date()
1881
2487
  };
1882
2488
  if (ticket && ticket.status === "ok") {
1883
- doc.status = "submitted";
1884
- if (ticket.ticketId) doc.ticketId = ticket.ticketId;
2489
+ update.status = "submitted";
2490
+ if (ticket.ticketId) update.ticketId = ticket.ticketId;
1885
2491
  } else {
1886
- doc.errorCode = ticket?.errorCode ?? "TRANSIENT";
1887
- if (ticket?.ticketId) doc.ticketId = ticket.ticketId;
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
- await this.send({
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 isDuplicateKeyError2(err) {
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 pushService = new PushService(this._db, this.config.push.transport);
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
- send: (input) => pushService.send(input),
2040
- sendTo: (input) => pushService.sendTo(input),
2041
- pollReceipts: () => pushService.pollReceipts(),
2042
- find: (filter) => pushService.find(filter)
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(this.jwt, this.banService, this._db, config),
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
- function transientTickets(messages) {
2131
- return messages.map((m) => ({
2132
- token: m.token,
2133
- status: "error",
2134
- errorCode: "TRANSIENT"
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 body = batch.map((m) => {
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
- return item;
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
- try {
2206
- const res = await fetchImpl(SEND_URL, {
2207
- method: "POST",
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
- if (res.status >= 400 && res.status < 500) {
2216
- allTickets.push(...invalidPayloadTickets(batch));
2217
- continue;
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
- const json = await res.json();
2220
- if (!Array.isArray(json.data)) {
2221
- allTickets.push(...transientTickets(batch));
2222
- continue;
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 fetchImpl(RECEIPTS_URL, {
2238
- method: "POST",
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 json = await res.json();
2246
- if (typeof json.data !== "object" || json.data === null || Array.isArray(json.data)) {
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 = json.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