@open-mercato/core 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
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 +1 -1
- package/AGENTS.md +13 -1
- package/dist/helpers/integration/api.js +29 -16
- package/dist/helpers/integration/api.js.map +2 -2
- package/dist/helpers/integration/auth.js +11 -6
- package/dist/helpers/integration/auth.js.map +3 -3
- package/dist/modules/auth/commands/roles.js +9 -12
- package/dist/modules/auth/commands/roles.js.map +2 -2
- package/dist/modules/catalog/ai-agents-context.js +147 -0
- package/dist/modules/catalog/ai-agents-context.js.map +7 -0
- package/dist/modules/catalog/ai-agents.js +383 -0
- package/dist/modules/catalog/ai-agents.js.map +7 -0
- package/dist/modules/catalog/ai-tools/_shared.js +318 -0
- package/dist/modules/catalog/ai-tools/_shared.js.map +7 -0
- package/dist/modules/catalog/ai-tools/authoring-pack.js +391 -0
- package/dist/modules/catalog/ai-tools/authoring-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/categories-pack.js +167 -0
- package/dist/modules/catalog/ai-tools/categories-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/configuration-pack.js +120 -0
- package/dist/modules/catalog/ai-tools/configuration-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/media-tags-pack.js +107 -0
- package/dist/modules/catalog/ai-tools/media-tags-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/merchandising-pack.js +429 -0
- package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/mutation-pack.js +576 -0
- package/dist/modules/catalog/ai-tools/mutation-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js +208 -0
- package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/products-pack.js +298 -0
- package/dist/modules/catalog/ai-tools/products-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/stats-pack.js +57 -0
- package/dist/modules/catalog/ai-tools/stats-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools/types.js +10 -0
- package/dist/modules/catalog/ai-tools/types.js.map +7 -0
- package/dist/modules/catalog/ai-tools/variants-pack.js +75 -0
- package/dist/modules/catalog/ai-tools/variants-pack.js.map +7 -0
- package/dist/modules/catalog/ai-tools.js +28 -0
- package/dist/modules/catalog/ai-tools.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +466 -0
- package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/page.js +7 -1
- package/dist/modules/catalog/backend/catalog/products/page.js.map +2 -2
- package/dist/modules/catalog/components/CatalogStatsCard.js +91 -0
- package/dist/modules/catalog/components/CatalogStatsCard.js.map +7 -0
- package/dist/modules/catalog/components/products/ProductsDataTable.js +23 -3
- package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
- package/dist/modules/catalog/events.js +7 -4
- package/dist/modules/catalog/events.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +59 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js +17 -0
- package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +1 -1
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
- package/dist/modules/catalog/widgets/injection-table.js +13 -1
- package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +94 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js +17 -0
- package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js +9 -0
- package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
- package/dist/modules/customers/ai-agents-context.js +96 -0
- package/dist/modules/customers/ai-agents-context.js.map +7 -0
- package/dist/modules/customers/ai-agents.js +244 -0
- package/dist/modules/customers/ai-agents.js.map +7 -0
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +1015 -0
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/addresses-tags-pack.js +134 -0
- package/dist/modules/customers/ai-tools/addresses-tags-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/companies-pack.js +249 -0
- package/dist/modules/customers/ai-tools/companies-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/deals-pack.js +348 -0
- package/dist/modules/customers/ai-tools/deals-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/people-pack.js +261 -0
- package/dist/modules/customers/ai-tools/people-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/settings-pack.js +102 -0
- package/dist/modules/customers/ai-tools/settings-pack.js.map +7 -0
- package/dist/modules/customers/ai-tools/types.js +10 -0
- package/dist/modules/customers/ai-tools/types.js.map +7 -0
- package/dist/modules/customers/ai-tools.js +20 -0
- package/dist/modules/customers/ai-tools.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +469 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +117 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +7 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js +17 -0
- package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js.map +7 -0
- package/dist/modules/customers/widgets/injection-table.js +26 -0
- package/dist/modules/customers/widgets/injection-table.js.map +7 -0
- package/dist/modules/inbox_ops/ai-tools.js +4 -0
- package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
- package/dist/modules/inbox_ops/lib/llmProvider.js +52 -7
- package/dist/modules/inbox_ops/lib/llmProvider.js.map +2 -2
- package/dist/modules/notifications/setup.js +13 -0
- package/dist/modules/notifications/setup.js.map +7 -0
- package/jest.config.cjs +1 -0
- package/jest.setup.ts +18 -0
- package/package.json +5 -3
- package/src/helpers/integration/api.ts +38 -16
- package/src/helpers/integration/auth.ts +13 -6
- package/src/modules/auth/commands/roles.ts +10 -12
- package/src/modules/catalog/AGENTS.md +11 -0
- package/src/modules/catalog/ai-agents-context.ts +239 -0
- package/src/modules/catalog/ai-agents.ts +525 -0
- package/src/modules/catalog/ai-tools/_shared.ts +487 -0
- package/src/modules/catalog/ai-tools/authoring-pack.ts +600 -0
- package/src/modules/catalog/ai-tools/categories-pack.ts +192 -0
- package/src/modules/catalog/ai-tools/configuration-pack.ts +218 -0
- package/src/modules/catalog/ai-tools/media-tags-pack.ts +127 -0
- package/src/modules/catalog/ai-tools/merchandising-pack.ts +608 -0
- package/src/modules/catalog/ai-tools/mutation-pack.ts +761 -0
- package/src/modules/catalog/ai-tools/prices-offers-pack.ts +376 -0
- package/src/modules/catalog/ai-tools/products-pack.ts +387 -0
- package/src/modules/catalog/ai-tools/stats-pack.ts +84 -0
- package/src/modules/catalog/ai-tools/types.ts +81 -0
- package/src/modules/catalog/ai-tools/variants-pack.ts +147 -0
- package/src/modules/catalog/ai-tools.ts +78 -0
- package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +597 -0
- package/src/modules/catalog/backend/catalog/products/page.tsx +23 -2
- package/src/modules/catalog/components/CatalogStatsCard.tsx +118 -0
- package/src/modules/catalog/components/products/ProductsDataTable.tsx +54 -6
- package/src/modules/catalog/events.ts +7 -4
- package/src/modules/catalog/i18n/de.json +17 -0
- package/src/modules/catalog/i18n/en.json +17 -0
- package/src/modules/catalog/i18n/es.json +17 -0
- package/src/modules/catalog/i18n/pl.json +17 -0
- package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +109 -0
- package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.ts +29 -0
- package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +1 -1
- package/src/modules/catalog/widgets/injection-table.ts +12 -0
- package/src/modules/customer_accounts/i18n/de.json +5 -0
- package/src/modules/customer_accounts/i18n/en.json +5 -0
- package/src/modules/customer_accounts/i18n/es.json +5 -0
- package/src/modules/customer_accounts/i18n/pl.json +5 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +136 -0
- package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.ts +43 -0
- package/src/modules/customer_accounts/widgets/injection-table.ts +9 -0
- package/src/modules/customers/AGENTS.md +13 -0
- package/src/modules/customers/ai-agents-context.ts +150 -0
- package/src/modules/customers/ai-agents.ts +355 -0
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +1248 -0
- package/src/modules/customers/ai-tools/addresses-tags-pack.ts +145 -0
- package/src/modules/customers/ai-tools/companies-pack.ts +362 -0
- package/src/modules/customers/ai-tools/deals-pack.ts +505 -0
- package/src/modules/customers/ai-tools/people-pack.ts +369 -0
- package/src/modules/customers/ai-tools/settings-pack.ts +121 -0
- package/src/modules/customers/ai-tools/types.ts +76 -0
- package/src/modules/customers/ai-tools.ts +34 -0
- package/src/modules/customers/i18n/de.json +25 -0
- package/src/modules/customers/i18n/en.json +25 -0
- package/src/modules/customers/i18n/es.json +25 -0
- package/src/modules/customers/i18n/pl.json +25 -0
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +580 -0
- package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +36 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +191 -0
- package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.ts +37 -0
- package/src/modules/customers/widgets/injection-table.ts +41 -0
- package/src/modules/inbox_ops/ai-tools.ts +4 -0
- package/src/modules/inbox_ops/lib/llmProvider.ts +83 -7
- package/src/modules/notifications/setup.ts +11 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `customers.list_deals` + `customers.get_deal` (Phase 1 WS-C, Step 3.9).
|
|
3
|
+
* `customers.update_deal_stage` mutation tool (Phase 3 WS-C, Step 5.13).
|
|
4
|
+
*
|
|
5
|
+
* Phase 3a of `.ai/specs/2026-04-27-ai-tools-api-backed-dry-refactor.md`:
|
|
6
|
+
* `customers.list_deals` is now an API-backed wrapper over
|
|
7
|
+
* `GET /api/customers/deals`. Tool name, schema, requiredFeatures, and output
|
|
8
|
+
* shape are unchanged.
|
|
9
|
+
*
|
|
10
|
+
* Phase 3c of the same spec migrates `customers.get_deal` to the documented
|
|
11
|
+
* aggregate detail route. The handler issues 1 call without `includeRelated`
|
|
12
|
+
* (`GET /customers/deals/<id>`) and 3 bounded calls with `includeRelated`
|
|
13
|
+
* (deal detail + activities + comments by `dealId`). The 3-call cap matches
|
|
14
|
+
* the spec's residual N+1 budget; deeper aggregation can earn a first-class
|
|
15
|
+
* API later without touching the AI surface.
|
|
16
|
+
*/
|
|
17
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
18
|
+
import { z } from 'zod'
|
|
19
|
+
import { defineApiBackedAiTool } from '@open-mercato/ai-assistant/modules/ai_assistant/lib/api-backed-tool'
|
|
20
|
+
import {
|
|
21
|
+
createAiApiOperationRunner,
|
|
22
|
+
type AiApiOperationRequest,
|
|
23
|
+
type AiToolExecutionContext,
|
|
24
|
+
} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
|
|
25
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
26
|
+
import {
|
|
27
|
+
CustomerDeal,
|
|
28
|
+
CustomerPipelineStage,
|
|
29
|
+
} from '../data/entities'
|
|
30
|
+
import {
|
|
31
|
+
assertTenantScope,
|
|
32
|
+
type CustomersAiToolDefinition,
|
|
33
|
+
type CustomersToolContext,
|
|
34
|
+
type CustomersToolLoadBeforeSingleRecord,
|
|
35
|
+
} from './types'
|
|
36
|
+
|
|
37
|
+
function resolveEm(ctx: CustomersToolContext | AiToolExecutionContext): EntityManager {
|
|
38
|
+
return ctx.container.resolve<EntityManager>('em')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildScope(ctx: CustomersToolContext | AiToolExecutionContext, tenantId: string) {
|
|
42
|
+
return { tenantId, organizationId: ctx.organizationId }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const listDealsInput = z
|
|
46
|
+
.object({
|
|
47
|
+
q: z.string().trim().optional().describe('Search text matched against deal title / description. Omit or leave empty to list all.'),
|
|
48
|
+
limit: z.number().int().min(1).max(100).optional().describe('Maximum rows to return (default 50, max 100).'),
|
|
49
|
+
offset: z.number().int().min(0).optional().describe('Number of rows to skip (default 0).'),
|
|
50
|
+
personId: z.string().uuid().optional().describe('Return only deals linked to this person entity id.'),
|
|
51
|
+
companyId: z.string().uuid().optional().describe('Return only deals linked to this company entity id.'),
|
|
52
|
+
pipelineStageId: z.string().uuid().optional().describe('Return only deals at this pipeline stage.'),
|
|
53
|
+
status: z.string().optional().describe('Filter by deal status (e.g. "open", "won", "lost").'),
|
|
54
|
+
})
|
|
55
|
+
.passthrough()
|
|
56
|
+
|
|
57
|
+
type ListDealsInput = z.infer<typeof listDealsInput>
|
|
58
|
+
|
|
59
|
+
type ListDealsApiItem = {
|
|
60
|
+
id?: string
|
|
61
|
+
title?: string | null
|
|
62
|
+
description?: string | null
|
|
63
|
+
status?: string | null
|
|
64
|
+
pipeline_id?: string | null
|
|
65
|
+
pipelineId?: string | null
|
|
66
|
+
pipeline_stage_id?: string | null
|
|
67
|
+
pipelineStageId?: string | null
|
|
68
|
+
value_amount?: string | number | null
|
|
69
|
+
valueAmount?: string | number | null
|
|
70
|
+
value_currency?: string | null
|
|
71
|
+
valueCurrency?: string | null
|
|
72
|
+
probability?: number | null
|
|
73
|
+
owner_user_id?: string | null
|
|
74
|
+
ownerUserId?: string | null
|
|
75
|
+
expected_close_at?: string | null
|
|
76
|
+
expectedCloseAt?: string | null
|
|
77
|
+
source?: string | null
|
|
78
|
+
organization_id?: string | null
|
|
79
|
+
organizationId?: string | null
|
|
80
|
+
tenant_id?: string | null
|
|
81
|
+
tenantId?: string | null
|
|
82
|
+
created_at?: string | null
|
|
83
|
+
createdAt?: string | null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type ListDealsApiResponse = {
|
|
87
|
+
items?: ListDealsApiItem[]
|
|
88
|
+
total?: number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
type ListDealsOutput = {
|
|
92
|
+
items: Array<Record<string, unknown>>
|
|
93
|
+
total: number
|
|
94
|
+
limit: number
|
|
95
|
+
offset: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const listDealsTool = defineApiBackedAiTool<ListDealsInput, ListDealsApiResponse, ListDealsOutput>({
|
|
99
|
+
name: 'customers.list_deals',
|
|
100
|
+
displayName: 'List deals',
|
|
101
|
+
description:
|
|
102
|
+
'Search / list deals for the caller tenant + organization. Optional filters include linked person / company / pipeline stage.',
|
|
103
|
+
inputSchema: listDealsInput,
|
|
104
|
+
requiredFeatures: ['customers.deals.view'],
|
|
105
|
+
toOperation: (input, ctx) => {
|
|
106
|
+
assertTenantScope(ctx as unknown as CustomersToolContext)
|
|
107
|
+
const limit = input.limit ?? 50
|
|
108
|
+
const offset = input.offset ?? 0
|
|
109
|
+
const page = Math.floor(offset / limit) + 1
|
|
110
|
+
|
|
111
|
+
const query: Record<string, string | number | boolean | null | undefined> = {
|
|
112
|
+
page,
|
|
113
|
+
pageSize: limit,
|
|
114
|
+
}
|
|
115
|
+
if (input.q?.trim()) query.search = input.q.trim()
|
|
116
|
+
if (input.personId) query.personId = input.personId
|
|
117
|
+
if (input.companyId) query.companyId = input.companyId
|
|
118
|
+
if (input.pipelineStageId) query.pipelineStageId = input.pipelineStageId
|
|
119
|
+
if (input.status) query.status = input.status
|
|
120
|
+
|
|
121
|
+
const operation: AiApiOperationRequest = {
|
|
122
|
+
method: 'GET',
|
|
123
|
+
path: '/customers/deals',
|
|
124
|
+
query,
|
|
125
|
+
}
|
|
126
|
+
return operation
|
|
127
|
+
},
|
|
128
|
+
mapResponse: (response, input) => {
|
|
129
|
+
const limit = input.limit ?? 50
|
|
130
|
+
const offset = input.offset ?? 0
|
|
131
|
+
const data = (response.data ?? {}) as ListDealsApiResponse
|
|
132
|
+
const rawItems: ListDealsApiItem[] = Array.isArray(data.items) ? data.items : []
|
|
133
|
+
return {
|
|
134
|
+
items: rawItems.map((row) => {
|
|
135
|
+
const expectedCloseRaw = row.expected_close_at ?? row.expectedCloseAt ?? null
|
|
136
|
+
const expectedCloseAt = expectedCloseRaw ? new Date(String(expectedCloseRaw)).toISOString() : null
|
|
137
|
+
const createdAtRaw = row.created_at ?? row.createdAt ?? null
|
|
138
|
+
const createdAt = createdAtRaw ? new Date(String(createdAtRaw)).toISOString() : null
|
|
139
|
+
return {
|
|
140
|
+
id: row.id,
|
|
141
|
+
title: row.title ?? null,
|
|
142
|
+
description: row.description ?? null,
|
|
143
|
+
status: row.status ?? null,
|
|
144
|
+
pipelineId: row.pipeline_id ?? row.pipelineId ?? null,
|
|
145
|
+
pipelineStageId: row.pipeline_stage_id ?? row.pipelineStageId ?? null,
|
|
146
|
+
valueAmount: row.value_amount ?? row.valueAmount ?? null,
|
|
147
|
+
valueCurrency: row.value_currency ?? row.valueCurrency ?? null,
|
|
148
|
+
probability: row.probability ?? null,
|
|
149
|
+
ownerUserId: row.owner_user_id ?? row.ownerUserId ?? null,
|
|
150
|
+
expectedCloseAt,
|
|
151
|
+
source: row.source ?? null,
|
|
152
|
+
organizationId: row.organization_id ?? row.organizationId ?? null,
|
|
153
|
+
tenantId: row.tenant_id ?? row.tenantId ?? null,
|
|
154
|
+
createdAt,
|
|
155
|
+
}
|
|
156
|
+
}),
|
|
157
|
+
total: typeof data.total === 'number' ? data.total : 0,
|
|
158
|
+
limit,
|
|
159
|
+
offset,
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
}) as unknown as CustomersAiToolDefinition
|
|
163
|
+
|
|
164
|
+
const getDealInput = z.object({
|
|
165
|
+
dealId: z.string().uuid().describe('Deal id (UUID).'),
|
|
166
|
+
includeRelated: z
|
|
167
|
+
.boolean()
|
|
168
|
+
.optional()
|
|
169
|
+
.describe('When true, include notes, activities, linked people and companies (each capped at 100).'),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
type GetDealInput = z.infer<typeof getDealInput>
|
|
173
|
+
|
|
174
|
+
function toIsoDeal(value: unknown): string | null {
|
|
175
|
+
if (!value) return null
|
|
176
|
+
const dt = value instanceof Date ? value : new Date(String(value))
|
|
177
|
+
if (Number.isNaN(dt.getTime())) return null
|
|
178
|
+
return dt.toISOString()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const getDealTool: CustomersAiToolDefinition = {
|
|
182
|
+
name: 'customers.get_deal',
|
|
183
|
+
displayName: 'Get deal',
|
|
184
|
+
description:
|
|
185
|
+
'Fetch a deal by id with fields and (optionally) notes, activities, linked people, and linked companies. Returns { found: false } when outside tenant/org scope.',
|
|
186
|
+
inputSchema: getDealInput,
|
|
187
|
+
requiredFeatures: ['customers.deals.view'],
|
|
188
|
+
tags: ['read', 'customers'],
|
|
189
|
+
handler: async (rawInput, ctx) => {
|
|
190
|
+
const { tenantId: _tenantId } = assertTenantScope(ctx)
|
|
191
|
+
void _tenantId
|
|
192
|
+
const input: GetDealInput = getDealInput.parse(rawInput)
|
|
193
|
+
const includeRelated = !!input.includeRelated
|
|
194
|
+
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
195
|
+
|
|
196
|
+
const detailResponse = await runner.run<Record<string, unknown>>({
|
|
197
|
+
method: 'GET',
|
|
198
|
+
path: `/customers/deals/${input.dealId}`,
|
|
199
|
+
})
|
|
200
|
+
if (!detailResponse.success) {
|
|
201
|
+
if (detailResponse.statusCode === 404 || detailResponse.statusCode === 403) {
|
|
202
|
+
return { found: false as const, dealId: input.dealId }
|
|
203
|
+
}
|
|
204
|
+
throw new Error(detailResponse.error ?? `Failed to fetch deal ${input.dealId}`)
|
|
205
|
+
}
|
|
206
|
+
const detail = (detailResponse.data ?? {}) as Record<string, unknown>
|
|
207
|
+
const dealRow = (detail.deal ?? null) as Record<string, unknown> | null
|
|
208
|
+
if (!dealRow) {
|
|
209
|
+
return { found: false as const, dealId: input.dealId }
|
|
210
|
+
}
|
|
211
|
+
const customFields = (detail.customFields ?? {}) as Record<string, unknown>
|
|
212
|
+
const peopleRows = Array.isArray(detail.people) ? (detail.people as Array<Record<string, unknown>>) : []
|
|
213
|
+
const companiesRows = Array.isArray(detail.companies)
|
|
214
|
+
? (detail.companies as Array<Record<string, unknown>>)
|
|
215
|
+
: []
|
|
216
|
+
|
|
217
|
+
let related: Record<string, unknown> | null = null
|
|
218
|
+
if (includeRelated) {
|
|
219
|
+
const [activitiesResponse, commentsResponse] = await Promise.all([
|
|
220
|
+
runner.run<{ items?: Array<Record<string, unknown>>; total?: number }>({
|
|
221
|
+
method: 'GET',
|
|
222
|
+
path: '/customers/activities',
|
|
223
|
+
query: { dealId: input.dealId, page: 1, pageSize: 100, sortField: 'occurredAt', sortDir: 'desc' },
|
|
224
|
+
}),
|
|
225
|
+
runner.run<{ items?: Array<Record<string, unknown>>; total?: number }>({
|
|
226
|
+
method: 'GET',
|
|
227
|
+
path: '/customers/comments',
|
|
228
|
+
query: { dealId: input.dealId, page: 1, pageSize: 100 },
|
|
229
|
+
}),
|
|
230
|
+
])
|
|
231
|
+
const activities =
|
|
232
|
+
activitiesResponse.success && Array.isArray(activitiesResponse.data?.items)
|
|
233
|
+
? (activitiesResponse.data!.items as Array<Record<string, unknown>>)
|
|
234
|
+
: []
|
|
235
|
+
const comments =
|
|
236
|
+
commentsResponse.success && Array.isArray(commentsResponse.data?.items)
|
|
237
|
+
? (commentsResponse.data!.items as Array<Record<string, unknown>>)
|
|
238
|
+
: []
|
|
239
|
+
|
|
240
|
+
related = {
|
|
241
|
+
activities: activities.map((activity) => ({
|
|
242
|
+
id: activity.id,
|
|
243
|
+
activityType: activity.activityType ?? activity.activity_type ?? null,
|
|
244
|
+
subject: activity.subject ?? null,
|
|
245
|
+
body: activity.body ?? null,
|
|
246
|
+
occurredAt: toIsoDeal(activity.occurredAt ?? activity.occurred_at),
|
|
247
|
+
createdAt: toIsoDeal(activity.createdAt ?? activity.created_at),
|
|
248
|
+
})),
|
|
249
|
+
notes: comments.map((comment) => ({
|
|
250
|
+
id: comment.id,
|
|
251
|
+
body: comment.body,
|
|
252
|
+
authorUserId: comment.authorUserId ?? comment.author_user_id ?? null,
|
|
253
|
+
createdAt: toIsoDeal(comment.createdAt ?? comment.created_at),
|
|
254
|
+
})),
|
|
255
|
+
people: peopleRows
|
|
256
|
+
.map((person) => {
|
|
257
|
+
if (!person || typeof person !== 'object') return null
|
|
258
|
+
const id = typeof person.id === 'string' ? person.id : null
|
|
259
|
+
if (!id) return null
|
|
260
|
+
const subtitle = typeof person.subtitle === 'string' ? person.subtitle : null
|
|
261
|
+
const label = typeof person.label === 'string' ? person.label : ''
|
|
262
|
+
const entry: {
|
|
263
|
+
id: string
|
|
264
|
+
displayName: string
|
|
265
|
+
primaryEmail: string | null
|
|
266
|
+
primaryPhone: string | null
|
|
267
|
+
participantRole: string | null
|
|
268
|
+
} = {
|
|
269
|
+
id,
|
|
270
|
+
displayName: label,
|
|
271
|
+
primaryEmail: subtitle && subtitle.includes('@') ? subtitle : null,
|
|
272
|
+
primaryPhone: subtitle && !subtitle.includes('@') ? subtitle : null,
|
|
273
|
+
participantRole: null as string | null,
|
|
274
|
+
}
|
|
275
|
+
return entry
|
|
276
|
+
})
|
|
277
|
+
.filter(
|
|
278
|
+
(value): value is {
|
|
279
|
+
id: string
|
|
280
|
+
displayName: string
|
|
281
|
+
primaryEmail: string | null
|
|
282
|
+
primaryPhone: string | null
|
|
283
|
+
participantRole: string | null
|
|
284
|
+
} => value !== null,
|
|
285
|
+
),
|
|
286
|
+
companies: companiesRows
|
|
287
|
+
.map((company) => {
|
|
288
|
+
if (!company || typeof company !== 'object') return null
|
|
289
|
+
const id = typeof company.id === 'string' ? company.id : null
|
|
290
|
+
if (!id) return null
|
|
291
|
+
const label = typeof company.label === 'string' ? company.label : ''
|
|
292
|
+
const entry: {
|
|
293
|
+
id: string
|
|
294
|
+
displayName: string
|
|
295
|
+
primaryEmail: string | null
|
|
296
|
+
primaryPhone: string | null
|
|
297
|
+
} = {
|
|
298
|
+
id,
|
|
299
|
+
displayName: label,
|
|
300
|
+
primaryEmail: null as string | null,
|
|
301
|
+
primaryPhone: null as string | null,
|
|
302
|
+
}
|
|
303
|
+
return entry
|
|
304
|
+
})
|
|
305
|
+
.filter(
|
|
306
|
+
(value): value is {
|
|
307
|
+
id: string
|
|
308
|
+
displayName: string
|
|
309
|
+
primaryEmail: string | null
|
|
310
|
+
primaryPhone: string | null
|
|
311
|
+
} => value !== null,
|
|
312
|
+
),
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
found: true as const,
|
|
318
|
+
deal: {
|
|
319
|
+
id: dealRow.id,
|
|
320
|
+
title: typeof dealRow.title === 'string' ? dealRow.title : '',
|
|
321
|
+
description: dealRow.description ?? null,
|
|
322
|
+
status: dealRow.status ?? null,
|
|
323
|
+
pipelineId: dealRow.pipelineId ?? null,
|
|
324
|
+
pipelineStageId: dealRow.pipelineStageId ?? null,
|
|
325
|
+
valueAmount: dealRow.valueAmount ?? null,
|
|
326
|
+
valueCurrency: dealRow.valueCurrency ?? null,
|
|
327
|
+
probability: dealRow.probability ?? null,
|
|
328
|
+
ownerUserId: dealRow.ownerUserId ?? null,
|
|
329
|
+
expectedCloseAt: toIsoDeal(dealRow.expectedCloseAt),
|
|
330
|
+
source: dealRow.source ?? null,
|
|
331
|
+
organizationId: dealRow.organizationId ?? null,
|
|
332
|
+
tenantId: dealRow.tenantId ?? null,
|
|
333
|
+
createdAt: toIsoDeal(dealRow.createdAt),
|
|
334
|
+
updatedAt: toIsoDeal(dealRow.updatedAt),
|
|
335
|
+
},
|
|
336
|
+
customFields,
|
|
337
|
+
related,
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Mutation tool: move a deal to a different pipeline stage. Step 5.13 — first
|
|
344
|
+
* mutation-capable flow on the pending-action contract.
|
|
345
|
+
*
|
|
346
|
+
* Accepts either `toPipelineStageId` (UUID — preferred, tenant-scoped stage
|
|
347
|
+
* record) or `toStage` (free-form string that maps to `CustomerDeal.status`
|
|
348
|
+
* for pipeline roots like `open`/`won`/`lost`). Exactly one must be provided.
|
|
349
|
+
*
|
|
350
|
+
* The handler delegates to the existing `customers.deals.update` command so
|
|
351
|
+
* all side effects (audit log, `customers.deal.updated` event, query index
|
|
352
|
+
* refresh, notifications) stay identical to a direct API write.
|
|
353
|
+
*/
|
|
354
|
+
// LLMs frequently emit `""` for "not provided" — coerce blanks (and surrounding
|
|
355
|
+
// whitespace) to `undefined` BEFORE the per-field validators run so the
|
|
356
|
+
// `.uuid()` check on `toPipelineStageId` does not blow up on an empty string
|
|
357
|
+
// the caller actually meant as "skip this field".
|
|
358
|
+
const blankToUndefined = (value: unknown): unknown => {
|
|
359
|
+
if (typeof value !== 'string') return value
|
|
360
|
+
const trimmed = value.trim()
|
|
361
|
+
return trimmed.length === 0 ? undefined : trimmed
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const updateDealStageInput = z
|
|
365
|
+
.object({
|
|
366
|
+
dealId: z.string().uuid().describe('Deal id (UUID) to update.'),
|
|
367
|
+
toPipelineStageId: z
|
|
368
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
369
|
+
.describe('Target pipeline stage id (UUID). Preferred — tenant-scoped stage record.'),
|
|
370
|
+
toStage: z
|
|
371
|
+
.preprocess(blankToUndefined, z.string().min(1).max(50).optional())
|
|
372
|
+
.describe(
|
|
373
|
+
'Target status slug (e.g. "open", "won", "lost"). Used when the deal does not belong to a managed pipeline.',
|
|
374
|
+
),
|
|
375
|
+
})
|
|
376
|
+
.refine(
|
|
377
|
+
(value) => Boolean(value.toPipelineStageId) !== Boolean(value.toStage),
|
|
378
|
+
{
|
|
379
|
+
message: 'Provide exactly one of toPipelineStageId or toStage.',
|
|
380
|
+
path: ['toPipelineStageId'],
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
type UpdateDealStageInput = z.infer<typeof updateDealStageInput>
|
|
385
|
+
|
|
386
|
+
function recordVersionFromUpdatedAt(updatedAt: Date | null | undefined): string | null {
|
|
387
|
+
if (!updatedAt) return null
|
|
388
|
+
const value = updatedAt instanceof Date ? updatedAt : new Date(updatedAt)
|
|
389
|
+
if (Number.isNaN(value.getTime())) return null
|
|
390
|
+
return value.toISOString()
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function loadDealWithStage(
|
|
394
|
+
em: EntityManager,
|
|
395
|
+
ctx: CustomersToolContext,
|
|
396
|
+
tenantId: string,
|
|
397
|
+
dealId: string,
|
|
398
|
+
): Promise<CustomerDeal | null> {
|
|
399
|
+
const where: Record<string, unknown> = { id: dealId, tenantId, deletedAt: null }
|
|
400
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
401
|
+
const deal = await findOneWithDecryption<CustomerDeal>(
|
|
402
|
+
em,
|
|
403
|
+
CustomerDeal,
|
|
404
|
+
where as any,
|
|
405
|
+
undefined,
|
|
406
|
+
buildScope(ctx, tenantId),
|
|
407
|
+
)
|
|
408
|
+
if (!deal || deal.tenantId !== tenantId) return null
|
|
409
|
+
if (ctx.organizationId && deal.organizationId !== ctx.organizationId) return null
|
|
410
|
+
return deal
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const updateDealStageTool: CustomersAiToolDefinition = {
|
|
414
|
+
name: 'customers.update_deal_stage',
|
|
415
|
+
displayName: 'Update deal stage',
|
|
416
|
+
description:
|
|
417
|
+
'Move a deal to a different pipeline stage (by stage id) or change its top-level status (e.g. "open", "won", "lost"). Mutation tool — flows through the AI pending-action approval gate.',
|
|
418
|
+
inputSchema: updateDealStageInput as z.ZodType<unknown>,
|
|
419
|
+
requiredFeatures: ['customers.deals.manage'],
|
|
420
|
+
tags: ['write', 'customers'],
|
|
421
|
+
isMutation: true,
|
|
422
|
+
loadBeforeRecord: async (rawInput, ctx): Promise<CustomersToolLoadBeforeSingleRecord | null> => {
|
|
423
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
424
|
+
const input: UpdateDealStageInput = updateDealStageInput.parse(rawInput)
|
|
425
|
+
const em = resolveEm(ctx)
|
|
426
|
+
const deal = await loadDealWithStage(em, ctx, tenantId, input.dealId)
|
|
427
|
+
if (!deal) return null
|
|
428
|
+
return {
|
|
429
|
+
recordId: deal.id,
|
|
430
|
+
entityType: 'customers.deal',
|
|
431
|
+
recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
|
|
432
|
+
before: {
|
|
433
|
+
status: deal.status ?? null,
|
|
434
|
+
pipelineStage: deal.pipelineStage ?? null,
|
|
435
|
+
pipelineStageId: deal.pipelineStageId ?? null,
|
|
436
|
+
},
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
handler: async (rawInput, ctx) => {
|
|
440
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
441
|
+
const input: UpdateDealStageInput = updateDealStageInput.parse(rawInput)
|
|
442
|
+
const em = resolveEm(ctx)
|
|
443
|
+
const deal = await loadDealWithStage(em, ctx, tenantId, input.dealId)
|
|
444
|
+
if (!deal) {
|
|
445
|
+
throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
|
|
446
|
+
}
|
|
447
|
+
const organizationId = deal.organizationId
|
|
448
|
+
if (!organizationId) {
|
|
449
|
+
throw new Error(`Deal "${input.dealId}" has no organization scope.`)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const before = {
|
|
453
|
+
status: deal.status ?? null,
|
|
454
|
+
pipelineStage: deal.pipelineStage ?? null,
|
|
455
|
+
pipelineStageId: deal.pipelineStageId ?? null,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const body: Record<string, unknown> = {
|
|
459
|
+
id: deal.id,
|
|
460
|
+
tenantId,
|
|
461
|
+
organizationId,
|
|
462
|
+
}
|
|
463
|
+
if (input.toPipelineStageId) {
|
|
464
|
+
const stage = await em.findOne(CustomerPipelineStage, {
|
|
465
|
+
id: input.toPipelineStageId,
|
|
466
|
+
tenantId,
|
|
467
|
+
organizationId,
|
|
468
|
+
})
|
|
469
|
+
if (!stage) {
|
|
470
|
+
throw new Error('Pipeline stage not found.')
|
|
471
|
+
}
|
|
472
|
+
body.pipelineStageId = input.toPipelineStageId
|
|
473
|
+
} else if (input.toStage) {
|
|
474
|
+
body.status = input.toStage
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
478
|
+
const response = await runner.run({
|
|
479
|
+
method: 'PUT',
|
|
480
|
+
path: '/customers/deals',
|
|
481
|
+
body,
|
|
482
|
+
})
|
|
483
|
+
if (!response.success) {
|
|
484
|
+
throw new Error(response.error ?? `Failed to update deal "${deal.id}"`)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const after = await loadDealWithStage(em, ctx, tenantId, deal.id)
|
|
488
|
+
return {
|
|
489
|
+
recordId: deal.id,
|
|
490
|
+
commandName: 'customers.deals.update',
|
|
491
|
+
before,
|
|
492
|
+
after: after
|
|
493
|
+
? {
|
|
494
|
+
status: after.status ?? null,
|
|
495
|
+
pipelineStage: after.pipelineStage ?? null,
|
|
496
|
+
pipelineStageId: after.pipelineStageId ?? null,
|
|
497
|
+
}
|
|
498
|
+
: null,
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export const dealsAiTools: CustomersAiToolDefinition[] = [listDealsTool, getDealTool, updateDealStageTool]
|
|
504
|
+
|
|
505
|
+
export default dealsAiTools
|