@open-mercato/core 0.6.1-develop.3291.1.6fad645fd0 → 0.6.1-develop.3306.1.9ad9ff2526

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 (122) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -0
  3. package/dist/modules/auth/backend/roles/[id]/edit/page.js +2 -1
  4. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  5. package/dist/modules/auth/backend/users/[id]/edit/page.js +2 -5
  6. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  7. package/dist/modules/catalog/ai-agents.js +47 -5
  8. package/dist/modules/catalog/ai-agents.js.map +2 -2
  9. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js +2 -5
  10. package/dist/modules/catalog/backend/catalog/categories/[id]/edit/page.js.map +2 -2
  11. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +4 -3
  12. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
  13. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +2 -9
  14. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  15. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js +2 -9
  16. package/dist/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.js.map +2 -2
  17. package/dist/modules/customers/ai-agents.js +174 -1
  18. package/dist/modules/customers/ai-agents.js.map +2 -2
  19. package/dist/modules/customers/ai-tools/deal-analyzer-pack.js +196 -0
  20. package/dist/modules/customers/ai-tools/deal-analyzer-pack.js.map +7 -0
  21. package/dist/modules/customers/ai-tools.js +3 -1
  22. package/dist/modules/customers/ai-tools.js.map +2 -2
  23. package/dist/modules/customers/backend/customers/companies/page.js +11 -1
  24. package/dist/modules/customers/backend/customers/companies/page.js.map +2 -2
  25. package/dist/modules/customers/backend/customers/deals/page.js +11 -1
  26. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  27. package/dist/modules/customers/backend/customers/people/page.js +11 -1
  28. package/dist/modules/customers/backend/customers/people/page.js.map +2 -2
  29. package/dist/modules/customers/commands/companies.js +37 -15
  30. package/dist/modules/customers/commands/companies.js.map +2 -2
  31. package/dist/modules/customers/components/detail/AiActionChips.js +2 -2
  32. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  33. package/dist/modules/customers/components/detail/ChangelogTab.js +3 -9
  34. package/dist/modules/customers/components/detail/ChangelogTab.js.map +2 -2
  35. package/dist/modules/customers/components/detail/changelogValues.js +35 -0
  36. package/dist/modules/customers/components/detail/changelogValues.js.map +7 -0
  37. package/dist/modules/customers/components/detail/customFieldUtils.js +1 -1
  38. package/dist/modules/customers/components/detail/customFieldUtils.js.map +1 -1
  39. package/dist/modules/customers/components/formConfig.js +3 -2
  40. package/dist/modules/customers/components/formConfig.js.map +2 -2
  41. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js +4 -9
  42. package/dist/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.js.map +2 -2
  43. package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.client.js +302 -0
  44. package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.client.js.map +7 -0
  45. package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.js +17 -0
  46. package/dist/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.js.map +7 -0
  47. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js +2 -2
  48. package/dist/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.js.map +2 -2
  49. package/dist/modules/customers/widgets/injection-table.js +2 -2
  50. package/dist/modules/customers/widgets/injection-table.js.map +2 -2
  51. package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js +2 -5
  52. package/dist/modules/directory/backend/directory/organizations/[id]/edit/page.js.map +2 -2
  53. package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js +2 -5
  54. package/dist/modules/directory/backend/directory/tenants/[id]/edit/page.js.map +2 -2
  55. package/dist/modules/resources/backend/resources/resources/[id]/page.js +2 -5
  56. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  57. package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js +2 -25
  58. package/dist/modules/sales/backend/sales/channels/[channelId]/edit/page.js.map +2 -2
  59. package/dist/modules/sales/components/channels/ChannelOfferForm.js +2 -43
  60. package/dist/modules/sales/components/channels/ChannelOfferForm.js.map +2 -2
  61. package/dist/modules/sales/components/documents/LineItemDialog.js +162 -42
  62. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  63. package/dist/modules/sales/components/documents/customFieldHelpers.js +4 -31
  64. package/dist/modules/sales/components/documents/customFieldHelpers.js.map +2 -2
  65. package/package.json +15 -6
  66. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +2 -1
  67. package/src/modules/auth/backend/users/[id]/edit/page.tsx +2 -5
  68. package/src/modules/business_rules/migrations/.snapshot-open-mercato.json +746 -394
  69. package/src/modules/catalog/ai-agents.ts +62 -4
  70. package/src/modules/catalog/backend/catalog/categories/[id]/edit/page.tsx +2 -5
  71. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +4 -3
  72. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +2 -12
  73. package/src/modules/catalog/backend/catalog/products/[productId]/variants/[variantId]/page.tsx +2 -10
  74. package/src/modules/catalog/migrations/.snapshot-open-mercato.json +2519 -1307
  75. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +828 -834
  76. package/src/modules/customers/AGENTS.md +6 -1
  77. package/src/modules/customers/ai-agents.ts +213 -6
  78. package/src/modules/customers/ai-tools/deal-analyzer-pack.ts +286 -0
  79. package/src/modules/customers/ai-tools.ts +2 -0
  80. package/src/modules/customers/backend/customers/companies/page.tsx +10 -0
  81. package/src/modules/customers/backend/customers/deals/page.tsx +10 -0
  82. package/src/modules/customers/backend/customers/people/page.tsx +10 -0
  83. package/src/modules/customers/commands/companies.ts +38 -14
  84. package/src/modules/customers/components/detail/AiActionChips.tsx +2 -2
  85. package/src/modules/customers/components/detail/ChangelogTab.tsx +3 -10
  86. package/src/modules/customers/components/detail/changelogValues.ts +42 -0
  87. package/src/modules/customers/components/detail/customFieldUtils.ts +1 -1
  88. package/src/modules/customers/components/formConfig.tsx +3 -2
  89. package/src/modules/customers/i18n/de.json +7 -0
  90. package/src/modules/customers/i18n/en.json +7 -0
  91. package/src/modules/customers/i18n/es.json +7 -0
  92. package/src/modules/customers/i18n/pl.json +7 -0
  93. package/src/modules/customers/migrations/.snapshot-open-mercato.json +2313 -2336
  94. package/src/modules/customers/widgets/injection/ai-assistant-trigger/widget.client.tsx +4 -6
  95. package/src/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.client.tsx +358 -0
  96. package/src/modules/customers/widgets/injection/ai-deal-analyzer-trigger/widget.ts +34 -0
  97. package/src/modules/customers/widgets/injection/ai-deal-detail-trigger/widget.client.tsx +2 -2
  98. package/src/modules/customers/widgets/injection-table.ts +12 -9
  99. package/src/modules/data_sync/migrations/.snapshot-open-mercato.json +554 -210
  100. package/src/modules/dictionaries/migrations/.snapshot-open-mercato.json +146 -146
  101. package/src/modules/directory/backend/directory/organizations/[id]/edit/page.tsx +2 -5
  102. package/src/modules/directory/backend/directory/tenants/[id]/edit/page.tsx +2 -5
  103. package/src/modules/directory/migrations/.snapshot-open-mercato.json +273 -140
  104. package/src/modules/feature_toggles/migrations/.snapshot-open-mercato.json +242 -137
  105. package/src/modules/integrations/migrations/.snapshot-open-mercato.json +498 -195
  106. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +158 -157
  107. package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +274 -129
  108. package/src/modules/planner/migrations/.snapshot-open-mercato.json +327 -157
  109. package/src/modules/progress/AGENTS.md +72 -0
  110. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +2 -5
  111. package/src/modules/resources/migrations/.snapshot-open-mercato.json +920 -470
  112. package/src/modules/sales/backend/sales/channels/[channelId]/edit/page.tsx +2 -27
  113. package/src/modules/sales/components/channels/ChannelOfferForm.tsx +2 -49
  114. package/src/modules/sales/components/documents/LineItemDialog.tsx +214 -78
  115. package/src/modules/sales/components/documents/customFieldHelpers.ts +4 -43
  116. package/src/modules/sales/i18n/de.json +3 -0
  117. package/src/modules/sales/i18n/en.json +3 -0
  118. package/src/modules/sales/i18n/es.json +3 -0
  119. package/src/modules/sales/i18n/pl.json +3 -0
  120. package/src/modules/sales/migrations/.snapshot-open-mercato.json +4740 -4778
  121. package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +340 -81
  122. package/src/modules/staff/migrations/.snapshot-open-mercato.json +1439 -729
