@open-mercato/core 0.5.1-develop.2975.ccbadc8198 → 0.5.1-develop.2996.ce62fd491c

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 (82) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/sidebar_variant/index.js +25 -0
  3. package/dist/generated/entities/sidebar_variant/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +1 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +13 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/helpers/integration/authUi.js +1 -1
  9. package/dist/helpers/integration/authUi.js.map +2 -2
  10. package/dist/modules/audit_logs/services/actionLogService.js +4 -5
  11. package/dist/modules/audit_logs/services/actionLogService.js.map +2 -2
  12. package/dist/modules/auth/api/sidebar/preferences/route.js +224 -35
  13. package/dist/modules/auth/api/sidebar/preferences/route.js.map +3 -3
  14. package/dist/modules/auth/api/sidebar/variants/[id]/route.js +161 -0
  15. package/dist/modules/auth/api/sidebar/variants/[id]/route.js.map +7 -0
  16. package/dist/modules/auth/api/sidebar/variants/route.js +142 -0
  17. package/dist/modules/auth/api/sidebar/variants/route.js.map +7 -0
  18. package/dist/modules/auth/backend/sidebar-customization/page.js +16 -0
  19. package/dist/modules/auth/backend/sidebar-customization/page.js.map +7 -0
  20. package/dist/modules/auth/backend/sidebar-customization/page.meta.js +28 -0
  21. package/dist/modules/auth/backend/sidebar-customization/page.meta.js.map +7 -0
  22. package/dist/modules/auth/data/entities.js +45 -4
  23. package/dist/modules/auth/data/entities.js.map +2 -2
  24. package/dist/modules/auth/data/validators.js +63 -1
  25. package/dist/modules/auth/data/validators.js.map +2 -2
  26. package/dist/modules/auth/migrations/Migration20260427081815.js +15 -0
  27. package/dist/modules/auth/migrations/Migration20260427081815.js.map +7 -0
  28. package/dist/modules/auth/migrations/Migration20260427124900.js +15 -0
  29. package/dist/modules/auth/migrations/Migration20260427124900.js.map +7 -0
  30. package/dist/modules/auth/migrations/Migration20260427143311.js +72 -0
  31. package/dist/modules/auth/migrations/Migration20260427143311.js.map +7 -0
  32. package/dist/modules/auth/services/sidebarPreferencesService.js +176 -16
  33. package/dist/modules/auth/services/sidebarPreferencesService.js.map +2 -2
  34. package/dist/modules/customers/backend/customers/companies/[id]/page.js +3 -1
  35. package/dist/modules/customers/backend/customers/companies/[id]/page.js.map +2 -2
  36. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +4 -2
  37. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  38. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +8 -3
  39. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  40. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -2
  41. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  42. package/dist/modules/customers/components/formConfig.js +3 -3
  43. package/dist/modules/customers/components/formConfig.js.map +2 -2
  44. package/dist/modules/customers/lib/displayName.js +12 -0
  45. package/dist/modules/customers/lib/displayName.js.map +2 -2
  46. package/dist/modules/entities/cli.js +5 -6
  47. package/dist/modules/entities/cli.js.map +2 -2
  48. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js +124 -0
  49. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js.map +7 -0
  50. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js +11 -0
  51. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js.map +7 -0
  52. package/generated/entities/sidebar_variant/index.ts +11 -0
  53. package/generated/entities.ids.generated.ts +1 -0
  54. package/generated/entity-fields-registry.ts +13 -0
  55. package/package.json +6 -6
  56. package/src/helpers/integration/authUi.ts +1 -1
  57. package/src/modules/audit_logs/services/actionLogService.ts +5 -6
  58. package/src/modules/auth/api/sidebar/preferences/route.ts +266 -34
  59. package/src/modules/auth/api/sidebar/variants/[id]/route.ts +183 -0
  60. package/src/modules/auth/api/sidebar/variants/route.ts +157 -0
  61. package/src/modules/auth/backend/sidebar-customization/page.meta.ts +34 -0
  62. package/src/modules/auth/backend/sidebar-customization/page.tsx +17 -0
  63. package/src/modules/auth/data/entities.ts +48 -2
  64. package/src/modules/auth/data/validators.ts +70 -0
  65. package/src/modules/auth/migrations/.snapshot-open-mercato.json +790 -71
  66. package/src/modules/auth/migrations/Migration20260427081815.ts +16 -0
  67. package/src/modules/auth/migrations/Migration20260427124900.ts +19 -0
  68. package/src/modules/auth/migrations/Migration20260427143311.ts +83 -0
  69. package/src/modules/auth/services/sidebarPreferencesService.ts +243 -18
  70. package/src/modules/customers/backend/customers/companies/[id]/page.tsx +5 -4
  71. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +6 -5
  72. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +13 -9
  73. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +3 -2
  74. package/src/modules/customers/components/formConfig.tsx +3 -3
  75. package/src/modules/customers/lib/displayName.ts +21 -0
  76. package/src/modules/entities/cli.ts +5 -6
  77. package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.ts +9 -0
  78. package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.tsx +168 -0
  79. package/src/modules/portal/i18n/de.json +20 -0
  80. package/src/modules/portal/i18n/en.json +20 -0
  81. package/src/modules/portal/i18n/es.json +20 -0
  82. package/src/modules/portal/i18n/pl.json +20 -0
