@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.
- 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/lib/backendChrome.js +31 -14
- package/dist/modules/auth/lib/backendChrome.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/backend/customers/deals/pipeline/components/QuickDealDialog.js +17 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +7 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/page.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/ScheduleActivityDialog.js +4 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.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/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/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/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/lib/backendChrome.tsx +51 -13
- package/src/modules/customers/api/utils.ts +12 -31
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +35 -3
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +10 -1
- package/src/modules/customers/cli.ts +11 -4
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
- package/src/modules/customers/components/formConfig.tsx +12 -8
- 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/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
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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'
|