@teleporthq/teleport-plugin-next-workflows 0.45.6 → 0.45.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.
@@ -176,6 +176,62 @@ describe('analytics-track-outcome node', () => {
176
176
  })
177
177
  })
178
178
 
179
+ describe('server: an outcome the analytics service takes only from a browser', () => {
180
+ // The service's server endpoint counts `purchase` and `booking_made` alone;
181
+ // a lead, sign-up, newsletter, search or custom outcome comes from the page.
182
+ const configured = (calls: Array<{ body: string }>) =>
183
+ evalHandler(generator.generateServerHandler?.() ?? '', {
184
+ globalThis: {
185
+ process: {
186
+ env: {
187
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_URL: 'https://analytics.example',
188
+ NEXT_PUBLIC_TELEPORT_ANALYTICS_KEY: 'tpa_key',
189
+ TELEPORT_ANALYTICS_SERVER_KEY: 'tpas_secret',
190
+ },
191
+ },
192
+ },
193
+ fetch: (_url: string, init: { body: string }) => {
194
+ calls.push(init)
195
+ return Promise.resolve({ status: 202 })
196
+ },
197
+ AbortController: undefined,
198
+ })
199
+
200
+ let warn: jest.SpyInstance
201
+ beforeEach(() => {
202
+ warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
203
+ })
204
+ afterEach(() => warn.mockRestore())
205
+
206
+ it('is refused as browser-only, with the reason in the logs, and never sent', async () => {
207
+ const calls: Array<{ body: string }> = []
208
+ const report = configured(calls)
209
+ for (const name of ['lead', 'signup', 'newsletter', 'search', 'custom:demo_booked']) {
210
+ expect(await report({ name }, {})).toEqual({ sent: false, reason: 'browser-only' })
211
+ expect(await report({ name, outcomeId: 'row-1' }, {})).toEqual({
212
+ sent: false,
213
+ reason: 'browser-only',
214
+ })
215
+ }
216
+ expect(calls).toEqual([])
217
+ expect(warn).toHaveBeenCalledWith(
218
+ '[analytics] the "lead" outcome was not reported: only a visitor\'s browser can report it, never a server step'
219
+ )
220
+ })
221
+
222
+ it('says so in the logs when a money outcome has no id to report', async () => {
223
+ const calls: Array<{ body: string }> = []
224
+ expect(await configured(calls)({ name: 'booking_made', value: '40' }, {})).toEqual({
225
+ sent: false,
226
+ reason: 'no-outcome-id',
227
+ })
228
+ expect(calls).toEqual([])
229
+ expect(warn).toHaveBeenCalledWith(
230
+ '[analytics] the "booking_made" outcome was not reported: it has no order or booking id'
231
+ )
232
+ })
233
+ })
234
+
179
235
  it('server: answers not-configured without the key and never throws on a network failure', async () => {
180
236
  const unconfigured = evalHandler(generator.generateServerHandler?.() ?? '', {
181
237
  globalThis: { process: { env: {} } },
@@ -0,0 +1,430 @@
1
+ /* tslint:disable:function-constructor no-any */
2
+ import * as http from 'http'
3
+ import { AddressInfo } from 'net'
4
+ import * as nodePath from 'path'
5
+ import * as vm from 'vm'
6
+ import { NextWorkflowProjectPlugin } from '../src/workflow-project-plugin'
7
+ import { generateHandlerFunction } from '../src/api-route-generator'
8
+ import { withEnv } from './_helpers/fake-fetch'
9
+
10
+ /**
11
+ * A scheduled workflow on a published site failed every run with HTTP 500
12
+ * "Failed to parse URL from /api/data/<id>/raw-query": its data step called the
13
+ * site's own data API by a relative address, which a browser resolves against
14
+ * the page and Node's fetch refuses. A server job has no page, so each route
15
+ * that runs one hands its steps the absolute origin (`context.__baseUrl`).
16
+ *
17
+ * Every kind of server job is run here as the deployment runs it — the
18
+ * generated files, Node's own fetch, and a real HTTP server standing in for the
19
+ * site — so a relative self-call fails exactly as it did in production.
20
+ */
21
+
22
+ const DS = 'ds-store'
23
+ const ref = (nodeId: string, path: string[]) => ({ type: 'workflowContext', nodeId, path })
24
+
25
+ const CART_ROWS = [{ cart_id: 'cart-1', email: 'a@shop.test' }]
26
+
27
+ /** The predefined "Abandoned Cart Reminder", reduced to its database steps. */
28
+ const CART_REMINDER = {
29
+ id: 'wf-cart-reminder',
30
+ name: 'Abandoned Cart Reminder',
31
+ trigger: {
32
+ type: 'event-cron-triggered',
33
+ nodeId: 't',
34
+ scope: 'global',
35
+ config: { schedule: '0 * * * *', urlPath: 'cron_cartreminder' },
36
+ },
37
+ nodes: [
38
+ {
39
+ id: 'scan',
40
+ type: 'data-raw-query',
41
+ label: 'Scan Abandoned Carts',
42
+ config: {
43
+ dataSourceId: DS,
44
+ query:
45
+ 'SELECT c.id AS cart_id FROM teleport_cart c WHERE c.updated_at < NOW() - make_interval(hours => $1::int)',
46
+ params: ['2'],
47
+ },
48
+ stepNumber: 1,
49
+ },
50
+ {
51
+ id: 'loop',
52
+ type: 'general-loop',
53
+ label: 'Loop Over Each Abandoned Cart',
54
+ config: { loopType: 'forEach', collection: ref('scan', ['result']), itemVariable: 'cart' },
55
+ stepNumber: 2,
56
+ },
57
+ {
58
+ id: 'record',
59
+ type: 'data-create-item',
60
+ label: 'Record Abandoned Cart',
61
+ config: {
62
+ dataSourceId: DS,
63
+ tableName: 'teleport_abandoned_carts',
64
+ columnMappings: [{ column: 'cart_id', value: ref('loop', ['currentItem', 'cart_id']) }],
65
+ },
66
+ stepNumber: 3,
67
+ },
68
+ ],
69
+ edges: [
70
+ { id: 'e1', source: 't', target: 'scan' },
71
+ { id: 'e2', source: 'scan', target: 'loop' },
72
+ {
73
+ id: 'e3',
74
+ source: 'loop',
75
+ target: 'record',
76
+ sourceHandle: 'loop',
77
+ targetHandle: 'loop-body-in',
78
+ },
79
+ {
80
+ id: 'e4',
81
+ source: 'record',
82
+ target: 'loop',
83
+ sourceHandle: 'loop-body-out',
84
+ targetHandle: 'loop-back',
85
+ },
86
+ ],
87
+ }
88
+
89
+ /** A shared step the store's jobs call: its database read runs in its own server segment route. */
90
+ const RELEASE_NODE = {
91
+ id: 'cn-release',
92
+ name: 'Release Expired Holds',
93
+ parameters: [] as unknown[],
94
+ nodes: [
95
+ {
96
+ id: 'release-query',
97
+ type: 'data-raw-query',
98
+ label: 'Release Expired Holds',
99
+ config: { dataSourceId: DS, query: "UPDATE teleport_orders SET status = 'expired'" },
100
+ stepNumber: 0,
101
+ },
102
+ ],
103
+ edges: [] as unknown[],
104
+ }
105
+
106
+ const callRelease = (id: string, stepNumber: number) => ({
107
+ id,
108
+ type: 'general-custom-node',
109
+ label: 'Release Expired Holds',
110
+ config: { customNodeId: 'cn-release', parameters: [] as unknown[] },
111
+ stepNumber,
112
+ })
113
+
114
+ const RELEASE_CRON = {
115
+ id: 'wf-release',
116
+ name: 'Release Abandoned Checkouts',
117
+ trigger: {
118
+ type: 'event-cron-triggered',
119
+ nodeId: 't',
120
+ scope: 'global',
121
+ config: { schedule: '*/15 * * * *', urlPath: 'cron_release' },
122
+ },
123
+ nodes: [callRelease('release-call', 1)],
124
+ edges: [{ id: 'r1', source: 't', target: 'release-call' }],
125
+ }
126
+
127
+ const SYNC_WEBHOOK = {
128
+ id: 'wf-sync',
129
+ name: 'Inventory Sync',
130
+ trigger: { type: 'event-webhook-received', nodeId: 't', scope: 'global', config: {} },
131
+ webhookConfig: { urlPath: 'webhooks/inventory-sync', httpMethod: 'POST' },
132
+ nodes: [
133
+ {
134
+ id: 'sync-query',
135
+ type: 'data-raw-query',
136
+ label: 'Read Stock',
137
+ config: { dataSourceId: DS, query: 'SELECT id, stock FROM teleport_products' },
138
+ stepNumber: 1,
139
+ },
140
+ ],
141
+ edges: [{ id: 's1', source: 't', target: 'sync-query' }],
142
+ }
143
+
144
+ const ORDER_AUTOMATION = {
145
+ id: 'wf-order-placed',
146
+ name: 'When an order is placed',
147
+ trigger: { type: 'event-business-event', nodeId: 't', scope: 'global', config: {} },
148
+ nodes: [
149
+ {
150
+ id: 'auto-query',
151
+ type: 'data-raw-query',
152
+ label: 'Read The Order',
153
+ config: { dataSourceId: DS, query: 'SELECT id FROM teleport_orders LIMIT 1' },
154
+ stepNumber: 1,
155
+ },
156
+ ],
157
+ edges: [{ id: 'a1', source: 't', target: 'auto-query' }],
158
+ }
159
+
160
+ const ENV: Record<string, string | undefined> = {
161
+ NEXTAUTH_SECRET: 'app-secret',
162
+ NEXTAUTH_URL: undefined,
163
+ TELEPORT_CRON_ALLOW_UNSIGNED: 'true',
164
+ TELEPORT_CRON_SECRET: undefined,
165
+ VERCEL: undefined,
166
+ VERCEL_AUTOMATION_BYPASS_SECRET: undefined,
167
+ }
168
+
169
+ const generateFiles = async (): Promise<Map<string, string>> => {
170
+ const structure: any = {
171
+ uidl: {
172
+ name: 'Store',
173
+ globals: { settings: { title: 'Store', language: 'en' }, env: { NEXTAUTH_SECRET: 'k' } },
174
+ root: {
175
+ name: 'Root',
176
+ stateDefinitions: {
177
+ route: { type: 'string', defaultValue: 'Home', values: [{ value: 'Home' }] },
178
+ },
179
+ node: { type: 'element', content: { elementType: 'container', children: [] } },
180
+ },
181
+ components: {},
182
+ dataSources: { [DS]: { id: DS, type: 'teleport', name: 'Store', config: {} } },
183
+ workflows: {
184
+ workflows: JSON.parse(
185
+ JSON.stringify({
186
+ [CART_REMINDER.id]: CART_REMINDER,
187
+ [RELEASE_CRON.id]: RELEASE_CRON,
188
+ [SYNC_WEBHOOK.id]: SYNC_WEBHOOK,
189
+ })
190
+ ),
191
+ customNodes: JSON.parse(JSON.stringify({ [RELEASE_NODE.id]: RELEASE_NODE })),
192
+ },
193
+ },
194
+ strategy: { pages: { options: {} } },
195
+ files: new Map(),
196
+ dependencies: {},
197
+ devDependencies: {},
198
+ }
199
+ await new NextWorkflowProjectPlugin().runAfter(structure)
200
+ const files = new Map<string, string>()
201
+ for (const entry of structure.files.values()) {
202
+ for (const file of entry.files) {
203
+ files.set(
204
+ [...entry.path, `${file.name}.${file.fileType || 'js'}`].join('/'),
205
+ String(file.content)
206
+ )
207
+ }
208
+ }
209
+ files.set(
210
+ 'lib/automations/handlers/wf-order-placed.js',
211
+ generateHandlerFunction(JSON.parse(JSON.stringify(ORDER_AUTOMATION)), {
212
+ [RELEASE_NODE.id]: RELEASE_NODE,
213
+ } as any)
214
+ )
215
+ return files
216
+ }
217
+
218
+ interface Reply {
219
+ status: number
220
+ body: any
221
+ }
222
+
223
+ interface Site {
224
+ origin: string
225
+ /** Every request the site received: its path and the Host it was sent to. */
226
+ received: Array<{ path: string; host: string }>
227
+ call: (routeFile: string, body?: unknown) => Promise<Reply>
228
+ load: (path: string) => any
229
+ }
230
+
231
+ /**
232
+ * The generated files served by a real HTTP server: the data API answers with
233
+ * rows, and a call to one of the site's own workflow routes runs that route.
234
+ */
235
+ const startSite = async (files: Map<string, string>): Promise<Site & { close: () => void }> => {
236
+ let origin = ''
237
+ const received: Site['received'] = []
238
+ const cache = new Map<string, { exports: any }>()
239
+ const overrides: Record<string, unknown> = {
240
+ 'lib/automations/store.js': {
241
+ appBaseUrl: () => origin,
242
+ saveWait: async (): Promise<void> => undefined,
243
+ },
244
+ }
245
+
246
+ const load = (path: string): any => {
247
+ if (Object.prototype.hasOwnProperty.call(overrides, path)) {
248
+ return overrides[path]
249
+ }
250
+ const cached = cache.get(path)
251
+ if (cached) {
252
+ return cached.exports
253
+ }
254
+ const source = files.get(path)
255
+ if (source === undefined) {
256
+ throw new Error(`Cannot find module '${path}'`)
257
+ }
258
+ const module = { exports: {} as any }
259
+ cache.set(path, module)
260
+ const dir = nodePath.posix.dirname(path)
261
+ const localRequire = (id: string): unknown => {
262
+ if (id.startsWith('.')) {
263
+ const joined = nodePath.posix.normalize(nodePath.posix.join(dir, id))
264
+ const found = [joined, `${joined}.js`, `${joined}/index.js`].find(
265
+ (candidate) =>
266
+ files.has(candidate) || Object.prototype.hasOwnProperty.call(overrides, candidate)
267
+ )
268
+ return load(found || `${joined}.js`)
269
+ }
270
+ if (id === 'next-auth/jwt') {
271
+ return { getToken: async (): Promise<null> => null }
272
+ }
273
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
274
+ return require(id)
275
+ }
276
+ new Function('module', 'exports', 'require', source)(module, module.exports, localRequire)
277
+ return module.exports
278
+ }
279
+
280
+ const run = async (
281
+ routeFile: string,
282
+ headers: Record<string, string | string[] | undefined>,
283
+ body: unknown
284
+ ): Promise<Reply> => {
285
+ const reply: Reply = { status: 200, body: null }
286
+ const res: any = {
287
+ setHeader: (): any => res,
288
+ status(code: number) {
289
+ reply.status = code
290
+ return res
291
+ },
292
+ json(payload: unknown) {
293
+ reply.body = payload
294
+ return res
295
+ },
296
+ end() {
297
+ return res
298
+ },
299
+ }
300
+ const handler = load(routeFile)
301
+ await (handler.default || handler)(
302
+ {
303
+ method: 'POST',
304
+ url: '/' + routeFile.replace(/^pages\//, '').replace(/\.js$/, ''),
305
+ query: {},
306
+ headers,
307
+ body,
308
+ },
309
+ res
310
+ )
311
+ return reply
312
+ }
313
+
314
+ const server = http.createServer((req, res) => {
315
+ const chunks: Buffer[] = []
316
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
317
+ req.on('end', async () => {
318
+ const path = new URL(req.url || '/', 'http://site').pathname
319
+ received.push({ path, host: String(req.headers.host) })
320
+ const text = Buffer.concat(chunks).toString('utf8')
321
+ const body = text ? JSON.parse(text) : undefined
322
+ let reply: Reply = { status: 404, body: { error: 'not found' } }
323
+ if (/^\/api\/data\/[^/]+\/[^/]+$/.test(path)) {
324
+ reply = { status: 200, body: { success: true, rows: CART_ROWS, data: CART_ROWS[0] } }
325
+ } else if (files.has(`pages${path}.js`)) {
326
+ reply = await run(`pages${path}.js`, req.headers, body)
327
+ }
328
+ res.statusCode = reply.status
329
+ res.setHeader('content-type', 'application/json')
330
+ res.end(JSON.stringify(reply.body))
331
+ })
332
+ })
333
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
334
+ const { port } = server.address() as AddressInfo
335
+ origin = `http://127.0.0.1:${port}`
336
+
337
+ return {
338
+ origin,
339
+ received,
340
+ load,
341
+ call: (routeFile, body = {}) =>
342
+ run(routeFile, { host: `127.0.0.1:${port}`, 'content-type': 'application/json' }, body),
343
+ close: () => server.close(),
344
+ }
345
+ }
346
+
347
+ // Node's own fetch (undici), as the deployment has it: the one that refuses a
348
+ // relative address. Jest's sandbox has no global fetch of its own.
349
+ const nodeFetch = vm.runInThisContext('fetch')
350
+
351
+ const silenced = async <T>(run: () => Promise<T>): Promise<T> => {
352
+ const sink = console as unknown as Record<string, unknown>
353
+ const levels = ['warn', 'error', 'log', 'info']
354
+ const saved = levels.map((level) => sink[level])
355
+ levels.forEach((level) => {
356
+ sink[level] = (): undefined => undefined
357
+ })
358
+ try {
359
+ return await run()
360
+ } finally {
361
+ levels.forEach((level, index) => {
362
+ sink[level] = saved[index]
363
+ })
364
+ }
365
+ }
366
+
367
+ const onSite = async <T>(run: (site: Site) => Promise<T>): Promise<T> => {
368
+ const site = await startSite(await generateFiles())
369
+ const scope = globalThis as any
370
+ const previous = scope.fetch
371
+ scope.fetch = nodeFetch
372
+ try {
373
+ return await withEnv(ENV, () => silenced(() => run(site)))
374
+ } finally {
375
+ scope.fetch = previous
376
+ site.close()
377
+ }
378
+ }
379
+
380
+ /** The paths the site received, each asserted to have come to its own address. */
381
+ const selfCalls = (site: Site): string[] => {
382
+ const host = new URL(site.origin).host
383
+ site.received.forEach((call) => expect(call.host).toBe(host))
384
+ return site.received.map((call) => call.path)
385
+ }
386
+
387
+ const webhookDelivery = (payload: unknown) => Buffer.from(JSON.stringify(payload))
388
+
389
+ describe("a server job calls the site's own routes by their absolute address", () => {
390
+ it('a scheduled workflow reads with a raw query, then records each row', async () => {
391
+ await onSite(async (site) => {
392
+ const reply = await site.call('pages/api/workflows/cron_cartreminder.js')
393
+
394
+ expect(reply).toEqual({ status: 200, body: expect.objectContaining({ success: true }) })
395
+ expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`, `/api/data/${DS}/create`])
396
+ })
397
+ })
398
+
399
+ it("a scheduled workflow runs a shared step whose query is in that step's own route", async () => {
400
+ await onSite(async (site) => {
401
+ const reply = await site.call('pages/api/workflows/cron_release.js')
402
+
403
+ expect(reply).toEqual({ status: 200, body: expect.objectContaining({ success: true }) })
404
+ const [segmentRoute, ...rest] = selfCalls(site)
405
+ expect(segmentRoute).toMatch(/^\/api\/workflows\/release-expired-holds-/)
406
+ expect(rest).toEqual([`/api/data/${DS}/raw-query`])
407
+ })
408
+ })
409
+
410
+ it('a webhook reads with a raw query', async () => {
411
+ await onSite(async (site) => {
412
+ const reply = await site.call(
413
+ 'pages/api/webhooks/inventory-sync.js',
414
+ webhookDelivery({ event: 'stock' })
415
+ )
416
+
417
+ expect(reply).toEqual({ status: 200, body: { received: true } })
418
+ expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`])
419
+ })
420
+ })
421
+
422
+ it('an automation reads with a raw query', async () => {
423
+ await onSite(async (site) => {
424
+ const handler = site.load('lib/automations/handlers/wf-order-placed.js')
425
+ await handler.run({ event: { id: 'e1', type: 'order-placed' }, order: { id: 'o1' } }, {})
426
+
427
+ expect(selfCalls(site)).toEqual([`/api/data/${DS}/raw-query`])
428
+ })
429
+ })
430
+ })
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-track-outcome.d.ts","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AAkH/C,eAAO,MAAM,qBAAqB,EAAE,oBASnC,CAAA"}
1
+ {"version":3,"file":"analytics-track-outcome.d.ts","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAA;AA8H/C,eAAO,MAAM,qBAAqB,EAAE,oBASnC,CAAA"}
@@ -21,6 +21,13 @@ exports.analyticsTrackOutcome = void 0;
21
21
  * the visitor id is the tracker's alone: a browser may still hold a persistent
22
22
  * id from before its consent was withdrawn.
23
23
  *
24
+ * The service takes exactly the money outcomes from a server, each with the
25
+ * order or booking id it dedupes a retried report on, and every other outcome
26
+ * from a browser only. So on the server any other name is refused as
27
+ * `browser-only` (an automation, a webhook or a cron has no visitor's page to
28
+ * report it from), and a money outcome without an id as `no-outcome-id`: an id
29
+ * made up here would count a retried report twice. Both say why in the logs.
30
+ *
24
31
  * Never throws: an outcome that cannot be reported answers `{ sent: false }`
25
32
  * and the workflow continues — analytics must never cost a sale.
26
33
  */
@@ -56,6 +63,10 @@ const SERVER_BODY = `async function analytics_track_outcome(config, context) {
56
63
  if (!name) {
57
64
  return { sent: false, reason: 'no-name' };
58
65
  }
66
+ if (${JSON.stringify(SERVER_ONLY_OUTCOMES)}.indexOf(name) === -1) {
67
+ console.warn('[analytics] the "' + name + '" outcome was not reported: only a visitor\\'s browser can report it, never a server step');
68
+ return { sent: false, reason: 'browser-only' };
69
+ }
59
70
  // \`globalThis.process\` (member access): webpack rewrites a bare \`process\`.
60
71
  var env = globalThis.process && globalThis.process.env;
61
72
  var baseUrl = (env && env.NEXT_PUBLIC_TELEPORT_ANALYTICS_URL) || '';
@@ -66,6 +77,7 @@ const SERVER_BODY = `async function analytics_track_outcome(config, context) {
66
77
  }
67
78
  // The service dedupes on this id and drops an outcome without one.
68
79
  if (config.outcomeId == null || config.outcomeId === '') {
80
+ console.warn('[analytics] the "' + name + '" outcome was not reported: it has no order or booking id');
69
81
  return { sent: false, reason: 'no-outcome-id' };
70
82
  }
71
83
  var ids = context && context.__tpa && typeof context.__tpa === 'object' ? context.__tpa : {};
@@ -1 +1 @@
1
- {"version":3,"file":"analytics-track-outcome.js","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,oBAAoB,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;AAEzD,MAAM,WAAW,GAAG;;;;;QAKZ,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;EAoB1C,CAAA;AAEF,MAAM,WAAW,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0DlB,CAAA;AAEW,QAAA,qBAAqB,GAAyB;IACzD,QAAQ,EAAE,yBAAyB;IACnC,YAAY,EAAE,WAAW;IACzB,eAAe;QACb,OAAO,WAAW,CAAA;IACpB,CAAC;IACD,qBAAqB;QACnB,OAAO,WAAW,CAAA;IACpB,CAAC;CACF,CAAA"}
1
+ {"version":3,"file":"analytics-track-outcome.js","sourceRoot":"","sources":["../../../../src/nodes/analytics/analytics-track-outcome.ts"],"names":[],"mappings":";;;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,oBAAoB,GAAG,CAAC,UAAU,EAAE,cAAc,CAAC,CAAA;AAEzD,MAAM,WAAW,GAAG;;;;;QAKZ,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;EAoB1C,CAAA;AAEF,MAAM,WAAW,GAAG;;;;;QAKZ,IAAI,CAAC,SAAS,CAAC,oBAAoB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA0D1C,CAAA;AAEW,QAAA,qBAAqB,GAAyB;IACzD,QAAQ,EAAE,yBAAyB;IACnC,YAAY,EAAE,WAAW;IACzB,eAAe;QACb,OAAO,WAAW,CAAA;IACpB,CAAC;IACD,qBAAqB;QACnB,OAAO,WAAW,CAAA;IACpB,CAAC;CACF,CAAA"}