@open-mercato/tillio 0.7.1-develop.7176.1.d229567a50

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 (135) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/build.mjs +7 -0
  3. package/dist/index.js +5 -0
  4. package/dist/index.js.map +7 -0
  5. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
  6. package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
  7. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
  8. package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
  9. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
  10. package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
  11. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
  12. package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
  13. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
  14. package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
  15. package/dist/modules/tillio/acl.js +9 -0
  16. package/dist/modules/tillio/acl.js.map +7 -0
  17. package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
  18. package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
  19. package/dist/modules/tillio/api/operators/route.js +171 -0
  20. package/dist/modules/tillio/api/operators/route.js.map +7 -0
  21. package/dist/modules/tillio/api/pull/route.js +180 -0
  22. package/dist/modules/tillio/api/pull/route.js.map +7 -0
  23. package/dist/modules/tillio/cli.js +108 -0
  24. package/dist/modules/tillio/cli.js.map +7 -0
  25. package/dist/modules/tillio/di.js +16 -0
  26. package/dist/modules/tillio/di.js.map +7 -0
  27. package/dist/modules/tillio/index.js +12 -0
  28. package/dist/modules/tillio/index.js.map +7 -0
  29. package/dist/modules/tillio/integration.js +62 -0
  30. package/dist/modules/tillio/integration.js.map +7 -0
  31. package/dist/modules/tillio/lib/adapter.js +107 -0
  32. package/dist/modules/tillio/lib/adapter.js.map +7 -0
  33. package/dist/modules/tillio/lib/client.js +249 -0
  34. package/dist/modules/tillio/lib/client.js.map +7 -0
  35. package/dist/modules/tillio/lib/environment.js +34 -0
  36. package/dist/modules/tillio/lib/environment.js.map +7 -0
  37. package/dist/modules/tillio/lib/error-codes.js +59 -0
  38. package/dist/modules/tillio/lib/error-codes.js.map +7 -0
  39. package/dist/modules/tillio/lib/errors.js +12 -0
  40. package/dist/modules/tillio/lib/errors.js.map +7 -0
  41. package/dist/modules/tillio/lib/health.js +41 -0
  42. package/dist/modules/tillio/lib/health.js.map +7 -0
  43. package/dist/modules/tillio/lib/locking.js +18 -0
  44. package/dist/modules/tillio/lib/locking.js.map +7 -0
  45. package/dist/modules/tillio/lib/normalizer.js +134 -0
  46. package/dist/modules/tillio/lib/normalizer.js.map +7 -0
  47. package/dist/modules/tillio/lib/operators-store.js +62 -0
  48. package/dist/modules/tillio/lib/operators-store.js.map +7 -0
  49. package/dist/modules/tillio/lib/operators.js +168 -0
  50. package/dist/modules/tillio/lib/operators.js.map +7 -0
  51. package/dist/modules/tillio/lib/preset.js +122 -0
  52. package/dist/modules/tillio/lib/preset.js.map +7 -0
  53. package/dist/modules/tillio/lib/pull-job.js +265 -0
  54. package/dist/modules/tillio/lib/pull-job.js.map +7 -0
  55. package/dist/modules/tillio/lib/pull-readiness.js +36 -0
  56. package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
  57. package/dist/modules/tillio/lib/queue.js +22 -0
  58. package/dist/modules/tillio/lib/queue.js.map +7 -0
  59. package/dist/modules/tillio/lib/tz.js +85 -0
  60. package/dist/modules/tillio/lib/tz.js.map +7 -0
  61. package/dist/modules/tillio/lib/url-guard.js +24 -0
  62. package/dist/modules/tillio/lib/url-guard.js.map +7 -0
  63. package/dist/modules/tillio/setup.js +45 -0
  64. package/dist/modules/tillio/setup.js.map +7 -0
  65. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
  66. package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
  67. package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
  68. package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
  69. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
  70. package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
  71. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
  72. package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
  73. package/dist/modules/tillio/widgets/injection-table.js +21 -0
  74. package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
  75. package/dist/modules/tillio/workers/tillio-pull.js +17 -0
  76. package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
  77. package/jest.config.cjs +35 -0
  78. package/package.json +92 -0
  79. package/src/index.ts +1 -0
  80. package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
  81. package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
  82. package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
  83. package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
  84. package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
  85. package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
  86. package/src/modules/tillio/__tests__/client.test.ts +203 -0
  87. package/src/modules/tillio/__tests__/health.test.ts +122 -0
  88. package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
  89. package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
  90. package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
  91. package/src/modules/tillio/__tests__/operators.test.ts +250 -0
  92. package/src/modules/tillio/__tests__/preset.test.ts +337 -0
  93. package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
  94. package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
  95. package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
  96. package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
  97. package/src/modules/tillio/__tests__/tz.test.ts +53 -0
  98. package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
  99. package/src/modules/tillio/acl.ts +5 -0
  100. package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
  101. package/src/modules/tillio/api/operators/route.ts +191 -0
  102. package/src/modules/tillio/api/pull/route.ts +213 -0
  103. package/src/modules/tillio/cli.ts +125 -0
  104. package/src/modules/tillio/di.ts +16 -0
  105. package/src/modules/tillio/i18n/de.json +73 -0
  106. package/src/modules/tillio/i18n/en.json +73 -0
  107. package/src/modules/tillio/i18n/es.json +73 -0
  108. package/src/modules/tillio/i18n/ko.json +73 -0
  109. package/src/modules/tillio/i18n/pl.json +73 -0
  110. package/src/modules/tillio/index.ts +8 -0
  111. package/src/modules/tillio/integration.ts +58 -0
  112. package/src/modules/tillio/lib/adapter.ts +135 -0
  113. package/src/modules/tillio/lib/client.ts +329 -0
  114. package/src/modules/tillio/lib/environment.ts +53 -0
  115. package/src/modules/tillio/lib/error-codes.ts +67 -0
  116. package/src/modules/tillio/lib/errors.ts +11 -0
  117. package/src/modules/tillio/lib/health.ts +57 -0
  118. package/src/modules/tillio/lib/locking.ts +30 -0
  119. package/src/modules/tillio/lib/normalizer.ts +184 -0
  120. package/src/modules/tillio/lib/operators-store.ts +77 -0
  121. package/src/modules/tillio/lib/operators.ts +269 -0
  122. package/src/modules/tillio/lib/preset.ts +226 -0
  123. package/src/modules/tillio/lib/pull-job.ts +390 -0
  124. package/src/modules/tillio/lib/pull-readiness.ts +77 -0
  125. package/src/modules/tillio/lib/queue.ts +33 -0
  126. package/src/modules/tillio/lib/tz.ts +121 -0
  127. package/src/modules/tillio/lib/url-guard.ts +34 -0
  128. package/src/modules/tillio/setup.ts +49 -0
  129. package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
  130. package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
  131. package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
  132. package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
  133. package/src/modules/tillio/widgets/injection-table.ts +19 -0
  134. package/src/modules/tillio/workers/tillio-pull.ts +18 -0
  135. package/tsconfig.json +9 -0
