@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.
Files changed (111) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/api/admin/nav.js +14 -4
  3. package/dist/modules/auth/api/admin/nav.js.map +2 -2
  4. package/dist/modules/auth/api/users/route.js +8 -19
  5. package/dist/modules/auth/api/users/route.js.map +2 -2
  6. package/dist/modules/auth/lib/backendChrome.js +31 -14
  7. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  8. package/dist/modules/customers/api/utils.js +12 -18
  9. package/dist/modules/customers/api/utils.js.map +2 -2
  10. package/dist/modules/customers/cli.js +11 -4
  11. package/dist/modules/customers/cli.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +4 -2
  13. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  14. package/dist/modules/customers/components/formConfig.js +14 -10
  15. package/dist/modules/customers/components/formConfig.js.map +2 -2
  16. package/dist/modules/dashboards/api/widgets/data/batch/route.js +4 -2
  17. package/dist/modules/dashboards/api/widgets/data/batch/route.js.map +2 -2
  18. package/dist/modules/dashboards/api/widgets/data/route.js +20 -2
  19. package/dist/modules/dashboards/api/widgets/data/route.js.map +2 -2
  20. package/dist/modules/dashboards/lib/aggregations.js +76 -40
  21. package/dist/modules/dashboards/lib/aggregations.js.map +2 -2
  22. package/dist/modules/dashboards/lib/exactDecimal.js +69 -0
  23. package/dist/modules/dashboards/lib/exactDecimal.js.map +7 -0
  24. package/dist/modules/dashboards/services/widgetDataService.js +228 -5
  25. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  26. package/dist/modules/feature_toggles/components/FeatureGuard.js +3 -2
  27. package/dist/modules/feature_toggles/components/FeatureGuard.js.map +2 -2
  28. package/dist/modules/feature_toggles/components/hooks/staleTime.js +5 -0
  29. package/dist/modules/feature_toggles/components/hooks/staleTime.js.map +7 -0
  30. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js +5 -2
  31. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.js.map +2 -2
  32. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js +5 -2
  33. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagJson.js.map +2 -2
  34. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js +5 -2
  35. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagNumber.js.map +2 -2
  36. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js +5 -2
  37. package/dist/modules/feature_toggles/components/hooks/useFeatureFlagString.js.map +2 -2
  38. package/dist/modules/inbox_ops/api/proposals/route.js +16 -1
  39. package/dist/modules/inbox_ops/api/proposals/route.js.map +2 -2
  40. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js +32 -0
  41. package/dist/modules/inbox_ops/subscribers/search-reindex-proposal-created.js.map +7 -0
  42. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js +0 -5
  43. package/dist/modules/integrations/widgets/injection/external-ids/widget.client.js.map +2 -2
  44. package/dist/modules/integrations/widgets/injection/external-ids/widget.js +14 -0
  45. package/dist/modules/integrations/widgets/injection/external-ids/widget.js.map +7 -0
  46. package/dist/modules/messages/lib/searchLookup.js +8 -16
  47. package/dist/modules/messages/lib/searchLookup.js.map +2 -2
  48. package/dist/modules/query_index/api/openapi.js +13 -1
  49. package/dist/modules/query_index/api/openapi.js.map +2 -2
  50. package/dist/modules/query_index/api/status.js +165 -105
  51. package/dist/modules/query_index/api/status.js.map +2 -2
  52. package/dist/modules/query_index/components/QueryIndexesTable.js +128 -41
  53. package/dist/modules/query_index/components/QueryIndexesTable.js.map +2 -2
  54. package/dist/modules/query_index/lib/batch.js +11 -4
  55. package/dist/modules/query_index/lib/batch.js.map +2 -2
  56. package/dist/modules/query_index/lib/document.js +12 -5
  57. package/dist/modules/query_index/lib/document.js.map +2 -2
  58. package/dist/modules/query_index/lib/engine.js +36 -4
  59. package/dist/modules/query_index/lib/engine.js.map +2 -2
  60. package/dist/modules/query_index/lib/indexer.js +1 -1
  61. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  62. package/dist/modules/query_index/lib/search-tokens.js +7 -4
  63. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  64. package/dist/modules/sales/api/documents/factory.js +14 -3
  65. package/dist/modules/sales/api/documents/factory.js.map +2 -2
  66. package/dist/modules/sales/commands/documents.js +22 -1
  67. package/dist/modules/sales/commands/documents.js.map +2 -2
  68. package/dist/modules/sales/components/useSalesChannelsEnabled.js +2 -35
  69. package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +2 -2
  70. package/dist/modules/sales/data/validators.js +25 -4
  71. package/dist/modules/sales/data/validators.js.map +2 -2
  72. package/package.json +7 -7
  73. package/src/modules/auth/api/admin/nav.ts +24 -4
  74. package/src/modules/auth/api/users/route.ts +8 -28
  75. package/src/modules/auth/lib/backendChrome.tsx +51 -13
  76. package/src/modules/customers/api/utils.ts +12 -31
  77. package/src/modules/customers/cli.ts +11 -4
  78. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +4 -0
  79. package/src/modules/customers/components/formConfig.tsx +12 -8
  80. package/src/modules/dashboards/api/widgets/data/batch/route.ts +4 -0
  81. package/src/modules/dashboards/api/widgets/data/route.ts +21 -0
  82. package/src/modules/dashboards/lib/aggregations.ts +122 -43
  83. package/src/modules/dashboards/lib/exactDecimal.ts +96 -0
  84. package/src/modules/dashboards/services/widgetDataService.ts +342 -4
  85. package/src/modules/feature_toggles/components/FeatureGuard.tsx +9 -2
  86. package/src/modules/feature_toggles/components/hooks/staleTime.ts +5 -0
  87. package/src/modules/feature_toggles/components/hooks/useFeatureFlagBoolean.ts +13 -1
  88. package/src/modules/feature_toggles/components/hooks/useFeatureFlagJson.ts +12 -3
  89. package/src/modules/feature_toggles/components/hooks/useFeatureFlagNumber.ts +10 -1
  90. package/src/modules/feature_toggles/components/hooks/useFeatureFlagString.ts +10 -1
  91. package/src/modules/inbox_ops/api/proposals/route.ts +20 -1
  92. package/src/modules/inbox_ops/subscribers/search-reindex-proposal-created.ts +39 -0
  93. package/src/modules/integrations/widgets/injection/external-ids/widget.client.tsx +0 -5
  94. package/src/modules/integrations/widgets/injection/external-ids/widget.ts +13 -0
  95. package/src/modules/messages/lib/searchLookup.ts +11 -34
  96. package/src/modules/query_index/api/openapi.ts +12 -0
  97. package/src/modules/query_index/api/status.ts +238 -136
  98. package/src/modules/query_index/components/QueryIndexesTable.tsx +192 -46
  99. package/src/modules/query_index/i18n/de.json +5 -3
  100. package/src/modules/query_index/i18n/en.json +5 -3
  101. package/src/modules/query_index/i18n/es.json +5 -3
  102. package/src/modules/query_index/i18n/pl.json +5 -3
  103. package/src/modules/query_index/lib/batch.ts +11 -4
  104. package/src/modules/query_index/lib/document.ts +35 -4
  105. package/src/modules/query_index/lib/engine.ts +53 -5
  106. package/src/modules/query_index/lib/indexer.ts +1 -1
  107. package/src/modules/query_index/lib/search-tokens.ts +13 -4
  108. package/src/modules/sales/api/documents/factory.ts +28 -3
  109. package/src/modules/sales/commands/documents.ts +26 -2
  110. package/src/modules/sales/components/useSalesChannelsEnabled.ts +2 -48
  111. 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 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
