@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,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
|
+
}
|