@@ -0,0 +1,16 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260427081815 extends Migration {
4
+
5
+ override up(): void | Promise<void> {
6
+ this.addSql(`create table "sidebar_variants" ("id" uuid not null default gen_random_uuid(), "user_id" uuid not null, "tenant_id" uuid null, "organization_id" uuid null, "locale" text not null, "name" text not null, "settings_json" jsonb null, "is_active" boolean not null default false, "created_at" timestamptz not null, "updated_at" timestamptz null, "deleted_at" timestamptz null, primary key ("id"));`);
7
+ this.addSql(`alter table "sidebar_variants" add constraint "sidebar_variants_user_id_tenant_id_locale_name_unique" unique ("user_id", "tenant_id", "locale", "name");`);
8
+
9
+ this.addSql(`alter table "sidebar_variants" add constraint "sidebar_variants_user_id_foreign" foreign key ("user_id") references "users" ("id");`);
10
+ }
11
+
12
+ override down(): void | Promise<void> {
13
+ this.addSql(`drop table if exists "sidebar_variants" cascade;`);
14
+ }
15
+
16
+ }
@@ -0,0 +1,19 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // Replace the full unique constraint on sidebar_variants with a partial unique index
4
+ // scoped to live rows (deleted_at IS NULL). Without this, soft-deleting a variant and
5
+ // then trying to recreate one with the same name throws a duplicate-key error because
6
+ // the regular unique constraint considers tombstoned rows.
7
+ export class Migration20260427124900 extends Migration {
8
+
9
+ override up(): void | Promise<void> {
10
+ this.addSql(`alter table "sidebar_variants" drop constraint if exists "sidebar_variants_user_id_tenant_id_locale_name_unique";`);
11
+ this.addSql(`create unique index if not exists "sidebar_variants_active_name_unique_idx" on "sidebar_variants" ("user_id", "tenant_id", "locale", "name") where "deleted_at" is null;`);
12
+ }
13
+
14
+ override down(): void | Promise<void> {
15
+ this.addSql(`drop index if exists "sidebar_variants_active_name_unique_idx";`);
16
+ this.addSql(`alter table "sidebar_variants" add constraint "sidebar_variants_user_id_tenant_id_locale_name_unique" unique ("user_id", "tenant_id", "locale", "name");`);
17
+ }
18
+
19
+ }
@@ -0,0 +1,83 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // Cross-locale sidebar customization. Strip `locale` from uniqueness scope so the same
4
+ // variant/preference applies regardless of the user's active language. Soft-delete any
5
+ // historical duplicates first (keep the most-recently-updated row per scope) so the new
6
+ // constraints can be created without violations.
7
+ export class Migration20260427143311 extends Migration {
8
+
9
+ override up(): void | Promise<void> {
10
+ // --- sidebar_variants: collapse (user_id, tenant_id, name) across locales ---------
11
+ this.addSql(`
12
+ with ranked as (
13
+ select id,
14
+ row_number() over (
15
+ partition by user_id, tenant_id, name
16
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
17
+ ) as rn
18
+ from sidebar_variants
19
+ where deleted_at is null
20
+ )
21
+ update sidebar_variants
22
+ set deleted_at = now()
23
+ from ranked
24
+ where sidebar_variants.id = ranked.id and ranked.rn > 1;
25
+ `);
26
+ this.addSql(`drop index if exists "sidebar_variants_active_name_unique_idx";`);
27
+ this.addSql(`alter table "sidebar_variants" drop constraint if exists "sidebar_variants_user_id_tenant_id_locale_name_unique";`);
28
+ this.addSql(`create unique index if not exists "sidebar_variants_active_name_unique_idx" on "sidebar_variants" ("user_id", "tenant_id", "name") where "deleted_at" is null;`);
29
+
30
+ // --- user_sidebar_preferences: collapse (user_id, tenant_id, organization_id) -----
31
+ this.addSql(`
32
+ with ranked as (
33
+ select id,
34
+ row_number() over (
35
+ partition by user_id, tenant_id, organization_id
36
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
37
+ ) as rn
38
+ from user_sidebar_preferences
39
+ where deleted_at is null
40
+ )
41
+ update user_sidebar_preferences
42
+ set deleted_at = now()
43
+ from ranked
44
+ where user_sidebar_preferences.id = ranked.id and ranked.rn > 1;
45
+ `);
46
+ this.addSql(`alter table "user_sidebar_preferences" drop constraint if exists "user_sidebar_preferences_user_id_tenant_id_organi_35248_unique";`);
47
+ this.addSql(`alter table "user_sidebar_preferences" drop constraint if exists "user_sidebar_preferences_user_id_tenant_id_organi_f3f2f_unique";`);
48
+ this.addSql(`drop index if exists "user_sidebar_preferences_active_unique_idx";`);
49
+ this.addSql(`create unique index if not exists "user_sidebar_preferences_active_unique_idx" on "user_sidebar_preferences" ("user_id", "tenant_id", "organization_id") where "deleted_at" is null;`);
50
+
51
+ // --- role_sidebar_preferences: collapse (role_id, tenant_id) ----------------------
52
+ this.addSql(`
53
+ with ranked as (
54
+ select id,
55
+ row_number() over (
56
+ partition by role_id, tenant_id
57
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
58
+ ) as rn
59
+ from role_sidebar_preferences
60
+ where deleted_at is null
61
+ )
62
+ update role_sidebar_preferences
63
+ set deleted_at = now()
64
+ from ranked
65
+ where role_sidebar_preferences.id = ranked.id and ranked.rn > 1;
66
+ `);
67
+ this.addSql(`alter table "role_sidebar_preferences" drop constraint if exists "role_sidebar_preferences_role_id_tenant_id_locale_unique";`);
68
+ this.addSql(`drop index if exists "role_sidebar_preferences_active_unique_idx";`);
69
+ this.addSql(`create unique index if not exists "role_sidebar_preferences_active_unique_idx" on "role_sidebar_preferences" ("role_id", "tenant_id") where "deleted_at" is null;`);
70
+ }
71
+
72
+ override down(): void | Promise<void> {
73
+ this.addSql(`drop index if exists "sidebar_variants_active_name_unique_idx";`);
74
+ this.addSql(`alter table "sidebar_variants" add constraint "sidebar_variants_user_id_tenant_id_locale_name_unique" unique ("user_id", "tenant_id", "locale", "name");`);
75
+
76
+ this.addSql(`drop index if exists "user_sidebar_preferences_active_unique_idx";`);
77
+ this.addSql(`alter table "user_sidebar_preferences" add constraint "user_sidebar_preferences_user_id_tenant_id_organi_35248_unique" unique ("user_id", "tenant_id", "organization_id", "locale");`);
78
+
79
+ this.addSql(`drop index if exists "role_sidebar_preferences_active_unique_idx";`);
80
+ this.addSql(`alter table "role_sidebar_preferences" add constraint "role_sidebar_preferences_role_id_tenant_id_locale_unique" unique ("role_id", "tenant_id", "locale");`);
81
+ }
82
+
83
+ }
@@ -1,5 +1,6 @@
1
- import { EntityManager } from '@mikro-orm/postgresql'
2
- import { Role, RoleSidebarPreference, User, UserSidebarPreference } from '../data/entities'
1
+ import { EntityManager, type FilterQuery } from '@mikro-orm/postgresql'
2
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
3
+ import { Role, RoleSidebarPreference, SidebarVariant, User, UserSidebarPreference } from '../data/entities'
3
4
  import {
4
5
  SIDEBAR_PREFERENCES_VERSION,
5
6
  SidebarPreferencesSettings,
@@ -39,8 +40,16 @@ export async function loadSidebarPreference(
39
40
  em: EntityManager,
40
41
  scope: SidebarPreferenceScope,
41
42
  ): Promise<SidebarPreferencesSettings> {
42
- const { userId, tenantId, organizationId, locale } = normalizeScope(scope)
43
- const existing = await em.findOne(UserSidebarPreference, { user: userId, tenantId, organizationId, locale })
43
+ // Cross-locale: variants & preferences are scoped per (user, tenant, org) only.
44
+ // The `locale` field on the row is kept for audit / when the row was created.
45
+ const { userId, tenantId, organizationId } = normalizeScope(scope)
46
+ const existing = await findOneWithDecryption(
47
+ em,
48
+ UserSidebarPreference,
49
+ { user: userId, tenantId, organizationId },
50
+ undefined,
51
+ { tenantId, organizationId },
52
+ )
44
53
  return normalizeSidebarSettings(existing?.settingsJson as SidebarPreferencesSettings | undefined)
45
54
  }
46
55
 
@@ -54,7 +63,13 @@ export async function saveSidebarPreference(
54
63
  version: input?.version ?? SIDEBAR_PREFERENCES_VERSION,
55
64
  })
