@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
@@ -0,0 +1,48 @@
1
+ // Single source of truth for deal open/closed (terminal) semantics.
2
+ //
3
+ // `customer_deals.status` is a lenient text column and the supported writers do not agree on
4
+ // one spelling: the closure hooks and the kanban board persist `win` / `loose`, the AI tool
5
+ // `customers.update_deal_stage` persists `won` / `lost`, and the seeded vocabulary also carries
6
+ // `closed`. The terminal set below therefore covers every spelling a status writer persists, so
7
+ // status-based readers can share one classification. `closureOutcome` is a separate signal and
8
+ // callers that combine both columns must handle it explicitly.
9
+ //
10
+ // A status not known to code counts as OPEN. Deal stages are configurable per tenant, so the
11
+ // lenient column can hold values this module has never seen — treating them as open keeps them
12
+ // visible in active-deal counts instead of silently reclassifying them as closed.
13
+
14
+ export const DEAL_STATUS_WIN = 'win' as const
15
+ export const DEAL_STATUS_LOSE = 'loose' as const
16
+ export const DEAL_STATUS_CLOSED = 'closed' as const
17
+
18
+ // `won` / `lost` are the spellings the AI stage tool writes; `win` / `loose` are what the UI
19
+ // closure flows persist. Both are accepted so neither writer produces an unreadable status.
20
+ export const WON_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_WIN, 'won']
21
+
22
+ export const LOST_DEAL_STATUS_LIST: readonly string[] = [DEAL_STATUS_LOSE, 'lost']
23
+
24
+ export const CLOSED_DEAL_STATUS_LIST: readonly string[] = [
25
+ ...WON_DEAL_STATUS_LIST,
26
+ ...LOST_DEAL_STATUS_LIST,
27
+ DEAL_STATUS_CLOSED,
28
+ ]
29
+
30
+ const WON_DEAL_STATUSES = new Set<string>(WON_DEAL_STATUS_LIST)
31
+ const LOST_DEAL_STATUSES = new Set<string>(LOST_DEAL_STATUS_LIST)
32
+ const CLOSED_DEAL_STATUSES = new Set<string>(CLOSED_DEAL_STATUS_LIST)
33
+
34
+ export function isWonDealStatus(value: string | null | undefined): boolean {
35
+ return value != null && WON_DEAL_STATUSES.has(value)
36
+ }
37
+
38
+ export function isLostDealStatus(value: string | null | undefined): boolean {
39
+ return value != null && LOST_DEAL_STATUSES.has(value)
40
+ }
41
+
42
+ export function isClosedDealStatus(value: string | null | undefined): boolean {
43
+ return value != null && CLOSED_DEAL_STATUSES.has(value)
44
+ }
45
+
46
+ export function isOpenDealStatus(value: string | null | undefined): boolean {
47
+ return !isClosedDealStatus(value)
48
+ }
@@ -9,6 +9,8 @@ import {
9
9
  createWidgetDataService,
10
10
  type WidgetDataRequest,
11
11
  WidgetDataValidationError,
12
+ WidgetDataScanLimitError,
13
+ WidgetDataEncryptionUnavailableError,
12
14
  } from '../../../../services/widgetDataService'
13
15
  import { runWidgetDataBatch } from '../../../../lib/widgetDataBatch'
14
16
  import type { AnalyticsRegistry } from '../../../../services/analyticsRegistry'
@@ -125,6 +127,8 @@ export async function POST(req: Request) {
125
127
  fetchOne: (request) => service.fetchWidgetData(request),
126
128
  describeError: (error) =>
127
129
  error instanceof WidgetDataValidationError
130
+ || error instanceof WidgetDataScanLimitError
131
+ || error instanceof WidgetDataEncryptionUnavailableError
128
132
  ? error.message
129
133
  : 'An error occurred while processing your request',
130
134
  })
@@ -8,6 +8,8 @@ import {
8
8
  createWidgetDataService,
9
9
  type WidgetDataRequest,
10
10
  WidgetDataValidationError,
11
+ WidgetDataScanLimitError,
12
+ WidgetDataEncryptionUnavailableError,
11
13
  } from '../../../services/widgetDataService'
12
14
  import type { AnalyticsRegistry } from '../../../services/analyticsRegistry'
13
15
  import { runApiInterceptorsBefore } from '@open-mercato/shared/lib/crud/interceptor-runner'
@@ -129,6 +131,15 @@ export async function POST(req: Request) {
129
131
  if (err instanceof WidgetDataValidationError) {
130
132
  return NextResponse.json({ error: err.message }, { status: 400 })
131
133
  }
134
+ // The dataset is too large to group in application code; surface why rather than a masked 500.
135
+ if (err instanceof WidgetDataScanLimitError) {
136
+ return NextResponse.json({ error: err.message }, { status: 422 })
137
+ }
138
+ // Encryption is configured but currently unresolvable. Refusing the request keeps ciphertext and
139
+ // silently-wrong buckets out of the response, and 503 tells the client it is worth retrying.
140
+ if (err instanceof WidgetDataEncryptionUnavailableError) {
141
+ return NextResponse.json({ error: err.message }, { status: 503 })
142
+ }
132
143
  return NextResponse.json(
133
144
  { error: 'An error occurred while processing your request' },
134
145
  { status: 500 },
@@ -156,8 +167,18 @@ const widgetDataPostDoc: OpenApiMethodDoc = {
156
167
  errors: [
157
168
  { status: 400, description: 'Invalid request payload', schema: dashboardsErrorSchema },
158
169
  { status: 401, description: 'Authentication required', schema: dashboardsErrorSchema },
170
+ {
171
+ status: 422,
172
+ description: 'Too many rows to group an encrypted field in application code',
173
+ schema: dashboardsErrorSchema,
174
+ },
159
175
  { status: 403, description: 'Missing analytics.view feature', schema: dashboardsErrorSchema },
160
176
  { status: 500, description: 'Internal server error', schema: dashboardsErrorSchema },
177
+ {
178
+ status: 503,
179
+ description: 'Encryption is configured but the group source cannot currently be resolved',
180
+ schema: dashboardsErrorSchema,
181
+ },
161
182
  ],
162
183
  }
163
184
 
@@ -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
+ }