@open-mercato/core 0.6.7-develop.6696.1.4236709f7e → 0.6.7-develop.6725.1.07a748cfc2

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 (154) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/admin/nav.js +14 -4
  3. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +8 -19
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/frontend/login.js +4 -1
  7. package/dist/modules/auth/frontend/login.js.map +2 -2
  8. package/dist/modules/auth/lib/backendChrome.js +31 -14
  9. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  10. package/dist/modules/customers/api/companies/[id]/route.js +3 -4
  11. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  12. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -2
  13. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  14. package/dist/modules/customers/api/utils.js +12 -18
  15. package/dist/modules/customers/api/utils.js.map +2 -2
  16. package/dist/modules/customers/cli.js +11 -4
  17. package/dist/modules/customers/cli.js.map +2 -2
  18. package/dist/modules/customers/components/detail/ActiveDealCard.js +2 -1
  19. package/dist/modules/customers/components/detail/ActiveDealCard.js.map +2 -2
  20. package/dist/modules/customers/components/detail/CompanyKpiBar.js +6 -5
  21. package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
  23. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  24. package/dist/modules/customers/components/detail/dashboard/helpers.js +4 -3
  25. package/dist/modules/customers/components/detail/dashboard/helpers.js.map +2 -2
  26. package/dist/modules/customers/components/formConfig.js +14 -10
  27. package/dist/modules/customers/components/formConfig.js.map +2 -2
  28. package/dist/modules/customers/lib/dealStatus.js +38 -0
  29. package/dist/modules/customers/lib/dealStatus.js.map +7 -0
  30. package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
  31. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  32. package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
  33. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  34. package/dist/modules/dashboards/lib/aggregations.js +76 -40
  35. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  36. package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
  37. package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
  38. package/dist/modules/dashboards/services/widgetDataService.js +228 -5
  39. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  40. package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
  41. package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
  42. package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
  43. package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
  44. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
  45. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
  46. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
  47. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
  48. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
  49. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
  50. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
  51. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
  52. package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
  53. package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
  54. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
  55. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
  56. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
  57. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  58. package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
  59. package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
  60. package/dist/modules/messages/lib/searchLookup.js +8 -16
  61. package/dist/modules/messages/lib/searchLookup.js.map +2 -2
  62. package/dist/modules/payment_gateways/api/sessions/route.js +5 -0
  63. package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
  64. package/dist/modules/payment_gateways/di.js +17 -1
  65. package/dist/modules/payment_gateways/di.js.map +2 -2
  66. package/dist/modules/payment_gateways/lib/gateway-service.js +8 -0
  67. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  68. package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js +58 -0
  69. package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js.map +7 -0
  70. package/dist/modules/query_index/api/openapi.js +13 -1
  71. package/dist/modules/query_index/api/openapi.js.map +2 -2
  72. package/dist/modules/query_index/api/status.js +165 -105
  73. package/dist/modules/query_index/api/status.js.map +2 -2
  74. package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
  75. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  76. package/dist/modules/query_index/lib/batch.js +11 -4
  77. package/dist/modules/query_index/lib/batch.js.map +2 -2
  78. package/dist/modules/query_index/lib/document.js +12 -5
  79. package/dist/modules/query_index/lib/document.js.map +2 -2
  80. package/dist/modules/query_index/lib/engine.js +36 -4
  81. package/dist/modules/query_index/lib/engine.js.map +2 -2
  82. package/dist/modules/query_index/lib/indexer.js +1 -1
  83. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  84. package/dist/modules/query_index/lib/search-tokens.js +7 -4
  85. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  86. package/dist/modules/sales/api/documents/factory.js +14 -3
  87. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  88. package/dist/modules/sales/commands/documents.js +22 -1
  89. package/dist/modules/sales/commands/documents.js.map +2 -2
  90. package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
  91. package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
  92. package/dist/modules/sales/data/validators.js +25 -4
  93. package/dist/modules/sales/data/validators.js.map +2 -2
  94. package/dist/modules/sales/di.js +7 -0
  95. package/dist/modules/sales/di.js.map +2 -2
  96. package/dist/modules/sales/services/paymentOrderTotalResolver.js +52 -0
  97. package/dist/modules/sales/services/paymentOrderTotalResolver.js.map +7 -0
  98. package/package.json +7 -7
  99. package/src/modules/auth/api/admin/nav.ts +24 -4
  100. package/src/modules/auth/api/users/route.ts +8 -28
  101. package/src/modules/auth/frontend/login.tsx +4 -1
  102. package/src/modules/auth/lib/backendChrome.tsx +51 -13
  103. package/src/modules/customers/api/companies/[id]/route.ts +3 -4
  104. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -2
  105. package/src/modules/customers/api/utils.ts +12 -31
  106. package/src/modules/customers/cli.ts +11 -4
  107. package/src/modules/customers/components/detail/ActiveDealCard.tsx +2 -1
  108. package/src/modules/customers/components/detail/CompanyKpiBar.tsx +6 -5
  109. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
  110. package/src/modules/customers/components/detail/dashboard/helpers.ts +4 -3
  111. package/src/modules/customers/components/formConfig.tsx +12 -8
  112. package/src/modules/customers/lib/dealStatus.ts +48 -0
  113. package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
  114. package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
  115. package/src/modules/dashboards/lib/aggregations.ts +122 -43
  116. package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
  117. package/src/modules/dashboards/services/widgetDataService.ts +342 -4
  118. package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
  119. package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
  120. package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
  121. package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
  122. package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
  123. package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
  124. package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
  125. package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
  126. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
  127. package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
  128. package/src/modules/messages/lib/searchLookup.ts +11 -34
  129. package/src/modules/payment_gateways/api/sessions/route.ts +5 -0
  130. package/src/modules/payment_gateways/di.ts +28 -2
  131. package/src/modules/payment_gateways/i18n/de.json +3 -0
  132. package/src/modules/payment_gateways/i18n/en.json +3 -0
  133. package/src/modules/payment_gateways/i18n/es.json +3 -0
  134. package/src/modules/payment_gateways/i18n/pl.json +3 -0
  135. package/src/modules/payment_gateways/lib/gateway-service.ts +14 -0
  136. package/src/modules/payment_gateways/lib/order-amount-reconciliation.ts +93 -0
  137. package/src/modules/query_index/api/openapi.ts +12 -0
  138. package/src/modules/query_index/api/status.ts +238 -136
  139. package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
  140. package/src/modules/query_index/i18n/de.json +5 -3
  141. package/src/modules/query_index/i18n/en.json +5 -3
  142. package/src/modules/query_index/i18n/es.json +5 -3
  143. package/src/modules/query_index/i18n/pl.json +5 -3
  144. package/src/modules/query_index/lib/batch.ts +11 -4
  145. package/src/modules/query_index/lib/document.ts +35 -4
  146. package/src/modules/query_index/lib/engine.ts +53 -5
  147. package/src/modules/query_index/lib/indexer.ts +1 -1
  148. package/src/modules/query_index/lib/search-tokens.ts +13 -4
  149. package/src/modules/sales/api/documents/factory.ts +28 -3
  150. package/src/modules/sales/commands/documents.ts +26 -2
  151. package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
  152. package/src/modules/sales/data/validators.ts +50 -5
  153. package/src/modules/sales/di.ts +9 -0
  154. package/src/modules/sales/services/paymentOrderTotalResolver.ts +67 -0
