@open-mercato/core 0.6.7-develop.6706.1.b3a4c759bb → 0.6.7-develop.6726.1.983ae8a07e

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 (117) 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/lib/backendChrome.js +31 -14
  7. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  8. package/dist/modules/customers/api/utils.js +12 -18
  9. package/dist/modules/customers/api/utils.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +17 -3
  11. package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
  12. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +7 -1
  13. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  14. package/dist/modules/customers/cli.js +11 -4
  15. package/dist/modules/customers/cli.js.map +2 -2
  16. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
  17. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  18. package/dist/modules/customers/components/formConfig.js +14 -10
  19. package/dist/modules/customers/components/formConfig.js.map +2 -2
  20. package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
  21. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  22. package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
  23. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  24. package/dist/modules/dashboards/lib/aggregations.js +76 -40
  25. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  26. package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
  27. package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
  28. package/dist/modules/dashboards/services/widgetDataService.js +228 -5
  29. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  30. package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
  31. package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
  32. package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
  33. package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
  34. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
  35. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
  36. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
  37. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
  38. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
  39. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
  40. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
  41. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
  42. package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
  43. package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
  44. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
  45. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
  46. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
  47. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  48. package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
  49. package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
  50. package/dist/modules/messages/lib/searchLookup.js +8 -16
  51. package/dist/modules/messages/lib/searchLookup.js.map +2 -2
  52. package/dist/modules/query_index/api/openapi.js +13 -1
  53. package/dist/modules/query_index/api/openapi.js.map +2 -2
  54. package/dist/modules/query_index/api/status.js +165 -105
  55. package/dist/modules/query_index/api/status.js.map +2 -2
  56. package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
  57. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  58. package/dist/modules/query_index/lib/batch.js +11 -4
  59. package/dist/modules/query_index/lib/batch.js.map +2 -2
  60. package/dist/modules/query_index/lib/document.js +12 -5
  61. package/dist/modules/query_index/lib/document.js.map +2 -2
  62. package/dist/modules/query_index/lib/engine.js +36 -4
  63. package/dist/modules/query_index/lib/engine.js.map +2 -2
  64. package/dist/modules/query_index/lib/indexer.js +1 -1
  65. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  66. package/dist/modules/query_index/lib/search-tokens.js +7 -4
  67. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  68. package/dist/modules/sales/api/documents/factory.js +14 -3
  69. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  70. package/dist/modules/sales/commands/documents.js +22 -1
  71. package/dist/modules/sales/commands/documents.js.map +2 -2
  72. package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
  73. package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
  74. package/dist/modules/sales/data/validators.js +25 -4
  75. package/dist/modules/sales/data/validators.js.map +2 -2
  76. package/package.json +7 -7
  77. package/src/modules/auth/api/admin/nav.ts +24 -4
  78. package/src/modules/auth/api/users/route.ts +8 -28
  79. package/src/modules/auth/lib/backendChrome.tsx +51 -13
  80. package/src/modules/customers/api/utils.ts +12 -31
  81. package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +35 -3
  82. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +10 -1
  83. package/src/modules/customers/cli.ts +11 -4
  84. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
  85. package/src/modules/customers/components/formConfig.tsx +12 -8
  86. package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
  87. package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
  88. package/src/modules/dashboards/lib/aggregations.ts +122 -43
  89. package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
  90. package/src/modules/dashboards/services/widgetDataService.ts +342 -4
  91. package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
  92. package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
  93. package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
  94. package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
  95. package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
  96. package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
  97. package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
  98. package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
  99. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
  100. package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
  101. package/src/modules/messages/lib/searchLookup.ts +11 -34
  102. package/src/modules/query_index/api/openapi.ts +12 -0
  103. package/src/modules/query_index/api/status.ts +238 -136
  104. package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
  105. package/src/modules/query_index/i18n/de.json +5 -3
  106. package/src/modules/query_index/i18n/en.json +5 -3
  107. package/src/modules/query_index/i18n/es.json +5 -3
  108. package/src/modules/query_index/i18n/pl.json +5 -3
  109. package/src/modules/query_index/lib/batch.ts +11 -4
  110. package/src/modules/query_index/lib/document.ts +35 -4
  111. package/src/modules/query_index/lib/engine.ts +53 -5
  112. package/src/modules/query_index/lib/indexer.ts +1 -1
  113. package/src/modules/query_index/lib/search-tokens.ts +13 -4
  114. package/src/modules/sales/api/documents/factory.ts +28 -3
  115. package/src/modules/sales/commands/documents.ts +26 -2
  116. package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
  117. package/src/modules/sales/data/validators.ts +50 -5
