@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.
Files changed (163) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +13 -1
  3. package/dist/helpers/integration/api.js +29 -16
  4. package/dist/helpers/integration/api.js.map +2 -2
  5. package/dist/helpers/integration/auth.js +11 -6
  6. package/dist/helpers/integration/auth.js.map +3 -3
  7. package/dist/modules/auth/commands/roles.js +9 -12
  8. package/dist/modules/auth/commands/roles.js.map +2 -2
  9. package/dist/modules/catalog/ai-agents-context.js +147 -0
  10. package/dist/modules/catalog/ai-agents-context.js.map +7 -0
  11. package/dist/modules/catalog/ai-agents.js +383 -0
  12. package/dist/modules/catalog/ai-agents.js.map +7 -0
  13. package/dist/modules/catalog/ai-tools/_shared.js +318 -0
  14. package/dist/modules/catalog/ai-tools/_shared.js.map +7 -0
  15. package/dist/modules/catalog/ai-tools/authoring-pack.js +391 -0
  16. package/dist/modules/catalog/ai-tools/authoring-pack.js.map +7 -0
  17. package/dist/modules/catalog/ai-tools/categories-pack.js +167 -0
  18. package/dist/modules/catalog/ai-tools/categories-pack.js.map +7 -0
  19. package/dist/modules/catalog/ai-tools/configuration-pack.js +120 -0
  20. package/dist/modules/catalog/ai-tools/configuration-pack.js.map +7 -0
  21. package/dist/modules/catalog/ai-tools/media-tags-pack.js +107 -0
  22. package/dist/modules/catalog/ai-tools/media-tags-pack.js.map +7 -0
  23. package/dist/modules/catalog/ai-tools/merchandising-pack.js +429 -0
  24. package/dist/modules/catalog/ai-tools/merchandising-pack.js.map +7 -0
  25. package/dist/modules/catalog/ai-tools/mutation-pack.js +576 -0
  26. package/dist/modules/catalog/ai-tools/mutation-pack.js.map +7 -0
  27. package/dist/modules/catalog/ai-tools/prices-offers-pack.js +208 -0
  28. package/dist/modules/catalog/ai-tools/prices-offers-pack.js.map +7 -0
  29. package/dist/modules/catalog/ai-tools/products-pack.js +298 -0
  30. package/dist/modules/catalog/ai-tools/products-pack.js.map +7 -0
  31. package/dist/modules/catalog/ai-tools/stats-pack.js +57 -0
  32. package/dist/modules/catalog/ai-tools/stats-pack.js.map +7 -0
  33. package/dist/modules/catalog/ai-tools/types.js +10 -0
  34. package/dist/modules/catalog/ai-tools/types.js.map +7 -0
  35. package/dist/modules/catalog/ai-tools/variants-pack.js +75 -0
  36. package/dist/modules/catalog/ai-tools/variants-pack.js.map +7 -0
  37. package/dist/modules/catalog/ai-tools.js +28 -0
  38. package/dist/modules/catalog/ai-tools.js.map +7 -0
  39. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +466 -0
  40. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +7 -0
  41. package/dist/modules/catalog/backend/catalog/products/page.js +7 -1
  42. package/dist/modules/catalog/backend/catalog/products/page.js.map +2 -2
  43. package/dist/modules/catalog/components/CatalogStatsCard.js +91 -0
  44. package/dist/modules/catalog/components/CatalogStatsCard.js.map +7 -0
  45. package/dist/modules/catalog/components/products/ProductsDataTable.js +23 -3
  46. package/dist/modules/catalog/components/products/ProductsDataTable.js.map +2 -2
  47. package/dist/modules/catalog/events.js +7 -4
  48. package/dist/modules/catalog/events.js.map +2 -2
  49. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js +59 -0
  50. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.js.map +7 -0
  51. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js +17 -0
  52. package/dist/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.js.map +7 -0
  53. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +1 -1
  54. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  55. package/dist/modules/catalog/widgets/injection-table.js +13 -1
  56. package/dist/modules/catalog/widgets/injection-table.js.map +2 -2
  57. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js +94 -0
  58. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.js.map +7 -0
  59. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js +17 -0
  60. package/dist/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.js.map +7 -0
  61. package/dist/modules/customer_accounts/widgets/injection-table.js +9 -0
  62. package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
  63. package/dist/modules/customers/ai-agents-context.js +96 -0
  64. package/dist/modules/customers/ai-agents-context.js.map +7 -0
  65. package/dist/modules/customers/ai-agents.js +244 -0
  66. package/dist/modules/customers/ai-agents.js.map +7 -0
  67. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +1015 -0
  68. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +7 -0
  69. package/dist/modules/customers/ai-tools/addresses-tags-pack.js +134 -0
  70. package/dist/modules/customers/ai-tools/addresses-tags-pack.js.map +7 -0
  71. package/dist/modules/customers/ai-tools/companies-pack.js +249 -0
  72. package/dist/modules/customers/ai-tools/companies-pack.js.map +7 -0
  73. package/dist/modules/customers/ai-tools/deals-pack.js +348 -0
  74. package/dist/modules/customers/ai-tools/deals-pack.js.map +7 -0
  75. package/dist/modules/customers/ai-tools/people-pack.js +261 -0
  76. package/dist/modules/customers/ai-tools/people-pack.js.map +7 -0
  77. package/dist/modules/customers/ai-tools/settings-pack.js +102 -0
  78. package/dist/modules/customers/ai-tools/settings-pack.js.map +7 -0
  79. package/dist/modules/customers/ai-tools/types.js +10 -0
  80. package/dist/modules/customers/ai-tools/types.js.map +7 -0
  81. package/dist/modules/customers/ai-tools.js +20 -0
  82. package/dist/modules/customers/ai-tools.js.map +7 -0
  83. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +469 -0
  84. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +7 -0
  85. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js +17 -0
  86. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.js.map +7 -0
  87. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +117 -0
  88. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +7 -0
  89. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js +17 -0
  90. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.js.map +7 -0
  91. package/dist/modules/customers/widgets/injection-table.js +26 -0
  92. package/dist/modules/customers/widgets/injection-table.js.map +7 -0
  93. package/dist/modules/inbox_ops/ai-tools.js +4 -0
  94. package/dist/modules/inbox_ops/ai-tools.js.map +2 -2
  95. package/dist/modules/inbox_ops/lib/llmProvider.js +52 -7
  96. package/dist/modules/inbox_ops/lib/llmProvider.js.map +2 -2
  97. package/dist/modules/notifications/setup.js +13 -0
  98. package/dist/modules/notifications/setup.js.map +7 -0
  99. package/jest.config.cjs +1 -0
  100. package/jest.setup.ts +18 -0
  101. package/package.json +5 -3
  102. package/src/helpers/integration/api.ts +38 -16
  103. package/src/helpers/integration/auth.ts +13 -6
  104. package/src/modules/auth/commands/roles.ts +10 -12
  105. package/src/modules/catalog/AGENTS.md +11 -0
  106. package/src/modules/catalog/ai-agents-context.ts +239 -0
  107. package/src/modules/catalog/ai-agents.ts +525 -0
  108. package/src/modules/catalog/ai-tools/_shared.ts +487 -0
  109. package/src/modules/catalog/ai-tools/authoring-pack.ts +600 -0
  110. package/src/modules/catalog/ai-tools/categories-pack.ts +192 -0
  111. package/src/modules/catalog/ai-tools/configuration-pack.ts +218 -0
  112. package/src/modules/catalog/ai-tools/media-tags-pack.ts +127 -0
  113. package/src/modules/catalog/ai-tools/merchandising-pack.ts +608 -0
  114. package/src/modules/catalog/ai-tools/mutation-pack.ts +761 -0
  115. package/src/modules/catalog/ai-tools/prices-offers-pack.ts +376 -0
  116. package/src/modules/catalog/ai-tools/products-pack.ts +387 -0
  117. package/src/modules/catalog/ai-tools/stats-pack.ts +84 -0
  118. package/src/modules/catalog/ai-tools/types.ts +81 -0
  119. package/src/modules/catalog/ai-tools/variants-pack.ts +147 -0
  120. package/src/modules/catalog/ai-tools.ts +78 -0
  121. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +597 -0
  122. package/src/modules/catalog/backend/catalog/products/page.tsx +23 -2
  123. package/src/modules/catalog/components/CatalogStatsCard.tsx +118 -0
  124. package/src/modules/catalog/components/products/ProductsDataTable.tsx +54 -6
  125. package/src/modules/catalog/events.ts +7 -4
  126. package/src/modules/catalog/i18n/de.json +17 -0
  127. package/src/modules/catalog/i18n/en.json +17 -0
  128. package/src/modules/catalog/i18n/es.json +17 -0
  129. package/src/modules/catalog/i18n/pl.json +17 -0
  130. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.client.tsx +109 -0
  131. package/src/modules/catalog/widgets/injection/merchandising-assistant-trigger/widget.ts +29 -0
  132. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +1 -1
  133. package/src/modules/catalog/widgets/injection-table.ts +12 -0
  134. package/src/modules/customer_accounts/i18n/de.json +5 -0
  135. package/src/modules/customer_accounts/i18n/en.json +5 -0
  136. package/src/modules/customer_accounts/i18n/es.json +5 -0
  137. package/src/modules/customer_accounts/i18n/pl.json +5 -0
  138. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.client.tsx +136 -0
  139. package/src/modules/customer_accounts/widgets/injection/portal-ai-assistant-trigger/widget.ts +43 -0
  140. package/src/modules/customer_accounts/widgets/injection-table.ts +9 -0
  141. package/src/modules/customers/AGENTS.md +13 -0
  142. package/src/modules/customers/ai-agents-context.ts +150 -0
  143. package/src/modules/customers/ai-agents.ts +355 -0
  144. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +1248 -0
  145. package/src/modules/customers/ai-tools/addresses-tags-pack.ts +145 -0
  146. package/src/modules/customers/ai-tools/companies-pack.ts +362 -0
  147. package/src/modules/customers/ai-tools/deals-pack.ts +505 -0
  148. package/src/modules/customers/ai-tools/people-pack.ts +369 -0
  149. package/src/modules/customers/ai-tools/settings-pack.ts +121 -0
  150. package/src/modules/customers/ai-tools/types.ts +76 -0
  151. package/src/modules/customers/ai-tools.ts +34 -0
  152. package/src/modules/customers/i18n/de.json +25 -0
  153. package/src/modules/customers/i18n/en.json +25 -0
  154. package/src/modules/customers/i18n/es.json +25 -0
  155. package/src/modules/customers/i18n/pl.json +25 -0
  156. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +580 -0
  157. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.ts +36 -0
  158. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +191 -0
  159. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.ts +37 -0
  160. package/src/modules/customers/widgets/injection-table.ts +41 -0
  161. package/src/modules/inbox_ops/ai-tools.ts +4 -0
  162. package/src/modules/inbox_ops/lib/llmProvider.ts +83 -7
  163. 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