@voyant-travel/distribution 0.109.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.
Files changed (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +42 -0
  3. package/dist/booking-extension.d.ts +168 -0
  4. package/dist/booking-extension.d.ts.map +1 -0
  5. package/dist/booking-extension.js +102 -0
  6. package/dist/channel-push/admin-routes.d.ts +31 -0
  7. package/dist/channel-push/admin-routes.d.ts.map +1 -0
  8. package/dist/channel-push/admin-routes.js +165 -0
  9. package/dist/channel-push/availability-push.d.ts +76 -0
  10. package/dist/channel-push/availability-push.d.ts.map +1 -0
  11. package/dist/channel-push/availability-push.js +236 -0
  12. package/dist/channel-push/booking-push-helpers.d.ts +36 -0
  13. package/dist/channel-push/booking-push-helpers.d.ts.map +1 -0
  14. package/dist/channel-push/booking-push-helpers.js +169 -0
  15. package/dist/channel-push/booking-push.d.ts +108 -0
  16. package/dist/channel-push/booking-push.d.ts.map +1 -0
  17. package/dist/channel-push/booking-push.js +335 -0
  18. package/dist/channel-push/boundary-sql.d.ts +23 -0
  19. package/dist/channel-push/boundary-sql.d.ts.map +1 -0
  20. package/dist/channel-push/boundary-sql.js +75 -0
  21. package/dist/channel-push/content-push.d.ts +60 -0
  22. package/dist/channel-push/content-push.d.ts.map +1 -0
  23. package/dist/channel-push/content-push.js +252 -0
  24. package/dist/channel-push/index.d.ts +15 -0
  25. package/dist/channel-push/index.d.ts.map +1 -0
  26. package/dist/channel-push/index.js +18 -0
  27. package/dist/channel-push/plugin.d.ts +18 -0
  28. package/dist/channel-push/plugin.d.ts.map +1 -0
  29. package/dist/channel-push/plugin.js +21 -0
  30. package/dist/channel-push/reconciler.d.ts +85 -0
  31. package/dist/channel-push/reconciler.d.ts.map +1 -0
  32. package/dist/channel-push/reconciler.js +179 -0
  33. package/dist/channel-push/subscriber.d.ts +40 -0
  34. package/dist/channel-push/subscriber.d.ts.map +1 -0
  35. package/dist/channel-push/subscriber.js +199 -0
  36. package/dist/channel-push/types.d.ts +43 -0
  37. package/dist/channel-push/types.d.ts.map +1 -0
  38. package/dist/channel-push/types.js +32 -0
  39. package/dist/channel-push/workflows.d.ts +56 -0
  40. package/dist/channel-push/workflows.d.ts.map +1 -0
  41. package/dist/channel-push/workflows.js +100 -0
  42. package/dist/external-refs/index.d.ts +11 -0
  43. package/dist/external-refs/index.d.ts.map +1 -0
  44. package/dist/external-refs/index.js +12 -0
  45. package/dist/external-refs/routes.d.ts +253 -0
  46. package/dist/external-refs/routes.d.ts.map +1 -0
  47. package/dist/external-refs/routes.js +52 -0
  48. package/dist/external-refs/schema.d.ts +251 -0
  49. package/dist/external-refs/schema.d.ts.map +1 -0
  50. package/dist/external-refs/schema.js +32 -0
  51. package/dist/external-refs/service.d.ts +82 -0
  52. package/dist/external-refs/service.d.ts.map +1 -0
  53. package/dist/external-refs/service.js +112 -0
  54. package/dist/external-refs/validation.d.ts +91 -0
  55. package/dist/external-refs/validation.d.ts.map +1 -0
  56. package/dist/external-refs/validation.js +40 -0
  57. package/dist/index.d.ts +21 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +20 -0
  60. package/dist/interface-types.d.ts +128 -0
  61. package/dist/interface-types.d.ts.map +1 -0
  62. package/dist/interface-types.js +1 -0
  63. package/dist/interface.d.ts +10 -0
  64. package/dist/interface.d.ts.map +1 -0
  65. package/dist/interface.js +286 -0
  66. package/dist/rate-limit.d.ts +69 -0
  67. package/dist/rate-limit.d.ts.map +1 -0
  68. package/dist/rate-limit.js +135 -0
  69. package/dist/routes/batch.d.ts +200 -0
  70. package/dist/routes/batch.d.ts.map +1 -0
  71. package/dist/routes/batch.js +52 -0
  72. package/dist/routes/env.d.ts +8 -0
  73. package/dist/routes/env.d.ts.map +1 -0
  74. package/dist/routes/env.js +1 -0
  75. package/dist/routes/inventory.d.ts +604 -0
  76. package/dist/routes/inventory.d.ts.map +1 -0
  77. package/dist/routes/inventory.js +138 -0
  78. package/dist/routes/settlements.d.ts +1649 -0
  79. package/dist/routes/settlements.d.ts.map +1 -0
  80. package/dist/routes/settlements.js +265 -0
  81. package/dist/routes.d.ts +3909 -0
  82. package/dist/routes.d.ts.map +1 -0
  83. package/dist/routes.js +323 -0
  84. package/dist/schema-automation.d.ts +680 -0
  85. package/dist/schema-automation.d.ts.map +1 -0
  86. package/dist/schema-automation.js +76 -0
  87. package/dist/schema-core.d.ts +1674 -0
  88. package/dist/schema-core.d.ts.map +1 -0
  89. package/dist/schema-core.js +227 -0
  90. package/dist/schema-finance.d.ts +1372 -0
  91. package/dist/schema-finance.d.ts.map +1 -0
  92. package/dist/schema-finance.js +153 -0
  93. package/dist/schema-inventory.d.ts +855 -0
  94. package/dist/schema-inventory.d.ts.map +1 -0
  95. package/dist/schema-inventory.js +102 -0
  96. package/dist/schema-push-intents.d.ts +387 -0
  97. package/dist/schema-push-intents.d.ts.map +1 -0
  98. package/dist/schema-push-intents.js +77 -0
  99. package/dist/schema-relations.d.ts +95 -0
  100. package/dist/schema-relations.d.ts.map +1 -0
  101. package/dist/schema-relations.js +196 -0
  102. package/dist/schema-shared.d.ts +24 -0
  103. package/dist/schema-shared.d.ts.map +1 -0
  104. package/dist/schema-shared.js +123 -0
  105. package/dist/schema.d.ts +9 -0
  106. package/dist/schema.d.ts.map +1 -0
  107. package/dist/schema.js +8 -0
  108. package/dist/service/channels.d.ts +167 -0
  109. package/dist/service/channels.d.ts.map +1 -0
  110. package/dist/service/channels.js +305 -0
  111. package/dist/service/commercial.d.ts +385 -0
  112. package/dist/service/commercial.d.ts.map +1 -0
  113. package/dist/service/commercial.js +248 -0
  114. package/dist/service/helpers.d.ts +10 -0
  115. package/dist/service/helpers.d.ts.map +1 -0
  116. package/dist/service/helpers.js +7 -0
  117. package/dist/service/inventory.d.ts +193 -0
  118. package/dist/service/inventory.d.ts.map +1 -0
  119. package/dist/service/inventory.js +154 -0
  120. package/dist/service/settlement-policies.d.ts +325 -0
  121. package/dist/service/settlement-policies.d.ts.map +1 -0
  122. package/dist/service/settlement-policies.js +272 -0
  123. package/dist/service/settlements.d.ts +357 -0
  124. package/dist/service/settlements.d.ts.map +1 -0
  125. package/dist/service/settlements.js +319 -0
  126. package/dist/service/types.d.ts +60 -0
  127. package/dist/service/types.d.ts.map +1 -0
  128. package/dist/service/types.js +1 -0
  129. package/dist/service.d.ts +1418 -0
  130. package/dist/service.d.ts.map +1 -0
  131. package/dist/service.js +17 -0
  132. package/dist/suppliers/index.d.ts +15 -0
  133. package/dist/suppliers/index.d.ts.map +1 -0
  134. package/dist/suppliers/index.js +23 -0
  135. package/dist/suppliers/routes.d.ts +1202 -0
  136. package/dist/suppliers/routes.d.ts.map +1 -0
  137. package/dist/suppliers/routes.js +290 -0
  138. package/dist/suppliers/schema.d.ts +1272 -0
  139. package/dist/suppliers/schema.d.ts.map +1 -0
  140. package/dist/suppliers/schema.js +219 -0
  141. package/dist/suppliers/service-aggregates.d.ts +23 -0
  142. package/dist/suppliers/service-aggregates.d.ts.map +1 -0
  143. package/dist/suppliers/service-aggregates.js +51 -0
  144. package/dist/suppliers/service-core.d.ts +89 -0
  145. package/dist/suppliers/service-core.d.ts.map +1 -0
  146. package/dist/suppliers/service-core.js +164 -0
  147. package/dist/suppliers/service-identity.d.ts +162 -0
  148. package/dist/suppliers/service-identity.d.ts.map +1 -0
  149. package/dist/suppliers/service-identity.js +101 -0
  150. package/dist/suppliers/service-operations.d.ts +1500 -0
  151. package/dist/suppliers/service-operations.d.ts.map +1 -0
  152. package/dist/suppliers/service-operations.js +157 -0
  153. package/dist/suppliers/service-shared.d.ts +45 -0
  154. package/dist/suppliers/service-shared.d.ts.map +1 -0
  155. package/dist/suppliers/service-shared.js +294 -0
  156. package/dist/suppliers/service.d.ts +41 -0
  157. package/dist/suppliers/service.d.ts.map +1 -0
  158. package/dist/suppliers/service.js +40 -0
  159. package/dist/suppliers/validation.d.ts +2 -0
  160. package/dist/suppliers/validation.d.ts.map +1 -0
  161. package/dist/suppliers/validation.js +1 -0
  162. package/dist/validation.d.ts +1371 -0
  163. package/dist/validation.d.ts.map +1 -0
  164. package/dist/validation.js +445 -0
  165. package/dist/webhook-deliveries.d.ts +86 -0
  166. package/dist/webhook-deliveries.d.ts.map +1 -0
  167. package/dist/webhook-deliveries.js +296 -0
  168. package/package.json +71 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Admin API for the channel-push operator dashboard.
3
+ *
4
+ * Ships the data layer for "channel sync" views per §9 + §10 (Phase D)
5
+ * + §14.5 — operators want to see (a) which booking links are stuck,
6
+ * (b) the delivery log per booking, (c) per-channel throttling, and
7
+ * (d) a one-click retry button. The React surface lives in templates;
8
+ * this file is the backing API.
9
+ *
10
+ * Routes are mounted under `/v1/admin/distribution/*`.
11
+ *
12
+ * GET /links — counts + filterable list of channel_booking_links
13
+ * POST /retry/:bookingId — drain pending links for one booking
14
+ * GET /deliveries — webhook_deliveries scoped by booking/channel
15
+ * GET /throttling — per-channel rate-limited count in last hour
16
+ * POST /reconcile/:flow — manually trigger a reconciler scanner
17
+ *
18
+ * Per docs/architecture/channel-push-architecture.md §9 + §14.5.
19
+ */
20
+ import { infraWebhookDeliveriesTable, } from "@voyant-travel/db/schema/infra";
21
+ import { and, desc, eq, gte, sql } from "drizzle-orm";
22
+ import { Hono } from "hono";
23
+ import { channelBookingLinks, channels } from "../schema.js";
24
+ import { reconcileAvailability, reconcileBookingLinks, reconcileContent } from "./reconciler.js";
25
+ import { triggerBookingPushForBooking } from "./subscriber.js";
26
+ export function createChannelPushAdminRoutes() {
27
+ const app = new Hono();
28
+ // ── GET /links ───────────────────────────────────────────────────
29
+ // Status counts + filterable list of channel_booking_links. The
30
+ // dashboard's "channel sync" view consumes this for both the summary
31
+ // tiles ("X pending, Y failed, Z compensated") and the row table.
32
+ app.get("/links", async (c) => {
33
+ const db = c.get("db");
34
+ const status = c.req.query("status");
35
+ const channelId = c.req.query("channelId");
36
+ const bookingId = c.req.query("bookingId");
37
+ const limit = clampLimit(c.req.query("limit"));
38
+ const filters = [
39
+ status ? eq(channelBookingLinks.pushStatus, status) : sql `true`,
40
+ channelId ? eq(channelBookingLinks.channelId, channelId) : sql `true`,
41
+ bookingId ? eq(channelBookingLinks.bookingId, bookingId) : sql `true`,
42
+ ];
43
+ const rows = await db
44
+ .select({
45
+ link: channelBookingLinks,
46
+ channelName: channels.name,
47
+ channelKind: channels.kind,
48
+ })
49
+ .from(channelBookingLinks)
50
+ .innerJoin(channels, eq(channelBookingLinks.channelId, channels.id))
51
+ .where(and(...filters))
52
+ .orderBy(desc(channelBookingLinks.lastPushAt), desc(channelBookingLinks.createdAt))
53
+ .limit(limit);
54
+ const counts = await db
55
+ .select({
56
+ status: channelBookingLinks.pushStatus,
57
+ count: sql `count(*)::int`,
58
+ })
59
+ .from(channelBookingLinks)
60
+ .where(and(...filters))
61
+ .groupBy(channelBookingLinks.pushStatus);
62
+ return c.json({
63
+ data: rows,
64
+ counts: Object.fromEntries(counts.map((c) => [c.status, c.count])),
65
+ });
66
+ });
67
+ // ── POST /retry/:bookingId ───────────────────────────────────────
68
+ // Operator-driven retry. Re-resolves push targets, upserts pending
69
+ // intent rows, and runs processBookingPush inline. Idempotent on
70
+ // the booking_links unique constraint, so accidental double-clicks
71
+ // are safe.
72
+ app.post("/retry/:bookingId", async (c) => {
73
+ const bookingId = c.req.param("bookingId");
74
+ try {
75
+ await triggerBookingPushForBooking(bookingId);
76
+ return c.json({ data: { ok: true, bookingId } });
77
+ }
78
+ catch (err) {
79
+ const message = err instanceof Error ? err.message : String(err);
80
+ return c.json({ error: message }, 500);
81
+ }
82
+ });
83
+ // ── GET /deliveries ──────────────────────────────────────────────
84
+ // Drilldown view: every webhook_deliveries row scoped to a booking,
85
+ // channel, or both. Used by the "show me what we sent" link in the
86
+ // dashboard's failure rows.
87
+ app.get("/deliveries", async (c) => {
88
+ const db = c.get("db");
89
+ const bookingId = c.req.query("bookingId");
90
+ const channelId = c.req.query("channelId");
91
+ const limit = clampLimit(c.req.query("limit"));
92
+ const filters = [
93
+ eq(infraWebhookDeliveriesTable.sourceModule, "distribution"),
94
+ bookingId
95
+ ? and(eq(infraWebhookDeliveriesTable.sourceEntityModule, "bookings"), eq(infraWebhookDeliveriesTable.sourceEntityId, bookingId))
96
+ : sql `true`,
97
+ channelId ? eq(infraWebhookDeliveriesTable.targetRef, channelId) : sql `true`,
98
+ ];
99
+ const rows = (await db
100
+ .select()
101
+ .from(infraWebhookDeliveriesTable)
102
+ .where(and(...filters))
103
+ .orderBy(desc(infraWebhookDeliveriesTable.createdAt))
104
+ .limit(limit));
105
+ return c.json({ data: rows });
106
+ });
107
+ // ── GET /throttling ──────────────────────────────────────────────
108
+ // Per-channel rate-limited count in the last hour. The dashboard
109
+ // shows a yellow "throttled" badge when any channel has > 0
110
+ // rate_limited rows in the window. Per §14.5.
111
+ app.get("/throttling", async (c) => {
112
+ const db = c.get("db");
113
+ const sinceMs = Number.parseInt(c.req.query("sinceMs") ?? String(60 * 60 * 1000), 10);
114
+ const since = new Date(Date.now() - (Number.isFinite(sinceMs) ? sinceMs : 60 * 60 * 1000));
115
+ const rows = await db
116
+ .select({
117
+ channelId: infraWebhookDeliveriesTable.targetRef,
118
+ count: sql `count(*)::int`,
119
+ })
120
+ .from(infraWebhookDeliveriesTable)
121
+ .where(and(eq(infraWebhookDeliveriesTable.sourceModule, "distribution"), eq(infraWebhookDeliveriesTable.errorClass, "rate_limited"), gte(infraWebhookDeliveriesTable.createdAt, since)))
122
+ .groupBy(infraWebhookDeliveriesTable.targetRef);
123
+ return c.json({
124
+ data: rows.filter((r) => r.channelId != null),
125
+ sinceMs,
126
+ });
127
+ });
128
+ // ── POST /reconcile/:flow ────────────────────────────────────────
129
+ // Manual reconciler trigger for ops — useful when a channel comes
130
+ // back up after a long outage and you don't want to wait for the
131
+ // next scheduled run. `flow` is one of "bookings", "availability",
132
+ // "content".
133
+ app.post("/reconcile/:flow", async (c) => {
134
+ const flow = c.req.param("flow");
135
+ try {
136
+ switch (flow) {
137
+ case "bookings": {
138
+ const result = await reconcileBookingLinks({});
139
+ return c.json({ data: result });
140
+ }
141
+ case "availability": {
142
+ const result = await reconcileAvailability({});
143
+ return c.json({ data: result });
144
+ }
145
+ case "content": {
146
+ const result = await reconcileContent({});
147
+ return c.json({ data: result });
148
+ }
149
+ default:
150
+ return c.json({ error: `unknown flow "${flow}"` }, 400);
151
+ }
152
+ }
153
+ catch (err) {
154
+ const message = err instanceof Error ? err.message : String(err);
155
+ return c.json({ error: message }, 500);
156
+ }
157
+ });
158
+ return app;
159
+ }
160
+ function clampLimit(raw) {
161
+ const parsed = raw ? Number.parseInt(raw, 10) : 50;
162
+ if (!Number.isFinite(parsed) || parsed <= 0)
163
+ return 50;
164
+ return Math.min(parsed, 500);
165
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Availability-push pipeline.
3
+ *
4
+ * Triggered by `availability.slot.changed`. The subscriber upserts a
5
+ * `channel_availability_push_intents` row per (channel, slot) — concurrent
6
+ * supersession events collapse to one row via the unique constraint.
7
+ * The processor (`processAvailabilityPushIntents`) drains intents per
8
+ * channel, reads the *current* slot state, and dispatches via
9
+ * `adapter.pushAvailability()`. Stale-event protection comes from
10
+ * reading current state at processing time, not the event payload.
11
+ *
12
+ * Per docs/architecture/channel-push-architecture.md §5 + §12.2.
13
+ */
14
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
15
+ import { channelProductMappings, channels } from "../schema.js";
16
+ import { type ChannelPushDeps } from "./types.js";
17
+ /** Stable string identifier for the availability-push workflow. */
18
+ export declare const CHANNEL_AVAILABILITY_PUSH_WORKFLOW_ID: "channel.availability.push";
19
+ export interface ResolveAllotmentTargetsForSlotInput {
20
+ slotId: string;
21
+ productId: string;
22
+ optionId: string | null;
23
+ }
24
+ /**
25
+ * Resolve the channels that hold an allotment for this slot/product/option.
26
+ * Per §7.4 — availability push uses `channel_inventory_allotments` (NOT
27
+ * `channel_product_mappings`), so channels mapped to the product but
28
+ * with no per-slot allotment don't receive pushes.
29
+ *
30
+ * v1 returns one row per channel that has an active allotment whose
31
+ * scope matches the slot (by product, optionally by option). Per-slot
32
+ * targeting via `channel_inventory_allotment_targets` is consulted in a
33
+ * future iteration; v1 dispatches at allotment-level so any allotment
34
+ * row covering the product/option triggers a push.
35
+ */
36
+ export declare function resolveAllotmentTargetsForSlot(db: AnyDrizzleDb, input: ResolveAllotmentTargetsForSlotInput): Promise<Array<{
37
+ channelId: string;
38
+ sourceConnectionId: string;
39
+ mapping: typeof channelProductMappings.$inferSelect;
40
+ channel: typeof channels.$inferSelect;
41
+ }>>;
42
+ /**
43
+ * Insert/update an intent row per (channel, slot). The unique
44
+ * constraint on `(channel_id, slot_id)` collapses concurrent
45
+ * supersession events to one row; the worker reads the *current* slot
46
+ * state when it processes, so stale event payloads never propagate.
47
+ */
48
+ export declare function upsertAvailabilityIntent(db: AnyDrizzleDb, input: {
49
+ channelId: string;
50
+ sourceConnectionId: string;
51
+ slotId: string;
52
+ productId: string;
53
+ optionId: string | null;
54
+ startsAt: Date;
55
+ }): Promise<void>;
56
+ export interface ProcessAvailabilityPushInput {
57
+ /** When set, drain intents only for this channel. Otherwise drain all. */
58
+ channelId?: string;
59
+ /** Max intents to process per call (across all channels). Default 100. */
60
+ limit?: number;
61
+ }
62
+ export interface ProcessAvailabilityPushResult {
63
+ attempted: number;
64
+ succeeded: number;
65
+ failed: number;
66
+ skipped: number;
67
+ }
68
+ /**
69
+ * Drain pending availability intents. Reads CURRENT slot state for each
70
+ * intent (so superseded values never propagate). On success, deletes the
71
+ * intent row. On failure, increments `attempts` and stamps `last_error`.
72
+ *
73
+ * Per §5.3 + §12.2.
74
+ */
75
+ export declare function processAvailabilityPushIntents(input?: ProcessAvailabilityPushInput, deps?: ChannelPushDeps): Promise<ProcessAvailabilityPushResult>;
76
+ //# sourceMappingURL=availability-push.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"availability-push.d.ts","sourceRoot":"","sources":["../../src/channel-push/availability-push.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAOH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAIrD,OAAO,EAGL,sBAAsB,EACtB,QAAQ,EACT,MAAM,cAAc,CAAA;AAIrB,OAAO,EAAE,KAAK,eAAe,EAA4C,MAAM,YAAY,CAAA;AAE3F,mEAAmE;AACnE,eAAO,MAAM,qCAAqC,EAAG,2BAAoC,CAAA;AAEzF,MAAM,WAAW,mCAAmC;IAClD,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,8BAA8B,CAClD,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE,mCAAmC,GACzC,OAAO,CACR,KAAK,CAAC;IACJ,SAAS,EAAE,MAAM,CAAA;IACjB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,OAAO,EAAE,OAAO,sBAAsB,CAAC,YAAY,CAAA;IACnD,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,CAAA;CACtC,CAAC,CACH,CAoDA;AAED;;;;;GAKG;AACH,wBAAsB,wBAAwB,CAC5C,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE;IACL,SAAS,EAAE,MAAM,CAAA;IACjB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,IAAI,CAAA;CACf,GACA,OAAO,CAAC,IAAI,CAAC,CAsBf;AAED,MAAM,WAAW,4BAA4B;IAC3C,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,0EAA0E;IAC1E,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,6BAA6B;IAC5C,SAAS,EAAE,MAAM,CAAA;IACjB,SAAS,EAAE,MAAM,CAAA;IACjB,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;;;;;GAMG;AACH,wBAAsB,8BAA8B,CAClD,KAAK,GAAE,4BAAiC,EACxC,IAAI,CAAC,EAAE,eAAe,GACrB,OAAO,CAAC,6BAA6B,CAAC,CA2JxC"}
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Availability-push pipeline.
3
+ *
4
+ * Triggered by `availability.slot.changed`. The subscriber upserts a
5
+ * `channel_availability_push_intents` row per (channel, slot) — concurrent
6
+ * supersession events collapse to one row via the unique constraint.
7
+ * The processor (`processAvailabilityPushIntents`) drains intents per
8
+ * channel, reads the *current* slot state, and dispatches via
9
+ * `adapter.pushAvailability()`. Stale-event protection comes from
10
+ * reading current state at processing time, not the event payload.
11
+ *
12
+ * Per docs/architecture/channel-push-architecture.md §5 + §12.2.
13
+ */
14
+ import { AdapterRateLimitedError, } from "@voyant-travel/catalog";
15
+ import { newId } from "@voyant-travel/db/lib/typeid";
16
+ import { and, asc, eq, inArray, sql } from "drizzle-orm";
17
+ import { acquireToken, channelScopeKey, drainBucket } from "../rate-limit.js";
18
+ import { channelAvailabilityPushIntents, channelInventoryAllotments, channelProductMappings, channels, } from "../schema.js";
19
+ import { prepareOutboundEnvelope } from "../webhook-deliveries.js";
20
+ import { loadAvailabilityPushSlot } from "./boundary-sql.js";
21
+ import { defaultLogger, getChannelPushDepsOrThrow } from "./types.js";
22
+ /** Stable string identifier for the availability-push workflow. */
23
+ export const CHANNEL_AVAILABILITY_PUSH_WORKFLOW_ID = "channel.availability.push";
24
+ /**
25
+ * Resolve the channels that hold an allotment for this slot/product/option.
26
+ * Per §7.4 — availability push uses `channel_inventory_allotments` (NOT
27
+ * `channel_product_mappings`), so channels mapped to the product but
28
+ * with no per-slot allotment don't receive pushes.
29
+ *
30
+ * v1 returns one row per channel that has an active allotment whose
31
+ * scope matches the slot (by product, optionally by option). Per-slot
32
+ * targeting via `channel_inventory_allotment_targets` is consulted in a
33
+ * future iteration; v1 dispatches at allotment-level so any allotment
34
+ * row covering the product/option triggers a push.
35
+ */
36
+ export async function resolveAllotmentTargetsForSlot(db, input) {
37
+ // Resolve allotments for this product (optionally option-scoped).
38
+ const allotmentRows = (await db
39
+ .select({
40
+ channelId: channelInventoryAllotments.channelId,
41
+ })
42
+ .from(channelInventoryAllotments)
43
+ .innerJoin(channels, eq(channelInventoryAllotments.channelId, channels.id))
44
+ .where(and(eq(channelInventoryAllotments.productId, input.productId), eq(channelInventoryAllotments.active, true), eq(channels.status, "active"), input.optionId
45
+ ? // agent-quality: raw-sql reviewed -- owner: distribution; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
46
+ sql `(${channelInventoryAllotments.optionId} IS NULL OR ${channelInventoryAllotments.optionId} = ${input.optionId})`
47
+ : // agent-quality: raw-sql reviewed -- owner: distribution; dynamic SQL interpolation uses Drizzle parameter binding or vetted SQL identifiers.
48
+ sql `${channelInventoryAllotments.optionId} IS NULL`)));
49
+ if (allotmentRows.length === 0)
50
+ return [];
51
+ const channelIds = Array.from(new Set(allotmentRows.map((r) => r.channelId)));
52
+ const mappings = (await db
53
+ .select({
54
+ mapping: channelProductMappings,
55
+ channel: channels,
56
+ })
57
+ .from(channelProductMappings)
58
+ .innerJoin(channels, eq(channelProductMappings.channelId, channels.id))
59
+ .where(and(eq(channelProductMappings.productId, input.productId), eq(channelProductMappings.active, true), eq(channelProductMappings.pushAvailability, true), inArray(channelProductMappings.channelId, channelIds))));
60
+ return mappings
61
+ .filter((row) => row.mapping.sourceConnectionId)
62
+ .map((row) => ({
63
+ channelId: row.channel.id,
64
+ sourceConnectionId: row.mapping.sourceConnectionId,
65
+ mapping: row.mapping,
66
+ channel: row.channel,
67
+ }));
68
+ }
69
+ /**
70
+ * Insert/update an intent row per (channel, slot). The unique
71
+ * constraint on `(channel_id, slot_id)` collapses concurrent
72
+ * supersession events to one row; the worker reads the *current* slot
73
+ * state when it processes, so stale event payloads never propagate.
74
+ */
75
+ export async function upsertAvailabilityIntent(db, input) {
76
+ await db
77
+ .insert(channelAvailabilityPushIntents)
78
+ .values({
79
+ id: newId("channel_availability_push_intents"),
80
+ channelId: input.channelId,
81
+ sourceConnectionId: input.sourceConnectionId,
82
+ slotId: input.slotId,
83
+ productId: input.productId,
84
+ optionId: input.optionId,
85
+ startsAt: input.startsAt,
86
+ })
87
+ .onConflictDoUpdate({
88
+ target: [channelAvailabilityPushIntents.channelId, channelAvailabilityPushIntents.slotId],
89
+ set: {
90
+ requestedAt: new Date(),
91
+ updatedAt: new Date(),
92
+ // Reset attempts when a new event lands — fresh chance.
93
+ attempts: 0,
94
+ lastError: null,
95
+ },
96
+ });
97
+ }
98
+ /**
99
+ * Drain pending availability intents. Reads CURRENT slot state for each
100
+ * intent (so superseded values never propagate). On success, deletes the
101
+ * intent row. On failure, increments `attempts` and stamps `last_error`.
102
+ *
103
+ * Per §5.3 + §12.2.
104
+ */
105
+ export async function processAvailabilityPushIntents(input = {}, deps) {
106
+ const { db, registry, logger = defaultLogger } = deps ?? getChannelPushDepsOrThrow();
107
+ const limit = input.limit ?? 100;
108
+ const intents = (await db
109
+ .select({
110
+ intent: channelAvailabilityPushIntents,
111
+ channel: channels,
112
+ })
113
+ .from(channelAvailabilityPushIntents)
114
+ .innerJoin(channels, eq(channelAvailabilityPushIntents.channelId, channels.id))
115
+ .where(and(input.channelId ? eq(channelAvailabilityPushIntents.channelId, input.channelId) : sql `true`, eq(channels.status, "active")))
116
+ .orderBy(asc(channelAvailabilityPushIntents.requestedAt))
117
+ .limit(limit));
118
+ let succeeded = 0;
119
+ let failed = 0;
120
+ let skipped = 0;
121
+ for (const { intent, channel } of intents) {
122
+ // Read current slot state — stale events naturally don't propagate.
123
+ const slot = await loadAvailabilityPushSlot(db, intent.slotId);
124
+ if (!slot) {
125
+ // Slot deleted; drop the intent. Reconciler covers any drift.
126
+ await db
127
+ .delete(channelAvailabilityPushIntents)
128
+ .where(eq(channelAvailabilityPushIntents.id, intent.id));
129
+ skipped += 1;
130
+ continue;
131
+ }
132
+ const adapter = registry.resolveByConnection(intent.sourceConnectionId);
133
+ if (!adapter?.capabilities.supportsAvailabilityPush || !adapter.pushAvailability) {
134
+ await stampIntentError(db, intent.id, intent.attempts + 1, adapter ? "adapter_unsupported" : "no_adapter_registered");
135
+ failed += 1;
136
+ continue;
137
+ }
138
+ // Look up external ids via channel_product_mappings.
139
+ const [mapping] = (await db
140
+ .select()
141
+ .from(channelProductMappings)
142
+ .where(and(eq(channelProductMappings.channelId, channel.id), eq(channelProductMappings.productId, intent.productId)))
143
+ .limit(1));
144
+ if (!mapping) {
145
+ await stampIntentError(db, intent.id, intent.attempts + 1, "no_mapping");
146
+ failed += 1;
147
+ continue;
148
+ }
149
+ // Rate limit before dispatching. Availability uses the gated
150
+ // priority (default 0.3) so bookings always pre-empt.
151
+ const rlConfig = rateLimitConfigForChannel(channel);
152
+ if (rlConfig) {
153
+ const acq = await acquireToken(db, channelScopeKey(channel.id, intent.sourceConnectionId), rlConfig, "availability");
154
+ if (!acq.acquired) {
155
+ // Per §14.3: availability denials don't sleep. The next event
156
+ // for the same key supersedes; intent stays for next pass.
157
+ await stampIntentError(db, intent.id, intent.attempts + 1, "rate_limited");
158
+ failed += 1;
159
+ continue;
160
+ }
161
+ }
162
+ const request = {
163
+ channelId: channel.id,
164
+ externalProductId: mapping.externalProductId ?? "",
165
+ externalRateId: mapping.externalRateId ?? undefined,
166
+ externalCategoryId: mapping.externalCategoryId ?? undefined,
167
+ slotId: slot.id,
168
+ productId: slot.productId,
169
+ optionId: slot.optionId ?? undefined,
170
+ startsAt: slot.startsAt,
171
+ remainingPax: slot.unlimited ? Number.MAX_SAFE_INTEGER : (slot.remainingPax ?? 0),
172
+ source: "refresh",
173
+ };
174
+ const adapterCtx = {
175
+ connection_id: intent.sourceConnectionId,
176
+ };
177
+ const envelope = await prepareOutboundEnvelope(db, {
178
+ sourceModule: "distribution",
179
+ sourceEvent: "channel.availability.push",
180
+ sourceEntityModule: "availability",
181
+ sourceEntityId: slot.id,
182
+ targetUrl: `adapter:${adapter.kind}`,
183
+ targetKind: `channel:${adapter.kind}`,
184
+ targetRef: channel.id,
185
+ requestMethod: "POST",
186
+ requestBody: request,
187
+ attemptNumber: intent.attempts + 1,
188
+ });
189
+ try {
190
+ const result = await adapter.pushAvailability(adapterCtx, request);
191
+ await envelope.complete({ responseStatus: 200, responseBody: result });
192
+ // Drain on success.
193
+ await db
194
+ .delete(channelAvailabilityPushIntents)
195
+ .where(eq(channelAvailabilityPushIntents.id, intent.id));
196
+ succeeded += 1;
197
+ }
198
+ catch (err) {
199
+ const message = err instanceof Error ? err.message : String(err);
200
+ const isRateLimited = err instanceof AdapterRateLimitedError;
201
+ if (isRateLimited) {
202
+ await drainBucket(db, channelScopeKey(channel.id, intent.sourceConnectionId), err.retryAfterMs);
203
+ }
204
+ await envelope.complete({
205
+ errorClass: isRateLimited ? "rate_limited" : "adapter_error",
206
+ errorMessage: message,
207
+ });
208
+ await stampIntentError(db, intent.id, intent.attempts + 1, message);
209
+ failed += 1;
210
+ logger.error?.(`pushAvailability failed for slot ${slot.id} channel ${channel.id}`, {
211
+ error: message,
212
+ });
213
+ }
214
+ }
215
+ return {
216
+ attempted: intents.length,
217
+ succeeded,
218
+ failed,
219
+ skipped,
220
+ };
221
+ }
222
+ async function stampIntentError(db, id, attempts, message) {
223
+ await db
224
+ .update(channelAvailabilityPushIntents)
225
+ .set({ attempts, lastError: message, updatedAt: new Date() })
226
+ .where(eq(channelAvailabilityPushIntents.id, id));
227
+ }
228
+ function rateLimitConfigForChannel(channel) {
229
+ if (!channel.rateLimitRps || !channel.rateLimitBurst)
230
+ return null;
231
+ return {
232
+ rps: channel.rateLimitRps,
233
+ burst: channel.rateLimitBurst,
234
+ priorityGates: channel.rateLimitPriorityGates ?? undefined,
235
+ };
236
+ }
@@ -0,0 +1,36 @@
1
+ import { type bookings } from "@voyant-travel/bookings/schema";
2
+ import type { SourceAdapter, SourceAdapterContext } from "@voyant-travel/catalog";
3
+ import type { AnyDrizzleDb } from "@voyant-travel/db";
4
+ import { channelBookingLinks, channelProductMappings, type channels } from "@voyant-travel/distribution/schema";
5
+ import type { RateLimitConfig } from "../rate-limit.js";
6
+ import type { CompensationPolicy } from "./booking-push.js";
7
+ /**
8
+ * Read the compensation policy for a channel by walking
9
+ * `channel_contracts` (most-recent active contract wins). Returns
10
+ * `eventually-consistent` when no contract exists or no compensation
11
+ * key is set — that's the doc-default safe behavior for travel
12
+ * inventory.
13
+ */
14
+ export declare function resolveCompensationPolicy(db: AnyDrizzleDb, channelId: string | null): Promise<CompensationPolicy>;
15
+ /**
16
+ * Roll back a succeeded link by calling `adapter.cancel` for the
17
+ * upstream reference. Marks the link `compensated` regardless of the
18
+ * cancel call's outcome — leaving it `ok` would lie to the operator
19
+ * dashboard. Per §4.2.
20
+ */
21
+ export declare function compensateSucceededLink(db: AnyDrizzleDb, entry: {
22
+ link: typeof channelBookingLinks.$inferSelect;
23
+ channel: typeof channels.$inferSelect;
24
+ adapter: SourceAdapter;
25
+ adapterCtx: SourceAdapterContext;
26
+ upstreamRef: string;
27
+ }, bookingId: string, logger: {
28
+ error?: (message: string, meta?: Record<string, unknown>) => void;
29
+ warn?: (message: string, meta?: Record<string, unknown>) => void;
30
+ }): Promise<boolean>;
31
+ export declare function markLinkOk(db: AnyDrizzleDb, linkId: string, attempts: number, upstreamRef: string, externalReference: string | null, externalStatus: string | null): Promise<void>;
32
+ export declare function markLinkFailed(db: AnyDrizzleDb, linkId: string, attempts: number, message: string): Promise<void>;
33
+ export declare function readMappingForLink(db: AnyDrizzleDb, link: typeof channelBookingLinks.$inferSelect, booking: typeof bookings.$inferSelect): Promise<typeof channelProductMappings.$inferSelect | null>;
34
+ export declare function rateLimitConfigForChannel(channel: typeof channels.$inferSelect): RateLimitConfig | null;
35
+ export declare function serializeBookingForPush(booking: typeof bookings.$inferSelect, bookingItemId: string | null): Record<string, unknown>;
36
+ //# sourceMappingURL=booking-push-helpers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-push-helpers.d.ts","sourceRoot":"","sources":["../../src/channel-push/booking-push-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,QAAQ,EAAE,MAAM,gCAAgC,CAAA;AAC5E,OAAO,KAAK,EAAE,aAAa,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AACjF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AACrD,OAAO,EACL,mBAAmB,EAEnB,sBAAsB,EACtB,KAAK,QAAQ,EACd,MAAM,oCAAoC,CAAA;AAG3C,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAA;AAGvD,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAE3D;;;;;;GAMG;AACH,wBAAsB,yBAAyB,CAC7C,EAAE,EAAE,YAAY,EAChB,SAAS,EAAE,MAAM,GAAG,IAAI,GACvB,OAAO,CAAC,kBAAkB,CAAC,CAmB7B;AAED;;;;;GAKG;AACH,wBAAsB,uBAAuB,CAC3C,EAAE,EAAE,YAAY,EAChB,KAAK,EAAE;IACL,IAAI,EAAE,OAAO,mBAAmB,CAAC,YAAY,CAAA;IAC7C,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,CAAA;IACrC,OAAO,EAAE,aAAa,CAAA;IACtB,UAAU,EAAE,oBAAoB,CAAA;IAChC,WAAW,EAAE,MAAM,CAAA;CACpB,EACD,SAAS,EAAE,MAAM,EACjB,MAAM,EAAE;IACN,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;IACjE,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAA;CACjE,GACA,OAAO,CAAC,OAAO,CAAC,CA+ClB;AAMD,wBAAsB,UAAU,CAC9B,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EACnB,iBAAiB,EAAE,MAAM,GAAG,IAAI,EAChC,cAAc,EAAE,MAAM,GAAG,IAAI,GAC5B,OAAO,CAAC,IAAI,CAAC,CAgBf;AAED,wBAAsB,cAAc,CAClC,EAAE,EAAE,YAAY,EAChB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,IAAI,CAAC,CAYf;AAED,wBAAsB,kBAAkB,CACtC,EAAE,EAAE,YAAY,EAChB,IAAI,EAAE,OAAO,mBAAmB,CAAC,YAAY,EAC7C,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,GACpC,OAAO,CAAC,OAAO,sBAAsB,CAAC,YAAY,GAAG,IAAI,CAAC,CAmC5D;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,GACpC,eAAe,GAAG,IAAI,CAOxB;AAED,wBAAgB,uBAAuB,CACrC,OAAO,EAAE,OAAO,QAAQ,CAAC,YAAY,EACrC,aAAa,EAAE,MAAM,GAAG,IAAI,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAezB"}