@open-mercato/core 0.6.2-develop.3461.1.605f31c2c9 → 0.6.2

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 (173) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/helpers/integration/queue-runner.js +15 -3
  3. package/dist/helpers/integration/queue-runner.js.map +2 -2
  4. package/dist/helpers/integration/queue.js +1 -1
  5. package/dist/helpers/integration/queue.js.map +2 -2
  6. package/dist/helpers/integration/salesUi.js +24 -1
  7. package/dist/helpers/integration/salesUi.js.map +2 -2
  8. package/dist/modules/auth/api/roles/acl/route.js +37 -1
  9. package/dist/modules/auth/api/roles/acl/route.js.map +2 -2
  10. package/dist/modules/auth/api/roles/route.js +36 -2
  11. package/dist/modules/auth/api/roles/route.js.map +2 -2
  12. package/dist/modules/auth/api/users/acl/route.js +36 -1
  13. package/dist/modules/auth/api/users/acl/route.js.map +2 -2
  14. package/dist/modules/auth/api/users/route.js +101 -13
  15. package/dist/modules/auth/api/users/route.js.map +2 -2
  16. package/dist/modules/auth/backend/roles/[id]/edit/page.js +32 -14
  17. package/dist/modules/auth/backend/roles/[id]/edit/page.js.map +2 -2
  18. package/dist/modules/auth/backend/users/[id]/edit/page.js +1 -1
  19. package/dist/modules/auth/backend/users/[id]/edit/page.js.map +2 -2
  20. package/dist/modules/auth/backend/users/create/page.js +1 -1
  21. package/dist/modules/auth/backend/users/create/page.js.map +2 -2
  22. package/dist/modules/auth/backend/users/roleOptions.js +1 -1
  23. package/dist/modules/auth/backend/users/roleOptions.js.map +2 -2
  24. package/dist/modules/auth/commands/roles.js +8 -0
  25. package/dist/modules/auth/commands/roles.js.map +2 -2
  26. package/dist/modules/auth/commands/users.js +3 -0
  27. package/dist/modules/auth/commands/users.js.map +2 -2
  28. package/dist/modules/auth/components/AclEditor.js +53 -26
  29. package/dist/modules/auth/components/AclEditor.js.map +2 -2
  30. package/dist/modules/auth/frontend/login.js +35 -0
  31. package/dist/modules/auth/frontend/login.js.map +2 -2
  32. package/dist/modules/auth/lib/grantChecks.js +97 -2
  33. package/dist/modules/auth/lib/grantChecks.js.map +2 -2
  34. package/dist/modules/business_rules/lib/expression-evaluator.js +9 -7
  35. package/dist/modules/business_rules/lib/expression-evaluator.js.map +2 -2
  36. package/dist/modules/business_rules/lib/rule-evaluator.js +3 -3
  37. package/dist/modules/business_rules/lib/rule-evaluator.js.map +2 -2
  38. package/dist/modules/customers/ai-agents.js +10 -1
  39. package/dist/modules/customers/ai-agents.js.map +2 -2
  40. package/dist/modules/customers/ai-tools/settings-pack.js +55 -1
  41. package/dist/modules/customers/ai-tools/settings-pack.js.map +2 -2
  42. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +13 -5
  43. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  44. package/dist/modules/customers/lib/personCompanyLinkTable.js +4 -30
  45. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  46. package/dist/modules/customers/migrations/Migration20260521175027.js +12 -0
  47. package/dist/modules/customers/migrations/Migration20260521175027.js.map +7 -0
  48. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js +41 -12
  49. package/dist/modules/dashboards/components/WidgetVisibilityEditor.js.map +2 -2
  50. package/dist/modules/data_sync/api/options.js +2 -0
  51. package/dist/modules/data_sync/api/options.js.map +2 -2
  52. package/dist/modules/data_sync/api/run.js +77 -58
  53. package/dist/modules/data_sync/api/run.js.map +2 -2
  54. package/dist/modules/data_sync/backend/data-sync/page.js +3 -2
  55. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  56. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +8 -3
  57. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  58. package/dist/modules/integrations/backend/integrations/[id]/page.js +15 -13
  59. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  60. package/dist/modules/integrations/data/validators.js +9 -6
  61. package/dist/modules/integrations/data/validators.js.map +2 -2
  62. package/dist/modules/messages/api/route.js +2 -2
  63. package/dist/modules/messages/api/route.js.map +2 -2
  64. package/dist/modules/messages/commands/messages.js +42 -2
  65. package/dist/modules/messages/commands/messages.js.map +2 -2
  66. package/dist/modules/messages/components/MessagesInboxPageClient.js +70 -89
  67. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  68. package/dist/modules/messages/components/defaults/DefaultMessageListItem.js +8 -6
  69. package/dist/modules/messages/components/defaults/DefaultMessageListItem.js.map +2 -2
  70. package/dist/modules/messages/components/inboxFilters.js +133 -0
  71. package/dist/modules/messages/components/inboxFilters.js.map +7 -0
  72. package/dist/modules/messages/components/messageListLabels.js +14 -0
  73. package/dist/modules/messages/components/messageListLabels.js.map +7 -0
  74. package/dist/modules/messages/components/useMessagesInboxBulkActions.js +47 -23
  75. package/dist/modules/messages/components/useMessagesInboxBulkActions.js.map +2 -2
  76. package/dist/modules/messages/data/validators.js +8 -6
  77. package/dist/modules/messages/data/validators.js.map +2 -2
  78. package/dist/modules/progress/api/jobs/route.js +26 -16
  79. package/dist/modules/progress/api/jobs/route.js.map +2 -2
  80. package/dist/modules/query_index/di.js +8 -6
  81. package/dist/modules/query_index/di.js.map +2 -2
  82. package/dist/modules/query_index/lib/indexer.js +12 -10
  83. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  84. package/dist/modules/query_index/subscribers/upsert_one.js +7 -1
  85. package/dist/modules/query_index/subscribers/upsert_one.js.map +2 -2
  86. package/dist/modules/sales/api/order-adjustments/route.js.map +2 -2
  87. package/dist/modules/sales/api/quote-adjustments/route.js.map +2 -2
  88. package/dist/modules/sales/commands/documents.js +104 -0
  89. package/dist/modules/sales/commands/documents.js.map +2 -2
  90. package/dist/modules/sales/data/validators.js +20 -1
  91. package/dist/modules/sales/data/validators.js.map +2 -2
  92. package/dist/modules/sales/lib/makeSalesLineRoute.js.map +2 -2
  93. package/dist/modules/sync_excel/api/import/route.js +126 -116
  94. package/dist/modules/sync_excel/api/import/route.js.map +2 -2
  95. package/dist/modules/sync_excel/lib/adapters/customers.js +1 -0
  96. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  97. package/dist/modules/translations/components/TranslationManager.js +9 -4
  98. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  99. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  100. package/dist/modules/workflows/lib/event-trigger-service.js +23 -11
  101. package/dist/modules/workflows/lib/event-trigger-service.js.map +2 -2
  102. package/jest.config.cjs +4 -1
  103. package/package.json +15 -16
  104. package/src/helpers/integration/queue-runner.ts +21 -2
  105. package/src/helpers/integration/queue.ts +1 -1
  106. package/src/helpers/integration/salesUi.ts +26 -1
  107. package/src/modules/auth/api/roles/acl/route.ts +39 -1
  108. package/src/modules/auth/api/roles/route.ts +38 -2
  109. package/src/modules/auth/api/users/acl/route.ts +41 -1
  110. package/src/modules/auth/api/users/route.ts +105 -12
  111. package/src/modules/auth/backend/roles/[id]/edit/page.tsx +35 -13
  112. package/src/modules/auth/backend/users/[id]/edit/page.tsx +1 -1
  113. package/src/modules/auth/backend/users/create/page.tsx +1 -1
  114. package/src/modules/auth/backend/users/roleOptions.ts +2 -1
  115. package/src/modules/auth/commands/roles.ts +8 -0
  116. package/src/modules/auth/commands/users.ts +3 -0
  117. package/src/modules/auth/components/AclEditor.tsx +50 -24
  118. package/src/modules/auth/frontend/login.tsx +40 -0
  119. package/src/modules/auth/lib/grantChecks.ts +116 -2
  120. package/src/modules/business_rules/lib/expression-evaluator.ts +9 -13
  121. package/src/modules/business_rules/lib/rule-evaluator.ts +3 -3
  122. package/src/modules/customers/ai-agents.ts +10 -1
  123. package/src/modules/customers/ai-tools/settings-pack.ts +62 -1
  124. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +18 -7
  125. package/src/modules/customers/lib/personCompanyLinkTable.ts +33 -41
  126. package/src/modules/customers/migrations/Migration20260521175027.ts +39 -0
  127. package/src/modules/dashboards/components/WidgetVisibilityEditor.tsx +38 -12
  128. package/src/modules/data_sync/api/options.ts +2 -0
  129. package/src/modules/data_sync/api/run.ts +80 -61
  130. package/src/modules/data_sync/backend/data-sync/page.tsx +10 -0
  131. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +17 -3
  132. package/src/modules/data_sync/i18n/de.json +3 -0
  133. package/src/modules/data_sync/i18n/en.json +3 -0
  134. package/src/modules/data_sync/i18n/es.json +3 -0
  135. package/src/modules/data_sync/i18n/pl.json +3 -0
  136. package/src/modules/data_sync/lib/adapter.ts +9 -0
  137. package/src/modules/integrations/backend/integrations/[id]/page.tsx +15 -13
  138. package/src/modules/integrations/data/validators.ts +5 -4
  139. package/src/modules/messages/api/route.ts +2 -2
  140. package/src/modules/messages/commands/messages.ts +54 -2
  141. package/src/modules/messages/components/MessagesInboxPageClient.tsx +80 -92
  142. package/src/modules/messages/components/defaults/DefaultMessageListItem.tsx +9 -5
  143. package/src/modules/messages/components/inboxFilters.ts +162 -0
  144. package/src/modules/messages/components/messageListLabels.ts +29 -0
  145. package/src/modules/messages/components/useMessagesInboxBulkActions.ts +58 -26
  146. package/src/modules/messages/data/validators.ts +7 -5
  147. package/src/modules/messages/i18n/de.json +7 -4
  148. package/src/modules/messages/i18n/en.json +7 -4
  149. package/src/modules/messages/i18n/es.json +7 -4
  150. package/src/modules/messages/i18n/pl.json +7 -4
  151. package/src/modules/progress/api/jobs/route.ts +30 -20
  152. package/src/modules/query_index/di.ts +9 -7
  153. package/src/modules/query_index/lib/indexer.ts +13 -11
  154. package/src/modules/query_index/subscribers/upsert_one.ts +9 -1
  155. package/src/modules/sales/api/order-adjustments/route.ts +2 -1
  156. package/src/modules/sales/api/quote-adjustments/route.ts +2 -1
  157. package/src/modules/sales/commands/documents.ts +116 -0
  158. package/src/modules/sales/data/validators.ts +38 -0
  159. package/src/modules/sales/i18n/de.json +2 -0
  160. package/src/modules/sales/i18n/en.json +2 -0
  161. package/src/modules/sales/i18n/es.json +2 -0
  162. package/src/modules/sales/i18n/pl.json +2 -0
  163. package/src/modules/sales/lib/makeSalesLineRoute.ts +2 -1
  164. package/src/modules/staff/i18n/de.json +1 -0
  165. package/src/modules/staff/i18n/en.json +1 -0
  166. package/src/modules/staff/i18n/es.json +1 -0
  167. package/src/modules/staff/i18n/pl.json +1 -0
  168. package/src/modules/sync_excel/api/import/route.ts +141 -131
  169. package/src/modules/sync_excel/lib/adapters/customers.ts +1 -0
  170. package/src/modules/translations/components/TranslationManager.tsx +9 -4
  171. package/src/modules/workflows/lib/activity-executor.ts +4 -3
  172. package/src/modules/workflows/lib/event-trigger-service.ts +24 -11
  173. package/tsconfig.json +1 -0
