@open-mercato/core 0.5.1-develop.2987.9f8c1e0f68 → 0.5.1-develop.3032.01699048cb

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 (143) 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/api/companies/[id]/route.js +30 -20
  35. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  36. package/dist/modules/customers/api/companies/route.js +12 -7
  37. package/dist/modules/customers/api/companies/route.js.map +2 -2
  38. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js +12 -7
  39. package/dist/modules/customers/api/people/[id]/companies/enriched/route.js.map +2 -2
  40. package/dist/modules/customers/api/people/route.js +12 -7
  41. package/dist/modules/customers/api/people/route.js.map +2 -2
  42. package/dist/modules/customers/backend/customers/companies/[id]/page.js +3 -1
  43. package/dist/modules/customers/backend/customers/companies/[id]/page.js.map +2 -2
  44. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +25 -2
  45. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  46. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +35 -33
  47. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  48. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +56 -0
  49. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +7 -0
  50. package/dist/modules/customers/components/detail/ActivitiesCard.js +175 -0
  51. package/dist/modules/customers/components/detail/ActivitiesCard.js.map +7 -0
  52. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js +324 -0
  53. package/dist/modules/customers/components/detail/ActivitiesDayStrip.js.map +7 -0
  54. package/dist/modules/customers/components/detail/ActivitiesSection.js +62 -13
  55. package/dist/modules/customers/components/detail/ActivitiesSection.js.map +2 -2
  56. package/dist/modules/customers/components/detail/ActivityLogTab.js +14 -23
  57. package/dist/modules/customers/components/detail/ActivityLogTab.js.map +2 -2
  58. package/dist/modules/customers/components/detail/ActivityTimeline.js +13 -13
  59. package/dist/modules/customers/components/detail/ActivityTimeline.js.map +2 -2
  60. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js +35 -22
  61. package/dist/modules/customers/components/detail/ActivityTimelineFilters.js.map +2 -2
  62. package/dist/modules/customers/components/detail/AiActionChips.js +15 -22
  63. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  64. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -2
  65. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  66. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +196 -28
  67. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  68. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js +2 -2
  69. package/dist/modules/customers/components/detail/schedule/DateTimeFields.js.map +2 -2
  70. package/dist/modules/customers/components/detail/schedule/FooterFields.js +14 -2
  71. package/dist/modules/customers/components/detail/schedule/FooterFields.js.map +2 -2
  72. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js +9 -2
  73. package/dist/modules/customers/components/detail/schedule/LinkedEntitiesField.js.map +2 -2
  74. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +9 -2
  75. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  76. package/dist/modules/customers/components/detail/schedule/fieldConfig.js +25 -4
  77. package/dist/modules/customers/components/detail/schedule/fieldConfig.js.map +2 -2
  78. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js +20 -3
  79. package/dist/modules/customers/components/detail/schedule/useScheduleFormState.js.map +2 -2
  80. package/dist/modules/customers/components/formConfig.js +3 -3
  81. package/dist/modules/customers/components/formConfig.js.map +2 -2
  82. package/dist/modules/customers/lib/displayName.js +12 -0
  83. package/dist/modules/customers/lib/displayName.js.map +2 -2
  84. package/dist/modules/entities/cli.js +5 -6
  85. package/dist/modules/entities/cli.js.map +2 -2
  86. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js +124 -0
  87. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.js.map +7 -0
  88. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js +11 -0
  89. package/dist/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.js.map +7 -0
  90. package/generated/entities/sidebar_variant/index.ts +11 -0
  91. package/generated/entities.ids.generated.ts +1 -0
  92. package/generated/entity-fields-registry.ts +13 -0
  93. package/package.json +6 -6
  94. package/src/helpers/integration/authUi.ts +1 -1
  95. package/src/modules/audit_logs/services/actionLogService.ts +5 -6
  96. package/src/modules/auth/api/sidebar/preferences/route.ts +266 -34
  97. package/src/modules/auth/api/sidebar/variants/[id]/route.ts +183 -0
  98. package/src/modules/auth/api/sidebar/variants/route.ts +157 -0
  99. package/src/modules/auth/backend/sidebar-customization/page.meta.ts +34 -0
  100. package/src/modules/auth/backend/sidebar-customization/page.tsx +17 -0
  101. package/src/modules/auth/data/entities.ts +48 -2
  102. package/src/modules/auth/data/validators.ts +70 -0
  103. package/src/modules/auth/migrations/.snapshot-open-mercato.json +790 -71
  104. package/src/modules/auth/migrations/Migration20260427081815.ts +16 -0
  105. package/src/modules/auth/migrations/Migration20260427124900.ts +19 -0
  106. package/src/modules/auth/migrations/Migration20260427143311.ts +83 -0
  107. package/src/modules/auth/services/sidebarPreferencesService.ts +243 -18
  108. package/src/modules/customers/api/companies/[id]/route.ts +30 -20
  109. package/src/modules/customers/api/companies/route.ts +12 -7
  110. package/src/modules/customers/api/people/[id]/companies/enriched/route.ts +12 -7
  111. package/src/modules/customers/api/people/route.ts +12 -7
  112. package/src/modules/customers/backend/customers/companies/[id]/page.tsx +5 -4
  113. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +28 -5
  114. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +41 -30
  115. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +67 -0
  116. package/src/modules/customers/components/detail/ActivitiesCard.tsx +231 -0
  117. package/src/modules/customers/components/detail/ActivitiesDayStrip.tsx +390 -0
  118. package/src/modules/customers/components/detail/ActivitiesSection.tsx +91 -40
  119. package/src/modules/customers/components/detail/ActivityLogTab.tsx +25 -23
  120. package/src/modules/customers/components/detail/ActivityTimeline.tsx +15 -19
  121. package/src/modules/customers/components/detail/ActivityTimelineFilters.tsx +36 -29
  122. package/src/modules/customers/components/detail/AiActionChips.tsx +17 -23
  123. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +3 -2
  124. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +233 -41
  125. package/src/modules/customers/components/detail/schedule/DateTimeFields.tsx +6 -2
  126. package/src/modules/customers/components/detail/schedule/FooterFields.tsx +22 -2
  127. package/src/modules/customers/components/detail/schedule/LinkedEntitiesField.tsx +10 -2
  128. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +10 -2
  129. package/src/modules/customers/components/detail/schedule/fieldConfig.ts +26 -6
  130. package/src/modules/customers/components/detail/schedule/useScheduleFormState.ts +32 -3
  131. package/src/modules/customers/components/formConfig.tsx +3 -3
  132. package/src/modules/customers/i18n/de.json +69 -2
  133. package/src/modules/customers/i18n/en.json +69 -2
  134. package/src/modules/customers/i18n/es.json +69 -2
  135. package/src/modules/customers/i18n/pl.json +68 -1
  136. package/src/modules/customers/lib/displayName.ts +21 -0
  137. package/src/modules/entities/cli.ts +5 -6
  138. package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.meta.ts +9 -0
  139. package/src/modules/portal/frontend/[orgSlug]/portal/reset-password/page.tsx +168 -0
  140. package/src/modules/portal/i18n/de.json +20 -0
  141. package/src/modules/portal/i18n/en.json +20 -0
  142. package/src/modules/portal/i18n/es.json +20 -0
  143. 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