- 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 = defaultGroupOrder.length
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 { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
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
- const db = em.getKysely<any>() as any
117
- let searchQuery = db
118
- .selectFrom('search_tokens')
119
- .select('entity_id')
120
- .where('entity_type', '=', entityType)
121
- .where('field', 'in', fields)
122
- .where('token_hash', 'in', tokens.hashes)
123
- .groupBy('entity_id')
124
- .having(sql<boolean>`count(distinct token_hash) >= ${tokens.hashes.length}`)
125
-
126
- if (ctx.auth?.tenantId !== undefined) {
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(entry.baseRow, entry.customFields, {
2006
- organizationId: entry.organizationId,
2007
- tenantId: entry.tenantId,
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 function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {
101
- const { registry } = options
102
- const config = registry.getEntityTypeConfig(options.entityType)
103
- if (!config) return null
104
-
105
- const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
106
- if (!metricMapping) return null
107
-
108
- const params: unknown[] = []
100
+ export type ResolvedGroupExpression = {
101
+ expression: string
102
+ /** Column the group expression reads from — the encryption map is keyed by this. */
103
+ dbColumn: string
104
+ /** JSONB path below `dbColumn`, when the groupBy field used path notation. */
105
+ jsonPath: string | null
106
+ }
109
107
 
110
- const tableName = config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`
111
- const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)
108
+ /**
109
+ * Resolves the SQL expression a groupBy field maps to, together with the underlying column so
110
+ * callers can decide whether the source is encrypted at rest before grouping over it (#4622).
111
+ */
112
+ export function resolveGroupExpression(
113
+ registry: AnalyticsRegistry,
114
+ entityType: string,
115
+ groupBy: { field: string; granularity?: DateGranularity },
116
+ ): ResolvedGroupExpression | null {
117
+ const groupMapping = registry.getFieldMapping(entityType, groupBy.field)
112
118
 
113
- let selectClause = `SELECT ${aggregateExpr} AS value`
114
- let groupByClause = ''
115
- let orderByClause = ''
116
- let limitClause = ''
117
-
118
- if (options.groupBy) {
119
- let groupMapping = registry.getFieldMapping(options.entityType, options.groupBy.field)
120
- let groupExpr: string | null = null
121
-
122
- // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)
123
- if (!groupMapping && options.groupBy.field.includes('.')) {
124
- const [baseField, ...pathParts] = options.groupBy.field.split('.')
125
- const baseMapping = registry.getFieldMapping(options.entityType, baseField)
126
- if (baseMapping?.type === 'jsonb') {
127
- groupExpr = buildJsonbFieldExpression(baseMapping.dbColumn, pathParts.join('.'))
128
- }
129
- } else if (groupMapping) {
130
- if (groupMapping.type === 'timestamp' && options.groupBy.granularity) {
131
- groupExpr = buildDateTruncExpression(groupMapping.dbColumn, options.groupBy.granularity)
132
- } else {
133
- groupExpr = groupMapping.dbColumn
134
- }
119
+ // Handle JSONB path notation (e.g., shippingAddressSnapshot.region)
120
+ if (!groupMapping && groupBy.field.includes('.')) {
121
+ const [baseField, ...pathParts] = groupBy.field.split('.')
122
+ const baseMapping = registry.getFieldMapping(entityType, baseField)
123
+ if (baseMapping?.type !== 'jsonb') return null
124
+ const jsonPath = pathParts.join('.')
125
+ return {
126
+ expression: buildJsonbFieldExpression(baseMapping.dbColumn, jsonPath),
127
+ dbColumn: baseMapping.dbColumn,
128
+ jsonPath,
135
129
  }
130
+ }
136
131
 
137
- if (groupExpr) {
138
- selectClause = `SELECT ${groupExpr} AS group_key, ${aggregateExpr} AS value`
139
- groupByClause = `GROUP BY ${groupExpr}`
140
- orderByClause = `ORDER BY value DESC`
132
+ if (!groupMapping) return null
141
133
 
142
- if (options.groupBy.limit && options.groupBy.limit > 0) {
143
- limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`
144
- }
134
+ if (groupMapping.type === 'timestamp' && groupBy.granularity) {
135
+ return {
136
+ expression: buildDateTruncExpression(groupMapping.dbColumn, groupBy.granularity),
137
+ dbColumn: groupMapping.dbColumn,
138
+ jsonPath: null,
145
139
  }
146
140
  }
147
141
 
142
+ return { expression: groupMapping.dbColumn, dbColumn: groupMapping.dbColumn, jsonPath: null }
143
+ }
144
+
145
+ function buildWhereClause(
146
+ options: Pick<BuildAggregationQueryOptions, 'entityType' | 'dateRange' | 'filters' | 'scope' | 'registry'>,
147
+ ): { clause: string; params: unknown[] } {
148
+ const { registry } = options
149
+ const params: unknown[] = []
148
150
  const whereClauses: string[] = []
149
151
 
150
152
  whereClauses.push(`tenant_id = ?`)
@@ -215,11 +217,88 @@ export function buildAggregationQuery(options: BuildAggregationQueryOptions): Ag
215
217
  }
216
218
  }
217
219
 
218
- const whereClause = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
220
+ return { clause: whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : '', params }
221
+ }
222
+
223
+ function resolveTableName(config: EntityTypeConfig): string {
224
+ return config.schema ? `"${config.schema}"."${config.tableName}"` : `"${config.tableName}"`
225
+ }
226
+
227
+ export function buildAggregationQuery(options: BuildAggregationQueryOptions): AggregationQuery | null {
228
+ const { registry } = options
229
+ const config = registry.getEntityTypeConfig(options.entityType)
230
+ if (!config) return null
231
+
232
+ const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
233
+ if (!metricMapping) return null
234
+
235
+ const tableName = resolveTableName(config)
236
+ const aggregateExpr = buildAggregateExpression(options.metric.aggregate, metricMapping.dbColumn)
237
+
238
+ let selectClause = `SELECT ${aggregateExpr} AS value`
239
+ let groupByClause = ''
240
+ let orderByClause = ''
241
+ let limitClause = ''
242
+
243
+ if (options.groupBy) {
244
+ const resolved = resolveGroupExpression(registry, options.entityType, options.groupBy)
245
+
246
+ if (resolved) {
247
+ selectClause = `SELECT ${resolved.expression} AS group_key, ${aggregateExpr} AS value`
248
+ groupByClause = `GROUP BY ${resolved.expression}`
249
+ // NULLS LAST is stated explicitly (PostgreSQL defaults DESC to NULLS FIRST) so a group limit
250
+ // keeps the highest-value buckets instead of the empty ones, and so the application-side
251
+ // encrypted path can mirror the same ordering (#4622).
252
+ orderByClause = `ORDER BY value DESC NULLS LAST`
253
+
254
+ if (options.groupBy.limit && options.groupBy.limit > 0) {
255
+ limitClause = `LIMIT ${Math.min(options.groupBy.limit, 100)}`
256
+ }
257
+ }
258
+ }
259
+
260
+ const where = buildWhereClause(options)
261
+
262
+ const sql = [selectClause, `FROM ${tableName}`, where.clause, groupByClause, orderByClause, limitClause]
263
+ .filter(Boolean)
264
+ .join(' ')
265
+
266
+ return { sql, params: where.params }
267
+ }
268
+
269
+ export type BuildGroupSourceRowsQueryOptions = Omit<BuildAggregationQueryOptions, 'groupBy'> & {
270
+ /** Column holding the (encrypted) group source, resolved via `resolveGroupExpression`. */
271
+ groupColumn: string
272
+ /** Hard cap on scanned rows; callers fetch `rowLimit + 1` to detect overflow. */
273
+ rowLimit: number
274
+ }
275
+
276
+ /**
277
+ * Builds a per-row query for group sources that cannot be grouped in SQL because the column is
278
+ * encrypted at rest. Returns the raw group source alongside the metric value so the caller can
279
+ * decrypt, then aggregate in application code (#4622).
280
+ */
281
+ export function buildGroupSourceRowsQuery(options: BuildGroupSourceRowsQueryOptions): AggregationQuery | null {
282
+ const { registry } = options
283
+ const config = registry.getEntityTypeConfig(options.entityType)
284
+ if (!config) return null
285
+
286
+ const metricMapping = registry.getFieldMapping(options.entityType, options.metric.field)
287
+ if (!metricMapping) return null
288
+
289
+ if (!isSafeIdentifier(options.groupColumn)) {
290
+ throw new Error(`Invalid group column: ${options.groupColumn}`)
291
+ }
219
292
 
220
- const sql = [selectClause, `FROM ${tableName}`, whereClause, groupByClause, orderByClause, limitClause]
293
+ const where = buildWhereClause(options)
294
+ const sql = [
295
+ `SELECT ${options.groupColumn} AS group_source, ${metricMapping.dbColumn} AS metric_value`,
296
+ `FROM ${resolveTableName(config)}`,
297
+ where.clause,
298
+ `LIMIT ${Math.max(1, Math.floor(options.rowLimit)) + 1}`,
299
+ ]
221
300
  .filter(Boolean)
222
301
  .join(' ')
223
302
 
224
- return { sql, params }
303
+ return { sql, params: where.params }
225
304
  }