@@ -13,19 +13,42 @@ import {
13
13
  determineChangeDirection,
14
14
  isValidDateRangePreset,
15
15
  } from '@open-mercato/ui/backend/date-range'
16
+ import { parseDecryptedFieldValue } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
17
+ import { createLogger } from '@open-mercato/shared/lib/logger'
18
+ import type { TenantDek } from '@open-mercato/shared/lib/encryption/kms'
19
+ import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
16
20
  import {
17
21
  type AggregateFunction,
18
22
  type DateGranularity,
19
23
  buildAggregationQuery,
24
+ buildGroupSourceRowsQuery,
25
+ resolveGroupExpression,
20
26
  } from '../lib/aggregations'
27
+ import {
28
+ type ExactDecimal,
29
+ EXACT_DECIMAL_ZERO,
30
+ addExactDecimal,
31
+ compareExactDecimal,
32
+ exactDecimalToNumber,
33
+ parseExactDecimal,
34
+ } from '../lib/exactDecimal'
21
35
  import type { AnalyticsRegistry } from './analyticsRegistry'
22
36
 
23
37
  const WIDGET_DATA_CACHE_TTL = 120_000
24
38
  const WIDGET_DATA_SEGMENT_TTL = 86_400_000
25
39
  const WIDGET_DATA_SEGMENT_KEY = 'widget-data:__segment__'
26
40
 
41
+ /**
42
+ * Encrypted group sources cannot be grouped by the database, so the rows are scanned and
43
+ * aggregated in application code. The cap keeps that scan bounded; overflowing it fails loudly
44
+ * instead of charting a silently truncated result (#4622).
45
+ */
46
+ const ENCRYPTED_GROUP_SCAN_LIMIT = 20_000
47
+
27
48
  const SAFE_IDENTIFIER_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/
28
49
 
