@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/api/admin/nav.js +14 -4
- package/dist/modules/auth/api/admin/nav.js.map +2 -2
- package/dist/modules/auth/api/users/route.js +8 -19
- package/dist/modules/auth/api/users/route.js.map +2 -2
- package/dist/modules/auth/frontend/login.js +4 -1
- package/dist/modules/auth/frontend/login.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +31 -14
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +3 -4
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +3 -2
- package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
- package/dist/modules/customers/api/utils.js +12 -18
- package/dist/modules/customers/api/utils.js.map +2 -2
- package/dist/modules/customers/cli.js +11 -4
- package/dist/modules/customers/cli.js.map +2 -2
- package/dist/modules/customers/components/detail/ActiveDealCard.js +2 -1
- package/dist/modules/customers/components/detail/ActiveDealCard.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyKpiBar.js +6 -5
- package/dist/modules/customers/components/detail/CompanyKpiBar.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/dashboard/helpers.js +4 -3
- package/dist/modules/customers/components/detail/dashboard/helpers.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +14 -10
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/lib/dealStatus.js +38 -0
- package/dist/modules/customers/lib/dealStatus.js.map +7 -0
- package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
- package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
- package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
- package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
- package/dist/modules/dashboards/lib/aggregations.js +76 -40
- package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
- package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
- package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
- package/dist/modules/dashboards/services/widgetDataService.js +228 -5
- package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
- package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
- package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
- package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
- package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
- package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
- package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
- package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
- package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
- package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
- package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
- package/dist/modules/messages/lib/searchLookup.js +8 -16
- package/dist/modules/messages/lib/searchLookup.js.map +2 -2
- package/dist/modules/payment_gateways/api/sessions/route.js +5 -0
- package/dist/modules/payment_gateways/api/sessions/route.js.map +2 -2
- package/dist/modules/payment_gateways/di.js +17 -1
- package/dist/modules/payment_gateways/di.js.map +2 -2
- package/dist/modules/payment_gateways/lib/gateway-service.js +8 -0
- package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
- package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js +58 -0
- package/dist/modules/payment_gateways/lib/order-amount-reconciliation.js.map +7 -0
- package/dist/modules/query_index/api/openapi.js +13 -1
- package/dist/modules/query_index/api/openapi.js.map +2 -2
- package/dist/modules/query_index/api/status.js +165 -105
- package/dist/modules/query_index/api/status.js.map +2 -2
- package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
- package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
- package/dist/modules/query_index/lib/batch.js +11 -4
- package/dist/modules/query_index/lib/batch.js.map +2 -2
- package/dist/modules/query_index/lib/document.js +12 -5
- package/dist/modules/query_index/lib/document.js.map +2 -2
- package/dist/modules/query_index/lib/engine.js +36 -4
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/dist/modules/query_index/lib/indexer.js +1 -1
- package/dist/modules/query_index/lib/indexer.js.map +2 -2
- package/dist/modules/query_index/lib/search-tokens.js +7 -4
- package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
- package/dist/modules/sales/api/documents/factory.js +14 -3
- package/dist/modules/sales/api/documents/factory.js.map +2 -2
- package/dist/modules/sales/commands/documents.js +22 -1
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
- package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
- package/dist/modules/sales/data/validators.js +25 -4
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/dist/modules/sales/di.js +7 -0
- package/dist/modules/sales/di.js.map +2 -2
- package/dist/modules/sales/services/paymentOrderTotalResolver.js +52 -0
- package/dist/modules/sales/services/paymentOrderTotalResolver.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/api/admin/nav.ts +24 -4
- package/src/modules/auth/api/users/route.ts +8 -28
- package/src/modules/auth/frontend/login.tsx +4 -1
- package/src/modules/auth/lib/backendChrome.tsx +51 -13
- package/src/modules/customers/api/companies/[id]/route.ts +3 -4
- package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +3 -2
- package/src/modules/customers/api/utils.ts +12 -31
- package/src/modules/customers/cli.ts +11 -4
- package/src/modules/customers/components/detail/ActiveDealCard.tsx +2 -1
- package/src/modules/customers/components/detail/CompanyKpiBar.tsx +6 -5
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
- package/src/modules/customers/components/detail/dashboard/helpers.ts +4 -3
- package/src/modules/customers/components/formConfig.tsx +12 -8
- package/src/modules/customers/lib/dealStatus.ts +48 -0
- package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
- package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
- package/src/modules/dashboards/lib/aggregations.ts +122 -43
- package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
- package/src/modules/dashboards/services/widgetDataService.ts +342 -4
- package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
- package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
- package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
- package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
- package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
- package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
- package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
- package/src/modules/messages/lib/searchLookup.ts +11 -34
- package/src/modules/payment_gateways/api/sessions/route.ts +5 -0
- package/src/modules/payment_gateways/di.ts +28 -2
- package/src/modules/payment_gateways/i18n/de.json +3 -0
- package/src/modules/payment_gateways/i18n/en.json +3 -0
- package/src/modules/payment_gateways/i18n/es.json +3 -0
- package/src/modules/payment_gateways/i18n/pl.json +3 -0
- package/src/modules/payment_gateways/lib/gateway-service.ts +14 -0
- package/src/modules/payment_gateways/lib/order-amount-reconciliation.ts +93 -0
- package/src/modules/query_index/api/openapi.ts +12 -0
- package/src/modules/query_index/api/status.ts +238 -136
- package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
- package/src/modules/query_index/i18n/de.json +5 -3
- package/src/modules/query_index/i18n/en.json +5 -3
- package/src/modules/query_index/i18n/es.json +5 -3
- package/src/modules/query_index/i18n/pl.json +5 -3
- package/src/modules/query_index/lib/batch.ts +11 -4
- package/src/modules/query_index/lib/document.ts +35 -4
- package/src/modules/query_index/lib/engine.ts +53 -5
- package/src/modules/query_index/lib/indexer.ts +1 -1
- package/src/modules/query_index/lib/search-tokens.ts +13 -4
- package/src/modules/sales/api/documents/factory.ts +28 -3
- package/src/modules/sales/commands/documents.ts +26 -2
- package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
- package/src/modules/sales/data/validators.ts +50 -5
- package/src/modules/sales/di.ts +9 -0
- 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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|