@@ -102,7 +102,12 @@ This module is the reference implementation for the AI framework. Copy `ai-agent
102
102
  |----------|------|--------|---------|
103
103
  | `customers.account_assistant` | chat | read-only (mutation-capable via per-tenant override that unlocks `customers.update_deal_stage`; see `packages/core/src/modules/customers/ai-agents.ts` for the whitelist and prompt) | Operator-facing assistant that explores people, companies, deals, activities, tasks, addresses, tags, and settings through the customers tool pack. |
104
104
  | `customers.update_deal_stage` | tool (mutation) | `destructive-confirm-required` — goes through `prepareMutation` + the approval card | Moves a deal between stages / flips status between open, won, lost. Declared via `defineAiTool` in `ai-tools.ts` and exposed only when the tenant mutation-policy override promotes the agent above read-only. |
105
+ | `customers.deal_analyzer` | chat, `executionEngine: 'stream-text'` | `confirm-required` | Multi-step agentic demo that exercises loop primitives from PRs #1856..#1869. Calls `customers.analyze_deals`, ranks deals by health score, then calls `customers.update_deal_stage` for the highest-value stalled deal — loop stops immediately after (loop.stopWhen). `loop.prepareStep` scopes the active tool set by step without overriding the model. It intentionally leaves `defaultProvider` and `defaultModel` unset so runtime resolution uses `OM_AI_PROVIDER` / `OM_AI_MODEL` or tenant/runtime overrides. |
106
+ | `customers.deal_analyzer_tool_loop` | chat, `executionEngine: 'tool-loop-agent'` | `confirm-required` | Sibling agent identical to `customers.deal_analyzer` but dispatched via `ToolLoopAgent`. Proves the mutation-approval gate holds for both execution engines (TC-AI-AGENT-LOOP-006). |
105
107
 
106
108
  `<AiChat agent="customers.account_assistant" />` is injected in two places (both live in `widgets/injection/`):
107
- - People list: `data-table:customers.people.list:header` via the `ai-assistant-trigger` widget.
109
+ - People list: `data-table:customers.people.list:search-trailing` via the `ai-assistant-trigger` widget.
108
110
  - Deal detail: `detail:customers.deal:header` via the `ai-deal-detail-trigger` widget.
111
+
112
+ `<AiChat agent="customers.deal_analyzer" />` is injected on the Deals list page:
113
+ - Deals list: `data-table:customers.deals.list:search-trailing` via the `ai-deal-analyzer-trigger` widget. Selected deal IDs are passed as `pageContext.recordId` (comma-separated UUIDs) so the agent can scope its `customers.analyze_deals` call to the operator's current selection.
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Module-root AI agent contribution for the customers module.
3
3
  *
4
+ * See /framework/ai-assistant/agents for the structured PromptTemplate
5
+ * convention and the per-tenant override path that can downgrade
6
+ * mutationPolicy to read-only.
7
+ *
4
8
  * The generator walks every module root for a top-level `ai-agents.ts` and
5
9
  * takes the default/`aiAgents` export as the agent contribution. The
6
10
  * `customers.account_assistant` agent explores people / companies / deals /
@@ -15,11 +19,7 @@
15
19
  *
16
20
  * Prompt is declared as a structured `PromptTemplate` (not a flat string)
17
21
  * per spec §8 with the seven named sections: ROLE, SCOPE, DATA, TOOLS,
18
- * ATTACHMENTS, MUTATION POLICY, RESPONSE STYLE. The composed string is
19
- * fed into `systemPrompt` so the existing runtime continues to work, and
20
- * the structured template is additionally exported so downstream Phases
21
- * (5.3 prompt-override merge, 5.2 resolvePageContext hydration) can
22
- * address sections by name.
22
+ * ATTACHMENTS, MUTATION POLICY, RESPONSE STYLE.
23
23
  */
24
24
  import type {
25
25
  AiAgentDefinition,
@@ -303,6 +303,213 @@ const agent: AiAgentDefinition = {
303
303
  resolvePageContext,
304
304
  }
305
305
 
306
- export const aiAgents: AiAgentDefinition[] = [agent]
306
+ // customers.deal_analyzer — multi-step agentic loop demo.
307
+ // See /framework/ai-assistant/agents → "Deal Analyzer demo" for the loop
308
+ // primitives exercised here; the sibling tool-loop-agent below proves both
309
+ // execution engines honor the mutation gate.
310
+
311
+ type DealAnalyzerPromptSectionName =
312
+ | 'role'
313
+ | 'scope'
314
+ | 'data'
315
+ | 'tools'
316
+ | 'mutationPolicy'
317
+ | 'responseStyle'
318
+
319
+ interface DealAnalyzerPromptSection {
320
+ name: DealAnalyzerPromptSectionName
321
+ content: string
322
+ order: number
323
+ }
324
+
325
+ const DEAL_ANALYZER_PROMPT_SECTIONS: DealAnalyzerPromptSection[] = [
326
+ {
327
+ name: 'role',
328
+ order: 1,
329
+ content: [
330
+ 'ROLE',
331
+ 'You are the Deal Analyzer inside Open Mercato. You are a multi-step agentic',
332
+ 'assistant that analyzes the health of a tenant\'s open deals, surfaces stalled',
333
+ 'opportunities, and proposes pipeline stage transitions for operator approval.',
334
+ ].join('\n'),
335
+ },
336
+ {
337
+ name: 'scope',
338
+ order: 2,
339
+ content: [
340
+ 'SCOPE',
341
+ 'Stay inside the customers module. Respect tenant and organization isolation.',
342
+ 'ALWAYS call customers.analyze_deals as your FIRST tool call — do not skip this.',
343
+ 'Reason about stalled deals: any deal with no activity for more than 14 days',
344
+ 'is considered stalled. For each stalled deal with a value greater than $5,000',
345
+ 'propose a stage move via customers.update_deal_stage.',
346
+ 'After calling customers.update_deal_stage, finish with a concise conclusion',
347
+ 'summarizing the analysis and the approval action the operator should review.',
348
+ ].join('\n'),
349
+ },
350
+ {
351
+ name: 'data',
352
+ order: 3,
353
+ content: [
354
+ 'DATA',
355
+ 'You can read: customers.deal via customers.analyze_deals (analytical summary)',
356
+ 'and customers.list_deals / customers.get_deal (full detail).',
357
+ 'Use customers.analyze_deals first — it returns a ranked list of deals by',
358
+ 'health score (lowest = most at risk) with last-activity information.',
359
+ 'Use customers.list_activities to get more detail on a specific deal\'s activity.',
360
+ 'Use search.hybrid_search only for free-text queries spanning multiple entity types.',
361
+ 'CRITICAL: Only use IDs returned by a prior tool call. Never invent or guess UUIDs.',
362
+ ].join('\n'),
363
+ },
364
+ {
365
+ name: 'tools',
366
+ order: 4,
367
+ content: [
368
+ 'TOOLS',
369
+ 'Primary tools for this agent (call in this order on each turn):',
370
+ '1. customers.analyze_deals — analytical overview of deals ranked by health score.',
371
+ ' Supply dealStageFilter="open" to restrict to open deals.',
372
+ '2. customers.update_deal_stage — propose a stage move for a stalled high-value deal.',
373
+ ' The runtime intercepts this via the pending-action gate; do NOT claim the change',
374
+ ' is saved until the mutation-result-card arrives.',
375
+ 'Secondary read tools (use when you need more detail):',
376
+ '- customers.list_deals, customers.get_deal, customers.list_activities',
377
+ '- search.hybrid_search, meta.describe_agent',
378
+ ].join('\n'),
379
+ },
380
+ {
381
+ name: 'mutationPolicy',
382
+ order: 5,
383
+ content: [
384
+ 'MUTATION POLICY',
385
+ 'This agent ships with mutationPolicy: "confirm-required". Every write goes through',
386
+ 'the pending-action approval card and only persists after the operator confirms it.',
387
+ 'After calling customers.update_deal_stage, explain whether a pending approval',
388
+ 'card was created or whether the proposed move could not be prepared.',
389
+ 'Do NOT call update_deal_stage more than once per turn.',
390
+ 'If a per-tenant override has downgraded this agent to read-only, tell the operator',
391
+ 'the write is locked and point to /backend/customers/deals/<id>.',
392
+ ].join('\n'),
393
+ },
394
+ {
395
+ name: 'responseStyle',
396
+ order: 6,
397
+ content: [
398
+ 'RESPONSE STYLE',
399
+ '',
400
+ '═══════════════════════════════════════════════════════════════════════',
401
+ 'RULE #1 — DEAL RECORD CARDS ARE MANDATORY',
402
+ '═══════════════════════════════════════════════════════════════════════',
403
+ 'For every deal you surface in your analysis you MUST emit an open-mercato:deal',
404
+ 'fenced card. Use the deal id, title, value, and stage from the tool output.',
405
+ 'Always populate href with /backend/customers/deals/<id>.',
406
+ '',
407
+ 'Card schema: { "id", "title", "status"?, "stage"?, "amount"?, "currency"?,',
408
+ ' "closeDate"?, "ownerName"?, "personName"?, "companyName"?,',
409
+ ' "description"?, "tags"?, "href" }',
410
+ '',
411
+ '═══════════════════════════════════════════════════════════════════════',
412
+ 'RULE #2 — Analysis format',
413
+ '═══════════════════════════════════════════════════════════════════════',
414
+ 'Lead with a one-paragraph summary of the deal portfolio health, then emit one',
415
+ 'deal card per at-risk deal (sorted by health score ascending). After the cards,',
416
+ 'propose exactly one stage move for the highest-value stalled deal and call',
417
+ 'customers.update_deal_stage. Then finish with a short conclusion naming the',
418
+ 'highest-value stalled deal, the recommended move, and the approval status.',
419
+ ].join('\n'),
420
+ },
421
+ ]
422
+
423
+ function compileDealAnalyzerPrompt(): string {
424
+ return DEAL_ANALYZER_PROMPT_SECTIONS.slice()
425
+ .sort((a, b) => a.order - b.order)
426
+ .map((section) => section.content.trim())
427
+ .join('\n\n')
428
+ }
429
+
430
+ const DEAL_ANALYZER_ALLOWED_TOOLS: readonly string[] = [
431
+ 'customers.analyze_deals',
432
+ 'customers.update_deal_stage',
433
+ 'customers.list_deals',
434
+ 'customers.get_deal',
435
+ 'customers.list_activities',
436
+ 'search.hybrid_search',
437
+ 'meta.describe_agent',
438
+ ]
439
+
440
+ function buildDealAnalyzerPrepareStep() {
441
+ return async function dealAnalyzerPrepareStep(state: { stepNumber: number }) {
442
+ if (state.stepNumber === 0) {
443
+ return {
444
+ activeTools: [
445
+ 'customers.analyze_deals',
446
+ 'customers.list_deals',
447
+ 'customers.get_deal',
448
+ 'customers.list_activities',
449
+ 'search.hybrid_search',
450
+ 'meta.describe_agent',
451
+ ],
452
+ }
453
+ }
454
+ return { activeTools: [...DEAL_ANALYZER_ALLOWED_TOOLS] }
455
+ }
456
+ }
457
+
458
+ const dealAnalyzer: AiAgentDefinition = {
459
+ id: 'customers.deal_analyzer',
460
+ moduleId: 'customers',
461
+ label: 'Deal Analyzer',
462
+ description:
463
+ 'Multi-step CRM agent that analyzes deals, surfaces stalled opportunities, and proposes stage transitions for operator approval.',
464
+ systemPrompt: compileDealAnalyzerPrompt(),
465
+ allowedTools: [...DEAL_ANALYZER_ALLOWED_TOOLS],
466
+ executionMode: 'chat',
467
+ executionEngine: 'stream-text',
468
+ allowRuntimeOverride: true,
469
+ readOnly: false,
470
+ mutationPolicy: 'confirm-required',
471
+ requiredFeatures: ['customers.deals.view'],
472
+ uiParts: ['open-mercato:deal'],
473
+ keywords: ['deal', 'pipeline', 'stalled', 'crm', 'analysis', 'health'],
474
+ domain: 'customers',
475
+ loop: {
476
+ maxSteps: 12,
477
+ prepareStep: buildDealAnalyzerPrepareStep() as AiAgentDefinition['loop'] extends undefined
478
+ ? never
479
+ : NonNullable<AiAgentDefinition['loop']>['prepareStep'],
480
+ budget: {
481
+ maxToolCalls: 12,
482
+ maxWallClockMs: 60_000,
483
+ },
484
+ allowRuntimeOverride: true,
485
+ },
486
+ dataCapabilities: {
487
+ entities: ['customers.deal', 'customers.activity'],
488
+ operations: ['read', 'search', 'aggregate'],
489
+ },
490
+ suggestions: [
491
+ {
492
+ label: 'Analyze stalled deals',
493
+ prompt: 'Analyze stalled deals from the last 30 days and propose a stage move for the highest value one',
494
+ },
495
+ {
496
+ label: 'Show at-risk pipeline',
497
+ prompt: 'Show me deals with no activity in the last 14 days worth more than $5,000',
498
+ },
499
+ ],
500
+ }
501
+
502
+ // Sibling agent identical to customers.deal_analyzer except for
503
+ // executionEngine: 'tool-loop-agent' (TC-AI-AGENT-LOOP-006).
504
+ const dealAnalyzerToolLoop: AiAgentDefinition = {
505
+ ...dealAnalyzer,
506
+ id: 'customers.deal_analyzer_tool_loop',
507
+ label: 'Deal Analyzer (ToolLoopAgent)',
508
+ description:
509
+ 'Same as customers.deal_analyzer but dispatched via the ToolLoopAgent engine. Used by TC-AI-AGENT-LOOP-006 mutation-gate proof scenario.',
510
+ executionEngine: 'tool-loop-agent',
511
+ }
512
+
513
+ export const aiAgents: AiAgentDefinition[] = [agent, dealAnalyzer, dealAnalyzerToolLoop]
307
514
 
308
515
  export default aiAgents
@@ -0,0 +1,286 @@
1
+ /**
2
+ * `customers.analyze_deals` — analytical read-only tool for the deal_analyzer
3
+ * agent (Step d1 of the deal-analyzer-demo).
4
+ *
5
+ * Reads deals scoped to the caller tenant/org, enriches each deal with the
6
+ * most recent activity timestamp (days-since-last-activity), computes a simple
7
+ * healthScore, and returns a ranked list. The handler issues two bounded DB
8
+ * reads (deals + activities) — no N+1.
9
+ *
10
+ * `customers.update_deal_stage` already exists in deals-pack.ts and is
11
+ * imported and re-exported here so the deal_analyzer agent's `allowedTools`
12
+ * list can reference both under a single pack. The tool itself is NOT
13
+ * redeclared here.
14
+ */
15
+ import type { EntityManager } from '@mikro-orm/postgresql'
16
+ import { z } from 'zod'
17
+ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
18
+ import {
19
+ CustomerDeal,
20
+ CustomerActivity,
21
+ CustomerDealPersonLink,
22
+ CustomerEntity,
23
+ } from '../data/entities'
24
+ import {
25
+ assertTenantScope,
26
+ type CustomersAiToolDefinition,
27
+ type CustomersToolContext,
28
+ } from './types'
29
+
30
+ function refIdOf(ref: unknown): string | undefined {
31
+ if (!ref) return undefined
32
+ if (typeof ref === 'string') return ref
33
+ if (typeof ref === 'object' && typeof (ref as { id?: unknown }).id === 'string') {
34
+ return (ref as { id: string }).id
35
+ }
36
+ return undefined
37
+ }
38
+
39
+ function resolveEm(ctx: CustomersToolContext): EntityManager {
40
+ return ctx.container.resolve<EntityManager>('em')
41
+ }
42
+
43
+ function clamp(value: number, min: number, max: number): number {
44
+ return Math.min(Math.max(value, min), max)
45
+ }
46
+
47
+ /**
48
+ * Compute a health score for a deal:
49
+ * healthScore = clamp((30 - daysSinceLastActivity) / 30 * 100, 0, 100)
50
+ * A deal with activity within the last 30 days scores > 0. Deals with no
51
+ * activity default to daysSinceLastActivity = activityWindow so they score 0.
52
+ * Exported for unit testing.
53
+ */
54
+ export function computeHealthScore(daysSinceLastActivity: number): number {
55
+ return clamp(((30 - daysSinceLastActivity) / 30) * 100, 0, 100)
56
+ }
57
+
58
+ const analyzeDealsInput = z.object({
59
+ dealStageFilter: z
60
+ .string()
61
+ .optional()
62
+ .describe(
63
+ 'Optional pipeline stage name or status slug to restrict results (e.g. "open", "qualification", "negotiation").',
64
+ ),
65
+ daysOfActivityWindow: z
66
+ .number()
67
+ .int()
68
+ .min(1)
69
+ .max(365)
70
+ .default(30)
71
+ .describe(
72
+ 'Number of days to look back for "last activity" calculation. Deals with no activity in this window are flagged as stalled. Default 30.',
73
+ ),
74
+ limit: z
75
+ .number()
76
+ .int()
77
+ .min(1)
78
+ .max(100)
79
+ .default(25)
80
+ .describe('Maximum number of deals to return, ranked by healthScore ascending (most at-risk first). Default 25.'),
81
+ })
82
+
83
+ type AnalyzeDealsInput = z.infer<typeof analyzeDealsInput>
84
+
85
+ type DealAnalysisItem = {
86
+ id: string
87
+ title: string
88
+ value: number | null
89
+ valueCurrency: string | null
90
+ stage: string | null
91
+ status: string
92
+ daysSinceLastActivity: number
93
+ primaryContact: string | null
94
+ healthScore: number
95
+ }
96
+
97
+ type AnalyzeDealsOutput = {
98
+ deals: DealAnalysisItem[]
99
+ totalAnalyzed: number
100
+ stalledCount: number
101
+ windowDays: number
102
+ }
103
+
104
+ export function msTodays(ms: number): number {
105
+ return Math.floor(ms / (1000 * 60 * 60 * 24))
106
+ }
107
+
108
+ const analyzeDealsToolDefinition: CustomersAiToolDefinition<AnalyzeDealsInput, AnalyzeDealsOutput> = {
109
+ name: 'customers.analyze_deals',
110
+ displayName: 'Analyze deals',
111
+ description:
112
+ 'Fetch and analyze open deals for the caller tenant. Returns each deal with its days-since-last-activity and a health score (0–100, lower = more stalled). Results are ranked most-at-risk first.',
113
+ inputSchema: analyzeDealsInput as z.ZodType<AnalyzeDealsInput>,
114
+ requiredFeatures: ['customers.deals.view'],
115
+ tags: ['read', 'customers', 'analytics'],
116
+ isMutation: false,
117
+ handler: async (rawInput, ctx): Promise<AnalyzeDealsOutput> => {
118
+ const { tenantId } = assertTenantScope(ctx)
119
+ const input = analyzeDealsInput.parse(rawInput)
120
+ const em = resolveEm(ctx)
121
+ const scope = { tenantId, organizationId: ctx.organizationId }
122
+
123
+ // Build deal filter
124
+ const dealWhere: Record<string, unknown> = {
125
+ tenantId,
126
+ deletedAt: null,
127
+ }
128
+ if (ctx.organizationId) dealWhere.organizationId = ctx.organizationId
129
+ // Apply stage/status filter when provided. `$or` is composed with the
130
+ // outer `deletedAt: null` filter — both must hold — so the caller-supplied
131
+ // value matches either the canonical status slug or the stage label.
132
+ if (input.dealStageFilter) {
133
+ const filterValue = input.dealStageFilter.trim()
134
+ if (filterValue.length) {
135
+ dealWhere.$or = [
136
+ { status: filterValue },
137
+ { pipelineStage: filterValue },
138
+ ]
139
+ }
140
+ }
141
+
142
+ const deals = await findWithDecryption<CustomerDeal>(
143
+ em,
144
+ CustomerDeal,
145
+ dealWhere as any,
146
+ {
147
+ orderBy: { updatedAt: 'desc' },
148
+ limit: Math.min(input.limit * 4, 400), // Fetch extra so we can rank properly
149
+ },
150
+ scope,
151
+ )
152
+
153
+ if (!deals.length) {
154
+ return { deals: [], totalAnalyzed: 0, stalledCount: 0, windowDays: input.daysOfActivityWindow }
155
+ }
156
+
157
+ const dealIds = deals.map((d) => d.id)
158
+
159
+ // Fetch most recent activity per deal within the window in one batch.
160
+ // CustomerActivity has encrypted columns (subject, body); use the
161
+ // tenant-aware helper even though this query only reads occurredAt/deal,
162
+ // so future readers cannot accidentally surface ciphertext.
163
+ const activityWhere: Record<string, unknown> = {
164
+ deal: { $in: dealIds },
165
+ tenantId,
166
+ }
167
+ if (ctx.organizationId) activityWhere.organizationId = ctx.organizationId
168
+ const activities = await findWithDecryption<CustomerActivity>(
169
+ em,
170
+ CustomerActivity,
171
+ activityWhere as any,
172
+ {
173
+ orderBy: { occurredAt: 'desc' },
174
+ limit: 1000,
175
+ },
176
+ scope,
177
+ )
178
+
179
+ const lastActivityByDeal = new Map<string, Date>()
180
+ for (const activity of activities) {
181
+ const refId = refIdOf((activity as { deal?: unknown }).deal)
182
+ if (!refId) continue
183
+ const activityDate = activity.occurredAt ?? activity.createdAt
184
+ if (!activityDate) continue
185
+ const existing = lastActivityByDeal.get(refId)
186
+ if (!existing || activityDate > existing) {
187
+ lastActivityByDeal.set(refId, activityDate)
188
+ }
189
+ }
190
+
191
+ // Fetch primary contacts for deals in one batch. CustomerDealPersonLink
192
+ // has no encrypted columns, but the linked CustomerEntity.display_name is
193
+ // encrypted, so resolve names via findWithDecryption rather than
194
+ // populating the relation through raw em.find.
195
+ const personLinkWhere: Record<string, unknown> = {
196
+ deal: { $in: dealIds },
197
+ }
198
+ const personLinks = await em.find(
199
+ CustomerDealPersonLink,
200
+ personLinkWhere as any,
201
+ { limit: 500 },
202
+ )
203
+
204
+ const linksByDeal = new Map<string, string>() // dealId → personId (first link wins)
205
+ for (const link of personLinks) {
206
+ const dealRefId = refIdOf((link as { deal?: unknown }).deal)
207
+ const personRefId = refIdOf((link as { person?: unknown }).person)
208
+ if (!dealRefId || !personRefId) continue
209
+ if (!linksByDeal.has(dealRefId)) linksByDeal.set(dealRefId, personRefId)
210
+ }
211
+
212
+ const primaryContactByDeal = new Map<string, string>()
213
+ const personIds = Array.from(new Set(linksByDeal.values()))
214
+ if (personIds.length) {
215
+ const personWhere: Record<string, unknown> = {
216
+ id: { $in: personIds },
217
+ tenantId,
218
+ }
219
+ if (ctx.organizationId) personWhere.organizationId = ctx.organizationId
220
+ const persons = await findWithDecryption<CustomerEntity>(
221
+ em,
222
+ CustomerEntity,
223
+ personWhere as any,
224
+ undefined,
225
+ scope,
226
+ )
227
+ const nameByPersonId = new Map<string, string>()
228
+ for (const person of persons) {
229
+ const name = person.displayName
230
+ if (typeof name === 'string' && name.length) {
231
+ nameByPersonId.set(person.id, name)
232
+ }
233
+ }
234
+ for (const [dealId, personId] of linksByDeal) {
235
+ const name = nameByPersonId.get(personId)
236
+ if (name) primaryContactByDeal.set(dealId, name)
237
+ }
238
+ }
239
+
240
+ const now = Date.now()
241
+ const analyzed: DealAnalysisItem[] = deals.map((deal) => {
242
+ const lastActivity = lastActivityByDeal.get(deal.id)
243
+ const daysSinceLastActivity = lastActivity
244
+ ? msTodays(now - lastActivity.getTime())
245
+ : input.daysOfActivityWindow // Treat as fully stalled when no activity
246
+ const value = deal.valueAmount ? parseFloat(String(deal.valueAmount)) : null
247
+ return {
248
+ id: deal.id,
249
+ title: deal.title,
250
+ value: Number.isFinite(value) ? value : null,
251
+ valueCurrency: deal.valueCurrency ?? null,
252
+ stage: deal.pipelineStage ?? deal.status ?? null,
253
+ status: deal.status ?? 'open',
254
+ daysSinceLastActivity,
255
+ primaryContact: primaryContactByDeal.get(deal.id) ?? null,
256
+ healthScore: Math.round(computeHealthScore(daysSinceLastActivity)),
257
+ }
258
+ })
259
+
260
+ // Sort most-at-risk first (lowest health score first, then by value desc)
261
+ analyzed.sort((a, b) => {
262
+ if (a.healthScore !== b.healthScore) return a.healthScore - b.healthScore
263
+ const aVal = a.value ?? 0
264
+ const bVal = b.value ?? 0
265
+ return bVal - aVal
266
+ })
267
+
268
+ const truncated = analyzed.slice(0, input.limit)
269
+ const stalledCount = truncated.filter(
270
+ (d) => d.daysSinceLastActivity >= input.daysOfActivityWindow,
271
+ ).length
272
+
273
+ return {
274
+ deals: truncated,
275
+ totalAnalyzed: deals.length,
276
+ stalledCount,
277
+ windowDays: input.daysOfActivityWindow,
278
+ }
279
+ },
280
+ }
281
+
282
+ export const dealAnalyzerAiTools: CustomersAiToolDefinition[] = [
283
+ analyzeDealsToolDefinition as unknown as CustomersAiToolDefinition,
284
+ ]
285
+
286
+ export default dealAnalyzerAiTools
@@ -20,6 +20,7 @@ import dealsAiTools from './ai-tools/deals-pack'
20
20
  import activitiesTasksAiTools from './ai-tools/activities-tasks-pack'
21
21
  import addressesTagsAiTools from './ai-tools/addresses-tags-pack'
22
22
  import settingsAiTools from './ai-tools/settings-pack'
23
+ import dealAnalyzerAiTools from './ai-tools/deal-analyzer-pack'
23
24
  import type { CustomersAiToolDefinition } from './ai-tools/types'
24
25
 
25
26
  export const aiTools: CustomersAiToolDefinition[] = [
@@ -29,6 +30,7 @@ export const aiTools: CustomersAiToolDefinition[] = [
29
30
  ...activitiesTasksAiTools,
30
31
  ...addressesTagsAiTools,
31
32
  ...settingsAiTools,
33
+ ...dealAnalyzerAiTools,
32
34
  ]
33
35
 
34
36
  export default aiTools
@@ -496,6 +496,16 @@ export default function CustomersCompaniesPage() {
496
496
  {
497
497
  fallbackErrorMessage: t('customers.companies.list.deleteError', 'Failed to delete company.'),
498
498
  logTag: 'customers.companies.list',
499
+ progress: {
500
+ jobType: 'customers.companies.bulk_delete',
501
+ name: t('customers.companies.list.bulkDelete.progressName', 'Delete selected companies'),
502
+ description: t(
503
+ 'customers.companies.list.bulkDelete.progressDescription',
504
+ '{count} companies selected for deletion',
505
+ { count: selectedRows.length },
506
+ ),
507
+ meta: { source: 'customers.companies.list' },
508
+ },
499
509
  },
500
510
  ),
501
511
  context: {
@@ -505,6 +505,16 @@ export default function CustomersDealsPage() {
505
505
  {
506
506
  fallbackErrorMessage: t('customers.deals.list.deleteError', 'Failed to delete deal.'),
507
507
  logTag: 'customers.deals.list',
508
+ progress: {
509
+ jobType: 'customers.deals.bulk_delete',
510
+ name: t('customers.deals.list.bulkDelete.progressName', 'Delete selected deals'),
511
+ description: t(
512
+ 'customers.deals.list.bulkDelete.progressDescription',
513
+ '{count} deals selected for deletion',
514
+ { count: selectedRows.length },
515
+ ),
516
+ meta: { source: 'customers.deals.list' },
517
+ },
508
518
  },
509
519
  ),
510
520
  context: {
@@ -509,6 +509,16 @@ export default function CustomersPeoplePage() {
509
509
  {
510
510
  fallbackErrorMessage: t('customers.people.list.deleteError', 'Failed to delete person.'),
511
511
  logTag: 'customers.people.list',
512
+ progress: {
513
+ jobType: 'customers.people.bulk_delete',
514
+ name: t('customers.people.list.bulkDelete.progressName', 'Delete selected people'),
515
+ description: t(
516
+ 'customers.people.list.bulkDelete.progressDescription',
517
+ '{count} people selected for deletion',
518
+ { count: selectedRows.length },
519
+ ),
520
+ meta: { source: 'customers.people.list' },
521
+ },
512
522
  },
513
523
  ),
514
524
  context: {