@@ -97,54 +97,56 @@ export type BuildAggregationQueryOptions = {
97
97
  registry: AnalyticsRegistry
98
98
  }
99
99
 
100
- export function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {
101
- const { registry } = options
102
- const config = registry.getEntityTypeConfig(options.entityType)
103
- if (!config) return null
104
-
105
- const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
106
- if (!metricMapping) return null
107
-
108
- const params: unknown[] = []
100
+ export type ResolvedGroupExpression = {
101
+ expression: string
102
+ /** Column the group expression reads from — the encryption map is keyed by this. */
103
+ dbColumn: string
104
+ /** JSONB path below `dbColumn`, when the groupBy field used path notation. */
105
+ jsonPath: string | null
106
+ }
109
107
 
110
- const tableName = config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`
111
- const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)
108
+ /**
109
+ * Resolves the SQL expression a groupBy field maps to, together with the underlying column so
110
+ * callers can decide whether the source is encrypted at rest before grouping over it (#4622).
111
+ */
112
+ export function resolveGroupExpression(
113
+ registry: AnalyticsRegistry,
114
+ entityType: string,
115
+ groupBy: { field: string; granularity?: DateGranularity },
116
+ ): ResolvedGroupExpression | null {
117
+ const groupMapping = registry.getFieldMapping(entityType, groupBy.field)
112
118
 
113
- let selectClause = `SELECT ${aggregateExpr} AS value`
114
- let groupByClause = ''
115
- let orderByClause = ''
116
- let limitClause = ''
117
-
118
- if (options.groupBy) {
119
- let groupMapping = registry.getFieldMapping(options.entityType, options.groupBy.field)
120
- let groupExpr: string | null = null
121
-
122
- // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)
123
- if (!groupMapping && options.groupBy.field.includes('.')) {
124
- const [baseField, ...pathParts] = options.groupBy.field.split('.')
125
- const baseMapping = registry.getFieldMapping(options.entityType, baseField)
126
- if (baseMapping?.type === 'jsonb') {
127
- groupExpr = buildJsonbFieldExpression(baseMapping.dbColumn, pathParts.join('.'))
128
- }
129
- } else if (groupMapping) {
130
- if (groupMapping.type === 'timestamp' && options.groupBy.granularity) {
131
- groupExpr = buildDateTruncExpression(groupMapping.dbColumn, options.groupBy.granularity)
132
- } else {
133
- groupExpr = groupMapping.dbColumn
134
- }
119
+ // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)
120
+ if (!groupMapping && groupBy.field.includes('.')) {
121
+ const [baseField, ...pathParts] = groupBy.field.split('.')
122
+ const baseMapping = registry.getFieldMapping(entityType, baseField)
123
+ if (baseMapping?.type !== 'jsonb') return null
124
+ const jsonPath = pathParts.join('.')
125
+ return {
126
+ expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),
127
+ dbColumn: baseMapping.dbColumn,
128
+ jsonPath,
135
129
  }
130
+ }
136
131
 
137
- if (groupExpr) {
138
- selectClause = `SELECT ${groupExpr} AS group_key, ${aggregateExpr} AS value`
139
- groupByClause = `GROUP BY ${groupExpr}`
140
- orderByClause = `ORDER BY value DESC`
132
+ if (!groupMapping) return null
141
133
 
142
- if (options.groupBy.limit && options.groupBy.limit > 0) {
143
- limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`
144
- }
134
+ if (groupMapping.type === 'timestamp' && groupBy.granularity) {
135
+ return {
136
+ expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),
137
+ dbColumn: groupMapping.dbColumn,
138
+ jsonPath: null,
145
139
  }
