@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +7 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js +17 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js +27 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js +47 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-003-operators.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js +11 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.js.map +7 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js +13 -0
- package/dist/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.js.map +7 -0
- package/dist/modules/tillio/acl.js +9 -0
- package/dist/modules/tillio/acl.js.map +7 -0
- package/dist/modules/tillio/api/operators/[id]/route.js +78 -0
- package/dist/modules/tillio/api/operators/[id]/route.js.map +7 -0
- package/dist/modules/tillio/api/operators/route.js +171 -0
- package/dist/modules/tillio/api/operators/route.js.map +7 -0
- package/dist/modules/tillio/api/pull/route.js +180 -0
- package/dist/modules/tillio/api/pull/route.js.map +7 -0
- package/dist/modules/tillio/cli.js +108 -0
- package/dist/modules/tillio/cli.js.map +7 -0
- package/dist/modules/tillio/di.js +16 -0
- package/dist/modules/tillio/di.js.map +7 -0
- package/dist/modules/tillio/index.js +12 -0
- package/dist/modules/tillio/index.js.map +7 -0
- package/dist/modules/tillio/integration.js +62 -0
- package/dist/modules/tillio/integration.js.map +7 -0
- package/dist/modules/tillio/lib/adapter.js +107 -0
- package/dist/modules/tillio/lib/adapter.js.map +7 -0
- package/dist/modules/tillio/lib/client.js +249 -0
- package/dist/modules/tillio/lib/client.js.map +7 -0
- package/dist/modules/tillio/lib/environment.js +34 -0
- package/dist/modules/tillio/lib/environment.js.map +7 -0
- package/dist/modules/tillio/lib/error-codes.js +59 -0
- package/dist/modules/tillio/lib/error-codes.js.map +7 -0
- package/dist/modules/tillio/lib/errors.js +12 -0
- package/dist/modules/tillio/lib/errors.js.map +7 -0
- package/dist/modules/tillio/lib/health.js +41 -0
- package/dist/modules/tillio/lib/health.js.map +7 -0
- package/dist/modules/tillio/lib/locking.js +18 -0
- package/dist/modules/tillio/lib/locking.js.map +7 -0
- package/dist/modules/tillio/lib/normalizer.js +134 -0
- package/dist/modules/tillio/lib/normalizer.js.map +7 -0
- package/dist/modules/tillio/lib/operators-store.js +62 -0
- package/dist/modules/tillio/lib/operators-store.js.map +7 -0
- package/dist/modules/tillio/lib/operators.js +168 -0
- package/dist/modules/tillio/lib/operators.js.map +7 -0
- package/dist/modules/tillio/lib/preset.js +122 -0
- package/dist/modules/tillio/lib/preset.js.map +7 -0
- package/dist/modules/tillio/lib/pull-job.js +265 -0
- package/dist/modules/tillio/lib/pull-job.js.map +7 -0
- package/dist/modules/tillio/lib/pull-readiness.js +36 -0
- package/dist/modules/tillio/lib/pull-readiness.js.map +7 -0
- package/dist/modules/tillio/lib/queue.js +22 -0
- package/dist/modules/tillio/lib/queue.js.map +7 -0
- package/dist/modules/tillio/lib/tz.js +85 -0
- package/dist/modules/tillio/lib/tz.js.map +7 -0
- package/dist/modules/tillio/lib/url-guard.js +24 -0
- package/dist/modules/tillio/lib/url-guard.js.map +7 -0
- package/dist/modules/tillio/setup.js +45 -0
- package/dist/modules/tillio/setup.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js +208 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.client.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.js +17 -0
- package/dist/modules/tillio/widgets/injection/operators-config/widget.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js +148 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.client.js.map +7 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.js +17 -0
- package/dist/modules/tillio/widgets/injection/pull-calls/widget.js.map +7 -0
- package/dist/modules/tillio/widgets/injection-table.js +21 -0
- package/dist/modules/tillio/widgets/injection-table.js.map +7 -0
- package/dist/modules/tillio/workers/tillio-pull.js +17 -0
- package/dist/modules/tillio/workers/tillio-pull.js.map +7 -0
- package/jest.config.cjs +35 -0
- package/package.json +92 -0
- package/src/index.ts +1 -0
- package/src/modules/tillio/__integration__/TC-PHONE-001-pull-readiness.spec.ts +38 -0
- package/src/modules/tillio/__integration__/TC-PHONE-002-pull-blocked.spec.ts +44 -0
- package/src/modules/tillio/__integration__/TC-PHONE-003-operators.spec.ts +76 -0
- package/src/modules/tillio/__integration__/TC-PHONE-004-pull-widget.spec.ts +22 -0
- package/src/modules/tillio/__integration__/TC-PHONE-005-operators-widget.spec.ts +27 -0
- package/src/modules/tillio/__tests__/adapter-fetch-calls.test.ts +148 -0
- package/src/modules/tillio/__tests__/client.test.ts +203 -0
- package/src/modules/tillio/__tests__/health.test.ts +122 -0
- package/src/modules/tillio/__tests__/normalizer.test.ts +143 -0
- package/src/modules/tillio/__tests__/operators-routes.test.ts +100 -0
- package/src/modules/tillio/__tests__/operators-store.test.ts +104 -0
- package/src/modules/tillio/__tests__/operators.test.ts +250 -0
- package/src/modules/tillio/__tests__/preset.test.ts +337 -0
- package/src/modules/tillio/__tests__/pull-body.test.ts +43 -0
- package/src/modules/tillio/__tests__/pull-job.test.ts +250 -0
- package/src/modules/tillio/__tests__/pull-readiness.test.ts +143 -0
- package/src/modules/tillio/__tests__/pull-route.test.ts +148 -0
- package/src/modules/tillio/__tests__/tz.test.ts +53 -0
- package/src/modules/tillio/__tests__/url-guard.test.ts +46 -0
- package/src/modules/tillio/acl.ts +5 -0
- package/src/modules/tillio/api/operators/[id]/route.ts +90 -0
- package/src/modules/tillio/api/operators/route.ts +191 -0
- package/src/modules/tillio/api/pull/route.ts +213 -0
- package/src/modules/tillio/cli.ts +125 -0
- package/src/modules/tillio/di.ts +16 -0
- package/src/modules/tillio/i18n/de.json +73 -0
- package/src/modules/tillio/i18n/en.json +73 -0
- package/src/modules/tillio/i18n/es.json +73 -0
- package/src/modules/tillio/i18n/ko.json +73 -0
- package/src/modules/tillio/i18n/pl.json +73 -0
- package/src/modules/tillio/index.ts +8 -0
- package/src/modules/tillio/integration.ts +58 -0
- package/src/modules/tillio/lib/adapter.ts +135 -0
- package/src/modules/tillio/lib/client.ts +329 -0
- package/src/modules/tillio/lib/environment.ts +53 -0
- package/src/modules/tillio/lib/error-codes.ts +67 -0
- package/src/modules/tillio/lib/errors.ts +11 -0
- package/src/modules/tillio/lib/health.ts +57 -0
- package/src/modules/tillio/lib/locking.ts +30 -0
- package/src/modules/tillio/lib/normalizer.ts +184 -0
- package/src/modules/tillio/lib/operators-store.ts +77 -0
- package/src/modules/tillio/lib/operators.ts +269 -0
- package/src/modules/tillio/lib/preset.ts +226 -0
- package/src/modules/tillio/lib/pull-job.ts +390 -0
- package/src/modules/tillio/lib/pull-readiness.ts +77 -0
- package/src/modules/tillio/lib/queue.ts +33 -0
- package/src/modules/tillio/lib/tz.ts +121 -0
- package/src/modules/tillio/lib/url-guard.ts +34 -0
- package/src/modules/tillio/setup.ts +49 -0
- package/src/modules/tillio/widgets/injection/operators-config/widget.client.tsx +304 -0
- package/src/modules/tillio/widgets/injection/operators-config/widget.ts +16 -0
- package/src/modules/tillio/widgets/injection/pull-calls/widget.client.tsx +214 -0
- package/src/modules/tillio/widgets/injection/pull-calls/widget.ts +16 -0
- package/src/modules/tillio/widgets/injection-table.ts +19 -0
- package/src/modules/tillio/workers/tillio-pull.ts +18 -0
- 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
|
+
}
|