@@ -0,0 +1,226 @@
1
+ import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
4
+ import { TILLIO_INTEGRATION_ID } from '../integration'
5
+ import { environmentSchema, type TillioEnvironment } from './environment'
6
+ import { attachOperator, detachOperator, TillioOperatorLimitError } from './operators'
7
+ import type { TillioLockRunner } from './locking'
8
+ import { readOperatorsBlob, type TillioCredentialsService, type TillioOperatorRecord } from './operators-store'
9
+
10
+ const logger = createLogger('tillio').child({ component: 'preset' })
11
+
12
+ export const TILLIO_ENV_VARS = {
13
+ apiUrl: 'OM_INTEGRATION_TILLIO_API_URL',
14
+ apiKey: 'OM_INTEGRATION_TILLIO_API_KEY',
15
+ timeZone: 'OM_INTEGRATION_TILLIO_TIMEZONE',
16
+ ringostatKey: 'OM_INTEGRATION_TILLIO_RINGOSTAT_KEY',
17
+ force: 'OM_INTEGRATION_TILLIO_FORCE_PRECONFIGURE',
18
+ replaceOperator: 'OM_INTEGRATION_TILLIO_REPLACE_OPERATOR',
19
+ } as const
20
+
21
+ export type TillioEnvPreset =
22
+ | { status: 'absent' }
23
+ | { status: 'incomplete'; missing: string[] }
24
+ | {
25
+ status: 'ready'
26
+ credentials: TillioEnvironment
27
+ ringostatKey: string | null
28
+ force: boolean
29
+ replaceOperator: boolean
30
+ }
31
+
32
+ export type TillioPresetOutcome =
33
+ | { status: 'skipped'; reason: string }
34
+ | { status: 'blocked'; reason: string }
35
+ | {
36
+ status: 'applied'
37
+ credentialsAction: 'saved' | 'kept'
38
+ health: string
39
+ operator: 'attached' | 'kept' | 'not-requested' | 'failed'
40
+ }
41
+
42
+ type IntegrationStateServiceLike = {
43
+ upsert: (
44
+ integrationId: string,
45
+ input: { isEnabled?: boolean },
46
+ scope: IntegrationScope,
47
+ ) => Promise<unknown>
48
+ }
49
+
50
+ type IntegrationHealthServiceLike = {
51
+ runHealthCheck: (integrationId: string, scope: IntegrationScope) => Promise<{ status: string; message?: string }>
52
+ }
53
+
54
+ type IntegrationLogServiceLike = {
55
+ scoped: (
56
+ integrationId: string,
57
+ scope: IntegrationScope,
58
+ ) => {
59
+ info: (message: string, fields?: Record<string, unknown>) => Promise<unknown>
60
+ warn: (message: string, fields?: Record<string, unknown>) => Promise<unknown>
61
+ }
62
+ }
63
+
64
+ export type ApplyTillioEnvPresetParams = {
65
+ credentialsService: TillioCredentialsService
66
+ integrationStateService: IntegrationStateServiceLike
67
+ integrationHealthService: IntegrationHealthServiceLike
68
+ scope: IntegrationScope
69
+ force?: boolean
70
+ env?: NodeJS.ProcessEnv
71
+ appUrl?: string
72
+ integrationLogService?: IntegrationLogServiceLike
73
+ // Supplied by the CLI, where somebody is at the terminal to answer. Tenant bootstrap passes
74
+ // nothing, so the swap is refused unless the deployment declared it through the env var.
75
+ confirmOperatorReplacement?: (operator: TillioOperatorRecord) => Promise<boolean>
76
+ /**
77
+ * Serializes the operator write against a concurrent attach from the admin UI. Optional
78
+ * because a bootstrap that runs before the app serves traffic has nothing to contend with;
79
+ * both shipped callers pass a real one.
80
+ */
81
+ withLock?: TillioLockRunner
82
+ }
83
+
84
+ function readValue(env: NodeJS.ProcessEnv, key: string): string | undefined {
85
+ const value = env[key]?.trim()
86
+ return value ? value : undefined
87
+ }
88
+
89
+ export function readTillioEnvPreset(env: NodeJS.ProcessEnv = process.env): TillioEnvPreset {
90
+ const apiUrl = readValue(env, TILLIO_ENV_VARS.apiUrl)
91
+ const apiKey = readValue(env, TILLIO_ENV_VARS.apiKey)
92
+ if (!apiUrl && !apiKey) return { status: 'absent' }
93
+
94
+ const missing: string[] = []
95
+ if (!apiUrl) missing.push(TILLIO_ENV_VARS.apiUrl)
96
+ if (!apiKey) missing.push(TILLIO_ENV_VARS.apiKey)
97
+ // Reported rather than thrown: a half-set preset must not take tenant bootstrap down with it.
98
+ if (missing.length) return { status: 'incomplete', missing }
99
+
100
+ return {
101
+ status: 'ready',
102
+ credentials: environmentSchema.parse({ apiUrl, apiKey, timeZone: readValue(env, TILLIO_ENV_VARS.timeZone) }),
103
+ ringostatKey: readValue(env, TILLIO_ENV_VARS.ringostatKey) ?? null,
104
+ force: parseBooleanToken(readValue(env, TILLIO_ENV_VARS.force)) ?? false,
105
+ replaceOperator: parseBooleanToken(readValue(env, TILLIO_ENV_VARS.replaceOperator)) ?? false,
106
+ }
107
+ }
108
+
109
+ function pointsAtAnotherEnvironment(
110
+ stored: Record<string, unknown> | null,
111
+ next: TillioEnvironment,
112
+ ): boolean {
113
+ const parsed = environmentSchema.safeParse(stored ?? {})
114
+ if (!parsed.success) return false
115
+ return parsed.data.apiUrl !== next.apiUrl || parsed.data.apiKey !== next.apiKey
116
+ }
117
+
118
+ async function attachOperatorFromPreset(params: {
119
+ credentialsService: TillioCredentialsService
120
+ scope: IntegrationScope
121
+ appUrl: string
122
+ ringostatKey: string
123
+ withLock: TillioLockRunner
124
+ }): Promise<'attached' | 'kept' | 'failed'> {
125
+ if (!params.appUrl.trim()) {
126
+ logger.warn('cannot attach the operator without APP_URL')
127
+ return 'failed'
128
+ }
129
+
130
+ try {
131
+ await attachOperator(
132
+ {
133
+ credentialsService: params.credentialsService,
134
+ scope: params.scope,
135
+ appUrl: params.appUrl,
136
+ withLock: params.withLock,
137
+ },
138
+ { plugin: 'Ringostat', config: { key: params.ringostatKey } },
139
+ )
140
+ return 'attached'
141
+ } catch (err) {
142
+ // The slot is taken, which is the expected outcome of a rerun.
143
+ if (err instanceof TillioOperatorLimitError) return 'kept'
144
+ logger.warn('failed to attach the operator from the env preset', { err })
145
+ return 'failed'
146
+ }
147
+ }
148
+
149
+ // Every step goes through the service the admin UI uses for the same action, so an env-driven
150
+ // bootstrap and a hand-configured one converge on identical stored state, and anything the preset
151
+ // wrote stays editable in the UI afterwards.
152
+ export async function applyTillioEnvPreset(params: ApplyTillioEnvPresetParams): Promise<TillioPresetOutcome> {
153
+ const preset = readTillioEnvPreset(params.env)
154
+
155
+ if (preset.status === 'absent') {
156
+ return { status: 'skipped', reason: 'No Tillio preset environment variables were provided.' }
157
+ }
158
+ if (preset.status === 'incomplete') {
159
+ const reason = `Incomplete Tillio env preset; missing ${preset.missing.join(', ')}.`
160
+ await params.integrationLogService?.scoped(TILLIO_INTEGRATION_ID, params.scope).warn(reason).catch(() => undefined)
161
+ return { status: 'skipped', reason }
162
+ }
163
+
164
+ const force = params.force ?? preset.force
165
+ const existing = await params.credentialsService.getRaw(TILLIO_INTEGRATION_ID, params.scope)
166
+ // Overwriting silently would undo a rotation somebody performed in the UI.
167
+ const credentialsAction = existing && !force ? 'kept' : 'saved'
168
+
169
+ if (credentialsAction === 'saved' && pointsAtAnotherEnvironment(existing, preset.credentials)) {
170
+ const attached = (await readOperatorsBlob(params.credentialsService, params.scope)).operators[0] ?? null
171
+ if (attached) {
172
+ if (!preset.ringostatKey) {
173
+ return {
174
+ status: 'blocked',
175
+ reason: `The attached operator belongs to the current environment. Set ${TILLIO_ENV_VARS.ringostatKey} so it can be reattached to the new one.`,
176
+ }
177
+ }
178
+
179
+ const approved = preset.replaceOperator
180
+ || (params.confirmOperatorReplacement ? await params.confirmOperatorReplacement(attached) : false)
181
+ if (!approved) {
182
+ return {
183
+ status: 'blocked',
184
+ reason: `Switching environments would strand the attached operator. Confirm the replacement or set ${TILLIO_ENV_VARS.replaceOperator}.`,
185
+ }
186
+ }
187
+
188
+ // Revoked while the credentials that minted its token are still stored; afterwards the new
189
+ // environment would reject the token and the record could only be dropped by force.
190
+ await detachOperator({ credentialsService: params.credentialsService, scope: params.scope }, attached.id)
191
+ }
192
+ }
193
+
194
+ if (credentialsAction === 'saved') {
195
+ await params.credentialsService.save(TILLIO_INTEGRATION_ID, { ...preset.credentials }, params.scope)
196
+ }
197
+
198
+ await params.integrationStateService.upsert(TILLIO_INTEGRATION_ID, { isEnabled: true }, params.scope)
199
+
200
+ // Also mints the tenant identity the operator token is bound to, so it has to run before attaching.
201
+ const health = await params.integrationHealthService.runHealthCheck(TILLIO_INTEGRATION_ID, params.scope)
202
+
203
+ let operator: 'attached' | 'kept' | 'not-requested' | 'failed' = 'not-requested'
204
+ if (preset.ringostatKey) {
205
+ operator = health.status === 'healthy'
206
+ ? await attachOperatorFromPreset({
207
+ credentialsService: params.credentialsService,
208
+ scope: params.scope,
209
+ appUrl: params.appUrl ?? process.env.APP_URL ?? '',
210
+ ringostatKey: preset.ringostatKey,
211
+ withLock: params.withLock ?? ((run) => run()),
212
+ })
213
+ : 'failed'
214
+ }
215
+
216
+ await params.integrationLogService
217
+ ?.scoped(TILLIO_INTEGRATION_ID, params.scope)
218
+ .info('Tillio was preconfigured from environment variables.', {
219
+ credentialsAction,
220
+ health: health.status,
221
+ operator,
222
+ })
223
+ .catch(() => undefined)
224
+
225
+ return { status: 'applied', credentialsAction, health: health.status, operator }
226
+ }
@@ -0,0 +1,390 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { EntityManager } from '@mikro-orm/postgresql'
3
+ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
5
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
6
+ import type {
7
+ NormalizedPhoneCall,
8
+ NormalizedPhoneCallBatch,
9
+ PhoneCallBatchAnomaly,
10
+ } from '@open-mercato/shared/modules/phone_calls/types'
11
+ import type { PhoneCallProviderAdapter } from '@open-mercato/shared/modules/phone_calls/provider'
12
+ import type {
13
+ ProgressService,
14
+ ProgressServiceContext,
15
+ } from '@open-mercato/core/modules/progress/lib/progressService'
16
+ import {
17
+ PHONE_CALLS_CALL_INGEST_COMMAND_ID,
18
+ type IngestPhoneCallResult,
19
+ } from '@open-mercato/core/modules/phone_calls/commands/calls'
20
+ import { emitPhoneCallsEvent } from '@open-mercato/core/modules/phone_calls/events'
21
+ import { TILLIO_INTEGRATION_ID, TILLIO_PROVIDER_KEY } from '../integration'
22
+ import { tillioAdapter } from './adapter'
23
+ import { TillioApiError } from './errors'
24
+ import {
25
+ classifyTillioError,
26
+ readTillioIntegrationState,
27
+ resolveEnvironment,
28
+ type TillioResolvedEnvironment,
29
+ } from './operators'
30
+ import {
31
+ readOperatorsBlob,
32
+ type TillioCredentialsService,
33
+ type TillioOperatorRecord,
34
+ } from './operators-store'
35
+ import { evaluatePullReadiness, PULL_BLOCKER_MESSAGES, type PullReadiness } from './pull-readiness'
36
+ import { zonedDayEnd, zonedDayStart } from './tz'
37
+
38
+ const logger = createLogger('tillio').child({ component: 'pull-job' })
39
+
40
+ export const TILLIO_PULL_JOB_TYPE = 'tillio.calls.pull'
41
+
42
+ // Only trips when the provider keeps advertising further pages, which would spin forever.
43
+ const MAX_BATCHES = 200
44
+
45
+ export type TillioPullScope = {
46
+ tenantId: string
47
+ organizationId: string
48
+ userId?: string | null
49
+ }
50
+
51
+ // No operator token and no API key in the payload: the worker resolves credentials on every
52
+ // attempt, so a job replayed after a rotation uses the current ones instead of stale copies.
53
+ export type TillioPullJobPayload = {
54
+ progressJobId: string
55
+ scope: TillioPullScope
56
+ from: string
57
+ to: string
58
+ cursor?: string | null
59
+ limit?: number | null
60
+ }
61
+
62
+ export type TillioPullSummary = {
63
+ fetched: number
64
+ created: number
65
+ updated: number
66
+ failed: number
67
+ batches: number
68
+ cancelled: boolean
69
+ /** True when the sweep stopped before the range was exhausted. */
70
+ truncated: boolean
71
+ /** Where a follow-up pull should pick up. Null unless `truncated`. */
72
+ resumeCursor: string | null
73
+ }
74
+
75
+ export type TillioPullContext = {
76
+ readiness: PullReadiness
77
+ environment: TillioResolvedEnvironment | null
78
+ operator: TillioOperatorRecord | null
79
+ }
80
+
81
+ type IngestDeps = {
82
+ commandBus: CommandBus
83
+ commandContext: CommandRuntimeContext
84
+ scope: IntegrationScope
85
+ }
86
+
87
+ type BatchCounts = Pick<TillioPullSummary, 'created' | 'updated' | 'failed'>
88
+
89
+ export async function resolvePullContext(
90
+ credentialsService: TillioCredentialsService,
91
+ em: EntityManager,
92
+ scope: IntegrationScope,
93
+ ): Promise<TillioPullContext> {
94
+ const environment = await resolveEnvironment(credentialsService, scope)
95
+ const integrationState = await readTillioIntegrationState(em, scope)
96
+ const blob = await readOperatorsBlob(credentialsService, scope)
97
+ const operator =
98
+ blob.operators.find((entry) => entry.id === blob.defaultOperatorId) ?? blob.operators[0] ?? null
99
+
100
+ return {
101
+ readiness: evaluatePullReadiness({
102
+ environment,
103
+ integrationEnabled: integrationState.enabled,
104
+ environmentHealthy: integrationState.healthy,
105
+ operator,
106
+ }),
107
+ environment,
108
+ operator,
109
+ }
110
+ }
111
+
112
+ function buildIngestInput(call: NormalizedPhoneCall, scope: IntegrationScope): Record<string, unknown> {
113
+ return {
114
+ organizationId: scope.organizationId,
115
+ tenantId: scope.tenantId,
116
+ providerKey: TILLIO_PROVIDER_KEY,
117
+ integrationId: TILLIO_INTEGRATION_ID,
118
+ externalCallId: call.externalCallId,
119
+ externalConversationId: call.externalConversationId ?? null,
120
+ direction: call.direction,
121
+ status: call.status,
122
+ participants: call.participants,
123
+ recording: call.recording ?? null,
124
+ startedAt: call.startedAt ?? null,
125
+ answeredAt: call.answeredAt ?? null,
126
+ endedAt: call.endedAt ?? null,
127
+ durationSeconds: call.durationSeconds ?? null,
128
+ providerFacts: call.providerFacts,
129
+ rawPayload: call.rawPayload,
130
+ }
131
+ }
132
+
133
+ // The initiating user travels in the job payload, so ingest writes carry an actor instead of
134
+ // landing in the audit trail unattributed. The id was resolved from the session that queued
135
+ // the pull, never from request input.
136
+ function buildCommandContext(
137
+ container: AwilixContainer,
138
+ scope: IntegrationScope,
139
+ userId: string | null,
140
+ ): CommandRuntimeContext {
141
+ return {
142
+ container,
143
+ auth: userId
144
+ ? { sub: userId, tenantId: scope.tenantId, orgId: scope.organizationId, isSuperAdmin: false }
145
+ : null,
146
+ organizationScope: {
147
+ selectedId: scope.organizationId,
148
+ filterIds: [scope.organizationId],
149
+ allowedIds: [scope.organizationId],
150
+ tenantId: scope.tenantId,
151
+ },
152
+ selectedOrganizationId: scope.organizationId,
153
+ organizationIds: [scope.organizationId],
154
+ }
155
+ }
156
+
157
+ async function ingestBatch(calls: NormalizedPhoneCall[], deps: IngestDeps): Promise<BatchCounts> {
158
+ const counts: BatchCounts = { created: 0, updated: 0, failed: 0 }
159
+
160
+ for (const call of calls) {
161
+ try {
162
+ const executed = await deps.commandBus.execute<Record<string, unknown>, IngestPhoneCallResult>(
163
+ PHONE_CALLS_CALL_INGEST_COMMAND_ID,
164
+ { input: buildIngestInput(call, deps.scope), ctx: deps.commandContext },
165
+ )
166
+ if (executed.result.created) counts.created += 1
167
+ else counts.updated += 1
168
+ } catch (err) {
169
+ // One transaction per call, so a single bad record only bumps `failed` and the rest of
170
+ // the batch still lands.
171
+ counts.failed += 1
172
+ logger.warn('ingest failed for a pulled call', { externalCallId: call.externalCallId, err })
173
+ await emitPhoneCallsEvent('phone_calls.call.ingest_failed', {
174
+ providerKey: TILLIO_PROVIDER_KEY,
175
+ externalCallId: call.externalCallId,
176
+ organizationId: deps.scope.organizationId,
177
+ tenantId: deps.scope.tenantId,
178
+ }).catch(() => undefined)
179
+ }
180
+ }
181
+
182
+ return counts
183
+ }
184
+
185
+ type PullFailure = { code: string; message: string }
186
+
187
+ // The progress bar renders `errorMessage` verbatim and has no path to a translated string, so
188
+ // what goes in has to be ours: a provider message could carry Tillio's own wording, an internal
189
+ // hostname or a token fragment. The raw error stays in the log, the stable code in the summary.
190
+ function describeFailure(err: unknown): PullFailure {
191
+ if (err instanceof TillioApiError) {
192
+ return classifyTillioError(err) === 'environment'
193
+ ? { code: 'provider_unreachable', message: 'Tillio could not be reached or rejected the stored credentials.' }
194
+ : { code: 'provider_error', message: 'Tillio rejected the request for calls.' }
195
+ }
196
+ return { code: 'pull_failed', message: 'The Tillio pull could not be completed.' }
197
+ }
198
+
199
+ function describeTruncation(summary: TillioPullSummary, anomaly: PhoneCallBatchAnomaly | null): PullFailure {
200
+ if (anomaly) {
201
+ return {
202
+ code: 'pull_pagination_anomaly',
203
+ message: `Tillio returned an inconsistent page listing, so the sweep stopped early after ${summary.fetched} calls.`,
204
+ }
205
+ }
206
+ return {
207
+ code: 'pull_truncated',
208
+ message: `Stopped after ${summary.batches} batches with more calls left in the range; ${summary.fetched} calls were ingested.`,
209
+ }
210
+ }
211
+
212
+ // Safe to retry: the ingest command keys on (providerKey, externalCallId) and updates in place,
213
+ // so a job the queue replays after a crash repeats work instead of duplicating rows.
214
+ export async function runTillioPullJob(params: {
215
+ container: AwilixContainer
216
+ payload: TillioPullJobPayload
217
+ adapter?: PhoneCallProviderAdapter
218
+ }): Promise<TillioPullSummary> {
219
+ const { container, payload } = params
220
+ const adapter = params.adapter ?? tillioAdapter
221
+ const scope: IntegrationScope = {
222
+ organizationId: payload.scope.organizationId,
223
+ tenantId: payload.scope.tenantId,
224
+ }
225
+
226
+ const progressService = container.resolve('progressService') as ProgressService
227
+ const progressContext: ProgressServiceContext = {
228
+ tenantId: payload.scope.tenantId,
229
+ organizationId: payload.scope.organizationId,
230
+ userId: payload.scope.userId ?? null,
231
+ }
232
+ const summary: TillioPullSummary = {
233
+ fetched: 0,
234
+ created: 0,
235
+ updated: 0,
236
+ failed: 0,
237
+ batches: 0,
238
+ cancelled: false,
239
+ truncated: false,
240
+ resumeCursor: null,
241
+ }
242
+
243
+ await progressService.startJob(payload.progressJobId, progressContext)
244
+
245
+ try {
246
+ const credentialsService = container.resolve('integrationCredentialsService') as TillioCredentialsService
247
+ const em = container.resolve('em') as EntityManager
248
+
249
+ // Not trusted from enqueue time: credentials can rotate or the operator can be detached
250
+ // while the job waits in the queue.
251
+ const { readiness, environment, operator } = await resolvePullContext(credentialsService, em, scope)
252
+ if (readiness.blocker || !environment || !operator) {
253
+ const blocker = readiness.blocker ?? 'environment_not_ready'
254
+ await progressService.failJob(
255
+ payload.progressJobId,
256
+ {
257
+ errorMessage: PULL_BLOCKER_MESSAGES[blocker],
258
+ resultSummary: { ...summary, code: blocker },
259
+ },
260
+ progressContext,
261
+ )
262
+ return summary
263
+ }
264
+
265
+ const deps: IngestDeps = {
266
+ commandBus: container.resolve('commandBus') as CommandBus,
267
+ commandContext: buildCommandContext(container, scope, payload.scope.userId ?? null),
268
+ scope,
269
+ }
270
+ const credentials = {
271
+ apiUrl: environment.apiUrl,
272
+ apiKey: environment.apiKey,
273
+ tenantSystemId: environment.tenantSystemId,
274
+ timeZone: environment.timeZone,
275
+ operator: {
276
+ id: operator.id,
277
+ plugin: operator.plugin,
278
+ token: operator.token,
279
+ tenantDomain: operator.tenantDomain,
280
+ },
281
+ }
282
+ const fetchBatch = (cursor: string | null): Promise<NormalizedPhoneCallBatch> =>
283
+ adapter.fetchCalls({
284
+ credentials,
285
+ scope,
286
+ integrationId: TILLIO_INTEGRATION_ID,
287
+ from: zonedDayStart(payload.from, environment.timeZone),
288
+ to: zonedDayEnd(payload.to, environment.timeZone),
289
+ cursor,
290
+ limit: payload.limit ?? null,
291
+ })
292
+
293
+ let cursor = payload.cursor ?? null
294
+ let anomaly: PhoneCallBatchAnomaly | null = null
295
+
296
+ while (summary.batches < MAX_BATCHES) {
297
+ const cancelled = await progressService.isCancellationRequested(
298
+ payload.progressJobId,
299
+ payload.scope.tenantId,
300
+ payload.scope.organizationId,
301
+ )
302
+ if (cancelled) {
303
+ summary.cancelled = true
304
+ await progressService.markCancelled(payload.progressJobId, progressContext)
305
+ return summary
306
+ }
307
+
308
+ const batch = await fetchBatch(cursor)
309
+ const counts = await ingestBatch(batch.calls, deps)
310
+
311
+ summary.batches += 1
312
+ summary.fetched += batch.calls.length
313
+ summary.created += counts.created
314
+ summary.updated += counts.updated
315
+ summary.failed += counts.failed
316
+
317
+ // Tillio reports no range total, so the job carries a processed count without a total
318
+ // and the top bar renders it without a percentage.
319
+ await progressService.updateProgress(
320
+ payload.progressJobId,
321
+ { processedCount: summary.fetched },
322
+ progressContext,
323
+ )
324
+
325
+ if (batch.anomaly) {
326
+ anomaly = batch.anomaly
327
+ // Re-running the page we could not trust is safe, because ingest keys on the
328
+ // provider's call id and updates in place.
329
+ summary.resumeCursor = batch.anomalyCursor ?? cursor
330
+ break
331
+ }
332
+
333
+ cursor = batch.nextCursor ?? null
334
+ if (!cursor) break
335
+ }
336
+
337
+ // A cursor surviving the loop means the batch cap ended it, not the provider.
338
+ if (!anomaly && cursor) summary.resumeCursor = cursor
339
+
340
+ if (anomaly || summary.resumeCursor) {
341
+ summary.truncated = true
342
+ const failure = describeTruncation(summary, anomaly)
343
+ logger.warn('the Tillio pull did not cover the whole range', {
344
+ progressJobId: payload.progressJobId,
345
+ code: failure.code,
346
+ anomaly,
347
+ resumeCursor: summary.resumeCursor,
348
+ })
349
+ // No in-between job state exists, and a sweep that silently skipped calls must not read
350
+ // as a clean one. The counts and the resume point ride along so the failure still says
351
+ // what landed and where to pick it up; the calls already ingested stay committed.
352
+ await progressService.failJob(
353
+ payload.progressJobId,
354
+ {
355
+ errorMessage: failure.message,
356
+ resultSummary: { ...summary, code: failure.code, ...(anomaly ? { anomaly } : {}) },
357
+ },
358
+ progressContext,
359
+ )
360
+ return summary
361
+ }
362
+
363
+ await progressService.completeJob(
364
+ payload.progressJobId,
365
+ { resultSummary: { ...summary } },
366
+ progressContext,
367
+ )
368
+ return summary
369
+ } catch (err) {
370
+ const failure = describeFailure(err)
371
+ logger.error('the Tillio pull failed', { progressJobId: payload.progressJobId, code: failure.code, err })
372
+ try {
373
+ await progressService.failJob(
374
+ payload.progressJobId,
375
+ {
376
+ errorMessage: failure.message,
377
+ errorStack: err instanceof Error ? err.stack?.slice(0, 10000) : undefined,
378
+ resultSummary: { ...summary, code: failure.code },
379
+ },
380
+ progressContext,
381
+ )
382
+ } catch (failErr) {
383
+ logger.error('failed to mark the pull progress job as failed', {
384
+ progressJobId: payload.progressJobId,
385
+ err: failErr,
386
+ })
387
+ }
388
+ throw err
389
+ }
390
+ }
@@ -0,0 +1,77 @@
1
+ import { computeEnvFingerprint, type TillioOperatorRecord } from './operators-store'
2
+ import type { TillioResolvedEnvironment } from './operators'
3
+
4
+ // The two levels the environment alone can be blocked at. Pulling and attaching an operator ask
5
+ // different questions of the operator level, but the same question of this one, so they share it:
6
+ // the `enabled` term was once written out in both places and one copy drifted.
7
+ export type EnvironmentBlocker = 'integration_disabled' | 'environment_not_ready'
8
+
9
+ export type PullBlocker = EnvironmentBlocker | 'operator_missing' | 'environment_drift'
10
+
11
+ export type EnvironmentReadiness = {
12
+ ready: boolean
13
+ blocker: EnvironmentBlocker | null
14
+ }
15
+
16
+ export type EvaluateEnvironmentReadinessInput = {
17
+ environment: TillioResolvedEnvironment | null
18
+ integrationEnabled: boolean
19
+ environmentHealthy: boolean
20
+ }
21
+
22
+ export function evaluateEnvironmentReadiness(input: EvaluateEnvironmentReadinessInput): EnvironmentReadiness {
23
+ const ready = Boolean(input.environment) && input.integrationEnabled && input.environmentHealthy
24
+ // A switched-off integration is reported ahead of health because re-running a health check would
25
+ // not change anything while it is off - but only once there is something to switch on. A missing
26
+ // integration row also reads as disabled, so reporting that first on a fresh install would tell
27
+ // the user to enable an integration they have not configured yet, instead of to configure it.
28
+ if (!input.integrationEnabled && input.environment) return { ready, blocker: 'integration_disabled' }
29
+ if (!ready) return { ready, blocker: 'environment_not_ready' }
30
+ return { ready, blocker: null }
31
+ }
32
+
33
+ export type PullReadiness = {
34
+ environmentReady: boolean
35
+ operatorAttached: boolean
36
+ envDrift: boolean
37
+ blocker: PullBlocker | null
38
+ }
39
+
40
+ export type EvaluatePullReadinessInput = {
41
+ environment: TillioResolvedEnvironment | null
42
+ integrationEnabled: boolean
43
+ environmentHealthy: boolean
44
+ operator: TillioOperatorRecord | null
45
+ }
46
+
47
+ export function evaluatePullReadiness(input: EvaluatePullReadinessInput): PullReadiness {
48
+ const environment = evaluateEnvironmentReadiness(input)
49
+ const environmentReady = environment.ready
50
+ const operatorAttached = Boolean(input.operator)
51
+ const envDrift =
52
+ Boolean(input.environment) &&
53
+ Boolean(input.operator) &&
54
+ input.operator!.envFingerprint !== computeEnvFingerprint(input.environment!)
55
+
56
+ // Ordered so the reported blocker is the one the user can act on first: an unhealthy environment
57
+ // makes operator state meaningless, and drift only matters once both levels exist. Each code
58
+ // routes the UI to the settings section that fixes it.
59
+ if (environment.blocker) {
60
+ return { environmentReady, operatorAttached, envDrift, blocker: environment.blocker }
61
+ }
62
+ if (!operatorAttached) return { environmentReady, operatorAttached, envDrift, blocker: 'operator_missing' }
63
+ if (envDrift) return { environmentReady, operatorAttached, envDrift, blocker: 'environment_drift' }
64
+ return { environmentReady, operatorAttached, envDrift, blocker: null }
65
+ }
66
+
67
+ export const PULL_BLOCKER_MESSAGES: Record<PullBlocker, string> = {
68
+ integration_disabled: 'The Tillio integration is disabled. Enable it before pulling calls.',
69
+ environment_not_ready: 'Configure the Tillio environment and run the health check first.',
70
+ operator_missing: 'Attach a Tillio operator before pulling calls.',
71
+ environment_drift: 'The environment changed after this operator was attached. Detach and attach it again before pulling calls.',
72
+ }
73
+
74
+ export function blockerSection(blocker: PullBlocker): 'environment' | 'operator' {
75
+ if (blocker === 'integration_disabled' || blocker === 'environment_not_ready') return 'environment'
76
+ return 'operator'
77
+ }