@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,1248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `customers.list_activities` + `customers.list_tasks` (Phase 1 WS-C, Step 3.9)
|
|
3
|
+
* plus the deal comment / activity manage tools (Phase 3 WS-C, follow-up).
|
|
4
|
+
*
|
|
5
|
+
* The mutation tools route every write through the AI pending-action approval
|
|
6
|
+
* gate via `createAiApiOperationRunner` — same contract as
|
|
7
|
+
* `customers.update_deal_stage`.
|
|
8
|
+
*/
|
|
9
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import {
|
|
12
|
+
createAiApiOperationRunner,
|
|
13
|
+
type AiToolExecutionContext,
|
|
14
|
+
} from '@open-mercato/ai-assistant/modules/ai_assistant/lib/ai-api-operation-runner'
|
|
15
|
+
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
16
|
+
import {
|
|
17
|
+
CustomerActivity,
|
|
18
|
+
CustomerComment,
|
|
19
|
+
CustomerDeal,
|
|
20
|
+
CustomerDealCompanyLink,
|
|
21
|
+
CustomerDealPersonLink,
|
|
22
|
+
CustomerInteraction,
|
|
23
|
+
CustomerTodoLink,
|
|
24
|
+
} from '../data/entities'
|
|
25
|
+
import {
|
|
26
|
+
assertTenantScope,
|
|
27
|
+
type CustomersAiToolDefinition,
|
|
28
|
+
type CustomersToolContext,
|
|
29
|
+
type CustomersToolLoadBeforeSingleRecord,
|
|
30
|
+
} from './types'
|
|
31
|
+
|
|
32
|
+
function resolveEm(ctx: CustomersToolContext | AiToolExecutionContext): EntityManager {
|
|
33
|
+
return ctx.container.resolve<EntityManager>('em')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildScope(ctx: CustomersToolContext | AiToolExecutionContext, tenantId: string) {
|
|
37
|
+
return { tenantId, organizationId: ctx.organizationId }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function recordVersionFromUpdatedAt(updatedAt: Date | null | undefined): string | null {
|
|
41
|
+
if (!updatedAt) return null
|
|
42
|
+
const value = updatedAt instanceof Date ? updatedAt : new Date(updatedAt)
|
|
43
|
+
if (Number.isNaN(value.getTime())) return null
|
|
44
|
+
return value.toISOString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// LLMs frequently emit `""` for "not provided" — coerce blanks (and surrounding
|
|
48
|
+
// whitespace) to `undefined` BEFORE per-field validators run. Mirrors the
|
|
49
|
+
// `blankToUndefined` helper in deals-pack.ts.
|
|
50
|
+
const blankToUndefined = (value: unknown): unknown => {
|
|
51
|
+
if (typeof value !== 'string') return value
|
|
52
|
+
const trimmed = value.trim()
|
|
53
|
+
return trimmed.length === 0 ? undefined : trimmed
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function loadDealForScope(
|
|
57
|
+
em: EntityManager,
|
|
58
|
+
ctx: CustomersToolContext,
|
|
59
|
+
tenantId: string,
|
|
60
|
+
dealId: string,
|
|
61
|
+
): Promise<CustomerDeal | null> {
|
|
62
|
+
const where: Record<string, unknown> = { id: dealId, tenantId, deletedAt: null }
|
|
63
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
64
|
+
const deal = await findOneWithDecryption<CustomerDeal>(
|
|
65
|
+
em,
|
|
66
|
+
CustomerDeal,
|
|
67
|
+
where as any,
|
|
68
|
+
undefined,
|
|
69
|
+
buildScope(ctx, tenantId),
|
|
70
|
+
)
|
|
71
|
+
if (!deal || deal.tenantId !== tenantId) return null
|
|
72
|
+
if (ctx.organizationId && deal.organizationId !== ctx.organizationId) return null
|
|
73
|
+
return deal
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* `CustomerDeal` does NOT carry a direct `entity` field — deals are linked
|
|
78
|
+
* to people / companies via the `customer_deal_person_links` and
|
|
79
|
+
* `customer_deal_company_links` tables. Comments however need a non-null
|
|
80
|
+
* `entity_id` (the timeline owner) so this helper resolves the deal's
|
|
81
|
+
* first linked person, then falls back to its first linked company. When
|
|
82
|
+
* neither exists, the caller MUST instruct the operator to link a contact
|
|
83
|
+
* before commenting on the deal.
|
|
84
|
+
*/
|
|
85
|
+
async function resolveDealCommentEntityId(
|
|
86
|
+
em: EntityManager,
|
|
87
|
+
ctx: CustomersToolContext,
|
|
88
|
+
tenantId: string,
|
|
89
|
+
dealId: string,
|
|
90
|
+
): Promise<string | null> {
|
|
91
|
+
const personLink = await em.findOne(
|
|
92
|
+
CustomerDealPersonLink,
|
|
93
|
+
{ deal: dealId, tenantId } as never,
|
|
94
|
+
{ populate: ['personEntity'] as never },
|
|
95
|
+
)
|
|
96
|
+
if (personLink) {
|
|
97
|
+
const linked = (personLink as unknown as { personEntity?: { id?: string | null } | null }).personEntity
|
|
98
|
+
if (linked && typeof linked === 'object' && typeof linked.id === 'string') return linked.id
|
|
99
|
+
const raw = (personLink as unknown as { personEntity?: unknown }).personEntity
|
|
100
|
+
if (typeof raw === 'string') return raw
|
|
101
|
+
}
|
|
102
|
+
const companyLink = await em.findOne(
|
|
103
|
+
CustomerDealCompanyLink,
|
|
104
|
+
{ deal: dealId, tenantId } as never,
|
|
105
|
+
{ populate: ['companyEntity'] as never },
|
|
106
|
+
)
|
|
107
|
+
if (companyLink) {
|
|
108
|
+
const linked = (companyLink as unknown as { companyEntity?: { id?: string | null } | null }).companyEntity
|
|
109
|
+
if (linked && typeof linked === 'object' && typeof linked.id === 'string') return linked.id
|
|
110
|
+
const raw = (companyLink as unknown as { companyEntity?: unknown }).companyEntity
|
|
111
|
+
if (typeof raw === 'string') return raw
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function loadCommentForScope(
|
|
117
|
+
em: EntityManager,
|
|
118
|
+
ctx: CustomersToolContext,
|
|
119
|
+
tenantId: string,
|
|
120
|
+
commentId: string,
|
|
121
|
+
): Promise<CustomerComment | null> {
|
|
122
|
+
const where: Record<string, unknown> = { id: commentId, tenantId }
|
|
123
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
124
|
+
const row = await findOneWithDecryption<CustomerComment>(
|
|
125
|
+
em,
|
|
126
|
+
CustomerComment,
|
|
127
|
+
where as any,
|
|
128
|
+
undefined,
|
|
129
|
+
buildScope(ctx, tenantId),
|
|
130
|
+
)
|
|
131
|
+
if (!row || row.tenantId !== tenantId) return null
|
|
132
|
+
if (ctx.organizationId && row.organizationId !== ctx.organizationId) return null
|
|
133
|
+
return row
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function loadActivityForScope(
|
|
137
|
+
em: EntityManager,
|
|
138
|
+
ctx: CustomersToolContext,
|
|
139
|
+
tenantId: string,
|
|
140
|
+
activityId: string,
|
|
141
|
+
): Promise<CustomerActivity | null> {
|
|
142
|
+
const where: Record<string, unknown> = { id: activityId, tenantId }
|
|
143
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
144
|
+
const row = await findOneWithDecryption<CustomerActivity>(
|
|
145
|
+
em,
|
|
146
|
+
CustomerActivity,
|
|
147
|
+
where as any,
|
|
148
|
+
undefined,
|
|
149
|
+
buildScope(ctx, tenantId),
|
|
150
|
+
)
|
|
151
|
+
if (!row || row.tenantId !== tenantId) return null
|
|
152
|
+
if (ctx.organizationId && row.organizationId !== ctx.organizationId) return null
|
|
153
|
+
return row
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function commentEntityIdOf(row: CustomerComment): string | null {
|
|
157
|
+
const ent = (row as any).entity
|
|
158
|
+
if (!ent) return null
|
|
159
|
+
if (typeof ent === 'string') return ent
|
|
160
|
+
if (typeof ent === 'object' && typeof ent.id === 'string') return ent.id
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function activityEntityIdOf(row: CustomerActivity): string | null {
|
|
165
|
+
const ent = (row as any).entity
|
|
166
|
+
if (!ent) return null
|
|
167
|
+
if (typeof ent === 'string') return ent
|
|
168
|
+
if (typeof ent === 'object' && typeof ent.id === 'string') return ent.id
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const listActivitiesInput = z
|
|
173
|
+
.object({
|
|
174
|
+
personId: z.string().uuid().optional().describe('Restrict to activities attached to this person entity id.'),
|
|
175
|
+
companyId: z.string().uuid().optional().describe('Restrict to activities attached to this company entity id.'),
|
|
176
|
+
dealId: z.string().uuid().optional().describe('Restrict to activities attached to this deal id.'),
|
|
177
|
+
activityType: z.string().optional().describe('Filter by activity type (e.g. "call", "email").'),
|
|
178
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
|
|
179
|
+
offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
|
|
180
|
+
})
|
|
181
|
+
.passthrough()
|
|
182
|
+
|
|
183
|
+
const listActivitiesTool: CustomersAiToolDefinition = {
|
|
184
|
+
name: 'customers.list_activities',
|
|
185
|
+
displayName: 'List activities',
|
|
186
|
+
description:
|
|
187
|
+
'List logged customer activities (calls, emails, meetings, notes, etc.) scoped to tenant + organization. Supply `personId` / `companyId` / `dealId` to narrow; otherwise returns the most recent activities across the tenant.',
|
|
188
|
+
inputSchema: listActivitiesInput,
|
|
189
|
+
requiredFeatures: ['customers.activities.view'],
|
|
190
|
+
tags: ['read', 'customers'],
|
|
191
|
+
handler: async (rawInput, ctx) => {
|
|
192
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
193
|
+
const input = listActivitiesInput.parse(rawInput)
|
|
194
|
+
const em = resolveEm(ctx)
|
|
195
|
+
const limit = input.limit ?? 50
|
|
196
|
+
const offset = input.offset ?? 0
|
|
197
|
+
const where: Record<string, unknown> = { tenantId }
|
|
198
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
199
|
+
const entityId = input.personId ?? input.companyId ?? null
|
|
200
|
+
if (entityId) where.entity = entityId
|
|
201
|
+
if (input.dealId) where.deal = input.dealId
|
|
202
|
+
if (input.activityType) where.activityType = input.activityType
|
|
203
|
+
const [rows, total] = await Promise.all([
|
|
204
|
+
findWithDecryption<CustomerActivity>(
|
|
205
|
+
em,
|
|
206
|
+
CustomerActivity,
|
|
207
|
+
where as any,
|
|
208
|
+
{ limit, offset, orderBy: { occurredAt: 'desc', createdAt: 'desc' } as any } as any,
|
|
209
|
+
buildScope(ctx, tenantId),
|
|
210
|
+
),
|
|
211
|
+
em.count(CustomerActivity, where as any),
|
|
212
|
+
])
|
|
213
|
+
const filtered = rows.filter((row) => row.tenantId === tenantId)
|
|
214
|
+
return {
|
|
215
|
+
items: filtered.map((row) => ({
|
|
216
|
+
id: row.id,
|
|
217
|
+
activityType: row.activityType,
|
|
218
|
+
subject: row.subject ?? null,
|
|
219
|
+
body: row.body ?? null,
|
|
220
|
+
occurredAt: row.occurredAt ? new Date(row.occurredAt).toISOString() : null,
|
|
221
|
+
authorUserId: row.authorUserId ?? null,
|
|
222
|
+
entityId: activityEntityIdOf(row),
|
|
223
|
+
dealId: (row as any).deal && typeof (row as any).deal === 'object' ? (row as any).deal.id : (row as any).deal ?? null,
|
|
224
|
+
organizationId: row.organizationId ?? null,
|
|
225
|
+
tenantId: row.tenantId ?? null,
|
|
226
|
+
createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
|
|
227
|
+
})),
|
|
228
|
+
total,
|
|
229
|
+
limit,
|
|
230
|
+
offset,
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const listTasksInput = z
|
|
236
|
+
.object({
|
|
237
|
+
personId: z.string().uuid().optional().describe('Restrict to tasks linked to this person entity id.'),
|
|
238
|
+
companyId: z.string().uuid().optional().describe('Restrict to tasks linked to this company entity id.'),
|
|
239
|
+
dealId: z.string().uuid().optional().describe('Restrict to tasks connected to this deal id.'),
|
|
240
|
+
status: z
|
|
241
|
+
.enum(['open', 'done', 'cancelled'])
|
|
242
|
+
.optional()
|
|
243
|
+
.describe('Filter canonical interaction tasks by status. Ignored when listing legacy todo links.'),
|
|
244
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
|
|
245
|
+
offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
|
|
246
|
+
})
|
|
247
|
+
.passthrough()
|
|
248
|
+
|
|
249
|
+
const listTasksTool: CustomersAiToolDefinition = {
|
|
250
|
+
name: 'customers.list_tasks',
|
|
251
|
+
displayName: 'List tasks',
|
|
252
|
+
description:
|
|
253
|
+
'List customer tasks scoped to tenant + organization. Returns canonical interaction tasks (interactionType="task") merged with legacy todo links for compatibility.',
|
|
254
|
+
inputSchema: listTasksInput,
|
|
255
|
+
requiredFeatures: ['customers.activities.view'],
|
|
256
|
+
tags: ['read', 'customers'],
|
|
257
|
+
handler: async (rawInput, ctx) => {
|
|
258
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
259
|
+
const input = listTasksInput.parse(rawInput)
|
|
260
|
+
const em = resolveEm(ctx)
|
|
261
|
+
const limit = input.limit ?? 50
|
|
262
|
+
const offset = input.offset ?? 0
|
|
263
|
+
const entityId = input.personId ?? input.companyId ?? null
|
|
264
|
+
const interactionWhere: Record<string, unknown> = {
|
|
265
|
+
tenantId,
|
|
266
|
+
interactionType: 'task',
|
|
267
|
+
deletedAt: null,
|
|
268
|
+
}
|
|
269
|
+
if (ctx.organizationId) interactionWhere.organizationId = ctx.organizationId
|
|
270
|
+
if (entityId) interactionWhere.entity = entityId
|
|
271
|
+
if (input.dealId) interactionWhere.dealId = input.dealId
|
|
272
|
+
if (input.status) interactionWhere.status = input.status === 'open' ? 'planned' : input.status === 'done' ? 'completed' : 'cancelled'
|
|
273
|
+
const interactionRows = await findWithDecryption<CustomerInteraction>(
|
|
274
|
+
em,
|
|
275
|
+
CustomerInteraction,
|
|
276
|
+
interactionWhere as any,
|
|
277
|
+
{ limit, offset, orderBy: { scheduledAt: 'desc', createdAt: 'desc' } as any } as any,
|
|
278
|
+
buildScope(ctx, tenantId),
|
|
279
|
+
)
|
|
280
|
+
const legacyWhere: Record<string, unknown> = { tenantId }
|
|
281
|
+
if (ctx.organizationId) legacyWhere.organizationId = ctx.organizationId
|
|
282
|
+
if (entityId) legacyWhere.entity = entityId
|
|
283
|
+
const legacyRows =
|
|
284
|
+
input.status || input.dealId
|
|
285
|
+
? []
|
|
286
|
+
: await findWithDecryption<CustomerTodoLink>(
|
|
287
|
+
em,
|
|
288
|
+
CustomerTodoLink,
|
|
289
|
+
legacyWhere as any,
|
|
290
|
+
{ limit, offset, orderBy: { createdAt: 'desc' } as any } as any,
|
|
291
|
+
buildScope(ctx, tenantId),
|
|
292
|
+
)
|
|
293
|
+
const filteredInteractions = interactionRows.filter((row) => row.tenantId === tenantId)
|
|
294
|
+
const filteredLegacy = legacyRows.filter((row) => row.tenantId === tenantId)
|
|
295
|
+
const items = [
|
|
296
|
+
...filteredInteractions.map((row) => ({
|
|
297
|
+
kind: 'interaction' as const,
|
|
298
|
+
id: row.id,
|
|
299
|
+
title: row.title ?? null,
|
|
300
|
+
body: row.body ?? null,
|
|
301
|
+
status: row.status,
|
|
302
|
+
scheduledAt: row.scheduledAt ? new Date(row.scheduledAt).toISOString() : null,
|
|
303
|
+
occurredAt: row.occurredAt ? new Date(row.occurredAt).toISOString() : null,
|
|
304
|
+
dealId: row.dealId ?? null,
|
|
305
|
+
ownerUserId: row.ownerUserId ?? null,
|
|
306
|
+
priority: row.priority ?? null,
|
|
307
|
+
organizationId: row.organizationId ?? null,
|
|
308
|
+
tenantId: row.tenantId ?? null,
|
|
309
|
+
createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
|
|
310
|
+
})),
|
|
311
|
+
...filteredLegacy.map((row) => ({
|
|
312
|
+
kind: 'todo_link' as const,
|
|
313
|
+
id: row.id,
|
|
314
|
+
todoId: row.todoId,
|
|
315
|
+
todoSource: row.todoSource,
|
|
316
|
+
entityId: (row as any).entity && typeof (row as any).entity === 'object' ? (row as any).entity.id : (row as any).entity ?? null,
|
|
317
|
+
organizationId: row.organizationId ?? null,
|
|
318
|
+
tenantId: row.tenantId ?? null,
|
|
319
|
+
createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
|
|
320
|
+
})),
|
|
321
|
+
]
|
|
322
|
+
return {
|
|
323
|
+
items,
|
|
324
|
+
total: items.length,
|
|
325
|
+
limit,
|
|
326
|
+
offset,
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// customers.list_deal_comments — read-only dedicated comment listing
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
const listDealCommentsInput = z
|
|
336
|
+
.object({
|
|
337
|
+
dealId: z.string().uuid().describe('Deal id whose comments should be listed.'),
|
|
338
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
|
|
339
|
+
offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
|
|
340
|
+
})
|
|
341
|
+
.passthrough()
|
|
342
|
+
|
|
343
|
+
const listDealCommentsTool: CustomersAiToolDefinition = {
|
|
344
|
+
name: 'customers.list_deal_comments',
|
|
345
|
+
displayName: 'List deal comments',
|
|
346
|
+
description:
|
|
347
|
+
'List comments left on a specific deal, ordered by most recent first. Read-only; tenant + organization scoped.',
|
|
348
|
+
inputSchema: listDealCommentsInput,
|
|
349
|
+
requiredFeatures: ['customers.activities.view'],
|
|
350
|
+
tags: ['read', 'customers'],
|
|
351
|
+
handler: async (rawInput, ctx) => {
|
|
352
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
353
|
+
const input = listDealCommentsInput.parse(rawInput)
|
|
354
|
+
const em = resolveEm(ctx)
|
|
355
|
+
const limit = input.limit ?? 50
|
|
356
|
+
const offset = input.offset ?? 0
|
|
357
|
+
const where: Record<string, unknown> = { tenantId, deal: input.dealId }
|
|
358
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
359
|
+
const [rows, total] = await Promise.all([
|
|
360
|
+
findWithDecryption<CustomerComment>(
|
|
361
|
+
em,
|
|
362
|
+
CustomerComment,
|
|
363
|
+
where as any,
|
|
364
|
+
{ limit, offset, orderBy: { createdAt: 'desc' } as any } as any,
|
|
365
|
+
buildScope(ctx, tenantId),
|
|
366
|
+
),
|
|
367
|
+
em.count(CustomerComment, where as any),
|
|
368
|
+
])
|
|
369
|
+
const filtered = rows.filter((row) => row.tenantId === tenantId)
|
|
370
|
+
return {
|
|
371
|
+
items: filtered.map((row) => ({
|
|
372
|
+
id: row.id,
|
|
373
|
+
body: row.body ?? null,
|
|
374
|
+
entityId: commentEntityIdOf(row),
|
|
375
|
+
dealId: (row as any).deal && typeof (row as any).deal === 'object' ? (row as any).deal.id : (row as any).deal ?? null,
|
|
376
|
+
authorUserId: row.authorUserId ?? null,
|
|
377
|
+
organizationId: row.organizationId ?? null,
|
|
378
|
+
tenantId: row.tenantId ?? null,
|
|
379
|
+
createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
|
|
380
|
+
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
|
|
381
|
+
})),
|
|
382
|
+
total,
|
|
383
|
+
limit,
|
|
384
|
+
offset,
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// customers.manage_deal_comment — create / update / delete a deal comment
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
const manageDealCommentInput = z
|
|
394
|
+
.object({
|
|
395
|
+
operation: z
|
|
396
|
+
.enum(['create', 'update', 'delete'])
|
|
397
|
+
.describe('Which write to perform: create a new comment, update an existing one, or delete it.'),
|
|
398
|
+
dealId: z
|
|
399
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
400
|
+
.describe('Required for `create` — the deal the comment is attached to.'),
|
|
401
|
+
commentId: z
|
|
402
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
403
|
+
.describe('Required for `update` and `delete` — id of the existing comment row.'),
|
|
404
|
+
body: z
|
|
405
|
+
.preprocess(blankToUndefined, z.string().min(1).max(8000).optional())
|
|
406
|
+
.describe('Comment text. Required for `create`; optional on `update`.'),
|
|
407
|
+
})
|
|
408
|
+
.superRefine((value, ctx) => {
|
|
409
|
+
if (value.operation === 'create') {
|
|
410
|
+
if (!value.dealId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'dealId is required for create.', path: ['dealId'] })
|
|
411
|
+
if (!value.body) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'body is required for create.', path: ['body'] })
|
|
412
|
+
}
|
|
413
|
+
if (value.operation === 'update') {
|
|
414
|
+
if (!value.commentId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'commentId is required for update.', path: ['commentId'] })
|
|
415
|
+
if (!value.body) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'body is required for update.', path: ['body'] })
|
|
416
|
+
}
|
|
417
|
+
if (value.operation === 'delete') {
|
|
418
|
+
if (!value.commentId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'commentId is required for delete.', path: ['commentId'] })
|
|
419
|
+
}
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
type ManageDealCommentInput = z.infer<typeof manageDealCommentInput>
|
|
423
|
+
|
|
424
|
+
const manageDealCommentTool: CustomersAiToolDefinition = {
|
|
425
|
+
name: 'customers.manage_deal_comment',
|
|
426
|
+
displayName: 'Manage deal comment',
|
|
427
|
+
description:
|
|
428
|
+
'Create, update, or delete a comment on a deal. Use `operation` to pick the action. Under `destructive-confirm-required` policy, only the `delete` branch routes through the approval card; `create` and `update` execute directly.',
|
|
429
|
+
inputSchema: manageDealCommentInput as z.ZodType<unknown>,
|
|
430
|
+
requiredFeatures: ['customers.activities.manage'],
|
|
431
|
+
tags: ['write', 'customers'],
|
|
432
|
+
isMutation: true,
|
|
433
|
+
// Predicate `isDestructive`: only the `delete` branch counts as
|
|
434
|
+
// destructive. Under `confirm-required` policy every branch still
|
|
435
|
+
// gates (the framework ignores this flag); under
|
|
436
|
+
// `destructive-confirm-required` only deletes go through the approval
|
|
437
|
+
// card while creates/updates run directly.
|
|
438
|
+
isDestructive: (input: unknown) => {
|
|
439
|
+
if (!input || typeof input !== 'object') return false
|
|
440
|
+
return (input as { operation?: unknown }).operation === 'delete'
|
|
441
|
+
},
|
|
442
|
+
loadBeforeRecord: async (rawInput, ctx): Promise<CustomersToolLoadBeforeSingleRecord | null> => {
|
|
443
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
444
|
+
const input: ManageDealCommentInput = manageDealCommentInput.parse(rawInput)
|
|
445
|
+
const em = resolveEm(ctx)
|
|
446
|
+
if (input.operation === 'create') {
|
|
447
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId!)
|
|
448
|
+
if (!deal) return null
|
|
449
|
+
return {
|
|
450
|
+
recordId: deal.id,
|
|
451
|
+
entityType: 'customers.deal',
|
|
452
|
+
recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
|
|
453
|
+
before: { commentId: null, body: null, dealId: deal.id },
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const existing = await loadCommentForScope(em, ctx, tenantId, input.commentId!)
|
|
457
|
+
if (!existing) return null
|
|
458
|
+
return {
|
|
459
|
+
recordId: existing.id,
|
|
460
|
+
entityType: 'customers.customer_comment',
|
|
461
|
+
recordVersion: recordVersionFromUpdatedAt(existing.updatedAt),
|
|
462
|
+
before: {
|
|
463
|
+
body: existing.body ?? null,
|
|
464
|
+
dealId: (existing as any).deal && typeof (existing as any).deal === 'object'
|
|
465
|
+
? (existing as any).deal.id
|
|
466
|
+
: (existing as any).deal ?? null,
|
|
467
|
+
entityId: commentEntityIdOf(existing),
|
|
468
|
+
authorUserId: existing.authorUserId ?? null,
|
|
469
|
+
},
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
handler: async (rawInput, ctx) => {
|
|
473
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
474
|
+
const input: ManageDealCommentInput = manageDealCommentInput.parse(rawInput)
|
|
475
|
+
const em = resolveEm(ctx)
|
|
476
|
+
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
477
|
+
|
|
478
|
+
if (input.operation === 'create') {
|
|
479
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId!)
|
|
480
|
+
if (!deal) throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
|
|
481
|
+
const organizationId = deal.organizationId
|
|
482
|
+
if (!organizationId) throw new Error(`Deal "${deal.id}" has no organization scope.`)
|
|
483
|
+
// `CustomerDeal` has no direct `.entity` field — deals link to
|
|
484
|
+
// people/companies via two link tables. Resolve the first available
|
|
485
|
+
// person, then fall back to the first linked company. Only fail the
|
|
486
|
+
// operation when the deal has no linked contacts at all.
|
|
487
|
+
const dealEntityId = await resolveDealCommentEntityId(em, ctx, tenantId, deal.id)
|
|
488
|
+
if (!dealEntityId) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`Deal "${deal.id}" has no linked person or company. Link a contact to the deal in the backoffice before adding a comment, or post the comment directly on the person/company record instead.`,
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
const body: Record<string, unknown> = {
|
|
494
|
+
tenantId,
|
|
495
|
+
organizationId,
|
|
496
|
+
dealId: deal.id,
|
|
497
|
+
// Comments require an `entityId` (the person/company on the timeline).
|
|
498
|
+
entityId: dealEntityId,
|
|
499
|
+
body: input.body,
|
|
500
|
+
}
|
|
501
|
+
const response = await runner.run({ method: 'POST', path: '/customers/comments', body })
|
|
502
|
+
if (!response.success) {
|
|
503
|
+
throw new Error(response.error ?? 'Failed to create comment')
|
|
504
|
+
}
|
|
505
|
+
const result = (response.data ?? {}) as { id?: string | null }
|
|
506
|
+
return {
|
|
507
|
+
operation: 'create' as const,
|
|
508
|
+
commentId: result.id ?? null,
|
|
509
|
+
dealId: deal.id,
|
|
510
|
+
commandName: 'customers.comments.create',
|
|
511
|
+
before: null,
|
|
512
|
+
after: { body: input.body ?? null, dealId: deal.id },
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (input.operation === 'update') {
|
|
517
|
+
const existing = await loadCommentForScope(em, ctx, tenantId, input.commentId!)
|
|
518
|
+
if (!existing) throw new Error(`Comment "${input.commentId}" is not accessible to the caller.`)
|
|
519
|
+
const organizationId = existing.organizationId
|
|
520
|
+
if (!organizationId) throw new Error(`Comment "${existing.id}" has no organization scope.`)
|
|
521
|
+
const body: Record<string, unknown> = {
|
|
522
|
+
id: existing.id,
|
|
523
|
+
tenantId,
|
|
524
|
+
organizationId,
|
|
525
|
+
body: input.body,
|
|
526
|
+
}
|
|
527
|
+
const response = await runner.run({ method: 'PUT', path: '/customers/comments', body })
|
|
528
|
+
if (!response.success) {
|
|
529
|
+
throw new Error(response.error ?? `Failed to update comment "${existing.id}"`)
|
|
530
|
+
}
|
|
531
|
+
const after = await loadCommentForScope(em, ctx, tenantId, existing.id)
|
|
532
|
+
return {
|
|
533
|
+
operation: 'update' as const,
|
|
534
|
+
commentId: existing.id,
|
|
535
|
+
commandName: 'customers.comments.update',
|
|
536
|
+
before: { body: existing.body ?? null },
|
|
537
|
+
after: after ? { body: after.body ?? null } : null,
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// delete
|
|
542
|
+
const existing = await loadCommentForScope(em, ctx, tenantId, input.commentId!)
|
|
543
|
+
if (!existing) throw new Error(`Comment "${input.commentId}" is not accessible to the caller.`)
|
|
544
|
+
const body: Record<string, unknown> = { id: existing.id }
|
|
545
|
+
const response = await runner.run({ method: 'DELETE', path: '/customers/comments', body })
|
|
546
|
+
if (!response.success) {
|
|
547
|
+
throw new Error(response.error ?? `Failed to delete comment "${existing.id}"`)
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
operation: 'delete' as const,
|
|
551
|
+
commentId: existing.id,
|
|
552
|
+
commandName: 'customers.comments.delete',
|
|
553
|
+
before: { body: existing.body ?? null },
|
|
554
|
+
after: null,
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
// customers.manage_deal_activity — create / update / delete an activity
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
const manageDealActivityInput = z
|
|
564
|
+
.object({
|
|
565
|
+
operation: z
|
|
566
|
+
.enum(['create', 'update', 'delete'])
|
|
567
|
+
.describe('Which write to perform: create a new activity, update an existing one, or delete it.'),
|
|
568
|
+
activityId: z
|
|
569
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
570
|
+
.describe('Required for `update` and `delete`.'),
|
|
571
|
+
dealId: z
|
|
572
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
573
|
+
.describe('Required for `create` — the deal the activity is logged against.'),
|
|
574
|
+
activityType: z
|
|
575
|
+
.preprocess(blankToUndefined, z.string().min(1).max(100).optional())
|
|
576
|
+
.describe('Required for `create` — e.g. "call", "email", "meeting", "note".'),
|
|
577
|
+
subject: z
|
|
578
|
+
.preprocess(blankToUndefined, z.string().max(200).optional())
|
|
579
|
+
.describe('Optional short subject line.'),
|
|
580
|
+
body: z
|
|
581
|
+
.preprocess(blankToUndefined, z.string().max(8000).optional())
|
|
582
|
+
.describe('Optional free-text body.'),
|
|
583
|
+
occurredAt: z
|
|
584
|
+
.preprocess(blankToUndefined, z.string().datetime().optional())
|
|
585
|
+
.describe('ISO-8601 timestamp when the activity occurred. Omit for "now" (server-side default applies).'),
|
|
586
|
+
})
|
|
587
|
+
.superRefine((value, ctx) => {
|
|
588
|
+
if (value.operation === 'create') {
|
|
589
|
+
if (!value.dealId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'dealId is required for create.', path: ['dealId'] })
|
|
590
|
+
if (!value.activityType) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'activityType is required for create.', path: ['activityType'] })
|
|
591
|
+
}
|
|
592
|
+
if (value.operation === 'update' || value.operation === 'delete') {
|
|
593
|
+
if (!value.activityId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'activityId is required.', path: ['activityId'] })
|
|
594
|
+
}
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
type ManageDealActivityInput = z.infer<typeof manageDealActivityInput>
|
|
598
|
+
|
|
599
|
+
const manageDealActivityTool: CustomersAiToolDefinition = {
|
|
600
|
+
name: 'customers.manage_deal_activity',
|
|
601
|
+
displayName: 'Manage deal activity',
|
|
602
|
+
description:
|
|
603
|
+
'Create, update, or delete a deal activity (call, email, meeting, note, etc.). Mutation tool — every call routes through the AI pending-action approval gate. Use `operation` to pick the action.',
|
|
604
|
+
inputSchema: manageDealActivityInput as z.ZodType<unknown>,
|
|
605
|
+
requiredFeatures: ['customers.activities.manage'],
|
|
606
|
+
tags: ['write', 'customers'],
|
|
607
|
+
isMutation: true,
|
|
608
|
+
loadBeforeRecord: async (rawInput, ctx): Promise<CustomersToolLoadBeforeSingleRecord | null> => {
|
|
609
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
610
|
+
const input: ManageDealActivityInput = manageDealActivityInput.parse(rawInput)
|
|
611
|
+
const em = resolveEm(ctx)
|
|
612
|
+
if (input.operation === 'create') {
|
|
613
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId!)
|
|
614
|
+
if (!deal) return null
|
|
615
|
+
return {
|
|
616
|
+
recordId: deal.id,
|
|
617
|
+
entityType: 'customers.deal',
|
|
618
|
+
recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
|
|
619
|
+
before: { activityId: null, dealId: deal.id },
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
623
|
+
if (!existing) return null
|
|
624
|
+
return {
|
|
625
|
+
recordId: existing.id,
|
|
626
|
+
entityType: 'customers.customer_activity',
|
|
627
|
+
recordVersion: recordVersionFromUpdatedAt(existing.updatedAt),
|
|
628
|
+
before: {
|
|
629
|
+
activityType: existing.activityType,
|
|
630
|
+
subject: existing.subject ?? null,
|
|
631
|
+
body: existing.body ?? null,
|
|
632
|
+
occurredAt: existing.occurredAt ? new Date(existing.occurredAt).toISOString() : null,
|
|
633
|
+
dealId: (existing as any).deal && typeof (existing as any).deal === 'object'
|
|
634
|
+
? (existing as any).deal.id
|
|
635
|
+
: (existing as any).deal ?? null,
|
|
636
|
+
},
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
handler: async (rawInput, ctx) => {
|
|
640
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
641
|
+
const input: ManageDealActivityInput = manageDealActivityInput.parse(rawInput)
|
|
642
|
+
const em = resolveEm(ctx)
|
|
643
|
+
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
644
|
+
|
|
645
|
+
if (input.operation === 'create') {
|
|
646
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId!)
|
|
647
|
+
if (!deal) throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
|
|
648
|
+
const organizationId = deal.organizationId
|
|
649
|
+
if (!organizationId) throw new Error(`Deal "${deal.id}" has no organization scope.`)
|
|
650
|
+
const dealEntity = (deal as unknown as { entity?: unknown }).entity
|
|
651
|
+
const entityId =
|
|
652
|
+
dealEntity && typeof dealEntity === 'object'
|
|
653
|
+
? (dealEntity as { id?: string | null }).id ?? null
|
|
654
|
+
: typeof dealEntity === 'string'
|
|
655
|
+
? dealEntity
|
|
656
|
+
: null
|
|
657
|
+
if (!entityId) {
|
|
658
|
+
throw new Error(`Deal "${deal.id}" has no associated person/company; cannot attach an activity.`)
|
|
659
|
+
}
|
|
660
|
+
const body: Record<string, unknown> = {
|
|
661
|
+
tenantId,
|
|
662
|
+
organizationId,
|
|
663
|
+
entityId,
|
|
664
|
+
dealId: deal.id,
|
|
665
|
+
activityType: input.activityType,
|
|
666
|
+
}
|
|
667
|
+
if (input.subject) body.subject = input.subject
|
|
668
|
+
if (input.body) body.body = input.body
|
|
669
|
+
if (input.occurredAt) body.occurredAt = input.occurredAt
|
|
670
|
+
const response = await runner.run({ method: 'POST', path: '/customers/activities', body })
|
|
671
|
+
if (!response.success) {
|
|
672
|
+
throw new Error(response.error ?? 'Failed to create activity')
|
|
673
|
+
}
|
|
674
|
+
const result = (response.data ?? {}) as { id?: string | null }
|
|
675
|
+
return {
|
|
676
|
+
operation: 'create' as const,
|
|
677
|
+
activityId: result.id ?? null,
|
|
678
|
+
dealId: deal.id,
|
|
679
|
+
commandName: 'customers.activities.create',
|
|
680
|
+
before: null,
|
|
681
|
+
after: {
|
|
682
|
+
activityType: input.activityType ?? null,
|
|
683
|
+
subject: input.subject ?? null,
|
|
684
|
+
body: input.body ?? null,
|
|
685
|
+
occurredAt: input.occurredAt ?? null,
|
|
686
|
+
},
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (input.operation === 'update') {
|
|
691
|
+
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
692
|
+
if (!existing) throw new Error(`Activity "${input.activityId}" is not accessible to the caller.`)
|
|
693
|
+
const organizationId = existing.organizationId
|
|
694
|
+
if (!organizationId) throw new Error(`Activity "${existing.id}" has no organization scope.`)
|
|
695
|
+
const body: Record<string, unknown> = { id: existing.id, tenantId, organizationId }
|
|
696
|
+
if (input.activityType) body.activityType = input.activityType
|
|
697
|
+
if (input.subject !== undefined) body.subject = input.subject
|
|
698
|
+
if (input.body !== undefined) body.body = input.body
|
|
699
|
+
if (input.occurredAt !== undefined) body.occurredAt = input.occurredAt
|
|
700
|
+
const response = await runner.run({ method: 'PUT', path: '/customers/activities', body })
|
|
701
|
+
if (!response.success) {
|
|
702
|
+
throw new Error(response.error ?? `Failed to update activity "${existing.id}"`)
|
|
703
|
+
}
|
|
704
|
+
const after = await loadActivityForScope(em, ctx, tenantId, existing.id)
|
|
705
|
+
return {
|
|
706
|
+
operation: 'update' as const,
|
|
707
|
+
activityId: existing.id,
|
|
708
|
+
commandName: 'customers.activities.update',
|
|
709
|
+
before: {
|
|
710
|
+
activityType: existing.activityType,
|
|
711
|
+
subject: existing.subject ?? null,
|
|
712
|
+
body: existing.body ?? null,
|
|
713
|
+
occurredAt: existing.occurredAt ? new Date(existing.occurredAt).toISOString() : null,
|
|
714
|
+
},
|
|
715
|
+
after: after
|
|
716
|
+
? {
|
|
717
|
+
activityType: after.activityType,
|
|
718
|
+
subject: after.subject ?? null,
|
|
719
|
+
body: after.body ?? null,
|
|
720
|
+
occurredAt: after.occurredAt ? new Date(after.occurredAt).toISOString() : null,
|
|
721
|
+
}
|
|
722
|
+
: null,
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// delete
|
|
727
|
+
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
728
|
+
if (!existing) throw new Error(`Activity "${input.activityId}" is not accessible to the caller.`)
|
|
729
|
+
const body: Record<string, unknown> = { id: existing.id }
|
|
730
|
+
const response = await runner.run({ method: 'DELETE', path: '/customers/activities', body })
|
|
731
|
+
if (!response.success) {
|
|
732
|
+
throw new Error(response.error ?? `Failed to delete activity "${existing.id}"`)
|
|
733
|
+
}
|
|
734
|
+
return {
|
|
735
|
+
operation: 'delete' as const,
|
|
736
|
+
activityId: existing.id,
|
|
737
|
+
commandName: 'customers.activities.delete',
|
|
738
|
+
before: {
|
|
739
|
+
activityType: existing.activityType,
|
|
740
|
+
subject: existing.subject ?? null,
|
|
741
|
+
body: existing.body ?? null,
|
|
742
|
+
},
|
|
743
|
+
after: null,
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
// customers.list_record_comments — list comments on a person / company / deal
|
|
750
|
+
// ---------------------------------------------------------------------------
|
|
751
|
+
|
|
752
|
+
const listRecordCommentsInput = z
|
|
753
|
+
.object({
|
|
754
|
+
personId: z
|
|
755
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
756
|
+
.describe('Restrict to comments on this person entity id.'),
|
|
757
|
+
companyId: z
|
|
758
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
759
|
+
.describe('Restrict to comments on this company entity id.'),
|
|
760
|
+
dealId: z
|
|
761
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
762
|
+
.describe('Restrict to comments attached to this deal id.'),
|
|
763
|
+
limit: z.number().int().min(1).max(100).optional().describe('Max rows (default 50, max 100).'),
|
|
764
|
+
offset: z.number().int().min(0).optional().describe('Rows to skip (default 0).'),
|
|
765
|
+
})
|
|
766
|
+
.superRefine((value, ctx) => {
|
|
767
|
+
if (!value.personId && !value.companyId && !value.dealId) {
|
|
768
|
+
ctx.addIssue({
|
|
769
|
+
code: z.ZodIssueCode.custom,
|
|
770
|
+
message: 'Provide at least one of personId, companyId, or dealId.',
|
|
771
|
+
path: ['personId'],
|
|
772
|
+
})
|
|
773
|
+
}
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
const listRecordCommentsTool: CustomersAiToolDefinition = {
|
|
777
|
+
name: 'customers.list_record_comments',
|
|
778
|
+
displayName: 'List record comments',
|
|
779
|
+
description:
|
|
780
|
+
'List comments left on a person, company, or deal record. Read-only; tenant + organization scoped. Provide at least one of `personId`, `companyId`, or `dealId`.',
|
|
781
|
+
inputSchema: listRecordCommentsInput as z.ZodType<unknown>,
|
|
782
|
+
requiredFeatures: ['customers.activities.view'],
|
|
783
|
+
tags: ['read', 'customers'],
|
|
784
|
+
handler: async (rawInput, ctx) => {
|
|
785
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
786
|
+
const input = listRecordCommentsInput.parse(rawInput)
|
|
787
|
+
const em = resolveEm(ctx)
|
|
788
|
+
const limit = input.limit ?? 50
|
|
789
|
+
const offset = input.offset ?? 0
|
|
790
|
+
const where: Record<string, unknown> = { tenantId }
|
|
791
|
+
if (ctx.organizationId) where.organizationId = ctx.organizationId
|
|
792
|
+
const entityId = input.personId ?? input.companyId ?? null
|
|
793
|
+
if (entityId) where.entity = entityId
|
|
794
|
+
if (input.dealId) where.deal = input.dealId
|
|
795
|
+
const [rows, total] = await Promise.all([
|
|
796
|
+
findWithDecryption<CustomerComment>(
|
|
797
|
+
em,
|
|
798
|
+
CustomerComment,
|
|
799
|
+
where as any,
|
|
800
|
+
{ limit, offset, orderBy: { createdAt: 'desc' } as any } as any,
|
|
801
|
+
buildScope(ctx, tenantId),
|
|
802
|
+
),
|
|
803
|
+
em.count(CustomerComment, where as any),
|
|
804
|
+
])
|
|
805
|
+
const filtered = rows.filter((row) => row.tenantId === tenantId)
|
|
806
|
+
return {
|
|
807
|
+
items: filtered.map((row) => ({
|
|
808
|
+
id: row.id,
|
|
809
|
+
body: row.body ?? null,
|
|
810
|
+
entityId: commentEntityIdOf(row),
|
|
811
|
+
dealId: (row as any).deal && typeof (row as any).deal === 'object' ? (row as any).deal.id : (row as any).deal ?? null,
|
|
812
|
+
authorUserId: row.authorUserId ?? null,
|
|
813
|
+
organizationId: row.organizationId ?? null,
|
|
814
|
+
tenantId: row.tenantId ?? null,
|
|
815
|
+
createdAt: row.createdAt ? new Date(row.createdAt).toISOString() : null,
|
|
816
|
+
updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null,
|
|
817
|
+
})),
|
|
818
|
+
total,
|
|
819
|
+
limit,
|
|
820
|
+
offset,
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// ---------------------------------------------------------------------------
|
|
826
|
+
// customers.manage_record_comment — create / update / delete a comment on a
|
|
827
|
+
// person, company, or deal record. Mutation tool.
|
|
828
|
+
// ---------------------------------------------------------------------------
|
|
829
|
+
|
|
830
|
+
const manageRecordCommentInput = z
|
|
831
|
+
.object({
|
|
832
|
+
operation: z
|
|
833
|
+
.enum(['create', 'update', 'delete'])
|
|
834
|
+
.describe('Which write to perform: create a new comment, update an existing one, or delete it.'),
|
|
835
|
+
personId: z
|
|
836
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
837
|
+
.describe('Required for `create` (or supply `companyId`) — the person entity the comment is attached to.'),
|
|
838
|
+
companyId: z
|
|
839
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
840
|
+
.describe('Required for `create` (or supply `personId`) — the company entity the comment is attached to.'),
|
|
841
|
+
dealId: z
|
|
842
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
843
|
+
.describe('Optional on `create` — when set, the comment also shows up under that deal.'),
|
|
844
|
+
commentId: z
|
|
845
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
846
|
+
.describe('Required for `update` and `delete` — id of the existing comment row.'),
|
|
847
|
+
body: z
|
|
848
|
+
.preprocess(blankToUndefined, z.string().min(1).max(8000).optional())
|
|
849
|
+
.describe('Comment text. Required for `create`; optional on `update`.'),
|
|
850
|
+
})
|
|
851
|
+
.superRefine((value, ctx) => {
|
|
852
|
+
if (value.operation === 'create') {
|
|
853
|
+
if (!value.personId && !value.companyId) {
|
|
854
|
+
ctx.addIssue({
|
|
855
|
+
code: z.ZodIssueCode.custom,
|
|
856
|
+
message: 'Provide personId or companyId for create.',
|
|
857
|
+
path: ['personId'],
|
|
858
|
+
})
|
|
859
|
+
}
|
|
860
|
+
if (value.personId && value.companyId) {
|
|
861
|
+
ctx.addIssue({
|
|
862
|
+
code: z.ZodIssueCode.custom,
|
|
863
|
+
message: 'Provide only one of personId or companyId.',
|
|
864
|
+
path: ['personId'],
|
|
865
|
+
})
|
|
866
|
+
}
|
|
867
|
+
if (!value.body) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'body is required for create.', path: ['body'] })
|
|
868
|
+
}
|
|
869
|
+
if (value.operation === 'update') {
|
|
870
|
+
if (!value.commentId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'commentId is required for update.', path: ['commentId'] })
|
|
871
|
+
if (!value.body) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'body is required for update.', path: ['body'] })
|
|
872
|
+
}
|
|
873
|
+
if (value.operation === 'delete') {
|
|
874
|
+
if (!value.commentId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'commentId is required for delete.', path: ['commentId'] })
|
|
875
|
+
}
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
type ManageRecordCommentInput = z.infer<typeof manageRecordCommentInput>
|
|
879
|
+
|
|
880
|
+
const manageRecordCommentTool: CustomersAiToolDefinition = {
|
|
881
|
+
name: 'customers.manage_record_comment',
|
|
882
|
+
displayName: 'Manage record comment',
|
|
883
|
+
description:
|
|
884
|
+
'Create, update, or delete a comment on a person, company, or deal record. Mutation tool — every call routes through the AI pending-action approval gate. Use `operation` to pick the action; for `create` provide `personId` OR `companyId` (and optionally `dealId`).',
|
|
885
|
+
inputSchema: manageRecordCommentInput as z.ZodType<unknown>,
|
|
886
|
+
requiredFeatures: ['customers.activities.manage'],
|
|
887
|
+
tags: ['write', 'customers'],
|
|
888
|
+
isMutation: true,
|
|
889
|
+
loadBeforeRecord: async (rawInput, ctx): Promise<CustomersToolLoadBeforeSingleRecord | null> => {
|
|
890
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
891
|
+
const input: ManageRecordCommentInput = manageRecordCommentInput.parse(rawInput)
|
|
892
|
+
const em = resolveEm(ctx)
|
|
893
|
+
if (input.operation === 'create') {
|
|
894
|
+
const entityId = input.personId ?? input.companyId
|
|
895
|
+
// We do not load the host person/company entity here — the
|
|
896
|
+
// `customers/comments` POST handler validates its existence and tenant
|
|
897
|
+
// scope. We do hydrate the deal when supplied so the approval card has
|
|
898
|
+
// a stable record-version anchor.
|
|
899
|
+
if (input.dealId) {
|
|
900
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId)
|
|
901
|
+
if (!deal) return null
|
|
902
|
+
return {
|
|
903
|
+
recordId: deal.id,
|
|
904
|
+
entityType: 'customers.deal',
|
|
905
|
+
recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
|
|
906
|
+
before: { commentId: null, body: null, entityId, dealId: deal.id },
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
recordId: entityId!,
|
|
911
|
+
entityType: input.personId ? 'customers.person' : 'customers.company',
|
|
912
|
+
recordVersion: null,
|
|
913
|
+
before: { commentId: null, body: null, entityId, dealId: null },
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const existing = await loadCommentForScope(em, ctx, tenantId, input.commentId!)
|
|
917
|
+
if (!existing) return null
|
|
918
|
+
return {
|
|
919
|
+
recordId: existing.id,
|
|
920
|
+
entityType: 'customers.customer_comment',
|
|
921
|
+
recordVersion: recordVersionFromUpdatedAt(existing.updatedAt),
|
|
922
|
+
before: {
|
|
923
|
+
body: existing.body ?? null,
|
|
924
|
+
dealId: (existing as any).deal && typeof (existing as any).deal === 'object'
|
|
925
|
+
? (existing as any).deal.id
|
|
926
|
+
: (existing as any).deal ?? null,
|
|
927
|
+
entityId: commentEntityIdOf(existing),
|
|
928
|
+
authorUserId: existing.authorUserId ?? null,
|
|
929
|
+
},
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
handler: async (rawInput, ctx) => {
|
|
933
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
934
|
+
const input: ManageRecordCommentInput = manageRecordCommentInput.parse(rawInput)
|
|
935
|
+
const em = resolveEm(ctx)
|
|
936
|
+
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
937
|
+
|
|
938
|
+
if (input.operation === 'create') {
|
|
939
|
+
const entityId = input.personId ?? input.companyId!
|
|
940
|
+
// Resolve organization scope: prefer the deal's org when one is supplied,
|
|
941
|
+
// otherwise fall back to the caller context's org.
|
|
942
|
+
let organizationId: string | null = ctx.organizationId
|
|
943
|
+
let dealId: string | null = null
|
|
944
|
+
if (input.dealId) {
|
|
945
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId)
|
|
946
|
+
if (!deal) throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
|
|
947
|
+
organizationId = deal.organizationId ?? organizationId
|
|
948
|
+
dealId = deal.id
|
|
949
|
+
}
|
|
950
|
+
if (!organizationId) {
|
|
951
|
+
throw new Error('Organization scope is required to create a comment.')
|
|
952
|
+
}
|
|
953
|
+
const body: Record<string, unknown> = {
|
|
954
|
+
tenantId,
|
|
955
|
+
organizationId,
|
|
956
|
+
entityId,
|
|
957
|
+
body: input.body,
|
|
958
|
+
}
|
|
959
|
+
if (dealId) body.dealId = dealId
|
|
960
|
+
const response = await runner.run({ method: 'POST', path: '/customers/comments', body })
|
|
961
|
+
if (!response.success) {
|
|
962
|
+
throw new Error(response.error ?? 'Failed to create comment')
|
|
963
|
+
}
|
|
964
|
+
const result = (response.data ?? {}) as { id?: string | null }
|
|
965
|
+
return {
|
|
966
|
+
operation: 'create' as const,
|
|
967
|
+
commentId: result.id ?? null,
|
|
968
|
+
entityId,
|
|
969
|
+
dealId,
|
|
970
|
+
commandName: 'customers.comments.create',
|
|
971
|
+
before: null,
|
|
972
|
+
after: { body: input.body ?? null, entityId, dealId },
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
if (input.operation === 'update') {
|
|
977
|
+
const existing = await loadCommentForScope(em, ctx, tenantId, input.commentId!)
|
|
978
|
+
if (!existing) throw new Error(`Comment "${input.commentId}" is not accessible to the caller.`)
|
|
979
|
+
const organizationId = existing.organizationId
|
|
980
|
+
if (!organizationId) throw new Error(`Comment "${existing.id}" has no organization scope.`)
|
|
981
|
+
const body: Record<string, unknown> = {
|
|
982
|
+
id: existing.id,
|
|
983
|
+
tenantId,
|
|
984
|
+
organizationId,
|
|
985
|
+
body: input.body,
|
|
986
|
+
}
|
|
987
|
+
const response = await runner.run({ method: 'PUT', path: '/customers/comments', body })
|
|
988
|
+
if (!response.success) {
|
|
989
|
+
throw new Error(response.error ?? `Failed to update comment "${existing.id}"`)
|
|
990
|
+
}
|
|
991
|
+
const after = await loadCommentForScope(em, ctx, tenantId, existing.id)
|
|
992
|
+
return {
|
|
993
|
+
operation: 'update' as const,
|
|
994
|
+
commentId: existing.id,
|
|
995
|
+
commandName: 'customers.comments.update',
|
|
996
|
+
before: { body: existing.body ?? null },
|
|
997
|
+
after: after ? { body: after.body ?? null } : null,
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// delete
|
|
1002
|
+
const existing = await loadCommentForScope(em, ctx, tenantId, input.commentId!)
|
|
1003
|
+
if (!existing) throw new Error(`Comment "${input.commentId}" is not accessible to the caller.`)
|
|
1004
|
+
const body: Record<string, unknown> = { id: existing.id }
|
|
1005
|
+
const response = await runner.run({ method: 'DELETE', path: '/customers/comments', body })
|
|
1006
|
+
if (!response.success) {
|
|
1007
|
+
throw new Error(response.error ?? `Failed to delete comment "${existing.id}"`)
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
operation: 'delete' as const,
|
|
1011
|
+
commentId: existing.id,
|
|
1012
|
+
commandName: 'customers.comments.delete',
|
|
1013
|
+
before: { body: existing.body ?? null },
|
|
1014
|
+
after: null,
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ---------------------------------------------------------------------------
|
|
1020
|
+
// customers.manage_record_activity — create / update / delete an activity on
|
|
1021
|
+
// a person, company, or deal record. Mutation tool.
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
|
|
1024
|
+
const manageRecordActivityInput = z
|
|
1025
|
+
.object({
|
|
1026
|
+
operation: z
|
|
1027
|
+
.enum(['create', 'update', 'delete'])
|
|
1028
|
+
.describe('Which write to perform: create a new activity, update an existing one, or delete it.'),
|
|
1029
|
+
activityId: z
|
|
1030
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
1031
|
+
.describe('Required for `update` and `delete`.'),
|
|
1032
|
+
personId: z
|
|
1033
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
1034
|
+
.describe('Required for `create` (or supply `companyId`) — the person entity the activity is logged on.'),
|
|
1035
|
+
companyId: z
|
|
1036
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
1037
|
+
.describe('Required for `create` (or supply `personId`) — the company entity the activity is logged on.'),
|
|
1038
|
+
dealId: z
|
|
1039
|
+
.preprocess(blankToUndefined, z.string().uuid().optional())
|
|
1040
|
+
.describe('Optional on `create` — when set, the activity is also linked to that deal.'),
|
|
1041
|
+
activityType: z
|
|
1042
|
+
.preprocess(blankToUndefined, z.string().min(1).max(100).optional())
|
|
1043
|
+
.describe('Required for `create` — e.g. "call", "email", "meeting", "note".'),
|
|
1044
|
+
subject: z
|
|
1045
|
+
.preprocess(blankToUndefined, z.string().max(200).optional())
|
|
1046
|
+
.describe('Optional short subject line.'),
|
|
1047
|
+
body: z
|
|
1048
|
+
.preprocess(blankToUndefined, z.string().max(8000).optional())
|
|
1049
|
+
.describe('Optional free-text body.'),
|
|
1050
|
+
occurredAt: z
|
|
1051
|
+
.preprocess(blankToUndefined, z.string().datetime().optional())
|
|
1052
|
+
.describe('ISO-8601 timestamp when the activity occurred. Omit for "now" (server-side default applies).'),
|
|
1053
|
+
})
|
|
1054
|
+
.superRefine((value, ctx) => {
|
|
1055
|
+
if (value.operation === 'create') {
|
|
1056
|
+
if (!value.personId && !value.companyId) {
|
|
1057
|
+
ctx.addIssue({
|
|
1058
|
+
code: z.ZodIssueCode.custom,
|
|
1059
|
+
message: 'Provide personId or companyId for create.',
|
|
1060
|
+
path: ['personId'],
|
|
1061
|
+
})
|
|
1062
|
+
}
|
|
1063
|
+
if (value.personId && value.companyId) {
|
|
1064
|
+
ctx.addIssue({
|
|
1065
|
+
code: z.ZodIssueCode.custom,
|
|
1066
|
+
message: 'Provide only one of personId or companyId.',
|
|
1067
|
+
path: ['personId'],
|
|
1068
|
+
})
|
|
1069
|
+
}
|
|
1070
|
+
if (!value.activityType) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'activityType is required for create.', path: ['activityType'] })
|
|
1071
|
+
}
|
|
1072
|
+
if (value.operation === 'update' || value.operation === 'delete') {
|
|
1073
|
+
if (!value.activityId) ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'activityId is required.', path: ['activityId'] })
|
|
1074
|
+
}
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
type ManageRecordActivityInput = z.infer<typeof manageRecordActivityInput>
|
|
1078
|
+
|
|
1079
|
+
const manageRecordActivityTool: CustomersAiToolDefinition = {
|
|
1080
|
+
name: 'customers.manage_record_activity',
|
|
1081
|
+
displayName: 'Manage record activity',
|
|
1082
|
+
description:
|
|
1083
|
+
'Create, update, or delete an activity (call, email, meeting, note) on a person, company, or deal record. Mutation tool — every call routes through the AI pending-action approval gate. For `create` provide `personId` OR `companyId` (and optionally `dealId`).',
|
|
1084
|
+
inputSchema: manageRecordActivityInput as z.ZodType<unknown>,
|
|
1085
|
+
requiredFeatures: ['customers.activities.manage'],
|
|
1086
|
+
tags: ['write', 'customers'],
|
|
1087
|
+
isMutation: true,
|
|
1088
|
+
loadBeforeRecord: async (rawInput, ctx): Promise<CustomersToolLoadBeforeSingleRecord | null> => {
|
|
1089
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
1090
|
+
const input: ManageRecordActivityInput = manageRecordActivityInput.parse(rawInput)
|
|
1091
|
+
const em = resolveEm(ctx)
|
|
1092
|
+
if (input.operation === 'create') {
|
|
1093
|
+
const entityId = input.personId ?? input.companyId
|
|
1094
|
+
if (input.dealId) {
|
|
1095
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId)
|
|
1096
|
+
if (!deal) return null
|
|
1097
|
+
return {
|
|
1098
|
+
recordId: deal.id,
|
|
1099
|
+
entityType: 'customers.deal',
|
|
1100
|
+
recordVersion: recordVersionFromUpdatedAt(deal.updatedAt),
|
|
1101
|
+
before: { activityId: null, dealId: deal.id, entityId },
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
return {
|
|
1105
|
+
recordId: entityId!,
|
|
1106
|
+
entityType: input.personId ? 'customers.person' : 'customers.company',
|
|
1107
|
+
recordVersion: null,
|
|
1108
|
+
before: { activityId: null, entityId, dealId: null },
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
1112
|
+
if (!existing) return null
|
|
1113
|
+
return {
|
|
1114
|
+
recordId: existing.id,
|
|
1115
|
+
entityType: 'customers.customer_activity',
|
|
1116
|
+
recordVersion: recordVersionFromUpdatedAt(existing.updatedAt),
|
|
1117
|
+
before: {
|
|
1118
|
+
activityType: existing.activityType,
|
|
1119
|
+
subject: existing.subject ?? null,
|
|
1120
|
+
body: existing.body ?? null,
|
|
1121
|
+
occurredAt: existing.occurredAt ? new Date(existing.occurredAt).toISOString() : null,
|
|
1122
|
+
entityId: activityEntityIdOf(existing),
|
|
1123
|
+
dealId: (existing as any).deal && typeof (existing as any).deal === 'object'
|
|
1124
|
+
? (existing as any).deal.id
|
|
1125
|
+
: (existing as any).deal ?? null,
|
|
1126
|
+
},
|
|
1127
|
+
}
|
|
1128
|
+
},
|
|
1129
|
+
handler: async (rawInput, ctx) => {
|
|
1130
|
+
const { tenantId } = assertTenantScope(ctx)
|
|
1131
|
+
const input: ManageRecordActivityInput = manageRecordActivityInput.parse(rawInput)
|
|
1132
|
+
const em = resolveEm(ctx)
|
|
1133
|
+
const runner = createAiApiOperationRunner(ctx as unknown as AiToolExecutionContext)
|
|
1134
|
+
|
|
1135
|
+
if (input.operation === 'create') {
|
|
1136
|
+
const entityId = input.personId ?? input.companyId!
|
|
1137
|
+
let organizationId: string | null = ctx.organizationId
|
|
1138
|
+
let dealId: string | null = null
|
|
1139
|
+
if (input.dealId) {
|
|
1140
|
+
const deal = await loadDealForScope(em, ctx, tenantId, input.dealId)
|
|
1141
|
+
if (!deal) throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
|
|
1142
|
+
organizationId = deal.organizationId ?? organizationId
|
|
1143
|
+
dealId = deal.id
|
|
1144
|
+
}
|
|
1145
|
+
if (!organizationId) {
|
|
1146
|
+
throw new Error('Organization scope is required to create an activity.')
|
|
1147
|
+
}
|
|
1148
|
+
const body: Record<string, unknown> = {
|
|
1149
|
+
tenantId,
|
|
1150
|
+
organizationId,
|
|
1151
|
+
entityId,
|
|
1152
|
+
activityType: input.activityType,
|
|
1153
|
+
}
|
|
1154
|
+
if (dealId) body.dealId = dealId
|
|
1155
|
+
if (input.subject) body.subject = input.subject
|
|
1156
|
+
if (input.body) body.body = input.body
|
|
1157
|
+
if (input.occurredAt) body.occurredAt = input.occurredAt
|
|
1158
|
+
const response = await runner.run({ method: 'POST', path: '/customers/activities', body })
|
|
1159
|
+
if (!response.success) {
|
|
1160
|
+
throw new Error(response.error ?? 'Failed to create activity')
|
|
1161
|
+
}
|
|
1162
|
+
const result = (response.data ?? {}) as { id?: string | null }
|
|
1163
|
+
return {
|
|
1164
|
+
operation: 'create' as const,
|
|
1165
|
+
activityId: result.id ?? null,
|
|
1166
|
+
entityId,
|
|
1167
|
+
dealId,
|
|
1168
|
+
commandName: 'customers.activities.create',
|
|
1169
|
+
before: null,
|
|
1170
|
+
after: {
|
|
1171
|
+
activityType: input.activityType ?? null,
|
|
1172
|
+
subject: input.subject ?? null,
|
|
1173
|
+
body: input.body ?? null,
|
|
1174
|
+
occurredAt: input.occurredAt ?? null,
|
|
1175
|
+
},
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (input.operation === 'update') {
|
|
1180
|
+
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
1181
|
+
if (!existing) throw new Error(`Activity "${input.activityId}" is not accessible to the caller.`)
|
|
1182
|
+
const organizationId = existing.organizationId
|
|
1183
|
+
if (!organizationId) throw new Error(`Activity "${existing.id}" has no organization scope.`)
|
|
1184
|
+
const body: Record<string, unknown> = { id: existing.id, tenantId, organizationId }
|
|
1185
|
+
if (input.activityType) body.activityType = input.activityType
|
|
1186
|
+
if (input.subject !== undefined) body.subject = input.subject
|
|
1187
|
+
if (input.body !== undefined) body.body = input.body
|
|
1188
|
+
if (input.occurredAt !== undefined) body.occurredAt = input.occurredAt
|
|
1189
|
+
const response = await runner.run({ method: 'PUT', path: '/customers/activities', body })
|
|
1190
|
+
if (!response.success) {
|
|
1191
|
+
throw new Error(response.error ?? `Failed to update activity "${existing.id}"`)
|
|
1192
|
+
}
|
|
1193
|
+
const after = await loadActivityForScope(em, ctx, tenantId, existing.id)
|
|
1194
|
+
return {
|
|
1195
|
+
operation: 'update' as const,
|
|
1196
|
+
activityId: existing.id,
|
|
1197
|
+
commandName: 'customers.activities.update',
|
|
1198
|
+
before: {
|
|
1199
|
+
activityType: existing.activityType,
|
|
1200
|
+
subject: existing.subject ?? null,
|
|
1201
|
+
body: existing.body ?? null,
|
|
1202
|
+
occurredAt: existing.occurredAt ? new Date(existing.occurredAt).toISOString() : null,
|
|
1203
|
+
},
|
|
1204
|
+
after: after
|
|
1205
|
+
? {
|
|
1206
|
+
activityType: after.activityType,
|
|
1207
|
+
subject: after.subject ?? null,
|
|
1208
|
+
body: after.body ?? null,
|
|
1209
|
+
occurredAt: after.occurredAt ? new Date(after.occurredAt).toISOString() : null,
|
|
1210
|
+
}
|
|
1211
|
+
: null,
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// delete
|
|
1216
|
+
const existing = await loadActivityForScope(em, ctx, tenantId, input.activityId!)
|
|
1217
|
+
if (!existing) throw new Error(`Activity "${input.activityId}" is not accessible to the caller.`)
|
|
1218
|
+
const body: Record<string, unknown> = { id: existing.id }
|
|
1219
|
+
const response = await runner.run({ method: 'DELETE', path: '/customers/activities', body })
|
|
1220
|
+
if (!response.success) {
|
|
1221
|
+
throw new Error(response.error ?? `Failed to delete activity "${existing.id}"`)
|
|
1222
|
+
}
|
|
1223
|
+
return {
|
|
1224
|
+
operation: 'delete' as const,
|
|
1225
|
+
activityId: existing.id,
|
|
1226
|
+
commandName: 'customers.activities.delete',
|
|
1227
|
+
before: {
|
|
1228
|
+
activityType: existing.activityType,
|
|
1229
|
+
subject: existing.subject ?? null,
|
|
1230
|
+
body: existing.body ?? null,
|
|
1231
|
+
},
|
|
1232
|
+
after: null,
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
export const activitiesTasksAiTools: CustomersAiToolDefinition[] = [
|
|
1238
|
+
listActivitiesTool,
|
|
1239
|
+
listTasksTool,
|
|
1240
|
+
listDealCommentsTool,
|
|
1241
|
+
manageDealCommentTool,
|
|
1242
|
+
manageDealActivityTool,
|
|
1243
|
+
listRecordCommentsTool,
|
|
1244
|
+
manageRecordCommentTool,
|
|
1245
|
+
manageRecordActivityTool,
|
|
1246
|
+
]
|
|
1247
|
+
|
|
1248
|
+
export default activitiesTasksAiTools
|