@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,90 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
6
+ import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
7
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
8
+ import { tillioErrorCopy } from '../../../lib/error-codes'
9
+ import type { TillioCredentialsService } from '../../../lib/operators-store'
10
+ import {
11
+ detachOperator,
12
+ TILLIO_OPERATOR_RESOURCE_KIND,
13
+ TillioRevocationFailedError,
14
+ } from '../../../lib/operators'
15
+
16
+ const idParamsSchema = z.object({ id: z.string().trim().min(1) })
17
+
18
+ export const metadata = {
19
+ DELETE: { requireAuth: true, requireFeatures: ['tillio.manage', 'integrations.manage'] },
20
+ }
21
+
22
+ export const openApi = {
23
+ tags: ['Tillio'],
24
+ summary: 'Detach a Tillio operator',
25
+ }
26
+
27
+ export async function DELETE(req: Request, ctx: { params?: Promise<{ id?: string }> | { id?: string } }) {
28
+ const auth = await getAuthFromRequest(req)
29
+ if (!auth?.tenantId || !auth.orgId) {
30
+ return NextResponse.json({ ok: false, code: 'unauthorized', message: 'Unauthorized' }, { status: 401 })
31
+ }
32
+
33
+ const rawParams = (ctx.params && typeof (ctx.params as Promise<unknown>).then === 'function')
34
+ ? await (ctx.params as Promise<{ id?: string }>)
35
+ : (ctx.params as { id?: string } | undefined)
36
+
37
+ const parsedParams = idParamsSchema.safeParse(rawParams)
38
+ if (!parsedParams.success) {
39
+ return NextResponse.json({ ok: false, code: 'invalid_operator_id', message: 'Invalid operator id' }, { status: 400 })
40
+ }
41
+ const operatorId = parsedParams.data.id
42
+ const force = parseBooleanWithDefault(new URL(req.url).searchParams.get('force'), false)
43
+
44
+ const container = await createRequestContainer()
45
+ const credentialsService = container.resolve('integrationCredentialsService') as TillioCredentialsService
46
+ const scope: IntegrationScope = { organizationId: auth.orgId, tenantId: auth.tenantId }
47
+
48
+ const guarded = await runRouteMutationGuards({
49
+ container,
50
+ req,
51
+ auth: { userId: auth.sub, tenantId: auth.tenantId, organizationId: auth.orgId },
52
+ input: {
53
+ resourceKind: TILLIO_OPERATOR_RESOURCE_KIND,
54
+ resourceId: operatorId,
55
+ operation: 'delete',
56
+ mutationPayload: { operatorId, force },
57
+ },
58
+ })
59
+ if (!guarded.ok) return guarded.response
60
+
61
+ // A guard that refuses to let `force` through has to be able to say so. Only the flag is
62
+ // read back: rewriting which operator gets detached would silently retarget the request.
63
+ const guardedForce = typeof guarded.modifiedPayload?.force === 'boolean'
64
+ ? guarded.modifiedPayload.force
65
+ : force
66
+
67
+ try {
68
+ const result = await detachOperator({ credentialsService, scope }, operatorId, { force: guardedForce })
69
+ await guarded.runAfterSuccess()
70
+ return NextResponse.json({ ok: true, detached: result.detached, revoked: result.revoked })
71
+ } catch (err) {
72
+ if (err instanceof TillioRevocationFailedError) {
73
+ const code = err.environmentMissing ? 'environment_not_ready' : 'revocation_failed'
74
+ return NextResponse.json(
75
+ {
76
+ ok: false,
77
+ code,
78
+ section: err.environmentMissing ? 'environment' : 'operator',
79
+ message: tillioErrorCopy(code, 'detach_failed').fallback,
80
+ canForce: true,
81
+ },
82
+ { status: 502 },
83
+ )
84
+ }
85
+ return NextResponse.json(
86
+ { ok: false, code: 'detach_failed', section: 'operator', message: 'Failed to detach the operator.' },
87
+ { status: 500 },
88
+ )
89
+ }
90
+ }
@@ -0,0 +1,191 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
7
+ import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
8
+ import { createLogger } from '@open-mercato/shared/lib/logger'
9
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
10
+ import {
11
+ computeEnvFingerprint,
12
+ readOperatorsBlob,
13
+ type TillioCredentialsService,
14
+ } from '../../lib/operators-store'
15
+ import { createTillioLock, tillioOperatorLockKey } from '../../lib/locking'
16
+ import { evaluateEnvironmentReadiness, type EnvironmentBlocker } from '../../lib/pull-readiness'
17
+ import {
18
+ TillioApiError,
19
+ } from '../../lib/errors'
20
+ import { tillioErrorCopy } from '../../lib/error-codes'
21
+ import {
22
+ attachOperator,
23
+ classifyTillioError,
24
+ readTillioIntegrationState,
25
+ resolveEnvironment,
26
+ TILLIO_OPERATOR_RESOURCE_KIND,
27
+ TillioEnvironmentNotReadyError,
28
+ TillioOperatorLimitError,
29
+ } from '../../lib/operators'
30
+
31
+ const SUPPORTED_PLUGINS = ['Ringostat'] as const
32
+ const logger = createLogger('tillio').child({ component: 'operators-route' })
33
+
34
+ // Same blockers as the pull reports, worded for the action the caller was attempting.
35
+ const ATTACH_BLOCKER_MESSAGES: Record<EnvironmentBlocker, string> = {
36
+ integration_disabled: 'The Tillio integration is disabled. Enable it before attaching an operator.',
37
+ environment_not_ready: 'Run the Tillio environment health check before attaching an operator.',
38
+ }
39
+
40
+ const attachBodySchema = z.object({
41
+ plugin: z.literal('Ringostat'),
42
+ config: z.object({ key: z.string().trim().min(1) }),
43
+ label: z.string().trim().optional(),
44
+ })
45
+
46
+ export const metadata = {
47
+ GET: { requireAuth: true, requireFeatures: ['tillio.manage', 'integrations.manage'] },
48
+ POST: { requireAuth: true, requireFeatures: ['tillio.manage', 'integrations.manage'] },
49
+ }
50
+
51
+ export const openApi = {
52
+ tags: ['Tillio'],
53
+ summary: 'List and attach Tillio operators',
54
+ }
55
+
56
+ export async function GET(req: Request) {
57
+ const auth = await getAuthFromRequest(req)
58
+ if (!auth?.tenantId || !auth.orgId) {
59
+ return NextResponse.json({ ok: false, code: 'unauthorized', message: 'Unauthorized' }, { status: 401 })
60
+ }
61
+
62
+ const container = await createRequestContainer()
63
+ const credentialsService = container.resolve('integrationCredentialsService') as TillioCredentialsService
64
+ const em = container.resolve('em') as EntityManager
65
+ const scope: IntegrationScope = { organizationId: auth.orgId, tenantId: auth.tenantId }
66
+
67
+ const environment = await resolveEnvironment(credentialsService, scope)
68
+ const integrationState = await readTillioIntegrationState(em, scope)
69
+ const { ready: environmentReady, blocker: environmentBlocker } = evaluateEnvironmentReadiness({
70
+ environment,
71
+ integrationEnabled: integrationState.enabled,
72
+ environmentHealthy: integrationState.healthy,
73
+ })
74
+ const blob = await readOperatorsBlob(credentialsService, scope)
75
+ const currentFingerprint = environment ? computeEnvFingerprint(environment) : null
76
+
77
+ const operators = blob.operators.map((operator) => ({
78
+ id: operator.id,
79
+ plugin: operator.plugin,
80
+ tenantDomain: operator.tenantDomain,
81
+ stale: currentFingerprint ? operator.envFingerprint !== currentFingerprint : true,
82
+ }))
83
+
84
+ return NextResponse.json({
85
+ ok: true,
86
+ environmentReady,
87
+ environmentBlocker,
88
+ tenantSystemId: environment?.tenantSystemId ?? null,
89
+ supportedPlugins: SUPPORTED_PLUGINS,
90
+ operators,
91
+ defaultOperatorId: blob.defaultOperatorId,
92
+ envDrift: operators.some((operator) => operator.stale),
93
+ })
94
+ }
95
+
96
+ export async function POST(req: Request) {
97
+ const auth = await getAuthFromRequest(req)
98
+ if (!auth?.tenantId || !auth.orgId) {
99
+ return NextResponse.json({ ok: false, code: 'unauthorized', message: 'Unauthorized' }, { status: 401 })
100
+ }
101
+
102
+ const parsedBody = attachBodySchema.safeParse(await readJsonSafe(req))
103
+ if (!parsedBody.success) {
104
+ return NextResponse.json({ ok: false, code: 'invalid_payload', message: 'Invalid operator payload' }, { status: 400 })
105
+ }
106
+
107
+ const container = await createRequestContainer()
108
+ const credentialsService = container.resolve('integrationCredentialsService') as TillioCredentialsService
109
+ const em = container.resolve('em') as EntityManager
110
+ const scope: IntegrationScope = { organizationId: auth.orgId, tenantId: auth.tenantId }
111
+
112
+ const environment = await resolveEnvironment(credentialsService, scope)
113
+ const integrationState = await readTillioIntegrationState(em, scope)
114
+ // Provisioning an operator registers a config on Tillio's side, so the environment is checked
115
+ // here rather than left to the pull to discover. The decision is the shared one; only the
116
+ // wording is specific to attaching.
117
+ const { blocker } = evaluateEnvironmentReadiness({
118
+ environment,
119
+ integrationEnabled: integrationState.enabled,
120
+ environmentHealthy: integrationState.healthy,
121
+ })
122
+ if (blocker) {
123
+ return NextResponse.json(
124
+ { ok: false, code: blocker, section: 'environment', message: ATTACH_BLOCKER_MESSAGES[blocker] },
125
+ { status: 409 },
126
+ )
127
+ }
128
+
129
+ const appUrl = process.env.APP_URL ?? ''
130
+ if (!appUrl.trim()) {
131
+ return NextResponse.json(
132
+ { ok: false, code: 'app_url_missing', section: 'environment', message: 'APP_URL is not configured; it is required to derive the operator webhook domain.' },
133
+ { status: 500 },
134
+ )
135
+ }
136
+
137
+ const guarded = await runRouteMutationGuards({
138
+ container,
139
+ req,
140
+ auth: { userId: auth.sub, tenantId: auth.tenantId, organizationId: auth.orgId },
141
+ input: {
142
+ resourceKind: TILLIO_OPERATOR_RESOURCE_KIND,
143
+ operation: 'create',
144
+ // The operator key is a credential, so it is described to guards, never handed to them.
145
+ mutationPayload: { plugin: parsedBody.data.plugin, label: parsedBody.data.label ?? null },
146
+ },
147
+ })
148
+ if (!guarded.ok) return guarded.response
149
+
150
+ const guardedLabel = typeof guarded.modifiedPayload?.label === 'string'
151
+ ? guarded.modifiedPayload.label
152
+ : parsedBody.data.label
153
+
154
+ try {
155
+ const operator = await attachOperator(
156
+ { credentialsService, scope, appUrl, withLock: createTillioLock(em, tillioOperatorLockKey(scope)) },
157
+ { plugin: parsedBody.data.plugin, config: parsedBody.data.config, label: guardedLabel },
158
+ )
159
+ await guarded.runAfterSuccess()
160
+ return NextResponse.json({
161
+ ok: true,
162
+ operator: { id: operator.id, plugin: operator.plugin, tenantDomain: operator.tenantDomain },
163
+ })
164
+ } catch (err) {
165
+ if (err instanceof TillioOperatorLimitError) {
166
+ return NextResponse.json({ ok: false, code: 'operator_limit', section: 'operator', message: err.message }, { status: 409 })
167
+ }
168
+ if (err instanceof TillioEnvironmentNotReadyError) {
169
+ return NextResponse.json({ ok: false, code: 'environment_not_ready', section: 'environment', message: err.message }, { status: 409 })
170
+ }
171
+ if (err instanceof TillioApiError) {
172
+ const section = classifyTillioError(err)
173
+ logger.error('Tillio operator attach failed at provider', {
174
+ section,
175
+ status: err.status,
176
+ detail: err.detail,
177
+ err,
178
+ })
179
+ return NextResponse.json(
180
+ {
181
+ ok: false,
182
+ code: 'provider_error',
183
+ section,
184
+ message: tillioErrorCopy('provider_error', 'provider_error').fallback,
185
+ },
186
+ { status: section === 'environment' ? 502 : 422 },
187
+ )
188
+ }
189
+ return NextResponse.json({ ok: false, code: 'attach_failed', section: 'operator', message: 'Failed to attach the operator.' }, { status: 500 })
190
+ }
191
+ }
@@ -0,0 +1,213 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import type { EntityManager } from '@mikro-orm/postgresql'
4
+ import { createLogger } from '@open-mercato/shared/lib/logger'
5
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
+ import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
8
+ import { runRouteMutationGuards } from '@open-mercato/shared/lib/crud/route-mutation-guard'
9
+ import type { IntegrationScope } from '@open-mercato/shared/modules/integrations/types'
10
+ import type { ProgressService } from '@open-mercato/core/modules/progress/lib/progressService'
11
+ import { PHONE_CALL_RESOURCE_KIND } from '@open-mercato/shared/modules/phone_calls/types'
12
+ import { TILLIO_PROVIDER_KEY } from '../../integration'
13
+ import {
14
+ resolvePullContext,
15
+ TILLIO_PULL_JOB_TYPE,
16
+ type TillioPullJobPayload,
17
+ } from '../../lib/pull-job'
18
+ import type { TillioCredentialsService } from '../../lib/operators-store'
19
+ import { blockerSection, PULL_BLOCKER_MESSAGES } from '../../lib/pull-readiness'
20
+ import { getTillioQueue, TILLIO_PULL_QUEUE } from '../../lib/queue'
21
+ import { createTillioLock, tillioPullLockKey } from '../../lib/locking'
22
+
23
+ const logger = createLogger('tillio').child({ component: 'pull-route' })
24
+
25
+ const daySchema = z.iso.date()
26
+
27
+ export const pullBodySchema = z
28
+ .object({
29
+ from: daySchema,
30
+ to: daySchema,
31
+ cursor: z.string().trim().min(1).optional(),
32
+ limit: z.number().int().positive().max(1000).optional(),
33
+ })
34
+ .refine((body) => body.from <= body.to, { message: 'The start day must not be after the end day.' })
35
+
36
+ export const metadata = {
37
+ GET: { requireAuth: true, requireFeatures: ['phone_calls.manage', 'integrations.manage'] },
38
+ POST: { requireAuth: true, requireFeatures: ['phone_calls.manage', 'integrations.manage'] },
39
+ }
40
+
41
+ export const openApi = {
42
+ GET: {
43
+ tags: ['Tillio'],
44
+ summary: 'Report whether Tillio is ready to pull phone calls',
45
+ },
46
+ POST: {
47
+ tags: ['Tillio'],
48
+ summary: 'Queue a Tillio phone-call pull and return its progress job id',
49
+ responses: [
50
+ { status: 202, description: 'Pull queued; returns { progressJobId }' },
51
+ { status: 400, description: 'Invalid day range' },
52
+ { status: 401, description: 'Unauthorized' },
53
+ { status: 409, description: 'Tillio is not ready to pull (environment or operator blocker)' },
54
+ { status: 429, description: 'A pull is already running for this scope' },
55
+ ],
56
+ },
57
+ }
58
+
59
+ export async function GET(req: Request) {
60
+ const auth = await getAuthFromRequest(req)
61
+ if (!auth?.tenantId || !auth.orgId) {
62
+ return NextResponse.json({ ok: false, code: 'unauthorized', message: 'Unauthorized' }, { status: 401 })
63
+ }
64
+
65
+ const container = await createRequestContainer()
66
+ const credentialsService = container.resolve('integrationCredentialsService') as TillioCredentialsService
67
+ const em = container.resolve('em') as EntityManager
68
+ const scope: IntegrationScope = { organizationId: auth.orgId, tenantId: auth.tenantId }
69
+
70
+ const { readiness, environment, operator } = await resolvePullContext(credentialsService, em, scope)
71
+
72
+ return NextResponse.json({
73
+ ok: true,
74
+ ...readiness,
75
+ // The dialog states which zone the picked days are read in, and that zone is per
76
+ // environment, so it comes from here rather than from a string baked into the widget.
77
+ timeZone: environment?.timeZone ?? null,
78
+ operatorId: operator?.id ?? null,
79
+ plugin: operator?.plugin ?? null,
80
+ })
81
+ }
82
+
83
+ export async function POST(req: Request) {
84
+ const auth = await getAuthFromRequest(req)
85
+ if (!auth?.tenantId || !auth.orgId) {
86
+ return NextResponse.json({ ok: false, code: 'unauthorized', message: 'Unauthorized' }, { status: 401 })
87
+ }
88
+
89
+ const parsedBody = pullBodySchema.safeParse(await readJsonSafe(req))
90
+ if (!parsedBody.success) {
91
+ return NextResponse.json({ ok: false, code: 'invalid_payload', message: 'Invalid pull payload' }, { status: 400 })
92
+ }
93
+ const body = parsedBody.data
94
+
95
+ const container = await createRequestContainer()
96
+ const credentialsService = container.resolve('integrationCredentialsService') as TillioCredentialsService
97
+ const progressService = container.resolve('progressService') as ProgressService
98
+ const em = container.resolve('em') as EntityManager
99
+ const scope: IntegrationScope = { organizationId: auth.orgId, tenantId: auth.tenantId }
100
+ const progressContext = {
101
+ tenantId: auth.tenantId,
102
+ organizationId: auth.orgId,
103
+ userId: auth.sub ?? null,
104
+ }
105
+
106
+ // Checked here as well as in the worker so an unconfigured provider answers immediately
107
+ // instead of parking a job that can only fail.
108
+ const { readiness } = await resolvePullContext(credentialsService, em, scope)
109
+ if (readiness.blocker) {
110
+ return NextResponse.json(
111
+ {
112
+ ok: false,
113
+ code: readiness.blocker,
114
+ section: blockerSection(readiness.blocker),
115
+ message: PULL_BLOCKER_MESSAGES[readiness.blocker],
116
+ },
117
+ { status: 409 },
118
+ )
119
+ }
120
+
121
+ const guarded = await runRouteMutationGuards({
122
+ container,
123
+ req,
124
+ auth: {
125
+ userId: auth.sub,
126
+ tenantId: auth.tenantId,
127
+ organizationId: auth.orgId,
128
+ },
129
+ input: {
130
+ resourceKind: PHONE_CALL_RESOURCE_KIND,
131
+ operation: 'custom',
132
+ mutationPayload: { providerKey: TILLIO_PROVIDER_KEY, from: body.from, to: body.to },
133
+ },
134
+ })
135
+ if (!guarded.ok) return guarded.response
136
+
137
+ // A guard may narrow the window it was shown, so the job runs on what the guard returned
138
+ // rather than on what the caller asked for. The rewrite comes from another module, so it
139
+ // goes back through the same schema before it reaches the queue.
140
+ const guardedBody = pullBodySchema.safeParse({
141
+ ...body,
142
+ ...(typeof guarded.modifiedPayload?.from === 'string' ? { from: guarded.modifiedPayload.from } : {}),
143
+ ...(typeof guarded.modifiedPayload?.to === 'string' ? { to: guarded.modifiedPayload.to } : {}),
144
+ })
145
+ if (!guardedBody.success) {
146
+ return NextResponse.json(
147
+ { ok: false, code: 'invalid_payload', message: 'A guard rewrote the pull range into an invalid one' },
148
+ { status: 400 },
149
+ )
150
+ }
151
+ const range = guardedBody.data
152
+
153
+ // The 429 below is a documented contract, so the check and the create happen under a lock
154
+ // rather than merely close together. Two requests arriving at once would otherwise both see
155
+ // no active job and both sweep the provider.
156
+ const withLock = createTillioLock(em, tillioPullLockKey(scope))
157
+
158
+ return withLock(async () => {
159
+ const active = await progressService.getActiveJobs(progressContext)
160
+ if (active.some((job) => job.jobType === TILLIO_PULL_JOB_TYPE)) {
161
+ return NextResponse.json(
162
+ { ok: false, code: 'pull_already_running', section: 'operator', message: 'A Tillio pull is already running.' },
163
+ { status: 429 },
164
+ )
165
+ }
166
+
167
+ const progressJob = await progressService.createJob(
168
+ {
169
+ jobType: TILLIO_PULL_JOB_TYPE,
170
+ name: 'Pull calls from Tillio',
171
+ description: `Pulling Tillio calls from ${range.from} to ${range.to}`,
172
+ cancellable: true,
173
+ meta: {
174
+ resourceKind: PHONE_CALL_RESOURCE_KIND,
175
+ providerKey: TILLIO_PROVIDER_KEY,
176
+ from: range.from,
177
+ to: range.to,
178
+ },
179
+ },
180
+ progressContext,
181
+ )
182
+
183
+ const payload: TillioPullJobPayload = {
184
+ progressJobId: progressJob.id,
185
+ scope: { tenantId: scope.tenantId, organizationId: scope.organizationId, userId: auth.sub ?? null },
186
+ from: range.from,
187
+ to: range.to,
188
+ cursor: range.cursor ?? null,
189
+ limit: range.limit ?? null,
190
+ }
191
+ try {
192
+ await getTillioQueue(TILLIO_PULL_QUEUE).enqueue(payload as unknown as Record<string, unknown>)
193
+ } catch (err) {
194
+ // The job row exists but nothing will ever advance it, and the active-job check above
195
+ // would answer 429 to every later pull until the stale sweep clears it. One unreachable
196
+ // queue would lock the tenant out of pulling.
197
+ logger.error('could not enqueue the Tillio pull', { progressJobId: progressJob.id, err })
198
+ await progressService
199
+ .failJob(progressJob.id, { errorMessage: 'The pull could not be handed to the queue.' }, progressContext)
200
+ .catch((failErr: unknown) => {
201
+ logger.error('could not fail the orphaned pull job', { progressJobId: progressJob.id, err: failErr })
202
+ })
203
+ return NextResponse.json(
204
+ { ok: false, code: 'pull_failed', section: 'operator', message: 'Could not queue the Tillio pull.' },
205
+ { status: 500 },
206
+ )
207
+ }
208
+
209
+ await guarded.runAfterSuccess()
210
+
211
+ return NextResponse.json({ ok: true, progressJobId: progressJob.id }, { status: 202 })
212
+ })
213
+ }
@@ -0,0 +1,125 @@
1
+ import { createInterface } from 'node:readline'
2
+ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import type { EntityManager } from '@mikro-orm/postgresql'
5
+ import { applyTillioEnvPreset, TILLIO_ENV_VARS } from './lib/preset'
6
+ import { createTillioLock, tillioOperatorLockKey } from './lib/locking'
7
+ import type { TillioCredentialsService, TillioOperatorRecord } from './lib/operators-store'
8
+
9
+ const USAGE = [
10
+ 'Usage: yarn mercato tillio configure-from-env --tenant <tenantId> --org <organizationId> [--force]',
11
+ `Required env: ${TILLIO_ENV_VARS.apiUrl}, ${TILLIO_ENV_VARS.apiKey}`,
12
+ `Optional env: ${TILLIO_ENV_VARS.ringostatKey} (attaches the operator), ${TILLIO_ENV_VARS.force} (overwrites existing credentials), ${TILLIO_ENV_VARS.replaceOperator} (answers the replacement prompt for unattended runs)`,
13
+ ].join('\n')
14
+
15
+ async function confirmOperatorReplacement(operator: TillioOperatorRecord): Promise<boolean> {
16
+ if (!process.stdin.isTTY) return false
17
+
18
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
19
+ try {
20
+ console.log(`[tillio] The environment variables point at a different Tillio instance than the stored ones.`)
21
+ console.log(`[tillio] Operator ${operator.id} (${operator.plugin}) is attached to the current one and will be revoked before the switch.`)
22
+ const answer = await new Promise<string>((resolve) => {
23
+ rl.question('[tillio] Detach it and attach a new one from env? [y/N] ', (value) => resolve(value.trim().toLowerCase()))
24
+ })
25
+ return answer === 'y' || answer === 'yes'
26
+ } finally {
27
+ rl.close()
28
+ }
29
+ }
30
+
31
+ function parseArgs(rest: string[]): Record<string, string | boolean> {
32
+ const args: Record<string, string | boolean> = {}
33
+
34
+ for (let index = 0; index < rest.length; index += 1) {
35
+ const arg = rest[index]
36
+ if (!arg || !arg.startsWith('--')) continue
37
+
38
+ const [key, inlineValue] = arg.replace(/^--/, '').split('=')
39
+ if (inlineValue !== undefined) {
40
+ args[key] = inlineValue
41
+ continue
42
+ }
43
+
44
+ const next = rest[index + 1]
45
+ if (next && !next.startsWith('--')) {
46
+ args[key] = next
47
+ index += 1
48
+ continue
49
+ }
50
+
51
+ args[key] = true
52
+ }
53
+
54
+ return args
55
+ }
56
+
57
+ function readScopeArg(args: Record<string, string | boolean>, keys: string[]): string | null {
58
+ for (const key of keys) {
59
+ const value = args[key]
60
+ if (typeof value === 'string' && value.trim()) return value.trim()
61
+ }
62
+ return null
63
+ }
64
+
65
+ const configureFromEnv: ModuleCli = {
66
+ command: 'configure-from-env',
67
+ async run(rest) {
68
+ const args = parseArgs(rest)
69
+ const tenantId = readScopeArg(args, ['tenantId', 'tenant'])
70
+ const organizationId = readScopeArg(args, ['organizationId', 'orgId', 'org'])
71
+ const force = args.force === true
72
+
73
+ if (!tenantId || !organizationId) {
74
+ console.log(USAGE)
75
+ process.exitCode = 1
76
+ return
77
+ }
78
+
79
+ const container = await createRequestContainer()
80
+ try {
81
+ console.log(`[tillio] Configuring tenant ${tenantId}, organization ${organizationId}${force ? ' (force)' : ''}.`)
82
+
83
+ const result = await applyTillioEnvPreset({
84
+ credentialsService: container.resolve('integrationCredentialsService') as TillioCredentialsService,
85
+ integrationStateService: container.resolve('integrationStateService'),
86
+ integrationHealthService: container.resolve('integrationHealthService'),
87
+ integrationLogService: container.resolve('integrationLogService'),
88
+ scope: { tenantId, organizationId },
89
+ force,
90
+ confirmOperatorReplacement,
91
+ withLock: createTillioLock(
92
+ container.resolve('em') as EntityManager,
93
+ tillioOperatorLockKey({ tenantId, organizationId }),
94
+ ),
95
+ })
96
+
97
+ if (result.status === 'skipped') {
98
+ console.log(`[tillio] Skipped: ${result.reason}`)
99
+ return
100
+ }
101
+ if (result.status === 'blocked') {
102
+ console.error(`[tillio] Blocked: ${result.reason}`)
103
+ process.exitCode = 1
104
+ return
105
+ }
106
+
107
+ console.log(`[tillio] Credentials ${result.credentialsAction}, health check reported ${result.health}, operator ${result.operator}.`)
108
+ if (result.health !== 'healthy') {
109
+ console.log('[tillio] Fix the environment, then rerun this command to finish provisioning.')
110
+ } else if (result.operator === 'not-requested') {
111
+ console.log(`[tillio] Set ${TILLIO_ENV_VARS.ringostatKey} and rerun to attach the operator, or attach it in the UI.`)
112
+ }
113
+ } catch (error) {
114
+ console.error(`[tillio] ${error instanceof Error ? error.message : 'Failed to apply the env preset'}`)
115
+ process.exitCode = 1
116
+ } finally {
117
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
118
+ if (typeof disposable.dispose === 'function') await disposable.dispose()
119
+ }
120
+ },
121
+ }
122
+
123
+ const commands: ModuleCli[] = [configureFromEnv]
124
+
125
+ export default commands
@@ -0,0 +1,16 @@
1
+ import { asFunction } from 'awilix'
2
+ import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { registerPhoneCallProvider } from '@open-mercato/shared/modules/phone_calls/provider'
4
+ import { tillioAdapter } from './lib/adapter'
5
+ import { createTillioEnvironmentHealthCheck, type CredentialsServiceLike } from './lib/health'
6
+
7
+ export function register(container: AppContainer) {
8
+ registerPhoneCallProvider(tillioAdapter)
9
+
10
+ container.register({
11
+ tillioEnvironmentHealthCheck: asFunction(
12
+ ({ integrationCredentialsService }: { integrationCredentialsService: CredentialsServiceLike }) =>
13
+ createTillioEnvironmentHealthCheck({ credentialsService: integrationCredentialsService }),
14
+ ).scoped().proxy(),
15
+ })
16
+ }