50
+ const logger = createLogger('dashboards').child({ component: 'widget-data' })
51
+
29
52
  export class WidgetDataValidationError extends Error {
30
53
  constructor(message: string) {
31
54
  super(message)
@@ -33,12 +56,120 @@ export class WidgetDataValidationError extends Error {
33
56
  }
34
57
  }
35
58
 
59
+ export class WidgetDataScanLimitError extends Error {
60
+ constructor(message: string) {
61
+ super(message)
62
+ this.name = 'WidgetDataScanLimitError'
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Raised when encryption is configured but the group source cannot currently be resolved — an
68
+ * unhealthy KMS, an unreadable encryption map, or a missing tenant DEK. Grouping would either read
69
+ * ciphertext in SQL or silently collapse every encrypted row into "Unknown", so the request fails
70
+ * closed instead (#4622).
71
+ */
72
+ export class WidgetDataEncryptionUnavailableError extends Error {
73
+ constructor(message: string) {
74
+ super(message)
75
+ this.name = 'WidgetDataEncryptionUnavailableError'
76
+ }
77
+ }
78
+
79
+ type EncryptedGroupSource = {
80
+ entityId: string
81
+ dbColumn: string
82
+ jsonPath: string | null
83
+ }
84
+
36
85
  function assertSafeIdentifier(value: string, name: string): void {
37
86
  if (!SAFE_IDENTIFIER_PATTERN.test(value)) {
38
87
  throw new Error(`Invalid ${name}: ${value}`)
39
88
  }
40
89
  }
41
90
 
91
+ /**
92
+ * Encryption maps may register a field in either casing (`findKey` in the encryption service
93
+ * accepts both), so match the analytics column tolerantly rather than by exact snake_case.
94
+ */
95
+ function matchesColumn(fieldName: string, dbColumn: string): boolean {
96
+ const normalize = (value: string) => value.replace(/_/g, '').toLowerCase()
97
+ return normalize(fieldName) === normalize(dbColumn)
98
+ }
99
+
100
+ function readJsonPath(value: unknown, path: string): unknown {
101
+ let current = value
102
+ for (const part of path.split('.')) {
103
+ if (current === null || typeof current !== 'object') return null
104
+ current = (current as Record<string, unknown>)[part]
105
+ }
106
+ return current
107
+ }
108
+
109
+ /**
110
+ * Running aggregate state for one group. Values are folded in as they are scanned — the bucket
111
+ * never holds the row set — so the scan cost stays linear and independent of the row cap.
112
+ */
113
+ type GroupBucket = {
114
+ /** Rows with a non-null metric column, mirroring SQL `COUNT(column)`. */
115
+ count: number
116
+ /** Rows whose metric parsed as a decimal, mirroring the row set SQL aggregates operate on. */
117
+ numericCount: number
118
+ /** Exact running sum, so money never accumulates in binary floating point. */
119
+ sum: ExactDecimal
120
+ min: ExactDecimal | null
121
+ max: ExactDecimal | null
122
+ }
123
+
124
+ function createGroupBucket(): GroupBucket {
125
+ return { count: 0, numericCount: 0, sum: EXACT_DECIMAL_ZERO, min: null, max: null }
126
+ }
127
+
128
+ function foldMetricValue(bucket: GroupBucket, value: ExactDecimal): void {
129
+ bucket.numericCount += 1
130
+ bucket.sum = addExactDecimal(bucket.sum, value)
131
+ if (bucket.min === null || compareExactDecimal(value, bucket.min) < 0) bucket.min = value
132
+ if (bucket.max === null || compareExactDecimal(value, bucket.max) > 0) bucket.max = value
133
+ }
134
+
135
+ /**
136
+ * Mirrors the SQL semantics of `buildAggregateExpression` for application-side aggregation. That
137
+ * expression wraps `SUM`/`AVG` in `COALESCE(..., 0)`, so an empty value set aggregates to `0` on
138
+ * both paths, while the uncoalesced `MIN`/`MAX` keep PostgreSQL's `NULL`. An encrypted group source
139
+ * must not report a different value than the same data would report in plaintext (#4622).
140
+ */
141
+ function aggregateBucket(aggregate: AggregateFunction, bucket: GroupBucket): number | null {
142
+ switch (aggregate) {
143
+ case 'count':
144
+ return bucket.count
145
+ case 'sum':
146
+ return bucket.numericCount === 0 ? 0 : exactDecimalToNumber(bucket.sum)
147
+ case 'avg':
148
+ return bucket.numericCount === 0 ? 0 : exactDecimalToNumber(bucket.sum) / bucket.numericCount
149
+ case 'min':
150
+ return bucket.min === null ? null : exactDecimalToNumber(bucket.min)
151
+ case 'max':
152
+ return bucket.max === null ? null : exactDecimalToNumber(bucket.max)
153
+ default:
154
+ return bucket.count
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Mirrors the SQL path's `ORDER BY value DESC NULLS LAST`. PostgreSQL defaults DESC to NULLS FIRST,
160
+ * which would let empty buckets displace real ones under a group limit, so both paths state the
161
+ * policy explicitly: highest value first, buckets without a value last.
162
+ */
163
+ function compareWidgetDataItemsByValueDesc(
164
+ left: { value: number | null },
165
+ right: { value: number | null },
166
+ ): number {
167
+ if (left.value === null && right.value === null) return 0
168
+ if (left.value === null) return 1
169
+ if (right.value === null) return -1
170
+ return right.value - left.value
171
+ }
172
+
42
173
  export type WidgetDataRequest = {
43
174
  entityType: string
44
175
  metric: {
@@ -229,6 +360,13 @@ export class WidgetDataService {
229
360
  request: WidgetDataRequest,
230
361
  dateRange?: { start: Date; end: Date },
231
362
  ): Promise<{ value: number | null; data: WidgetDataItem[] }> {
363
+ if (request.groupBy) {
364
+ const encryptedSource = await this.resolveEncryptedGroupSource(request.entityType, request.groupBy)
365
+ if (encryptedSource) {
366
+ return this.executeEncryptedGroupQuery(request, encryptedSource, dateRange)
367
+ }
368
+ }
369
+
232
370
  const query = buildAggregationQuery({
233
371
  entityType: request.entityType,
234
372
  metric: request.metric,
@@ -264,6 +402,193 @@ export class WidgetDataService {
264
402
  return { value: singleValue, data: [] }
265
403
  }
266
404
 
405
+ /**
406
+ * Reports the encrypted column a groupBy field reads from, or null when the source is stored in
407
+ * plaintext and can be grouped by the database. Grouping over an encrypted column in SQL buckets
408
+ * ciphertext and renders it to the user (#4622).
409
+ */
410
+ private async resolveEncryptedGroupSource(
411
+ entityType: string,
412
+ groupBy: NonNullable<WidgetDataRequest['groupBy']>,
413
+ ): Promise<EncryptedGroupSource | null> {
414
+ // Encryption is not configured for this deployment, so no column can hold ciphertext and the
415
+ // database may group the source directly.
416
+ if (!isTenantDataEncryptionEnabled()) return null
417
+
418
+ const resolved = resolveGroupExpression(this.registry, entityType, groupBy)
419
+ if (!resolved) return null
420
+
421
+ const tableName = this.registry.getEntityTypeConfig(entityType)?.tableName
422
+ if (!tableName) return null
423
+
424
+ // Without an encryption entity id the table is outside the encryption map entirely, which is
425
+ // the same state the encrypting subscriber sees when it skips the row.
426
+ const entityId = this.resolveEncryptionEntityId(tableName)
427
+ if (!entityId) return null
428
+
429
+ const encryptionService = resolveTenantEncryptionService(this.em)
430
+ if (!encryptionService) {
431
+ throw new WidgetDataEncryptionUnavailableError(
432
+ `Cannot determine whether ${groupBy.field} is encrypted: encryption service unavailable`,
433
+ )
434
+ }
435
+
436
+ const organizationId = this.resolveOrganizationId()
437
+ let encryptedFields: string[]
438
+ try {
439
+ // Deliberately independent of KMS health: the map describes how the rows were written, and
440
+ // an unhealthy KMS must not be read as "this column is plaintext" (#4622).
441
+ encryptedFields = await encryptionService.getEncryptedFieldNames(
442
+ entityId,
443
+ this.scope.tenantId,
444
+ organizationId,
445
+ { ignoreRuntimeHealth: true },
446
+ )
447
+ } catch (err) {
448
+ logger.error('Failed to resolve encrypted fields for widget grouping', { err, entityId, entityType })
449
+ throw new WidgetDataEncryptionUnavailableError(
450
+ `Cannot determine whether ${groupBy.field} is encrypted: encryption map lookup failed`,
451
+ )
452
+ }
453
+
454
+ if (!encryptedFields.some((field) => matchesColumn(field, resolved.dbColumn))) return null
455
+
456
+ if (resolved.jsonPath === null && groupBy.granularity) {
457
+ throw new WidgetDataValidationError(
458
+ `Cannot group encrypted field by granularity: ${groupBy.field}`,
459
+ )
460
+ }
461
+
462
+ return { entityId, dbColumn: resolved.dbColumn, jsonPath: resolved.jsonPath }
463
+ }
464
+
465
+ /**
466
+ * Aggregates an encrypted group source in application code: rows are scanned, the group column is
467
+ * decrypted, and only the resulting plaintext keys are grouped. Values that cannot be decrypted
468
+ * collapse into the null ("Unknown") bucket so ciphertext never reaches the response.
469
+ */
470
+ private async executeEncryptedGroupQuery(
471
+ request: WidgetDataRequest,
472
+ source: EncryptedGroupSource,
473
+ dateRange?: { start: Date; end: Date },
474
+ ): Promise<{ value: number | null; data: WidgetDataItem[] }> {
475
+ const query = buildGroupSourceRowsQuery({
476
+ entityType: request.entityType,
477
+ metric: request.metric,
478
+ dateRange: dateRange && request.dateRange ? { field: request.dateRange.field, ...dateRange } : undefined,
479
+ filters: request.filters,
480
+ scope: this.scope,
481
+ registry: this.registry,
482
+ groupColumn: source.dbColumn,
483
+ rowLimit: ENCRYPTED_GROUP_SCAN_LIMIT,
484
+ })
485
+
486
+ if (!query) {
487
+ throw new Error('Failed to build aggregation query')
488
+ }
489
+
490
+ const rows = await this.em.getConnection().execute(query.sql, query.params)
491
+ const results = Array.isArray(rows) ? rows : []
492
+
493
+ if (results.length > ENCRYPTED_GROUP_SCAN_LIMIT) {
494
+ throw new WidgetDataScanLimitError(
495
+ `Too many rows to group encrypted field ${request.groupBy?.field} (limit ${ENCRYPTED_GROUP_SCAN_LIMIT})`,
496
+ )
497
+ }
498
+
499
+ const encryptionService = resolveTenantEncryptionService(this.em)
500
+ let dek: TenantDek | null = null
501
+ try {
502
+ dek = encryptionService ? await encryptionService.getDek(this.scope.tenantId) : null
503
+ } catch (err) {
504
+ logger.error('Failed to resolve the tenant DEK for widget grouping', { err, entityId: source.entityId })
505
+ dek = null
506
+ }
507
+
508
+ const buckets = new Map<string | null, GroupBucket>()
509
+ let undecryptableRows = 0
510
+
511
+ for (const row of results as Array<Record<string, unknown>>) {
512
+ const resolvedKey = this.resolveDecryptedGroupKey(row.group_source, source, dek?.key ?? null)
513
+ // An undecryptable source joins the null ("Unknown") bucket rather than being dropped, so the
514
+ // widget total still matches the underlying rows.
515
+ if (resolvedKey === undefined) undecryptableRows += 1
516
+ const groupKey = resolvedKey === undefined ? null : resolvedKey
517
+
518
+ let bucket = buckets.get(groupKey)
519
+ if (!bucket) {
520
+ bucket = createGroupBucket()
521
+ buckets.set(groupKey, bucket)
522
+ }
523
+
524
+ const metricValue = row.metric_value
525
+ if (metricValue === null || metricValue === undefined) continue
526
+ bucket.count += 1
527
+ const numeric = parseExactDecimal(metricValue)
528
+ if (numeric) foldMetricValue(bucket, numeric)
529
+ }
530
+
531
+ if (undecryptableRows > 0) {
532
+ // No DEK at all means every ciphertext row would collapse into "Unknown" and the widget would
533
+ // report a confidently wrong distribution, so fail closed rather than chart it (#4622).
534
+ if (!dek) {
535
+ throw new WidgetDataEncryptionUnavailableError(
536
+ `Cannot group encrypted field ${request.groupBy?.field}: tenant encryption key is unavailable`,
537
+ )
538
+ }
539
+ logger.warn('Grouped rows with an undecryptable group source as unknown', {
540
+ entityId: source.entityId,
541
+ column: source.dbColumn,
542
+ rows: undecryptableRows,
543
+ })
544
+ }
545
+
546
+ let data: WidgetDataItem[] = Array.from(buckets.entries())
547
+ .map(([groupKey, bucket]) => ({ groupKey, value: aggregateBucket(request.metric.aggregate, bucket) }))
548
+ .sort(compareWidgetDataItemsByValueDesc)
549
+
550
+ if (request.groupBy?.limit && request.groupBy.limit > 0) {
551
+ data = data.slice(0, Math.min(request.groupBy.limit, 100))
552
+ }
553
+
554
+ if (request.groupBy?.resolveLabels) {
555
+ data = await this.resolveGroupLabels(data, request.entityType, request.groupBy.field)
556
+ }
557
+
558
+ const totalValue = data.reduce((sum: number, item: WidgetDataItem) => sum + (item.value ?? 0), 0)
559
+ return { value: totalValue, data }
560
+ }
561
+
562
+ /**
563
+ * Returns the plaintext group key for a scanned row, `null` for an empty/absent value, or
564
+ * `undefined` when the source is ciphertext that could not be decrypted.
565
+ */
566
+ private resolveDecryptedGroupKey(
567
+ rawValue: unknown,
568
+ source: EncryptedGroupSource,
569
+ dek: string | null,
570
+ ): string | null | undefined {
571
+ if (rawValue === null || rawValue === undefined) return null
572
+
573
+ let value: unknown = rawValue
574
+ if (typeof value === 'string' && this.isEncryptedPayload(value)) {
575
+ if (!dek) return undefined
576
+ const decrypted = this.decryptWithDek(value, dek)
577
+ if (decrypted === null) return undefined
578
+ value = parseDecryptedFieldValue(decrypted)
579
+ }
580
+
581
+ if (source.jsonPath !== null) {
582
+ value = readJsonPath(value, source.jsonPath)
583
+ }
584
+
585
+ if (value === null || value === undefined || value === '') return null
586
+ if (typeof value === 'object') return null
587
+ const key = String(value)
588
+ // Defense in depth: a nested value that is itself ciphertext must never reach the response.
589
+ return this.isEncryptedPayload(key) ? undefined : key
590
+ }
591
+
267
592
  private async resolveGroupLabels(
268
593
  data: WidgetDataItem[],
269
594
  entityType: string,
@@ -272,10 +597,13 @@ export class WidgetDataService {
272
597
  const config = this.registry.getLabelResolverConfig(entityType, groupByField)
273
598
 
274
599
  if (!config) {
275
- return data.map((item) => ({
276
- ...item,
277
- groupLabel: item.groupKey != null && item.groupKey !== '' ? String(item.groupKey) : undefined,
278
- }))
600
+ return data.map((item) => {
601
+ if (item.groupKey == null || item.groupKey === '') return { ...item, groupLabel: undefined }
602
+ const label = String(item.groupKey)
603
+ // A ciphertext group key has no meaningful label; echoing it would render encrypted data
604
+ // into the chart legend (#4622).
605
+ return { ...item, groupLabel: this.isEncryptedPayload(label) ? undefined : label }
606
+ })
279
607
  }
280
608
 
281
609
  const ids = data
@@ -468,6 +796,16 @@ export class WidgetDataService {
468
796
  }
469
797
  }
470
798
 
799
+ /**
800
+ * Resolves the encryption entity id for an analytics table. A request-scoped `EntityManager` fork
801
+ * frequently reports an empty metadata registry, which used to make every group source look
802
+ * unencrypted and send the request back to the ciphertext-grouping SQL path (#4622). The table
803
+ * name alone is enough for the entity-id lookup, so it is the fallback.
804
+ */
805
+ private resolveEncryptionEntityId(tableName: string): string | null {
806
+ return this.resolveEntityId(this.resolveEntityMetadata(tableName)) ?? this.resolveEntityId({ tableName })
807
+ }
808
+
471
809
  private isEncryptedPayload(value: string): boolean {
472
810
  const parts = value.split(':')
473
811
  return parts.length === 4 && parts[3] === 'v1'
@@ -8,23 +8,30 @@ export type FeatureGuardProps = {
8
8
  children: React.ReactNode
9
9
  fallback?: React.ReactNode
10
10
  loadingFallback?: React.ReactNode
11
+ defaultValue?: boolean
11
12
  }
12
13
 
13
14
  /**
14
15
  * FeatureGuard component that conditionally renders children based on feature toggle state.
15
- *
16
+ *
16
17
  * @param id - The feature toggle identifier
17
18
  * @param children - Content to render when the feature is enabled
18
19
  * @param fallback - Optional content to render when the feature is disabled
19
20
  * @param loadingFallback - Optional content to render while loading the feature state
21
+ * @param defaultValue - Toggle value assumed when the check cannot be resolved
22
+ * (undefined toggle, type mismatch, network or permission failure). Defaults
23
+ * to `false`; pass `true` to keep guarded UI visible when the check fails.
24
+ * Distinct from `fallback`, which is what gets rendered once the feature is
25
+ * known to be disabled.
20
26
  */
21
27
  export function FeatureGuard({
22
28
  id,
23
29
  children,
24
30
  fallback = null,
25
31
  loadingFallback = null,
32
+ defaultValue = false,
26
33
  }: FeatureGuardProps) {
27
- const { enabled, isLoading } = useFeatureFlagBoolean({ id })
34
+ const { enabled, isLoading } = useFeatureFlagBoolean({ id, defaultValue })
28
35
 
29
36
  if (isLoading) {
30
37
  return <>{loadingFallback}</>
@@ -0,0 +1,5 @@
1
+ // Mirrors the server-side toggle resolution cache TTL in
2
+ // `lib/feature-flag-check.ts`, so several components reading the same toggle
3
+ // share one request instead of refetching a value the server would not have
4
+ // re-resolved anyway.
5
+ export const FEATURE_FLAG_STALE_TIME_MS = 60_000
@@ -2,9 +2,19 @@
2
2
 
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { FEATURE_FLAG_STALE_TIME_MS } from './staleTime'
5
6
 
6
7
  export type UseFeatureFlagOptions = {
7
8
  id: string
9
+ /**
10
+ * Value used while the check is in flight and whenever the toggle cannot be
11
+ * resolved (undefined toggle, type mismatch, network or permission failure).
12
+ *
13
+ * Defaults to `false` so an unresolved toggle keeps a feature switched off.
14
+ * Pass `true` for toggles that hide existing UI — there a failed check must
15
+ * not make the feature disappear.
16
+ */
17
+ defaultValue?: boolean
8
18
  }
9
19
 
10
20
  export type UseFeatureFlagResult = {
@@ -21,6 +31,7 @@ type Result<T> = {
21
31
  }
22
32
 
23
33
  export function useFeatureFlagBoolean(options: UseFeatureFlagOptions): UseFeatureFlagResult {
34
+ const defaultValue = options.defaultValue ?? false
24
35
  const query = useQuery({
25
36
  queryKey: ['featureToggles', 'check', options?.id],
26
37
  queryFn: async () => {
@@ -37,9 +48,10 @@ export function useFeatureFlagBoolean(options: UseFeatureFlagOptions): UseFeatur
37
48
  return result
38
49
  },
39
50
  enabled: !!options.id,
51
+ staleTime: FEATURE_FLAG_STALE_TIME_MS,
40
52
  })
41
53
 
42
- const enabled = query.data?.ok ? query.data.value : false
54
+ const enabled = query.data?.ok ? query.data.value : defaultValue
43
55
  const isLoading = query.isLoading
44
56
 
45
57
  return {
@@ -2,9 +2,16 @@
2
2
 
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { FEATURE_FLAG_STALE_TIME_MS } from './staleTime'
5
6
 
6
- export type UseFeatureFlagJsonOptions = {
7
+ export type UseFeatureFlagJsonOptions<T = unknown> = {
7
8
  id: string
9
+ /**
10
+ * Value used while the check is in flight and whenever the toggle cannot be
11
+ * resolved (undefined toggle, type mismatch, network or permission failure).
12
+ * Defaults to `null`.
13
+ */
14
+ defaultValue?: T | null
8
15
  }
9
16
 
10
17
  export type UseFeatureFlagJsonResult<T = unknown> = {
@@ -20,7 +27,8 @@ type Result<T> = {
20
27
  error: unknown
21
28
  }
22
29
 
23
- export function useFeatureFlagJson<T = unknown>(options: UseFeatureFlagJsonOptions): UseFeatureFlagJsonResult<T> {
30
+ export function useFeatureFlagJson<T = unknown>(options: UseFeatureFlagJsonOptions<T>): UseFeatureFlagJsonResult<T> {
31
+ const defaultValue = options.defaultValue ?? null
24
32
  const query = useQuery({
25
33
  queryKey: ['featureToggles', 'check', 'json', options?.id],
26
34
  queryFn: async () => {
@@ -37,9 +45,10 @@ export function useFeatureFlagJson<T = unknown>(options: UseFeatureFlagJsonOptio
37
45
  return result
38
46
  },
39
47
  enabled: !!options.id,
48
+ staleTime: FEATURE_FLAG_STALE_TIME_MS,
40
49
  })
41
50
 
42
- const value = query.data?.ok ? query.data.value : null
51
+ const value = query.data?.ok ? query.data.value : defaultValue
43
52
  const isLoading = query.isLoading
44
53
 
45
54
  return {
@@ -2,9 +2,16 @@
2
2
 
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { FEATURE_FLAG_STALE_TIME_MS } from './staleTime'
5
6
 
6
7
  export type UseFeatureFlagNumberOptions = {
7
8
  id: string
9
+ /**
10
+ * Value used while the check is in flight and whenever the toggle cannot be
11
+ * resolved (undefined toggle, type mismatch, network or permission failure).
12
+ * Defaults to `null`.
13
+ */
14
+ defaultValue?: number | null
8
15
  }
9
16
 
10
17
  export type UseFeatureFlagNumberResult = {
@@ -21,6 +28,7 @@ type Result<T> = {
21
28
  }
22
29
 
23
30
  export function useFeatureFlagNumber(options: UseFeatureFlagNumberOptions): UseFeatureFlagNumberResult {
31
+ const defaultValue = options.defaultValue ?? null
24
32
  const query = useQuery({
25
33
  queryKey: ['featureToggles', 'check', 'number', options?.id],
26
34
  queryFn: async () => {
@@ -37,9 +45,10 @@ export function useFeatureFlagNumber(options: UseFeatureFlagNumberOptions): UseF
37
45
  return result
38
46
  },
39
47
  enabled: !!options.id,
48
+ staleTime: FEATURE_FLAG_STALE_TIME_MS,
40
49
  })
41
50
 
42
- const value = query.data?.ok ? query.data.value : null
51
+ const value = query.data?.ok ? query.data.value : defaultValue
43
52
  const isLoading = query.isLoading
44
53
 
45
54
  return {
@@ -2,9 +2,16 @@
2
2
 
3
3
  import { useQuery } from '@tanstack/react-query'
4
4
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
5
+ import { FEATURE_FLAG_STALE_TIME_MS } from './staleTime'
5
6
 
6
7
  export type UseFeatureFlagStringOptions = {
7
8
  id: string
9
+ /**
10
+ * Value used while the check is in flight and whenever the toggle cannot be
11
+ * resolved (undefined toggle, type mismatch, network or permission failure).
12
+ * Defaults to `null`.
13
+ */
14
+ defaultValue?: string | null
8
15
  }
9
16
 
10
17
  export type UseFeatureFlagStringResult = {
@@ -21,6 +28,7 @@ type Result<T> = {
21
28
  }
22
29
 
23
30
  export function useFeatureFlagString(options: UseFeatureFlagStringOptions): UseFeatureFlagStringResult {
31
+ const defaultValue = options.defaultValue ?? null
24
32
  const query = useQuery({
25
33
  queryKey: ['featureToggles', 'check', 'string', options?.id],
26
34
  queryFn: async () => {
@@ -37,9 +45,10 @@ export function useFeatureFlagString(options: UseFeatureFlagStringOptions): UseF
37
45
  return result
38
46
  },
39
47
  enabled: !!options.id,
48
+ staleTime: FEATURE_FLAG_STALE_TIME_MS,
40
49
  })
41
50
 
42
- const value = query.data?.ok ? query.data.value : null
51
+ const value = query.data?.ok ? query.data.value : defaultValue
43
52
  const isLoading = query.isLoading
44
53
 
45
54
  return {
@@ -4,6 +4,8 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
4
4
  import type { FilterQuery } from '@mikro-orm/postgresql'
5
5
  import { findAndCountWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
6
6
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
7
+ import { findEntityIdsBySearchTokens, type SearchTokenDatabase } from '@open-mercato/shared/lib/search/tokenLookup'
8
+ import { E } from '#generated/entities.ids.generated'
7
9
  import { InboxProposal, InboxEmail, InboxProposalAction, InboxDiscrepancy, type InboxProposalCategory } from '../../data/entities'
8
10
  import { proposalListQuerySchema } from '../../data/validators'
9
11
  import { resolveRequestContext, UnauthorizedError } from '../routeHelpers'
@@ -47,7 +49,24 @@ export async function GET(req: Request) {
47
49
  }
48
50
  }
49
51
  if (query.search) {
50
- where.summary = { $ilike: `%${escapeLikePattern(query.search)}%` }
52
+ // `summary` is covered by the inbox_ops encryption map, so an ILIKE alone
53
+ // compares the pattern against ciphertext and matches nothing once
54
+ // encryption is on. Union it with the token index, which stores hashes of
55
+ // the plaintext. Issue #2990.
56
+ const searchOr: FilterQuery<InboxProposal>[] = [
57
+ { summary: { $ilike: `%${escapeLikePattern(query.search)}%` } },
58
+ ]
59
+ const tokenMatch = await findEntityIdsBySearchTokens({
60
+ db: ctx.em.getKysely<SearchTokenDatabase>(),
61
+ entityType: E.inbox_ops.inbox_proposal,
62
+ query: query.search,
63
+ fields: ['summary'],
64
+ scope: { tenantId: ctx.tenantId, organizationId: ctx.organizationId },
65
+ })
66
+ if (tokenMatch.matched && tokenMatch.ids.length) {
67
+ searchOr.push({ id: { $in: tokenMatch.ids } })
68
+ }
69
+ where.$or = searchOr
51
70
  }
52
71
 
53
72
  const offset = (query.page - 1) * query.pageSize
@@ -0,0 +1,39 @@
1
+ export const metadata = {
2
+ event: 'inbox_ops.proposal.created',
3
+ persistent: false,
4
+ id: 'inbox_ops:query-index-reindex-proposal-created',
5
+ }
6
+
7
+ type Payload = {
8
+ proposalId?: string
9
+ tenantId?: string | null
10
+ organizationId?: string | null
11
+ }
12
+
13
+ type EventBus = { emitEvent: (name: string, body: unknown, options?: unknown) => Promise<void> }
14
+ type HandlerContext = { resolve: <T = unknown>(name: string) => T }
15
+
16
+ export default async function handle(payload: Payload, ctx: HandlerContext): Promise<void> {
17
+ const recordId = typeof payload.proposalId === 'string' ? payload.proposalId : null
18
+ const tenantId = typeof payload.tenantId === 'string' ? payload.tenantId : null
19
+ if (!recordId || !tenantId) return
20
+
21
+ let eventBus: EventBus | null = null
22
+ try {
23
+ eventBus = ctx.resolve<EventBus>('eventBus')
24
+ } catch {
25
+ return
26
+ }
27
+
28
+ await eventBus.emitEvent('query_index.upsert_one', {
29
+ entityType: 'inbox_ops:inbox_proposal',
30
+ recordId,
31
+ organizationId: payload.organizationId ?? null,
32
+ tenantId,
33
+ crudAction: 'created',
34
+ coverageBaseDelta: 1,
35
+ }, {
36
+ tenantId,
37
+ organizationId: payload.organizationId ?? null,
38
+ }).catch(() => undefined)
39
+ }
@@ -87,8 +87,3 @@ export default function ExternalIdsWidget({ data }: InjectionWidgetComponentProp
87
87
  )
88
88
  }
89
89
 
90
- ExternalIdsWidget.metadata = {
91
- id: 'integrations.injection.external-ids',
92
- title: 'External IDs',
93
- features: ['integrations.view'],
94
- }