146
140
  }
147
141
 
142
+ return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null }
143
+ }
144
+
145
+ function buildWhereClause(
146
+ options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,
147
+ ): { clause: string; params: unknown[] } {
148
+ const { registry } = options
149
+ const params: unknown[] = []
148
150
  const whereClauses: string[] = []
149
151
 
150
152
  whereClauses.push(`tenant_id = ?`)
@@ -215,11 +217,88 @@ export function buildAggregationQuery(options: BuildAggregationQueryOptions): Ag
215
217
  }
216
218
  }
217
219
 
218
- const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
220
+ return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '', params }
221
+ }
222
+
223
+ function resolveTableName(config: EntityTypeConfig): string {
224
+ return config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`
225
+ }
226
+
227
+ export function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {
228
+ const { registry } = options
229
+ const config = registry.getEntityTypeConfig(options.entityType)
230
+ if (!config) return null
231
+
232
+ const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
233
+ if (!metricMapping) return null
234
+
235
+ const tableName = resolveTableName(config)
236
+ const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)
237
+
238
+ let selectClause = `SELECT ${aggregateExpr} AS value`
239
+ let groupByClause = ''
240
+ let orderByClause = ''
241
+ let limitClause = ''
242
+
243
+ if (options.groupBy) {
244
+ const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy)
245
+
246
+ if (resolved) {
247
+ selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`
248
+ groupByClause = `GROUP BY ${resolved.expression}`
249
+ // NULLS LAST is stated explicitly (PostgreSQL defaults DESC to NULLS FIRST) so a group limit
250
+ // keeps the highest-value buckets instead of the empty ones, and so the application-side
251
+ // encrypted path can mirror the same ordering (#4622).
252
+ orderByClause = `ORDER BY value DESC NULLS LAST`
253
+
254
+ if (options.groupBy.limit && options.groupBy.limit > 0) {
255
+ limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`
256
+ }
257
+ }
258
+ }
259
+
260
+ const where = buildWhereClause(options)
261
+
262
+ const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause]
263
+ .filter(Boolean)
264
+ .join(' ')
265
+
266
+ return { sql, params: where.params }
267
+ }
268
+
269
+ export type BuildGroupSourceRowsQueryOptions = Omit<BuildAggregationQueryOptions, 'groupBy'> & {
270
+ /** Column holding the (encrypted) group source, resolved via `resolveGroupExpression`. */
271
+ groupColumn: string
272
+ /** Hard cap on scanned rows; callers fetch `rowLimit + 1` to detect overflow. */
273
+ rowLimit: number
274
+ }
275
+
276
+ /**
277
+ * Builds a per-row query for group sources that cannot be grouped in SQL because the column is
278
+ * encrypted at rest. Returns the raw group source alongside the metric value so the caller can
279
+ * decrypt, then aggregate in application code (#4622).
280
+ */
281
+ export function buildGroupSourceRowsQuery(options: BuildGroupSourceRowsQueryOptions): AggregationQuery | null {
282
+ const { registry } = options
283
+ const config = registry.getEntityTypeConfig(options.entityType)
284
+ if (!config) return null
285
+
286
+ const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
287
+ if (!metricMapping) return null
288
+
289
+ if (!isSafeIdentifier(options.groupColumn)) {
290
+ throw new Error(`Invalid group column: ${options.groupColumn}`)
291
+ }
219
292
 
220
- const sql = [selectClause, `FROM ${tableName}`, whereClause, groupByClause, orderByClause, limitClause]
293
+ const where = buildWhereClause(options)
294
+ const sql = [
295
+ `SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,
296
+ `FROM ${resolveTableName(config)}`,
297
+ where.clause,
298
+ `LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`,
299
+ ]
221
300
  .filter(Boolean)
222
301
  .join(' ')
223
302
 
224
- return { sql, params }
303
+ return { sql, params: where.params }
225
304
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Minimal exact decimal arithmetic for application-side aggregation of PostgreSQL `numeric`
3
+ * columns. The SQL path sums money in `numeric` and converts once, so the application path must
4
+ * not accumulate in binary floating point — `0.1 + 0.2` would drift the totals of encrypted group
5
+ * sources away from the plaintext ones (#4622).
6
+ *
7
+ * A value is stored as scaled integer `units` plus a decimal `scale`, so `12.34` is
8
+ * `{ units: 1234n, scale: 2 }`. Conversion to `number` happens only at the response boundary.
9
+ */
10
+
11
+ export type ExactDecimal = {
12
+ units: bigint
13
+ scale: number
14
+ }
15
+
16
+ const DECIMAL_PATTERN = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/
17
+
18
+ export const EXACT_DECIMAL_ZERO: ExactDecimal = { units: 0n, scale: 0 }
19
+
20
+ function pow10(exponent: number): bigint {
21
+ return 10n ** BigInt(exponent)
22
+ }
23
+
24
+ /**
25
+ * Parses a `numeric` column value. The PostgreSQL driver hands these over as strings to preserve
26
+ * precision, so the string form is authoritative; numbers and bigints are accepted for callers that
27
+ * already hold a JavaScript value. Returns `null` for anything that is not a finite decimal.
28
+ */
29
+ export function parseExactDecimal(raw: unknown): ExactDecimal | null {
30
+ if (raw === null || raw === undefined) return null
31
+ if (typeof raw === 'bigint') return { units: raw, scale: 0 }
32
+
33
+ let text: string
34
+ if (typeof raw === 'number') {
35
+ if (!Number.isFinite(raw)) return null
36
+ text = String(raw)
37
+ } else if (typeof raw === 'string') {
38
+ text = raw.trim()
39
+ } else {
40
+ return null
41
+ }
42
+
43
+ if (!text) return null
44
+
45
+ const match = DECIMAL_PATTERN.exec(text)
46
+ if (!match) return null
47
+
48
+ const [, sign, intPart = '', fracPart = '', exponentPart] = match
49
+ if (!intPart && !fracPart) return null
50
+
51
+ const digits = `${intPart}${fracPart}`
52
+ let units = BigInt(digits)
53
+ let scale = fracPart.length
54
+
55
+ if (exponentPart) {
56
+ scale -= Number(exponentPart)
57
+ if (scale < 0) {
58
+ units *= pow10(-scale)
59
+ scale = 0
60
+ }
61
+ }
62
+
63
+ if (sign === '-') units = -units
64
+ return { units, scale }
65
+ }
66
+
67
+ function rescale(value: ExactDecimal, scale: number): bigint {
68
+ return scale === value.scale ? value.units : value.units * pow10(scale - value.scale)
69
+ }
70
+
71
+ export function addExactDecimal(left: ExactDecimal, right: ExactDecimal): ExactDecimal {
72
+ const scale = Math.max(left.scale, right.scale)
73
+ return { units: rescale(left, scale) + rescale(right, scale), scale }
74
+ }
75
+
76
+ export function compareExactDecimal(left: ExactDecimal, right: ExactDecimal): number {
77
+ const scale = Math.max(left.scale, right.scale)
78
+ const leftUnits = rescale(left, scale)
79
+ const rightUnits = rescale(right, scale)
80
+ if (leftUnits === rightUnits) return 0
81
+ return leftUnits < rightUnits ? -1 : 1
82
+ }
83
+
84
+ export function exactDecimalToString(value: ExactDecimal): string {
85
+ const negative = value.units < 0n
86
+ const digits = (negative ? -value.units : value.units).toString().padStart(value.scale + 1, '0')
87
+ const boundary = digits.length - value.scale
88
+ const integerPart = digits.slice(0, boundary)
89
+ const fractionPart = value.scale > 0 ? digits.slice(boundary) : ''
90
+ return `${negative ? '-' : ''}${integerPart}${fractionPart ? `.${fractionPart}` : ''}`
91
+ }
92
+
93
+ /** Response-boundary conversion: the only place where the exact value becomes a binary float. */
94
+ export function exactDecimalToNumber(value: ExactDecimal): number {
95
+ return Number(exactDecimalToString(value))
96
+ }
@@ -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'