@@ -1,8 +1,8 @@
1
1
  import type { EntityManager, FilterQuery } from '@mikro-orm/postgresql'
2
2
  import { CrudHttpError, forbidden } from '@open-mercato/shared/lib/crud/errors'
3
3
  import { hasFeature } from '@open-mercato/shared/security/features'
4
- import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
- import { Role, RoleAcl } from '@open-mercato/core/modules/auth/data/entities'
4
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
5
+ import { Role, RoleAcl, UserAcl, UserRole } from '@open-mercato/core/modules/auth/data/entities'
6
6
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
7
7
 
8
8
  type ActorAcl = {
@@ -33,6 +33,16 @@ type FeatureGrantCheckInput = GrantCheckContext & {
33
33
  organizations?: string[] | null
34
34
  }
35
35
 
36
+ type SuperAdminUserTargetInput = GrantCheckContext & {
37
+ targetUserId: string
38
+ actorIsSuperAdmin?: boolean
39
+ }
40
+
41
+ type SuperAdminRoleTargetInput = GrantCheckContext & {
42
+ targetRoleId: string
43
+ actorIsSuperAdmin?: boolean
44
+ }
45
+
36
46
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
37
47
 
38
48
  export async function assertActorCanGrantRoleTokens(input: RoleTokenGrantCheckInput): Promise<Role[]> {
@@ -99,6 +109,110 @@ export function normalizeGrantFeatureList(features: unknown): string[] {
99
109
  return normalizeStringList(features)
100
110
  }
101
111
 
112
+ export async function assertActorCanModifySuperAdminUserTarget(input: SuperAdminUserTargetInput): Promise<void> {
113
+ const actorIsSuperAdmin = await resolveActorIsSuperAdmin(input)
114
+ if (actorIsSuperAdmin) return
115
+ const targetIsSuperAdmin = await isUserEffectivelySuperAdmin(input.em, input.targetUserId)
116
+ if (targetIsSuperAdmin) {
117
+ throw forbidden('Only super administrators can modify super administrator accounts.')
118
+ }
119
+ }
120
+
121
+ export async function assertActorCanModifySuperAdminRoleTarget(input: SuperAdminRoleTargetInput): Promise<void> {
122
+ const actorIsSuperAdmin = await resolveActorIsSuperAdmin(input)
123
+ if (actorIsSuperAdmin) return
124
+ const targetIsSuperAdmin = await isRoleEffectivelySuperAdmin(input.em, input.targetRoleId)
125
+ if (targetIsSuperAdmin) {
126
+ throw forbidden('Only super administrators can modify super administrator roles.')
127
+ }
128
+ }
129
+
130
+ async function resolveActorIsSuperAdmin(input: GrantCheckContext & { actorIsSuperAdmin?: boolean }): Promise<boolean> {
131
+ if (typeof input.actorIsSuperAdmin === 'boolean') return input.actorIsSuperAdmin
132
+ const acl = await loadActorAcl(input)
133
+ return acl.isSuperAdmin
134
+ }
135
+
136
+ export async function isUserEffectivelySuperAdmin(em: EntityManager, userId: string): Promise<boolean> {
137
+ const directGrant = await em.findOne(
138
+ UserAcl,
139
+ { user: userId as unknown, isSuperAdmin: true } as FilterQuery<UserAcl>,
140
+ )
141
+ if (directGrant && (directGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true) return true
142
+ const links = await findWithDecryption(
143
+ em,
144
+ UserRole,
145
+ { user: userId as unknown } as FilterQuery<UserRole>,
146
+ { populate: ['role'] },
147
+ { tenantId: null, organizationId: null },
148
+ )
149
+ const roleIds = (Array.isArray(links) ? links : [])
150
+ .map((link) => {
151
+ const role = (link as { role?: { id?: unknown } | string | null }).role
152
+ if (!role) return null
153
+ if (typeof role === 'string') return role
154
+ return role.id ? String(role.id) : null
155
+ })
156
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
157
+ if (!roleIds.length) return false
158
+ const roleGrant = await em.findOne(
159
+ RoleAcl,
160
+ { role: { $in: roleIds } as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
161
+ )
162
+ return !!roleGrant && (roleGrant as { isSuperAdmin?: boolean }).isSuperAdmin === true
163
+ }
164
+
165
+ export async function isRoleEffectivelySuperAdmin(em: EntityManager, roleId: string): Promise<boolean> {
166
+ const grant = await em.findOne(
167
+ RoleAcl,
168
+ { role: roleId as unknown, isSuperAdmin: true } as FilterQuery<RoleAcl>,
169
+ )
170
+ return !!grant && (grant as { isSuperAdmin?: boolean }).isSuperAdmin === true
171
+ }
172
+
173
+ export async function listSuperAdminUserIds(em: EntityManager, tenantId: string | null): Promise<Set<string>> {
174
+ const ids = new Set<string>()
175
+ const userAclFilter: Record<string, unknown> = { isSuperAdmin: true }
176
+ if (tenantId) userAclFilter.tenantId = tenantId
177
+ const userAcls = await em.find(UserAcl, userAclFilter as FilterQuery<UserAcl>)
178
+ for (const acl of userAcls) {
179
+ const userRef = (acl as { user?: { id?: unknown } | string | null }).user
180
+ const userId = userRef && typeof userRef === 'object'
181
+ ? userRef.id
182
+ : userRef
183
+ if (userId) ids.add(String(userId))
184
+ }
185
+ const roleAcls = await em.find(
186
+ RoleAcl,
187
+ { isSuperAdmin: true } as FilterQuery<RoleAcl>,
188
+ )
189
+ const roleIds = roleAcls
190
+ .map((acl) => {
191
+ const roleRef = (acl as { role?: { id?: unknown } | string | null }).role
192
+ if (!roleRef) return null
193
+ if (typeof roleRef === 'string') return roleRef
194
+ return roleRef.id ? String(roleRef.id) : null
195
+ })
196
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
197
+ if (roleIds.length) {
198
+ const links = await findWithDecryption(
199
+ em,
200
+ UserRole,
201
+ { role: { $in: roleIds } as unknown } as FilterQuery<UserRole>,
202
+ {},
203
+ { tenantId: null, organizationId: null },
204
+ )
205
+ for (const link of Array.isArray(links) ? links : []) {
206
+ const userRef = (link as { user?: { id?: unknown } | string | null }).user
207
+ const userId = userRef && typeof userRef === 'object'
208
+ ? userRef.id
209
+ : userRef
210
+ if (userId) ids.add(String(userId))
211
+ }
212
+ }
213
+ return ids
214
+ }
215
+
102
216
  async function loadActorAcl(input: GrantCheckContext): Promise<ActorAcl> {
103
217
  const actorUserId = normalizeNullableString(input.actorUserId)
104
218
  if (!actorUserId) throw forbidden('Not authorized to grant ACL privileges.')
@@ -1,4 +1,5 @@
1
1
  import type { ComparisonOperator, LogicalOperator } from '../data/validators'
2
+ import { testLinearRegex } from '@open-mercato/shared/lib/regex/linear'
2
3
  import { getNestedValue, resolveSpecialValue } from './value-resolver'
3
4
 
4
5
  /**
@@ -319,11 +320,8 @@ function endsWith(str: any, suffix: any): boolean {
319
320
  return String(str).endsWith(String(suffix))
320
321
  }
321
322
 
322
- /**
323
- * Security limits for regex operations
324
- */
325
- const REGEX_TIMEOUT_MS = 100
326
323
  const MAX_REGEX_LENGTH = 200
324
+ const MAX_REGEX_INPUT_LENGTH = 10_000
327
325
 
328
326
  /**
329
327
  * Check if string matches regex pattern (with ReDoS protection)
@@ -345,18 +343,16 @@ function matches(str: any, pattern: any): boolean {
345
343
  return false
346
344
  }
347
345
 
348
- const regex = new RegExp(patternStr)
349
346
  const testStr = String(str)
350
-
351
- // Use a simple timeout mechanism
352
- const startTime = Date.now()
353
- const result = regex.test(testStr)
354
-
355
- if (Date.now() - startTime > REGEX_TIMEOUT_MS) {
356
- throw new Error('Regex execution timeout - potential ReDoS pattern detected')
347
+ if (testStr.length > MAX_REGEX_INPUT_LENGTH) {
348
+ return false
357
349
  }
358
350
 
359
- return result
351
+ const result = testLinearRegex(patternStr, testStr, {
352
+ maxPatternLength: MAX_REGEX_LENGTH,
353
+ maxInputLength: MAX_REGEX_INPUT_LENGTH,
354
+ })
355
+ return result.ok ? result.matched : false
360
356
  } catch (error) {
361
357
  // Log the error for debugging but don't expose to user
362
358
  const message = error instanceof Error ? error.message : 'Unknown error'
@@ -77,7 +77,7 @@ export async function evaluate(
77
77
  }
78
78
  }
79
79
 
80
- const evaluationTime = Date.now() - startTime
80
+ const evaluationTime = Math.max(0, Date.now() - startTime)
81
81
 
82
82
  // Determine if conditions were met (at least one rule matched)
83
83
  const conditionsPassed = matchedRules.length > 0
@@ -140,7 +140,7 @@ export async function evaluateSingleRule(
140
140
  // Evaluate conditions
141
141
  const conditionsPassed = await evaluateConditions(rule.conditionExpression, data, context)
142
142
 
143
- const evaluationTime = Date.now() - startTime
143
+ const evaluationTime = Math.max(0, Date.now() - startTime)
144
144
 
145
145
  console.log('[RULE EVAL] Final result:', {
146
146
  ruleId: rule.ruleId,
@@ -156,7 +156,7 @@ export async function evaluateSingleRule(
156
156
  evaluationTime,
157
157
  }
158
158
  } catch (error) {
159
- const evaluationTime = Date.now() - startTime
159
+ const evaluationTime = Math.max(0, Date.now() - startTime)
160
160
  const errorMessage = error instanceof Error ? error.message : String(error)
161
161
 
162
162
  console.log('[RULE EVAL] ✗ ERROR:', errorMessage)
@@ -373,8 +373,12 @@ const DEAL_ANALYZER_PROMPT_SECTIONS: DealAnalyzerPromptSection[] = [
373
373
  '2. customers.update_deal_stage — propose a stage move for a stalled high-value deal.',
374
374
  ' The runtime intercepts this via the pending-action gate; do NOT claim the change',
375
375
  ' is saved until the mutation-result-card arrives.',
376
+ ' When moving to a named pipeline stage, first call customers.list_pipeline_stages',
377
+ ' and pass the matching UUID as toPipelineStageId. Only use toStage for top-level',
378
+ ' status slugs such as open, won, or lost.',
376
379
  'Secondary read tools (use when you need more detail):',
377
380
  '- customers.list_deals, customers.get_deal, customers.list_activities',
381
+ '- customers.list_pipeline_stages',
378
382
  '- search.hybrid_search, meta.describe_agent',
379
383
  ].join('\n'),
380
384
  },
@@ -415,7 +419,10 @@ const DEAL_ANALYZER_PROMPT_SECTIONS: DealAnalyzerPromptSection[] = [
415
419
  'Lead with a one-paragraph summary of the deal portfolio health, then emit one',
416
420
  'deal card per at-risk deal (sorted by health score ascending). After the cards,',
417
421
  'propose exactly one stage move for the highest-value stalled deal and call',
418
- 'customers.update_deal_stage. Then finish with a short conclusion naming the',
422
+ 'customers.update_deal_stage. If the recommended stage is a pipeline stage label',
423
+ 'rather than a status slug, resolve it with customers.list_pipeline_stages first;',
424
+ 'do not ask the operator to paste a stage id unless no matching stage exists.',
425
+ 'Then finish with a short conclusion naming the',
419
426
  'highest-value stalled deal, the recommended move, and the approval status.',
420
427
  ].join('\n'),
421
428
  },
@@ -431,6 +438,7 @@ function compileDealAnalyzerPrompt(): string {
431
438
  const DEAL_ANALYZER_ALLOWED_TOOLS: readonly string[] = [
432
439
  'customers.analyze_deals',
433
440
  'customers.update_deal_stage',
441
+ 'customers.list_pipeline_stages',
434
442
  'customers.list_deals',
435
443
  'customers.get_deal',
436
444
  'customers.list_activities',
@@ -447,6 +455,7 @@ function buildDealAnalyzerPrepareStep() {
447
455
  'customers.list_deals',
448
456
  'customers.get_deal',
449
457
  'customers.list_activities',
458
+ 'customers.list_pipeline_stages',
450
459
  'search.hybrid_search',
451
460
  'meta.describe_agent',
452
461
  'meta.update_task_plan',
@@ -26,6 +26,10 @@ function buildScope(ctx: CustomersToolContext, tenantId: string) {
26
26
 
27
27
  const getSettingsInput = z.object({}).passthrough()
28
28
 
29
+ const listPipelineStagesInput = z.object({
30
+ pipelineId: z.string().uuid().optional().describe('Optional pipeline id to restrict returned stages.'),
31
+ }).passthrough()
32
+
29
33
  const getSettingsTool: CustomersAiToolDefinition = {
30
34
  name: 'customers.get_settings',
31
35
  displayName: 'Get customers module settings',
@@ -116,6 +120,63 @@ const getSettingsTool: CustomersAiToolDefinition = {
116
120
  },
117
121
  }
118
122
 
119
- export const settingsAiTools: CustomersAiToolDefinition[] = [getSettingsTool]
123
+ const listPipelineStagesTool: CustomersAiToolDefinition = {
124
+ name: 'customers.list_pipeline_stages',
125
+ displayName: 'List customer pipeline stages',
126
+ description:
127
+ 'Return customer deal pipelines and their pipeline stages, including stage ids. Use this before moving a deal to a named pipeline stage.',
128
+ inputSchema: listPipelineStagesInput,
129
+ requiredFeatures: ['customers.pipelines.view'],
130
+ tags: ['read', 'customers', 'pipelines'],
131
+ isMutation: false,
132
+ handler: async (rawInput, ctx) => {
133
+ const { tenantId } = assertTenantScope(ctx)
134
+ const input = listPipelineStagesInput.parse(rawInput)
135
+ const em = resolveEm(ctx)
136
+ const where: Record<string, unknown> = { tenantId }
137
+ if (ctx.organizationId) where.organizationId = ctx.organizationId
138
+ const stageWhere: Record<string, unknown> = { ...where }
139
+ if (input.pipelineId) stageWhere.pipelineId = input.pipelineId
140
+ const [pipelines, stages] = await Promise.all([
141
+ findWithDecryption<CustomerPipeline>(
142
+ em,
143
+ CustomerPipeline,
144
+ where as any,
145
+ { orderBy: { createdAt: 'asc' } as any } as any,
146
+ buildScope(ctx, tenantId),
147
+ ),
148
+ findWithDecryption<CustomerPipelineStage>(
149
+ em,
150
+ CustomerPipelineStage,
151
+ stageWhere as any,
152
+ { orderBy: { pipelineId: 'asc', order: 'asc' } as any } as any,
153
+ buildScope(ctx, tenantId),
154
+ ),
155
+ ])
156
+ return {
157
+ pipelines: pipelines
158
+ .filter((row) => row.tenantId === tenantId)
159
+ .map((row) => ({
160
+ id: row.id,
161
+ name: row.name,
162
+ isDefault: !!row.isDefault,
163
+ organizationId: row.organizationId ?? null,
164
+ tenantId: row.tenantId ?? null,
165
+ })),
166
+ pipelineStages: stages
167
+ .filter((row) => row.tenantId === tenantId)
168
+ .map((row) => ({
169
+ id: row.id,
170
+ pipelineId: row.pipelineId,
171
+ label: row.label,
172
+ order: row.order,
173
+ organizationId: row.organizationId ?? null,
174
+ tenantId: row.tenantId ?? null,
175
+ })),
176
+ }
177
+ },
178
+ }
179
+
180
+ export const settingsAiTools: CustomersAiToolDefinition[] = [getSettingsTool, listPipelineStagesTool]
120
181
 
121
182
  export default settingsAiTools
@@ -1,5 +1,4 @@
1
1
  import * as React from 'react'
2
- import { format } from 'date-fns'
3
2
  import type { ActivityType } from './fieldConfig'
4
3
 
5
4
  export type RsvpStatus = 'pending' | 'accepted' | 'declined' | 'tentative'
@@ -63,6 +62,18 @@ const DEFAULT_REMINDER_MINUTES: Record<ActivityType, number> = {
63
62
  email: 15,
64
63
  }
65
64
 
65
+ function padDatePart(value: number): string {
66
+ return String(value).padStart(2, '0')
67
+ }
68
+
69
+ function formatLocalDateInput(date: Date): string {
70
+ return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`
71
+ }
72
+
73
+ function formatLocalTimeInput(date: Date): string {
74
+ return `${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`
75
+ }
76
+
66
77
  interface UseScheduleFormStateParams {
67
78
  open: boolean
68
79
  editData: ScheduleActivityEditData | null | undefined
@@ -71,7 +82,7 @@ interface UseScheduleFormStateParams {
71
82
  export function useScheduleFormState({ open, editData }: UseScheduleFormStateParams) {
72
83
  const [activityType, setActivityType] = React.useState<ActivityType>('meeting')
73
84
  const [title, setTitle] = React.useState('')
74
- const [date, setDate] = React.useState(() => format(new Date(), 'yyyy-MM-dd'))
85
+ const [date, setDate] = React.useState(() => formatLocalDateInput(new Date()))
75
86
  const [startTime, setStartTime] = React.useState('10:00')
76
87
  const [duration, setDuration] = React.useState(30)
77
88
  const [allDay, setAllDay] = React.useState(false)
@@ -101,15 +112,15 @@ export function useScheduleFormState({ open, editData }: UseScheduleFormStatePar
101
112
  // For historical activities the canonical timestamp is `occurredAt`; for
102
113
  // planned/future ones it's `scheduledAt`. Without this fallback editing a
103
114
  // past activity prefilled to "today" instead of its actual moment (#1807).
104
- // Use `date-fns` `format(...)` so the seed values are in the user's local
105
- // timezone (matches the cluster-E local-day convention).
115
+ // Keep seed values in the user's local timezone, matching the cluster-E
116
+ // local-day convention.
106
117
  const sourceTimestamp = editData.occurredAt ?? editData.scheduledAt ?? null
107
118
  const seedDate = sourceTimestamp ? new Date(sourceTimestamp) : new Date()
108
119
  const seedDateValid = !Number.isNaN(seedDate.getTime())
109
120
  const fallbackNow = new Date()
110
121
  const dateForForm = seedDateValid ? seedDate : fallbackNow
111
- setDate(format(dateForForm, 'yyyy-MM-dd'))
112
- setStartTime(format(dateForForm, 'HH:mm'))
122
+ setDate(formatLocalDateInput(dateForForm))
123
+ setStartTime(formatLocalTimeInput(dateForForm))
113
124
  setDuration(editData.durationMinutes ?? 30)
114
125
  setAllDay(editData.allDay ?? false)
115
126
  setDescription(editData.body ?? '')
@@ -170,7 +181,7 @@ export function useScheduleFormState({ open, editData }: UseScheduleFormStatePar
170
181
  // Create mode: reset all fields
171
182
  setActivityType('meeting')
172
183
  setTitle('')
173
- setDate(format(new Date(), 'yyyy-MM-dd'))
184
+ setDate(formatLocalDateInput(new Date()))
174
185
  setStartTime('10:00')
175
186
  setDuration(30)
176
187
  setAllDay(false)
@@ -1,54 +1,46 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
- import { sql } from '@mikro-orm/postgresql'
3
2
 
4
- const PERSON_COMPANY_LINKS_TABLE = 'customer_person_company_links'
5
- const PERSON_COMPANY_LINKS_DELETED_AT_COLUMN = 'deleted_at'
3
+ // The previous implementation probed `information_schema.columns` on every
4
+ // first call via `em.getKysely()` to decide whether to add a `deletedAt: null`
5
+ // filter. When that probe ran inside an already-active MikroORM transaction it
6
+ // shared the transaction's connection — and any failure (including transient
7
+ // connection-pool state, an aborted TX from a sibling query, or pg returning
8
+ // 25P02) propagated back into the host transaction, poisoning every following
9
+ // query with `current transaction is aborted, commands ignored until end of
10
+ // transaction block`. That is what produced the standalone-CI failure burst
11
+ // (loadPersonCompanyLinks → 25P02 → customers.people.create → 500) and the
12
+ // sync_excel worker `failedCount: 1` regression.
13
+ //
14
+ // The `deleted_at` column on `customer_person_company_links` has been part of
15
+ // the committed module snapshot since `Migration20260415095203` (April 2026)
16
+ // and is referenced by `Migration20260417140000` (partial unique index uses
17
+ // `where deleted_at is null`). Any environment running these migrations
18
+ // already has the column. So the probe is no longer load-bearing — it only
19
+ // adds risk. We now unconditionally include `deletedAt: null` in the filter.
20
+ // If a downstream DB is genuinely un-migrated, the underlying `em.find` will
21
+ // surface the authentic `column "deleted_at" of relation
22
+ // "customer_person_company_links" does not exist` (pg 42703) pointing the
23
+ // operator at the real fix (`yarn db:migrate`).
24
+ //
25
+ // `customerPersonCompanyLinksSupportDeletedAt` and
26
+ // `warnMissingCustomerPersonCompanyLinksDeletedAt` are kept as exported
27
+ // no-op-equivalents so external callers (and the published @open-mercato/core
28
+ // API surface) don't break.
6
29
 
7
- let supportsDeletedAtColumnPromise: Promise<boolean> | null = null
8
- let warnedAboutMissingDeletedAtColumn = false
9
-
10
- export async function customerPersonCompanyLinksSupportDeletedAt(em: EntityManager): Promise<boolean> {
11
- if (typeof (em as { getKysely?: unknown }).getKysely !== 'function') {
12
- return true
13
- }
14
- if (!supportsDeletedAtColumnPromise) {
15
- const db = em.getKysely<any>() as any
16
- const probe: Promise<boolean> = db
17
- .selectFrom('information_schema.columns')
18
- .select(['column_name'])
19
- .where(sql<boolean>`table_schema = current_schema()`)
20
- .where('table_name', '=', PERSON_COMPANY_LINKS_TABLE)
21
- .where('column_name', '=', PERSON_COMPANY_LINKS_DELETED_AT_COLUMN)
22
- .executeTakeFirst()
23
- .then((row: unknown) => !!row)
24
- .catch(() => false)
25
- supportsDeletedAtColumnPromise = probe
26
- return probe
27
- }
28
- return supportsDeletedAtColumnPromise
30
+ export async function customerPersonCompanyLinksSupportDeletedAt(_em: EntityManager): Promise<boolean> {
31
+ return true
29
32
  }
30
33
 
31
- export function warnMissingCustomerPersonCompanyLinksDeletedAt(source: string): void {
32
- if (warnedAboutMissingDeletedAtColumn) {
33
- return
34
- }
35
- warnedAboutMissingDeletedAtColumn = true
36
- console.warn(
37
- `[${source}] missing ${PERSON_COMPANY_LINKS_TABLE}.${PERSON_COMPANY_LINKS_DELETED_AT_COLUMN}; ` +
38
- 'continuing without link soft-delete filtering. Run yarn db:migrate.',
39
- )
34
+ export function warnMissingCustomerPersonCompanyLinksDeletedAt(_source: string): void {
35
+ // No-op: column is guaranteed present by Migration20260415095203 and the
36
+ // module snapshot. Kept for backward-compatible exports.
40
37
  }
41
38
 
42
39
  export async function withActiveCustomerPersonCompanyLinkFilter<T extends Record<string, unknown>>(
43
- em: EntityManager,
40
+ _em: EntityManager,
44
41
  where: T,
45
- source: string,
42
+ _source: string,
46
43
  ): Promise<T & { deletedAt?: null }> {
47
- const supportsDeletedAt = await customerPersonCompanyLinksSupportDeletedAt(em)
48
- if (!supportsDeletedAt) {
49
- warnMissingCustomerPersonCompanyLinksDeletedAt(source)
50
- return { ...where }
51
- }
52
44
  return { ...where, deletedAt: null }
53
45
  }
54
46
 
@@ -0,0 +1,39 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ /**
4
+ * Idempotent backstop for `customer_person_company_links.deleted_at`.
5
+ *
6
+ * The column was originally added by `Migration20260415095203` and is
7
+ * referenced by `Migration20260417140000`'s partial unique index
8
+ * (`where "deleted_at" is null`). It is part of the committed
9
+ * `migrations/.snapshot-open-mercato.json`.
10
+ *
11
+ * However, the runtime defensive probe that lived in
12
+ * `packages/core/src/modules/customers/lib/personCompanyLinkTable.ts`
13
+ * historically existed because an aborted-transaction state on a
14
+ * shared connection could (very rarely) make `loadPersonCompanyLinks`
15
+ * fail in a way that *looked* like the column was missing — and the
16
+ * cached fallback then silently dropped the `deletedAt: null` filter.
17
+ * That probe is removed in the same change as this migration (no
18
+ * Kysely query is ever issued from inside an active MikroORM TX);
19
+ * this migration is the matching belt-and-suspenders guarantee that
20
+ * the column is physically present so the unconditional `deletedAt:
21
+ * null` filter cannot 42703.
22
+ *
23
+ * Postgres-native `add column if not exists` is used so this re-applies
24
+ * cleanly on already-migrated databases without raising.
25
+ */
26
+ export class Migration20260521175027 extends Migration {
27
+
28
+ override async up(): Promise<void> {
29
+ this.addSql(`alter table "customer_person_company_links" add column if not exists "deleted_at" timestamptz null;`);
30
+ }
31
+
32
+ override async down(): Promise<void> {
33
+ // No-op on the way down: dropping the column would break
34
+ // `Migration20260417140000`'s partial unique index, which is the
35
+ // authoritative owner of the column going forward. The original
36
+ // `Migration20260415095203` down still drops it for full rollback.
37
+ }
38
+
39
+ }
@@ -32,6 +32,7 @@ type UserResponse = {
32
32
  type BaseProps = {
33
33
  tenantId?: string | null
34
34
  organizationId?: string | null
35
+ preserveOnTenantChange?: boolean
35
36
  }
36
37
 
37
38
  type RoleProps = BaseProps & {
@@ -92,7 +93,7 @@ export const WidgetVisibilityEditor = React.forwardRef<WidgetVisibilityEditorHan
92
93
  (widget: WidgetCatalogItem) => resolveWidgetText(t, widget.id, 'description', widget.description || ''),
93
94
  [t],
94
95
  )
95
- const { kind, targetId, tenantId, organizationId } = props
96
+ const { kind, targetId, tenantId, organizationId, preserveOnTenantChange = false } = props
96
97
  const [catalog, setCatalog] = React.useState<WidgetCatalogItem[]>([])
97
98
  const [loading, setLoading] = React.useState(true)
98
99
  const [saving, setSaving] = React.useState(false)
@@ -136,10 +137,16 @@ export const WidgetVisibilityEditor = React.forwardRef<WidgetVisibilityEditorHan
136
137
  setCatalog(mapped)
137
138
  }, [])
138
139
 
139
- const loadRoleData = React.useCallback(async () => {
140
+ const tenantIdRef = React.useRef(tenantId)
141
+ React.useEffect(() => { tenantIdRef.current = tenantId }, [tenantId])
142
+ const organizationIdRef = React.useRef(organizationId)
143
+ React.useEffect(() => { organizationIdRef.current = organizationId }, [organizationId])
144
+ const hasMountedRef = React.useRef(false)
145
+
146
+ const loadRoleData = React.useCallback(async (forTenantId: string | null | undefined, forOrganizationId: string | null | undefined) => {
140
147
  const params = new URLSearchParams({ roleId: targetId })
141
- if (tenantId) params.set('tenantId', tenantId)
142
- if (organizationId) params.set('organizationId', organizationId)
148
+ if (forTenantId) params.set('tenantId', forTenantId)
149
+ if (forOrganizationId) params.set('organizationId', forOrganizationId)
143
150
  const data = await readApiResultOrThrow<RoleResponse>(
144
151
  `/api/dashboards/roles/widgets?${params.toString()}`,
145
152
  undefined,
@@ -151,12 +158,12 @@ export const WidgetVisibilityEditor = React.forwardRef<WidgetVisibilityEditorHan
151
158
  setMode('override')
152
159
  setOriginalMode('override')
153
160
  setEffective(ids)
154
- }, [organizationId, targetId, tenantId])
161
+ }, [targetId])
155
162
 
156
- const loadUserData = React.useCallback(async () => {
163
+ const loadUserData = React.useCallback(async (forTenantId: string | null | undefined, forOrganizationId: string | null | undefined) => {
157
164
  const params = new URLSearchParams({ userId: targetId })
158
- if (tenantId) params.set('tenantId', tenantId)
159
- if (organizationId) params.set('organizationId', organizationId)
165
+ if (forTenantId) params.set('tenantId', forTenantId)
166
+ if (forOrganizationId) params.set('organizationId', forOrganizationId)
160
167
  const data = await readApiResultOrThrow<UserResponse>(
161
168
  `/api/dashboards/users/widgets?${params.toString()}`,
162
169
  undefined,
@@ -168,7 +175,7 @@ export const WidgetVisibilityEditor = React.forwardRef<WidgetVisibilityEditorHan
168
175
  setMode(data.mode || 'inherit')
169
176
  setOriginalMode(data.mode || 'inherit')
170
177
  setEffective(Array.isArray(data.effectiveWidgetIds) ? data.effectiveWidgetIds : [])
171
- }, [organizationId, targetId, tenantId])
178
+ }, [targetId])
172
179
 
173
180
  React.useEffect(() => {
174
181
  let cancelled = false
@@ -177,21 +184,40 @@ export const WidgetVisibilityEditor = React.forwardRef<WidgetVisibilityEditorHan
177
184
  setError(null)
178
185
  try {
179
186
  await loadCatalog()
180
- if (kind === 'role') await loadRoleData()
181
- else await loadUserData()
187
+ if (kind === 'role') await loadRoleData(tenantIdRef.current, organizationIdRef.current)
188
+ else await loadUserData(tenantIdRef.current, organizationIdRef.current)
182
189
  } catch (err) {
183
190
  console.error('Failed to load widget visibility data', err)
184
191
  if (!cancelled) {
185
192
  setError(tRef.current('dashboards.widgets.error.load', 'Unable to load widget configuration.'))
186
193
  }
187
194
  } finally {
188
- if (!cancelled) setLoading(false)
195
+ if (!cancelled) {
196
+ setLoading(false)
197
+ hasMountedRef.current = true
198
+ }
189
199
  }
190
200
  }
191
201
  load()
192
202
  return () => { cancelled = true }
193
203
  }, [kind, loadCatalog, loadRoleData, loadUserData])
194
204
 
205
+ React.useEffect(() => {
206
+ if (!hasMountedRef.current) return
207
+ if (preserveOnTenantChange) return
208
+ let cancelled = false
209
+ async function refetch() {
210
+ try {
211
+ if (kind === 'role') await loadRoleData(tenantId, organizationId)
212
+ else await loadUserData(tenantId, organizationId)
213
+ } catch (err) {
214
+ console.error('Failed to reload widget visibility data', err)
215
+ }
216
+ }
217
+ refetch()
218
+ return () => { cancelled = true }
219
+ }, [tenantId, organizationId, kind, loadRoleData, loadUserData, preserveOnTenantChange])
220
+
195
221
  const toggle = React.useCallback((id: string) => {
196
222
  setSelected((prev) => (prev.includes(id) ? prev.filter((value) => value !== id) : [...prev, id]))
197
223
  }, [])
@@ -44,6 +44,8 @@ export async function GET(req: Request) {
44
44
  description: integration.description ?? null,
45
45
  providerKey: integration.providerKey ?? null,
46
46
  direction: adapter.direction,
47
+ runMode: adapter.runMode ?? 'generic',
48
+ canStartRun: adapter.runMode !== 'provider',
47
49
  supportedEntities: adapter.supportedEntities,
48
50
  hasCredentials: Boolean(credentials),
49
51
  isEnabled: state.isEnabled,