56
65
  const { userId, tenantId, organizationId, locale } = normalizeScope(scope)
57
- let pref = await em.findOne(UserSidebarPreference, { user: userId, tenantId, organizationId, locale })
66
+ let pref = await findOneWithDecryption(
67
+ em,
68
+ UserSidebarPreference,
69
+ { user: userId, tenantId, organizationId },
70
+ undefined,
71
+ { tenantId, organizationId },
72
+ )
58
73
  if (!pref) {
59
74
  pref = em.create(UserSidebarPreference, {
60
75
  user: em.getReference(User, userId),
@@ -73,16 +88,18 @@ export async function saveSidebarPreference(
73
88
 
74
89
  export async function loadRoleSidebarPreferences(
75
90
  em: EntityManager,
76
- options: { roleIds: string[]; tenantId?: string | null; locale: string },
91
+ options: { roleIds: string[]; tenantId?: string | null; locale?: string },
77
92
  ): Promise<Map<string, SidebarPreferencesSettings>> {
78
93
  if (!options.roleIds.length) return new Map()
79
94
  const tenantId = options.tenantId ?? null
80
95
  const tenantFilter = tenantId === null ? null : { $in: [tenantId, null] }
81
- const prefs = await em.find(RoleSidebarPreference, {
82
- role: { $in: options.roleIds },
83
- tenantId: tenantFilter,
84
- locale: options.locale,
85
- })
96
+ const prefs = await findWithDecryption(
97
+ em,
98
+ RoleSidebarPreference,
99
+ { role: { $in: options.roleIds }, tenantId: tenantFilter } as FilterQuery<RoleSidebarPreference>,
100
+ undefined,
101
+ { tenantId, organizationId: null },
102
+ )
86
103
  const map = new Map<string, SidebarPreferencesSettings>()
87
104
  for (const pref of prefs) {
88
105
  const key = pref.role.id
@@ -101,16 +118,18 @@ export async function loadRoleSidebarPreferences(
101
118
 
102
119
  export async function loadFirstRoleSidebarPreference(
103
120
  em: EntityManager,
104
- options: { roleIds: string[]; tenantId?: string | null; locale: string },
121
+ options: { roleIds: string[]; tenantId?: string | null; locale?: string },
105
122
  ): Promise<SidebarPreferencesSettings | null> {
106
123
  if (!options.roleIds.length) return null
107
124
  const tenantId = options.tenantId ?? null
108
125
  const tenantFilter = tenantId === null ? null : { $in: [tenantId, null] }
109
- const prefs = await em.find(RoleSidebarPreference, {
110
- role: { $in: options.roleIds },
111
- tenantId: tenantFilter,
112
- locale: options.locale,
113
- })
126
+ const prefs = await findWithDecryption(
127
+ em,
128
+ RoleSidebarPreference,
129
+ { role: { $in: options.roleIds }, tenantId: tenantFilter } as FilterQuery<RoleSidebarPreference>,
130
+ undefined,
131
+ { tenantId, organizationId: null },
132
+ )
114
133
  if (!prefs.length) return null
115
134
  const ordered = options.roleIds
116
135
  .map((id) => {
@@ -135,7 +154,13 @@ export async function saveRoleSidebarPreference(
135
154
  version: input?.version ?? SIDEBAR_PREFERENCES_VERSION,
136
155
  })
137
156
  const { roleId, tenantId, locale } = normalizeRoleScope(scope)
138
- let pref = await em.findOne(RoleSidebarPreference, { role: roleId, tenantId, locale })
157
+ let pref = await findOneWithDecryption(
158
+ em,
159
+ RoleSidebarPreference,
160
+ { role: roleId, tenantId },
161
+ undefined,
162
+ { tenantId, organizationId: null },
163
+ )
139
164
  if (!pref) {
140
165
  pref = em.create(RoleSidebarPreference, {
141
166
  role: em.getReference(Role, roleId),
@@ -217,3 +242,203 @@ function normalizeRoleScope(scope: RoleSidebarPreferenceScope) {
217
242
  locale: scope.locale,
218
243
  }
219
244
  }
245
+
246
+ // --- Named variants (per-user library of saved sidebar layouts) ----------------
247
+
248
+ export type VariantScope = {
249
+ userId: string
250
+ tenantId?: string | null
251
+ organizationId?: string | null
252
+ locale: string
253
+ }
254
+
255
+ export type SidebarVariantRecord = {
256
+ id: string
257
+ name: string
258
+ isActive: boolean
259
+ settings: SidebarPreferencesSettings
260
+ createdAt: Date
261
+ updatedAt?: Date | null
262
+ }
263
+
264
+ function toVariantRecord(variant: SidebarVariant): SidebarVariantRecord {
265
+ return {
266
+ id: variant.id,
267
+ name: variant.name,
268
+ isActive: variant.isActive === true,
269
+ settings: normalizeSidebarSettings(variant.settingsJson as SidebarPreferencesSettings | undefined),
270
+ createdAt: variant.createdAt,
271
+ updatedAt: variant.updatedAt ?? null,
272
+ }
273
+ }
274
+
275
+ export async function listSidebarVariants(
276
+ em: EntityManager,
277
+ scope: VariantScope,
278
+ ): Promise<SidebarVariantRecord[]> {
279
+ // Cross-locale: variants are scoped per (user, tenant) only.
280
+ const { userId, tenantId, organizationId } = normalizeVariantScope(scope)
281
+ const variants = await findWithDecryption(
282
+ em,
283
+ SidebarVariant,
284
+ { user: userId, tenantId, deletedAt: null },
285
+ { orderBy: { createdAt: 'asc' } },
286
+ { tenantId, organizationId },
287
+ )
288
+ return variants.map(toVariantRecord)
289
+ }
290
+
291
+ export async function loadSidebarVariant(
292
+ em: EntityManager,
293
+ scope: VariantScope,
294
+ variantId: string,
295
+ ): Promise<SidebarVariantRecord | null> {
296
+ const { userId, tenantId, organizationId } = normalizeVariantScope(scope)
297
+ const variant = await findOneWithDecryption(
298
+ em,
299
+ SidebarVariant,
300
+ { id: variantId, user: userId, tenantId, deletedAt: null },
301
+ undefined,
302
+ { tenantId, organizationId },
303
+ )
304
+ return variant ? toVariantRecord(variant) : null
305
+ }
306
+
307
+ export async function nextVariantAutoName(
308
+ em: EntityManager,
309
+ scope: VariantScope,
310
+ prefix = 'My preferences',
311
+ ): Promise<string> {
312
+ const variants = await listSidebarVariants(em, scope)
313
+ // Match names like "My preferences", "My preferences 2", "My preferences 17"
314
+ const usedNumbers = new Set<number>()
315
+ for (const variant of variants) {
316
+ if (variant.name === prefix) {
317
+ usedNumbers.add(1)
318
+ continue
319
+ }
320
+ const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
321
+ const match = variant.name.match(new RegExp(`^${escaped}\\s+(\\d+)$`))
322
+ if (match) {
323
+ const n = Number.parseInt(match[1], 10)
324
+ if (!Number.isNaN(n)) usedNumbers.add(n)
325
+ }
326
+ }
327
+ if (!usedNumbers.has(1)) return prefix
328
+ let next = 2
329
+ while (usedNumbers.has(next)) next += 1
330
+ return `${prefix} ${next}`
331
+ }
332
+
333
+ export async function createSidebarVariant(
334
+ em: EntityManager,
335
+ scope: VariantScope,
336
+ input: {
337
+ name?: string | null
338
+ settings?: Partial<SidebarPreferencesSettings> | null
339
+ isActive?: boolean
340
+ },
341
+ ): Promise<SidebarVariantRecord> {
342
+ const { userId, tenantId, organizationId, locale } = normalizeVariantScope(scope)
343
+ const finalName = (input.name ?? '').trim() || (await nextVariantAutoName(em, scope))
344
+ const settings = normalizeSidebarSettings({
345
+ ...(input.settings ?? {}),
346
+ version: input.settings?.version ?? SIDEBAR_PREFERENCES_VERSION,
347
+ })
348
+
349
+ if (input.isActive === true) {
350
+ await deactivateAllVariants(em, scope)
351
+ }
352
+
353
+ const variant = em.create(SidebarVariant, {
354
+ user: em.getReference(User, userId),
355
+ tenantId,
356
+ organizationId,
357
+ locale,
358
+ name: finalName,
359
+ settingsJson: settings,
360
+ isActive: input.isActive === true,
361
+ createdAt: new Date(),
362
+ })
363
+ await em.flush()
364
+ return toVariantRecord(variant)
365
+ }
366
+
367
+ export async function updateSidebarVariant(
368
+ em: EntityManager,
369
+ scope: VariantScope,
370
+ variantId: string,
371
+ input: {
372
+ name?: string
373
+ settings?: Partial<SidebarPreferencesSettings> | null
374
+ isActive?: boolean
375
+ },
376
+ ): Promise<SidebarVariantRecord | null> {
377
+ const { userId, tenantId, organizationId } = normalizeVariantScope(scope)
378
+ const variant = await findOneWithDecryption(
379
+ em,
380
+ SidebarVariant,
381
+ { id: variantId, user: userId, tenantId, deletedAt: null },
382
+ undefined,
383
+ { tenantId, organizationId },
384
+ )
385
+ if (!variant) return null
386
+ if (typeof input.name === 'string' && input.name.trim().length > 0) {
387
+ variant.name = input.name.trim()
388
+ }
389
+ if (input.settings) {
390
+ variant.settingsJson = normalizeSidebarSettings({
391
+ ...input.settings,
392
+ version: input.settings.version ?? SIDEBAR_PREFERENCES_VERSION,
393
+ })
394
+ }
395
+ if (typeof input.isActive === 'boolean') {
396
+ if (input.isActive) {
397
+ await deactivateAllVariants(em, scope, variantId)
398
+ }
399
+ variant.isActive = input.isActive
400
+ }
401
+ await em.flush()
402
+ return toVariantRecord(variant)
403
+ }
404
+
405
+ export async function deleteSidebarVariant(
406
+ em: EntityManager,
407
+ scope: VariantScope,
408
+ variantId: string,
409
+ ): Promise<boolean> {
410
+ const { userId, tenantId, organizationId } = normalizeVariantScope(scope)
411
+ const variant = await findOneWithDecryption(
412
+ em,
413
+ SidebarVariant,
414
+ { id: variantId, user: userId, tenantId, deletedAt: null },
415
+ undefined,
416
+ { tenantId, organizationId },
417
+ )
418
+ if (!variant) return false
419
+ variant.deletedAt = new Date()
420
+ variant.isActive = false
421
+ await em.flush()
422
+ return true
423
+ }
424
+
425
+ async function deactivateAllVariants(
426
+ em: EntityManager,
427
+ scope: VariantScope,
428
+ exceptId?: string,
429
+ ): Promise<void> {
430
+ const { userId, tenantId } = normalizeVariantScope(scope)
431
+ const where: FilterQuery<SidebarVariant> = exceptId
432
+ ? { user: userId, tenantId, isActive: true, deletedAt: null, id: { $ne: exceptId } }
433
+ : { user: userId, tenantId, isActive: true, deletedAt: null }
434
+ await em.nativeUpdate(SidebarVariant, where, { isActive: false })
435
+ }
436
+
437
+ function normalizeVariantScope(scope: VariantScope) {
438
+ return {
439
+ userId: scope.userId,
440
+ tenantId: scope.tenantId ?? null,
441
+ organizationId: scope.organizationId ?? null,
442
+ locale: scope.locale,
443
+ }
444
+ }
@@ -35,6 +35,7 @@ import { CompanyHighlights } from '../../../../components/detail/CompanyHighligh
35
35
  import { normalizeCustomFieldSubmitValue } from '../../../../components/detail/customFieldUtils'
36
36
  import { InlineDictionaryEditor, renderMultilineMarkdownDisplay } from '../../../../components/detail/InlineEditors'
37
37
  import { formatTemplate } from '../../../../components/detail/utils'
38
+ import { coerceDisplayName } from '../../../../lib/displayName'
38
39
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
39
40
  import {
40
41
  CompanyPeopleSection,
@@ -117,10 +118,10 @@ export default function CustomerCompanyDetailPage({ params }: { params?: { id?:
117
118
  const [sectionAction, setSectionAction] = React.useState<SectionAction | null>(null)
118
119
  const [isDeleting, setIsDeleting] = React.useState(false)
119
120
  const currentCompanyId = data?.company?.id ?? null
120
- const companyName =
121
- data?.company?.displayName && data.company.displayName.trim().length
122
- ? data.company.displayName
123
- : t('customers.companies.list.deleteFallbackName', 'this company')
121
+ const companyDisplayName = coerceDisplayName(data?.company?.displayName)
122
+ const companyName = companyDisplayName.trim().length
123
+ ? companyDisplayName
124
+ : t('customers.companies.list.deleteFallbackName', 'this company')
124
125
  const translateCompanyDetail = React.useCallback(
125
126
  (key: string, fallback?: string, params?: Record<string, string | number>) => {
126
127
  const mappedKey = key.startsWith('customers.people.detail.')
@@ -25,6 +25,7 @@ import { ActivityLogTab } from '../../../../components/detail/ActivityLogTab'
25
25
  import { CompanyPeopleSection, type CompanyPersonSummary } from '../../../../components/detail/CompanyPeopleSection'
26
26
  import type { TagSummary } from '../../../../components/detail/types'
27
27
  import type { TagsSectionController } from '@open-mercato/ui/backend/detail'
28
+ import { coerceDisplayName } from '../../../../lib/displayName'
28
29
  import { CompanyDetailHeader } from '../../../../components/detail/CompanyDetailHeader'
29
30
  import { CompanyDetailTabs, resolveLegacyTab, type CompanyTabId } from '../../../../components/detail/CompanyDetailTabs'
30
31
  import { CompanyKpiBar } from '../../../../components/detail/CompanyKpiBar'
@@ -103,10 +104,10 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
103
104
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
104
105
  })
105
106
 
106
- const companyName =
107
- data?.company?.displayName && data.company.displayName.trim().length
108
- ? data.company.displayName
109
- : t('customers.companies.list.deleteFallbackName', 'this company')
107
+ const companyDisplayName = coerceDisplayName(data?.company?.displayName)
108
+ const companyName = companyDisplayName.trim().length
109
+ ? companyDisplayName
110
+ : t('customers.companies.list.deleteFallbackName', 'this company')
110
111
 
111
112
  // Data loading
112
113
  const initialLoadDoneRef = React.useRef(false)
@@ -425,7 +426,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
425
426
  {activeTab === 'people' && (
426
427
  <CompanyPeopleSection
427
428
  companyId={companyId}
428
- companyName={data.company?.displayName ?? ''}
429
+ companyName={companyDisplayName}
429
430
  initialPeople={[]}
430
431
  addActionLabel={t('customers.companies.detail.people.add', 'Add person')}
431
432
  emptyLabel={t('customers.companies.detail.people.empty', 'No people linked to this company yet.')}
@@ -45,6 +45,7 @@ import {
45
45
  type PersonEditFormValues,
46
46
  type PersonOverview,
47
47
  } from '../../../../components/formConfig'
48
+ import { coerceDisplayName, coerceDisplayNameOrNull } from '../../../../lib/displayName'
48
49
 
49
50
  export default function PersonDetailV2Page({ params }: { params?: { id?: string } }) {
50
51
  const id = params?.id
@@ -95,15 +96,18 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
95
96
  contextId: mutationContextId,
96
97
  blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
97
98
  })
98
- const personName =
99
- data?.person?.displayName && data.person.displayName.trim().length
100
- ? data.person.displayName
101
- : t('customers.people.list.deleteFallbackName', 'this person')
99
+ const personDisplayName = coerceDisplayName(data?.person?.displayName)
100
+ const personName = personDisplayName.trim().length
101
+ ? personDisplayName
102
+ : t('customers.people.list.deleteFallbackName', 'this person')
102
103
 
103
- const personDisplayNameForGroups =
104
- typeof data?.person?.displayName === 'string' && data.person.displayName.trim().length
105
- ? data.person.displayName.trim()
106
- : null
104
+ const personDisplayNameForGroups = personDisplayName.trim().length
105
+ ? personDisplayName.trim()
106
+ : null
107
+
108
+ const scheduleDialogCompanyName = coerceDisplayNameOrNull(
109
+ data?.company?.displayName ?? data?.companies?.[0]?.displayName ?? null,
110
+ )
107
111
 
108
112
  const groups = React.useMemo(
109
113
  () => createPersonPersonalDataGroups(t, { entityName: personDisplayNameForGroups }),
@@ -579,7 +583,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
579
583
  onClose={() => { setScheduleDialogOpen(false); setScheduleEditData(null) }}
580
584
  entityId={personId}
581
585
  entityName={personName}
582
- companyName={data.company?.displayName ?? data.companies?.[0]?.displayName ?? null}
586
+ companyName={scheduleDialogCompanyName}
583
587
  entityType="person"
584
588
  onActivityCreated={handleActivityCreated}
585
589
  editData={scheduleEditData}
@@ -17,6 +17,7 @@ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
17
17
  import type { SectionAction, TabEmptyStateConfig, Translator } from './types'
18
18
  import { CreatePersonDialog } from './CreatePersonDialog'
19
19
  import { PersonCard } from './PersonCard'
20
+ import { coerceDisplayName } from '../../lib/displayName'
20
21
  import { DecisionMakersFooter } from './DecisionMakersFooter'
21
22
  import { RolesSection } from './RolesSection'
22
23
  import { LinkEntityDialog, type LinkEntityOption } from '../linking/LinkEntityDialog'
@@ -165,8 +166,8 @@ function sortCompanyPeople(
165
166
  const rightTimestamp = Date.parse(right.linkedAt ?? right.createdAt ?? '') || 0
166
167
  return rightTimestamp - leftTimestamp
167
168
  }
168
- const leftLabel = left.displayName.trim().toLowerCase()
169
- const rightLabel = right.displayName.trim().toLowerCase()
169
+ const leftLabel = coerceDisplayName(left.displayName).trim().toLowerCase()
170
+ const rightLabel = coerceDisplayName(right.displayName).trim().toLowerCase()
170
171
  if (sortMode === 'name-desc') return rightLabel.localeCompare(leftLabel)
171
172
  return leftLabel.localeCompare(rightLabel)
172
173
  })
@@ -15,7 +15,7 @@ import {
15
15
  SelectTrigger,
16
16
  SelectValue,
17
17
  } from '@open-mercato/ui/primitives/select'
18
- import { deriveDisplayName, isDerivedDisplayName } from '../lib/displayName'
18
+ import { coerceDisplayName, deriveDisplayName, isDerivedDisplayName } from '../lib/displayName'
19
19
  import {
20
20
  Dialog,
21
21
  DialogContent,
@@ -1952,7 +1952,7 @@ export function mapCompanyOverviewToFormValues(overview: CompanyOverview): Parti
1952
1952
  const phoneValue = rawPhone == null ? '' : String(rawPhone)
1953
1953
  return {
1954
1954
  id: overview.company.id,
1955
- displayName: overview.company.displayName,
1955
+ displayName: coerceDisplayName(overview.company.displayName),
1956
1956
  primaryEmail: overview.company.primaryEmail ?? '',
1957
1957
  primaryPhone: phoneValue,
1958
1958
  status: overview.company.status ?? '',
@@ -1975,7 +1975,7 @@ export function mapPersonOverviewToFormValues(overview: PersonOverview): Partial
1975
1975
  const phoneValue = rawPhone == null ? '' : String(rawPhone)
1976
1976
  return {
1977
1977
  id: overview.person.id,
1978
- displayName: overview.person.displayName,
1978
+ displayName: coerceDisplayName(overview.person.displayName),
1979
1979
  firstName: overview.profile?.firstName ?? '',
1980
1980
  lastName: overview.profile?.lastName ?? '',
1981
1981
  primaryEmail: overview.person.primaryEmail ?? '',
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Coerce an arbitrary `displayName` payload to a string for safe UI consumption.
3
+ *
4
+ * The encryption pipeline used to coerce numeric-looking string display names
5
+ * back into numbers (issue #1734). The root cause is fixed in
6
+ * `parseDecryptedFieldValue`, but this helper remains as belt-and-suspenders
7
+ * for any persisted data that was already corrupted on read paths that bypass
8
+ * the new heuristic.
9
+ */
10
+ export function coerceDisplayName(value: unknown): string {
11
+ if (typeof value === 'string') return value
12
+ if (value == null) return ''
13
+ return String(value)
14
+ }
15
+
16
+ export function coerceDisplayNameOrNull(value: unknown): string | null {
17
+ if (value == null) return null
18
+ if (typeof value === 'string') return value
19
+ return String(value)
20
+ }
21
+
1
22
  export function deriveDisplayName(
2
23
  firstName: string | null | undefined,
3
24
  lastName: string | null | undefined,
@@ -17,7 +17,10 @@ import {
17
17
  TenantDataEncryptionError,
18
18
  TenantDataEncryptionErrorCode,
19
19
  } from '@open-mercato/shared/lib/encryption/aes'
20
- import { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
20
+ import {
21
+ TenantDataEncryptionService,
22
+ parseDecryptedFieldValue,
23
+ } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
21
24
  import { resolveEntityIdFromMetadata } from '@open-mercato/shared/lib/encryption/entityIds'
22
25
  import { Organization } from '../directory/data/entities'
23
26
  import crypto from 'node:crypto'
@@ -563,11 +566,7 @@ const rotateEncryptionKey: ModuleCli = {
563
566
  if (typeof value !== 'string' || !isEncryptedPayload(value)) continue
564
567
  const decrypted = decryptWithOldKey(value, oldDek)
565
568
  if (decrypted === null) continue
566
- try {
567
- payload[rule.field] = JSON.parse(decrypted)
568
- } catch {
569
- payload[rule.field] = decrypted
570
- }
569
+ payload[rule.field] = parseDecryptedFieldValue(decrypted)
571
570
  }
572
571
  }
573
572
  const encrypted = await encryptionService.encryptEntityPayload(
@@ -0,0 +1,9 @@
1
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
2
+
3
+ export const metadata: PageMetadata = {
4
+ titleKey: 'portal.nav.resetPassword',
5
+ title: 'Reset Password',
6
+ navHidden: true,
7
+ }
8
+
9
+ export default metadata