+ }
@@ -42,7 +42,10 @@ import type { EntityId } from '@open-mercato/shared/modules/entities'
42
42
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
43
43
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
44
44
  import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
45
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../../lib/personCompanyLinkTable'
45
+ import {
46
+ filterActivePersonCompanyLinks,
47
+ withActiveCustomerPersonCompanyLinkFilter,
48
+ } from '../../../lib/personCompanyLinkTable'
46
49
  import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
47
50
 
48
51
  export const metadata = {
@@ -703,15 +706,17 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
703
706
  },
704
707
  'customers.companies.GET',
705
708
  )
706
- const companyLinks = await findWithDecryption(
707
- em,
708
- CustomerPersonCompanyLink,
709
- companyLinkWhere,
710
- {
711
- populate: ['person', 'person.personProfile'],
712
- orderBy: { isPrimary: 'desc', createdAt: 'asc' },
713
- },
714
- peopleDecryptionScope,
709
+ const companyLinks = filterActivePersonCompanyLinks(
710
+ await findWithDecryption(
711
+ em,
712
+ CustomerPersonCompanyLink,
713
+ companyLinkWhere,
714
+ {
715
+ populate: ['person', 'person.personProfile'],
716
+ orderBy: { isPrimary: 'desc', createdAt: 'asc' },
717
+ },
718
+ peopleDecryptionScope,
719
+ ),
715
720
  )
716
721
  companyLinks.forEach((link) => {
717
722
  const entity = typeof link.person === 'string' ? null : link.person
@@ -829,18 +834,23 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
829
834
  })
830
835
  const peopleCount = includePeople
831
836
  ? relatedPeople.length
832
- : await em.count(
833
- CustomerPersonCompanyLink,
834
- await withActiveCustomerPersonCompanyLinkFilter(
837
+ : filterActivePersonCompanyLinks(
838
+ await findWithDecryption(
835
839
  em,
836
- {
837
- company: company.id,
838
- organizationId: company.organizationId,
839
- tenantId: company.tenantId,
840
- },
841
- 'customers.companies.GET',
840
+ CustomerPersonCompanyLink,
841
+ await withActiveCustomerPersonCompanyLinkFilter(
842
+ em,
843
+ {
844
+ company: company.id,
845
+ organizationId: company.organizationId,
846
+ tenantId: company.tenantId,
847
+ },
848
+ 'customers.companies.GET',
849
+ ),
850
+ {},
851
+ { tenantId: company.tenantId, organizationId: company.organizationId },
842
852
  ),
843
- )
853
+ ).length
844
854
  const kpiInteractionRows = canonicalActiveInteractions.length
845
855
  ? canonicalActiveInteractions
846
856
  : await findWithDecryption(
@@ -33,7 +33,10 @@ import {
33
33
  createPagedListResponseSchema,
34
34
  defaultOkResponseSchema,
35
35
  } from '../openapi'
36
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../lib/personCompanyLinkTable'
36
+ import {
37
+ filterActivePersonCompanyLinks,
38
+ withActiveCustomerPersonCompanyLinkFilter,
39
+ } from '../../lib/personCompanyLinkTable'
37
40
  import { normalizeCompanyProfilePayload } from './payload'
38
41
 
39
42
  const rawBodySchema = z.object({}).passthrough()
@@ -217,12 +220,14 @@ const crud = makeCrudRoute({
217
220
  { person: query.excludeLinkedPersonId },
218
221
  'customers.companies.GET',
219
222
  )
220
- const links = await findWithDecryption(
221
- em,
222
- CustomerPersonCompanyLink,
223
- linkWhere,
224
- { populate: ['company'] },
225
- decryptionScope,
223
+ const links = filterActivePersonCompanyLinks(
224
+ await findWithDecryption(
225
+ em,
226
+ CustomerPersonCompanyLink,
227
+ linkWhere,
228
+ { populate: ['company'] },
229
+ decryptionScope,
230
+ ),
226
231
  )
227
232
  links.forEach((link) => {
228
233
  const companyId = link.company?.id
@@ -20,7 +20,10 @@ import {
20
20
  CustomerDeal,
21
21
  CustomerInteraction,
22
22
  } from '../../../../../data/entities'
23
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../../../../lib/personCompanyLinkTable'
23
+ import {
24
+ filterActivePersonCompanyLinks,
25
+ withActiveCustomerPersonCompanyLinkFilter,
26
+ } from '../../../../../lib/personCompanyLinkTable'
24
27
 
25
28
  const paramsSchema = z.object({
26
29
  id: z.string().uuid(),
@@ -187,12 +190,14 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
187
190
  },
188
191
  'customers.people.companiesEnriched.GET',
189
192
  )
190
- const links = await findWithDecryption(
191
- em,
192
- CustomerPersonCompanyLink,
193
- linkWhere,
194
- { populate: ['company'] },
195
- entityScope,
193
+ const links = filterActivePersonCompanyLinks(
194
+ await findWithDecryption(
195
+ em,
196
+ CustomerPersonCompanyLink,
197
+ linkWhere,
198
+ { populate: ['company'] },
199
+ entityScope,
200
+ ),
196
201
  )
197
202
 
198
203
  const companyIds = links.map((link) => (link.company as CustomerEntity).id)
@@ -29,7 +29,10 @@ import {
29
29
  createPagedListResponseSchema,
30
30
  defaultOkResponseSchema,
31
31
  } from '../openapi'
32
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../lib/personCompanyLinkTable'
32
+ import {
33
+ filterActivePersonCompanyLinks,
34
+ withActiveCustomerPersonCompanyLinkFilter,
35
+ } from '../../lib/personCompanyLinkTable'
33
36
  import { normalizeProfilePayload } from './payload'
34
37
 
35
38
  const rawBodySchema = z.object({}).passthrough()
@@ -221,12 +224,14 @@ const crud = makeCrudRoute({
221
224
  { company: query.excludeLinkedCompanyId },
222
225
  'customers.people.GET',
223
226
  )
224
- const links = await findWithDecryption(
225
- em,
226
- CustomerPersonCompanyLink,
227
- linkWhere,
228
- { populate: ['person'] },
229
- decryptionScope,
227
+ const links = filterActivePersonCompanyLinks(
228
+ await findWithDecryption(
229
+ em,
230
+ CustomerPersonCompanyLink,
231
+ linkWhere,
232
+ { populate: ['person'] },
233
+ decryptionScope,
234
+ ),
230
235
  )
231
236
  links.forEach((link) => {
232
237
  const personId = link.person?.id
@@ -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.')