@open-mercato/core 0.6.7-develop.6706.1.b3a4c759bb → 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/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/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/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
|
@@ -22,6 +22,7 @@ import { resolveRegisteredLucideIconNode } from '@open-mercato/ui/backend/icons/
|
|
|
22
22
|
import { profilePathPrefixes, profileSections } from './profile-sections'
|
|
23
23
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
24
24
|
import { filterGrantsByEnabledModules } from '@open-mercato/shared/security/enabledModulesRegistry'
|
|
25
|
+
import { getNavGroupOrderOverride } from '@open-mercato/shared/modules/overrides'
|
|
25
26
|
import {
|
|
26
27
|
getSelectedOrganizationFromRequest,
|
|
27
28
|
resolveFeatureCheckContext,
|
|
@@ -147,19 +148,35 @@ async function serializeNavItem(item: AdminNavItem): Promise<ResolvedNavItem> {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
|
|
151
|
+
const defaultGroupOrder = [
|
|
152
|
+
'customers.nav.group',
|
|
153
|
+
'catalog.nav.group',
|
|
154
|
+
'customers~sales.nav.group',
|
|
155
|
+
'wms.nav.group',
|
|
156
|
+
'resources.nav.group',
|
|
157
|
+
'staff.nav.group',
|
|
158
|
+
'entities.nav.group',
|
|
159
|
+
'directory.nav.group',
|
|
160
|
+
'attachments.nav.group',
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Group ids ranked ahead of everything else, most significant first.
|
|
165
|
+
*
|
|
166
|
+
* An app may prepend its own ids via `overrides.nav.groupOrder` in `modules.ts`; ids it does not name
|
|
167
|
+
* keep the ordering they have today. With no override configured this returns `defaultGroupOrder`
|
|
168
|
+
* itself, so ordering is unchanged for every existing install.
|
|
169
|
+
*/
|
|
170
|
+
function resolveGroupOrder(): string[] {
|
|
171
|
+
const override = getNavGroupOrderOverride()
|
|
172
|
+
if (!override || override.length === 0) return defaultGroupOrder
|
|
173
|
+
const overridden = new Set(override)
|
|
174
|
+
return [...override, ...defaultGroupOrder.filter((id) => !overridden.has(id))]
|
|
175
|
+
}
|
|
176
|
+
|
|
150
177
|
function normalizeGroupWeights(groups: NavGroupWithWeight[]): NavGroupWithWeight[] {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
'catalog.nav.group',
|
|
154
|
-
'customers~sales.nav.group',
|
|
155
|
-
'wms.nav.group',
|
|
156
|
-
'resources.nav.group',
|
|
157
|
-
'staff.nav.group',
|
|
158
|
-
'entities.nav.group',
|
|
159
|
-
'directory.nav.group',
|
|
160
|
-
'attachments.nav.group',
|
|
161
|
-
]
|
|
162
|
-
const groupOrderIndex = new Map(defaultGroupOrder.map((id, index) => [id, index]))
|
|
178
|
+
const groupOrder = resolveGroupOrder()
|
|
179
|
+
const groupOrderIndex = new Map(groupOrder.map((id, index) => [id, index]))
|
|
163
180
|
groups.sort((a, b) => {
|
|
164
181
|
const aIndex = groupOrderIndex.get(a.id)
|
|
165
182
|
const bIndex = groupOrderIndex.get(b.id)
|
|
@@ -171,7 +188,7 @@ function normalizeGroupWeights(groups: NavGroupWithWeight[]): NavGroupWithWeight
|
|
|
171
188
|
if (a.weight !== b.weight) return a.weight - b.weight
|
|
172
189
|
return a.name.localeCompare(b.name)
|
|
173
190
|
})
|
|
174
|
-
const defaultGroupCount =
|
|
191
|
+
const defaultGroupCount = groupOrder.length
|
|
175
192
|
groups.forEach((group, index) => {
|
|
176
193
|
const rank = groupOrderIndex.get(group.id)
|
|
177
194
|
const fallbackWeight = typeof group.weight === 'number' ? group.weight : 10_000
|
|
@@ -275,6 +292,11 @@ export async function resolveBackendChromePayload({
|
|
|
275
292
|
|
|
276
293
|
let scopedOrganizationId: string | null = auth.orgId ?? null
|
|
277
294
|
let scopedTenantId: string | null = auth.tenantId ?? null
|
|
295
|
+
// The organization the caller actually *selected*, as distinct from the one the scope resolver fell
|
|
296
|
+
// back to. `resolveFeatureCheckContext` resolves `organizationId` to `auth.orgId` when no concrete
|
|
297
|
+
// organization is selected — which is precisely what an all-organizations view produces — so the
|
|
298
|
+
// resolved id cannot answer "which organization am I viewing".
|
|
299
|
+
let concretelySelectedOrganizationId: string | null = null
|
|
278
300
|
let allowNavigation = true
|
|
279
301
|
|
|
280
302
|
try {
|
|
@@ -287,12 +309,14 @@ export async function resolveBackendChromePayload({
|
|
|
287
309
|
})
|
|
288
310
|
scopedOrganizationId = organizationId
|
|
289
311
|
scopedTenantId = scope.tenantId ?? auth.tenantId ?? null
|
|
312
|
+
concretelySelectedOrganizationId = scope.selectedId ?? null
|
|
290
313
|
if (Array.isArray(allowedOrganizationIds) && allowedOrganizationIds.length === 0) {
|
|
291
314
|
allowNavigation = false
|
|
292
315
|
}
|
|
293
316
|
} catch {
|
|
294
317
|
scopedOrganizationId = auth.orgId ?? null
|
|
295
318
|
scopedTenantId = auth.tenantId ?? null
|
|
319
|
+
concretelySelectedOrganizationId = null
|
|
296
320
|
}
|
|
297
321
|
|
|
298
322
|
const acl = allowNavigation
|
|
@@ -411,6 +435,11 @@ export async function resolveBackendChromePayload({
|
|
|
411
435
|
?? (fallbackOrganizationId && !isAllOrganizationsSelection(fallbackOrganizationId) ? fallbackOrganizationId : null)
|
|
412
436
|
|
|
413
437
|
let brand: BackendChromePayload['brand'] = null
|
|
438
|
+
// Resolved here rather than left to callers. `brand` only populates when the organization has a
|
|
439
|
+
// logo, so it is a branding channel, not a dependable "which organization am I viewing" source.
|
|
440
|
+
// Without this field every downstream app has to fetch `/api/directory/organization-switcher` and
|
|
441
|
+
// walk its tree for the selected id. The row is already loaded below, so the name costs nothing.
|
|
442
|
+
let currentOrganization: BackendChromePayload['currentOrganization'] = null
|
|
414
443
|
if (brandOrganizationId && scopedTenantId) {
|
|
415
444
|
try {
|
|
416
445
|
const organization = await findOneWithDecryption(
|
|
@@ -420,6 +449,12 @@ export async function resolveBackendChromePayload({
|
|
|
420
449
|
undefined,
|
|
421
450
|
{ tenantId: scopedTenantId, organizationId: brandOrganizationId },
|
|
422
451
|
)
|
|
452
|
+
// Only when a concrete organization was selected. Under an all-organizations view
|
|
453
|
+
// `brandOrganizationId` still resolves (to the caller's own organization, which is what keeps
|
|
454
|
+
// branding working), so gating on the loaded row alone would misreport the scope.
|
|
455
|
+
if (organization && concretelySelectedOrganizationId === brandOrganizationId) {
|
|
456
|
+
currentOrganization = { id: String(organization.id), name: organization.name }
|
|
457
|
+
}
|
|
423
458
|
if (organization?.logoUrl) {
|
|
424
459
|
brand = {
|
|
425
460
|
name: organization.name,
|
|
@@ -430,7 +465,9 @@ export async function resolveBackendChromePayload({
|
|
|
430
465
|
}
|
|
431
466
|
}
|
|
432
467
|
} catch {
|
|
468
|
+
// Fail soft, as before: a failed organization lookup must not take down the nav payload.
|
|
433
469
|
brand = null
|
|
470
|
+
currentOrganization = null
|
|
434
471
|
}
|
|
435
472
|
}
|
|
436
473
|
|
|
@@ -443,5 +480,6 @@ export async function resolveBackendChromePayload({
|
|
|
443
480
|
grantedFeatures,
|
|
444
481
|
roles: Array.isArray(auth.roles) ? auth.roles : [],
|
|
445
482
|
brand,
|
|
483
|
+
currentOrganization,
|
|
446
484
|
}
|
|
447
485
|
}
|
|
@@ -4,8 +4,7 @@ import { sql } from 'kysely'
|
|
|
4
4
|
import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
5
5
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
6
6
|
import type { QueryCustomFieldSource, QueryJoinEdge, QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
7
|
-
import {
|
|
8
|
-
import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
|
|
7
|
+
import { findEntityIdsBySearchTokensCompat, type SearchTokenDatabase } from '@open-mercato/shared/lib/search/tokenLookup'
|
|
9
8
|
import { SortDir } from '@open-mercato/shared/lib/query/types'
|
|
10
9
|
|
|
11
10
|
const { withScopedPayload, parseScopedCommandInput } = createScopedApiHelpers({
|
|
@@ -106,36 +105,18 @@ async function findSearchTokenEntityIds({
|
|
|
106
105
|
fields,
|
|
107
106
|
query,
|
|
108
107
|
}: SearchTokenMatchInput): Promise<string[] | null> {
|
|
109
|
-
const trimmed = query.trim()
|
|
110
|
-
if (!trimmed) return null
|
|
111
|
-
|
|
112
|
-
const tokens = tokenizeText(trimmed, resolveSearchConfig())
|
|
113
|
-
if (!tokens.hashes.length) return []
|
|
114
|
-
|
|
115
108
|
const em = ctx.container.resolve('em') as EntityManager
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
searchQuery = searchQuery.where(sql<boolean>`tenant_id is not distinct from ${ctx.auth?.tenantId ?? null}`)
|
|
128
|
-
}
|
|
129
|
-
if (ctx.selectedOrganizationId) {
|
|
130
|
-
searchQuery = searchQuery.where('organization_id', '=', ctx.selectedOrganizationId)
|
|
131
|
-
} else if (Array.isArray(ctx.organizationIds) && ctx.organizationIds.length > 0) {
|
|
132
|
-
searchQuery = searchQuery.where('organization_id', 'in', ctx.organizationIds)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const rows = await searchQuery.execute() as Array<{ entity_id?: unknown }>
|
|
136
|
-
return rows
|
|
137
|
-
.map((row) => (typeof row.entity_id === 'string' ? row.entity_id : null))
|
|
138
|
-
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
|
109
|
+
return findEntityIdsBySearchTokensCompat({
|
|
110
|
+
db: em.getKysely<SearchTokenDatabase>(),
|
|
111
|
+
entityType,
|
|
112
|
+
query,
|
|
113
|
+
fields,
|
|
114
|
+
scope: {
|
|
115
|
+
tenantId: ctx.auth?.tenantId !== undefined ? ctx.auth?.tenantId ?? null : undefined,
|
|
116
|
+
organizationId: ctx.selectedOrganizationId ?? undefined,
|
|
117
|
+
organizationIds: ctx.organizationIds,
|
|
118
|
+
},
|
|
119
|
+
})
|
|
139
120
|
}
|
|
140
121
|
|
|
141
122
|
async function mapScopedEntityIds({
|
|
@@ -12,6 +12,7 @@ import { E as CoreEntities } from '#generated/entities.ids.generated'
|
|
|
12
12
|
import { createProgressBar } from '@open-mercato/shared/lib/cli/progress'
|
|
13
13
|
import { buildIndexDocument, type IndexCustomFieldValue } from '@open-mercato/core/modules/query_index/lib/document'
|
|
14
14
|
import { parseBooleanToken } from '@open-mercato/shared/lib/boolean'
|
|
15
|
+
import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
|
|
15
16
|
import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
16
17
|
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
17
18
|
import {
|
|
@@ -1994,6 +1995,7 @@ async function seedCustomerStressTest(
|
|
|
1994
1995
|
updated_at: Date
|
|
1995
1996
|
deleted_at: null
|
|
1996
1997
|
}> = []
|
|
1998
|
+
const searchConfig = resolveSearchConfig()
|
|
1997
1999
|
for (const [entityType, bucket] of pendingIndexDocs.entries()) {
|
|
1998
2000
|
for (const entry of bucket.values()) {
|
|
1999
2001
|
if (!entry.baseRow || Object.keys(entry.baseRow).length === 0) continue
|
|
@@ -2002,10 +2004,15 @@ async function seedCustomerStressTest(
|
|
|
2002
2004
|
entity_id: entry.recordId,
|
|
2003
2005
|
organization_id: entry.organizationId,
|
|
2004
2006
|
tenant_id: entry.tenantId,
|
|
2005
|
-
doc: buildIndexDocument(
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2007
|
+
doc: buildIndexDocument(
|
|
2008
|
+
entry.baseRow,
|
|
2009
|
+
entry.customFields,
|
|
2010
|
+
{
|
|
2011
|
+
organizationId: entry.organizationId,
|
|
2012
|
+
tenantId: entry.tenantId,
|
|
2013
|
+
},
|
|
2014
|
+
{ entityType, config: searchConfig },
|
|
2015
|
+
),
|
|
2009
2016
|
index_version: 1,
|
|
2010
2017
|
created_at: entry.createdAt,
|
|
2011
2018
|
updated_at: entry.updatedAt,
|
|
@@ -99,6 +99,8 @@ interface ScheduleActivityDialogProps {
|
|
|
99
99
|
onActivityCreated?: () => void
|
|
100
100
|
/** When provided, dialog opens in edit mode with pre-filled data */
|
|
101
101
|
editData?: ScheduleActivityEditData | null
|
|
102
|
+
/** Default country ISO2 code for phone number input when value is empty/unparsed */
|
|
103
|
+
defaultCountryIso2?: string
|
|
102
104
|
}
|
|
103
105
|
|
|
104
106
|
export function ScheduleActivityDialog({
|
|
@@ -111,6 +113,7 @@ export function ScheduleActivityDialog({
|
|
|
111
113
|
entityType,
|
|
112
114
|
onActivityCreated,
|
|
113
115
|
editData,
|
|
116
|
+
defaultCountryIso2,
|
|
114
117
|
}: ScheduleActivityDialogProps) {
|
|
115
118
|
const t = useT()
|
|
116
119
|
const state = useScheduleFormState({ open, editData: editData ?? null })
|
|
@@ -689,6 +692,7 @@ export function ScheduleActivityDialog({
|
|
|
689
692
|
externalError={callPhoneError}
|
|
690
693
|
invalidLabel={callPhoneInvalidMessage}
|
|
691
694
|
minDigits={7}
|
|
695
|
+
defaultCountryIso2={defaultCountryIso2}
|
|
692
696
|
/>
|
|
693
697
|
</div>
|
|
694
698
|
) : (
|
|
@@ -418,7 +418,7 @@ const companyDictionaryFieldDefinitions: DictionaryFieldDefinition[] = [
|
|
|
418
418
|
},
|
|
419
419
|
]
|
|
420
420
|
|
|
421
|
-
const createPrimaryPhoneField = (t: Translator): CrudField => ({
|
|
421
|
+
const createPrimaryPhoneField = (t: Translator, defaultCountryIso2?: string): CrudField => ({
|
|
422
422
|
id: 'primaryPhone',
|
|
423
423
|
label: t('customers.people.form.primaryPhone'),
|
|
424
424
|
type: 'custom',
|
|
@@ -447,6 +447,7 @@ const createPrimaryPhoneField = (t: Translator): CrudField => ({
|
|
|
447
447
|
invalidLabel={t('customers.people.form.primaryPhone.invalid', 'Enter a valid phone number with country code (e.g. +1 212 555 1234)')}
|
|
448
448
|
minDigits={7}
|
|
449
449
|
onDuplicateLookup={!disabled && !error ? duplicateLookup : undefined}
|
|
450
|
+
defaultCountryIso2={defaultCountryIso2}
|
|
450
451
|
/>
|
|
451
452
|
)
|
|
452
453
|
},
|
|
@@ -869,7 +870,8 @@ export const createDisplayNameSection = (t: Translator) =>
|
|
|
869
870
|
)
|
|
870
871
|
}
|
|
871
872
|
|
|
872
|
-
export const createPersonFormFields = (t: Translator): CrudField[] => {
|
|
873
|
+
export const createPersonFormFields = (t: Translator, options?: { defaultCountryIso2?: string }): CrudField[] => {
|
|
874
|
+
const defaultCountryIso2 = options?.defaultCountryIso2
|
|
873
875
|
const contactSection = createSectionHeadingField('__contactInformationSection', t('customers.people.form.sections.contactInformation'))
|
|
874
876
|
const companySection = createSectionHeadingField('__companyInformationSection', t('customers.people.form.sections.companyInformation'))
|
|
875
877
|
const dictionaryFields: CrudField[] = dictionaryFieldDefinitions.map((definition) => ({
|
|
@@ -930,7 +932,7 @@ export const createPersonFormFields = (t: Translator): CrudField[] => {
|
|
|
930
932
|
},
|
|
931
933
|
contactSection,
|
|
932
934
|
createPrimaryEmailField(t),
|
|
933
|
-
createPrimaryPhoneField(t),
|
|
935
|
+
createPrimaryPhoneField(t, defaultCountryIso2),
|
|
934
936
|
companySection,
|
|
935
937
|
{
|
|
936
938
|
id: 'companyEntityId',
|
|
@@ -1225,7 +1227,8 @@ export const createCompanyFormSchema = () =>
|
|
|
1225
1227
|
})
|
|
1226
1228
|
.passthrough()
|
|
1227
1229
|
|
|
1228
|
-
export const createCompanyFormFields = (t: Translator): CrudField[] => {
|
|
1230
|
+
export const createCompanyFormFields = (t: Translator, options?: { defaultCountryIso2?: string }): CrudField[] => {
|
|
1231
|
+
const defaultCountryIso2 = options?.defaultCountryIso2
|
|
1229
1232
|
const dictionaryFields: CrudField[] = companyDictionaryFieldDefinitions.map((definition) => ({
|
|
1230
1233
|
id: definition.id,
|
|
1231
1234
|
label: t(definition.labelKey),
|
|
@@ -1270,6 +1273,7 @@ export const createCompanyFormFields = (t: Translator): CrudField[] => {
|
|
|
1270
1273
|
placeholder={t('customers.companies.form.primaryPhonePlaceholder', '+1 555 123 4567')}
|
|
1271
1274
|
invalidLabel={t('customers.people.form.primaryPhone.invalid', 'Enter a valid phone number with country code (e.g. +1 212 555 1234)')}
|
|
1272
1275
|
minDigits={7}
|
|
1276
|
+
defaultCountryIso2={defaultCountryIso2}
|
|
1273
1277
|
/>
|
|
1274
1278
|
),
|
|
1275
1279
|
} as CrudField,
|
|
@@ -1651,8 +1655,8 @@ const buildIndustryLabels = (t: Translator): DictionarySelectLabels => ({
|
|
|
1651
1655
|
manageTitle: t('customers.people.form.dictionary.manage'),
|
|
1652
1656
|
})
|
|
1653
1657
|
|
|
1654
|
-
export const createCompanyEditFields = (t: Translator): CrudField[] => {
|
|
1655
|
-
const baseFields = createCompanyFormFields(t)
|
|
1658
|
+
export const createCompanyEditFields = (t: Translator, options?: { defaultCountryIso2?: string }): CrudField[] => {
|
|
1659
|
+
const baseFields = createCompanyFormFields(t, options)
|
|
1656
1660
|
const industryLabels = buildIndustryLabels(t)
|
|
1657
1661
|
|
|
1658
1662
|
return baseFields.map((field) => {
|
|
@@ -1676,8 +1680,8 @@ export const createCompanyEditFields = (t: Translator): CrudField[] => {
|
|
|
1676
1680
|
})
|
|
1677
1681
|
}
|
|
1678
1682
|
|
|
1679
|
-
export const createPersonEditFields = (t: Translator): CrudField[] => {
|
|
1680
|
-
const baseFields = createPersonFormFields(t)
|
|
1683
|
+
export const createPersonEditFields = (t: Translator, options?: { defaultCountryIso2?: string }): CrudField[] => {
|
|
1684
|
+
const baseFields = createPersonFormFields(t, options)
|
|
1681
1685
|
return [
|
|
1682
1686
|
...baseFields,
|
|
1683
1687
|
{
|
|
@@ -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
|
}
|