@open-mercato/core 0.8.1-develop.7241.1.fad7b2ce2a → 0.8.1-develop.7261.1.5d42353f61

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 (87) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/lib/storage.js +15 -1
  3. package/dist/modules/attachments/lib/storage.js.map +2 -2
  4. package/dist/modules/auth/api/reset/confirm.js +3 -2
  5. package/dist/modules/auth/api/reset/confirm.js.map +2 -2
  6. package/dist/modules/auth/api/reset.js +2 -1
  7. package/dist/modules/auth/api/reset.js.map +2 -2
  8. package/dist/modules/auth/di.js +6 -6
  9. package/dist/modules/auth/di.js.map +2 -2
  10. package/dist/modules/auth/lib/tenantHint.js +29 -0
  11. package/dist/modules/auth/lib/tenantHint.js.map +7 -0
  12. package/dist/modules/catalog/backend/catalog/products/[id]/page.js +11 -3
  13. package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
  14. package/dist/modules/customer_accounts/api/admin/users.js +27 -14
  15. package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
  16. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +9 -0
  17. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  18. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js +8 -8
  19. package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js.map +2 -2
  20. package/dist/modules/customer_accounts/di.js +3 -3
  21. package/dist/modules/customer_accounts/di.js.map +2 -2
  22. package/dist/modules/customer_accounts/lib/customerAuth.js +18 -7
  23. package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
  24. package/dist/modules/customer_accounts/services/customerSessionService.js +23 -0
  25. package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
  26. package/dist/modules/customers/ai-tools/activities-tasks-pack.js +28 -24
  27. package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
  28. package/dist/modules/customers/backend/customers/deals/map/page.js +10 -2
  29. package/dist/modules/customers/backend/customers/deals/map/page.js.map +2 -2
  30. package/dist/modules/customers/backend/customers/deals/page.js +10 -2
  31. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  32. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +41 -66
  33. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  34. package/dist/modules/customers/backend/customers/deals/pipeline/page.js +6 -2
  35. package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
  36. package/dist/modules/customers/commands/companies.js +5 -0
  37. package/dist/modules/customers/commands/companies.js.map +2 -2
  38. package/dist/modules/customers/components/formConfig.js +3 -3
  39. package/dist/modules/customers/components/formConfig.js.map +2 -2
  40. package/dist/modules/customers/data/validators.js +3 -1
  41. package/dist/modules/customers/data/validators.js.map +2 -2
  42. package/dist/modules/data_sync/lib/sync-engine.js +3 -7
  43. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  44. package/dist/modules/data_sync/lib/sync-schedule-service.js +52 -3
  45. package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
  46. package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
  47. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  48. package/package.json +7 -7
  49. package/src/modules/attachments/lib/storage.ts +24 -1
  50. package/src/modules/auth/api/reset/confirm.ts +3 -2
  51. package/src/modules/auth/api/reset.ts +2 -1
  52. package/src/modules/auth/di.ts +6 -6
  53. package/src/modules/auth/lib/tenantHint.ts +49 -0
  54. package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +19 -5
  55. package/src/modules/catalog/i18n/de.json +1 -0
  56. package/src/modules/catalog/i18n/en.json +1 -0
  57. package/src/modules/catalog/i18n/es.json +1 -0
  58. package/src/modules/catalog/i18n/ko.json +1 -0
  59. package/src/modules/catalog/i18n/pl.json +1 -0
  60. package/src/modules/customer_accounts/api/admin/users.ts +30 -14
  61. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +11 -0
  62. package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.tsx +11 -11
  63. package/src/modules/customer_accounts/di.ts +3 -3
  64. package/src/modules/customer_accounts/i18n/de.json +1 -0
  65. package/src/modules/customer_accounts/i18n/en.json +1 -0
  66. package/src/modules/customer_accounts/i18n/es.json +1 -0
  67. package/src/modules/customer_accounts/i18n/ko.json +1 -0
  68. package/src/modules/customer_accounts/i18n/pl.json +1 -0
  69. package/src/modules/customer_accounts/lib/customerAuth.ts +36 -7
  70. package/src/modules/customer_accounts/services/customerSessionService.ts +28 -0
  71. package/src/modules/customers/ai-tools/activities-tasks-pack.ts +49 -39
  72. package/src/modules/customers/backend/customers/deals/map/page.tsx +13 -2
  73. package/src/modules/customers/backend/customers/deals/page.tsx +13 -2
  74. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +69 -56
  75. package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +8 -2
  76. package/src/modules/customers/commands/companies.ts +8 -0
  77. package/src/modules/customers/components/formConfig.tsx +3 -3
  78. package/src/modules/customers/data/validators.ts +2 -1
  79. package/src/modules/customers/i18n/de.json +1 -0
  80. package/src/modules/customers/i18n/en.json +1 -0
  81. package/src/modules/customers/i18n/es.json +1 -0
  82. package/src/modules/customers/i18n/ko.json +1 -0
  83. package/src/modules/customers/i18n/pl.json +1 -0
  84. package/src/modules/data_sync/AGENTS.md +1 -1
  85. package/src/modules/data_sync/lib/sync-engine.ts +23 -12
  86. package/src/modules/data_sync/lib/sync-schedule-service.ts +54 -3
  87. package/src/modules/progress/lib/progressServiceImpl.ts +7 -1
@@ -259,18 +259,18 @@ export function PortalUserDetailPageClient({ params, portalOrigin }: PortalUserD
259
259
  async function loadPersonName() {
260
260
  if (!data!.personEntityId) return
261
261
  try {
262
- const call = await apiCall<{ id?: string; firstName?: string; lastName?: string }>(`/api/customers/people/${encodeURIComponent(data!.personEntityId)}`)
263
- if (!cancelled && call.ok && call.result) {
264
- setPersonName([call.result.firstName, call.result.lastName].filter(Boolean).join(' ') || call.result.id || null)
262
+ const call = await apiCall<{ person?: { id?: string; displayName?: string | null } }>(`/api/customers/people/${encodeURIComponent(data!.personEntityId)}`)
263
+ if (!cancelled && call.ok && call.result?.person) {
264
+ setPersonName(call.result.person.displayName || call.result.person.id || null)
265
265
  }
266
266
  } catch { /* ignore */ }
267
267
  }
268
268
  async function loadCompanyName() {
269
269
  if (!data!.customerEntityId) return
270
270
  try {
271
- const call = await apiCall<{ id?: string; name?: string }>(`/api/customers/${encodeURIComponent(data!.customerEntityId)}`)
272
- if (!cancelled && call.ok && call.result) {
273
- setCompanyName(call.result.name || call.result.id || null)
271
+ const call = await apiCall<{ company?: { id?: string; displayName?: string | null } }>(`/api/customers/companies/${encodeURIComponent(data!.customerEntityId)}`)
272
+ if (!cancelled && call.ok && call.result?.company) {
273
+ setCompanyName(call.result.company.displayName || call.result.company.id || null)
274
274
  }
275
275
  } catch { /* ignore */ }
276
276
  }
@@ -282,13 +282,13 @@ export function PortalUserDetailPageClient({ params, portalOrigin }: PortalUserD
282
282
  setPersonSearchQuery(query)
283
283
  if (query.trim().length < 2) { setPersonResults([]); return }
284
284
  try {
285
- const call = await apiCall<{ items?: Array<{ id: string; firstName?: string; lastName?: string; email?: string }> }>(
285
+ const call = await apiCall<{ items?: Array<{ id: string; first_name?: string; last_name?: string; primary_email?: string }> }>(
286
286
  `/api/customers/people?search=${encodeURIComponent(query.trim())}&pageSize=10`,
287
287
  )
288
288
  if (call.ok && Array.isArray(call.result?.items)) {
289
289
  setPersonResults(call.result!.items.map((person) => ({
290
290
  id: person.id,
291
- label: [person.firstName, person.lastName].filter(Boolean).join(' ') || person.email || person.id,
291
+ label: [person.first_name, person.last_name].filter(Boolean).join(' ') || person.primary_email || person.id,
292
292
  })))
293
293
  }
294
294
  } catch { /* ignore */ }
@@ -298,13 +298,13 @@ export function PortalUserDetailPageClient({ params, portalOrigin }: PortalUserD
298
298
  setCompanySearchQuery(query)
299
299
  if (query.trim().length < 2) { setCompanyResults([]); return }
300
300
  try {
301
- const call = await apiCall<{ items?: Array<{ id: string; name?: string }> }>(
302
- `/api/customers?search=${encodeURIComponent(query.trim())}&pageSize=10`,
301
+ const call = await apiCall<{ items?: Array<{ id: string; display_name?: string }> }>(
302
+ `/api/customers/companies?search=${encodeURIComponent(query.trim())}&pageSize=10`,
303
303
  )
304
304
  if (call.ok && Array.isArray(call.result?.items)) {
305
305
  setCompanyResults(call.result!.items.map((company) => ({
306
306
  id: company.id,
307
- label: company.name || company.id,
307
+ label: company.display_name || company.id,
308
308
  })))
309
309
  }
310
310
  } catch { /* ignore */ }
@@ -28,7 +28,7 @@ export function register(container: AppContainer) {
28
28
  container.register({ customerInvitationService: asClass(CustomerInvitationService).scoped() })
29
29
  container.register({
30
30
  domainMappingService: asFunction(
31
- function domainMappingServiceFactory(em: EntityManager) {
31
+ function domainMappingServiceFactory(cradle: { em: EntityManager }) {
32
32
  // Resolve the cache lazily so registrations from `bootstrap(container)` (which runs after
33
33
  // module DI registrars) are visible. Awilix CLASSIC mode would fail to resolve a
34
34
  // destructured `cache` param if the key is not yet registered; using `hasRegistration`
@@ -42,8 +42,8 @@ export function register(container: AppContainer) {
42
42
  } catch {
43
43
  cacheService = undefined
44
44
  }
45
- return new DomainMappingService(em, cacheService ? { cacheService } : undefined)
45
+ return new DomainMappingService(cradle.em, cacheService ? { cacheService } : undefined)
46
46
  },
47
- ).scoped(),
47
+ ).scoped().proxy(),
48
48
  })
49
49
  }
@@ -10,6 +10,7 @@
10
10
  "customer_accounts.admin.columns.email": "Email",
11
11
  "customer_accounts.admin.columns.emailVerified": "Verified",
12
12
  "customer_accounts.admin.columns.lastLogin": "Last Login",
13
+ "customer_accounts.admin.columns.organization": "Organisation",
13
14
  "customer_accounts.admin.columns.roles": "Roles",
14
15
  "customer_accounts.admin.columns.status": "Status",
15
16
  "customer_accounts.admin.confirm.delete": "Delete user \"{{name}}\"?",
@@ -10,6 +10,7 @@
10
10
  "customer_accounts.admin.columns.email": "Email",
11
11
  "customer_accounts.admin.columns.emailVerified": "Verified",
12
12
  "customer_accounts.admin.columns.lastLogin": "Last Login",
13
+ "customer_accounts.admin.columns.organization": "Organization",
13
14
  "customer_accounts.admin.columns.roles": "Roles",
14
15
  "customer_accounts.admin.columns.status": "Status",
15
16
  "customer_accounts.admin.confirm.delete": "Delete user \"{{name}}\"?",
@@ -10,6 +10,7 @@
10
10
  "customer_accounts.admin.columns.email": "Email",
11
11
  "customer_accounts.admin.columns.emailVerified": "Verified",
12
12
  "customer_accounts.admin.columns.lastLogin": "Last Login",
13
+ "customer_accounts.admin.columns.organization": "Organización",
13
14
  "customer_accounts.admin.columns.roles": "Roles",
14
15
  "customer_accounts.admin.columns.status": "Status",
15
16
  "customer_accounts.admin.confirm.delete": "Delete user \"{{name}}\"?",
@@ -10,6 +10,7 @@
10
10
  "customer_accounts.admin.columns.email": "이메일",
11
11
  "customer_accounts.admin.columns.emailVerified": "확인됨",
12
12
  "customer_accounts.admin.columns.lastLogin": "마지막 로그인",
13
+ "customer_accounts.admin.columns.organization": "조직",
13
14
  "customer_accounts.admin.columns.roles": "역할",
14
15
  "customer_accounts.admin.columns.status": "상태",
15
16
  "customer_accounts.admin.confirm.delete": "사용자 \"{{name}}\"을(를) 삭제하시겠습니까?",
@@ -10,6 +10,7 @@
10
10
  "customer_accounts.admin.columns.email": "E-mail",
11
11
  "customer_accounts.admin.columns.emailVerified": "Zweryfikowany",
12
12
  "customer_accounts.admin.columns.lastLogin": "Ostatnie logowanie",
13
+ "customer_accounts.admin.columns.organization": "Organizacja",
13
14
  "customer_accounts.admin.columns.roles": "Role",
14
15
  "customer_accounts.admin.columns.status": "Status",
15
16
  "customer_accounts.admin.confirm.delete": "Usunąć użytkownika \"{{name}}\"?",
@@ -18,6 +18,17 @@ export interface CustomerAuthContext {
18
18
  isPortalAdmin?: boolean
19
19
  }
20
20
 
21
+ async function resolveSessionService(): Promise<
22
+ InstanceType<typeof import('@open-mercato/core/modules/customer_accounts/services/customerSessionService').CustomerSessionService>
23
+ > {
24
+ const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([
25
+ import('@open-mercato/shared/lib/di/container'),
26
+ import('@open-mercato/core/modules/customer_accounts/services/customerSessionService'),
27
+ ])
28
+ const container = await createRequestContainer()
29
+ return container.resolve('customerSessionService') as InstanceType<typeof CustomerSessionService>
30
+ }
31
+
21
32
  async function assertSessionStillActive(input: {
22
33
  sessionId: string
23
34
  userId: string
@@ -25,12 +36,7 @@ async function assertSessionStillActive(input: {
25
36
  organizationId: string
26
37
  }): Promise<boolean> {
27
38
  try {
28
- const [{ createRequestContainer }, { CustomerSessionService }] = await Promise.all([
29
- import('@open-mercato/shared/lib/di/container'),
30
- import('@open-mercato/core/modules/customer_accounts/services/customerSessionService'),
31
- ])
32
- const container = await createRequestContainer()
33
- const service = container.resolve('customerSessionService') as InstanceType<typeof CustomerSessionService>
39
+ const service = await resolveSessionService()
34
40
  const session = await service.findActiveSessionForClaims(input)
35
41
  return session !== null
36
42
  } catch {
@@ -40,6 +46,26 @@ async function assertSessionStillActive(input: {
40
46
  }
41
47
  }
42
48
 
49
+ /**
50
+ * Liveness re-check for a legacy token that carries no `sid` claim. It cannot name the session it
51
+ * was issued for, so the check falls back to the strongest available statement: the customer must
52
+ * still be signed in somewhere. Once every session is revoked — logout, per-device revoke, admin
53
+ * action — the token stops authenticating instead of surviving until its own expiry.
54
+ */
55
+ async function assertUserStillHasActiveSession(input: {
56
+ userId: string
57
+ tenantId: string
58
+ organizationId: string
59
+ }): Promise<boolean> {
60
+ try {
61
+ const service = await resolveSessionService()
62
+ return await service.hasActiveSessionForUser(input)
63
+ } catch {
64
+ // Same fail-closed contract as the sid-bound check above.
65
+ return false
66
+ }
67
+ }
68
+
43
69
  export function readCookieFromHeader(header: string | null | undefined, name: string): string | undefined {
44
70
  if (!header) return undefined
45
71
  const parts = header.split(';')
@@ -129,6 +155,9 @@ export async function getCustomerAuthFromRequest(req: Request): Promise<Customer
129
155
  const userId = String(payload.sub)
130
156
  const tenantId = String(payload.tenantId)
131
157
  const organizationId = String(payload.orgId)
158
+ // Every accepted token is re-checked for liveness, exactly like the SSR path does — a token
159
+ // without a `sid` is checked against the customer's remaining sessions rather than skipped,
160
+ // so session revocation is never structurally unreachable on the API path.
132
161
  const stillActive = sid
133
162
  ? await assertSessionStillActive({
134
163
  sessionId: sid,
@@ -136,7 +165,7 @@ export async function getCustomerAuthFromRequest(req: Request): Promise<Customer
136
165
  tenantId,
137
166
  organizationId,
138
167
  })
139
- : true
168
+ : await assertUserStillHasActiveSession({ userId, tenantId, organizationId })
140
169
  if (!stillActive) return null
141
170
 
142
171
  const userState = await validateUserState(
@@ -126,6 +126,34 @@ export class CustomerSessionService {
126
126
  return session
127
127
  }
128
128
 
129
+ /**
130
+ * Liveness probe for tokens that carry no `sid` claim — the pre-migration customer JWTs the
131
+ * legacy raw-secret fallback still admits during a key rotation. Such a token cannot name the
132
+ * session it was issued for, so the strongest statement we can make about it is whether the
133
+ * customer is still signed in anywhere. A customer whose sessions have all been revoked
134
+ * (logout, per-device revoke, admin action) has none, and the token must stop working.
135
+ *
136
+ * Existence is the whole answer, so this reads no encrypted column and needs no decryption
137
+ * round-trip on the authentication path — the same reason `findActiveSessionForClaims` next to
138
+ * it queries directly. Scope comes from the owning user, since sessions carry no scope columns.
139
+ */
140
+ async hasActiveSessionForUser(input: {
141
+ userId: string
142
+ tenantId: string
143
+ organizationId: string
144
+ }): Promise<boolean> {
145
+ const session = await this.em.findOne(CustomerUserSession, {
146
+ user: {
147
+ id: input.userId,
148
+ tenantId: input.tenantId,
149
+ organizationId: input.organizationId,
150
+ },
151
+ deletedAt: null,
152
+ expiresAt: { $gt: new Date() },
153
+ })
154
+ return session !== null
155
+ }
156
+
129
157
  async revokeSession(sessionId: string): Promise<void> {
130
158
  await this.em.nativeUpdate(CustomerUserSession, { id: sessionId }, { deletedAt: new Date() })
131
159
  }
@@ -77,41 +77,56 @@ async function loadDealForScope(
77
77
  /**
78
78
  * `CustomerDeal` does NOT carry a direct `entity` field — deals are linked
79
79
  * to people / companies via the `customer_deal_person_links` and
80
- * `customer_deal_company_links` tables. Comments however need a non-null
81
- * `entity_id` (the timeline owner) so this helper resolves the deal's
82
- * first linked person, then falls back to its first linked company. When
83
- * neither exists, the caller MUST instruct the operator to link a contact
84
- * before commenting on the deal.
80
+ * `customer_deal_company_links` tables. Comments and activities however need
81
+ * a non-null `entity_id` (the timeline owner) so this helper resolves the
82
+ * deal's primary linked person, then the oldest person, then the oldest company.
83
+ * When neither exists, the caller MUST instruct the operator to link a
84
+ * contact before writing to the deal.
85
+ *
86
+ * The relations are `CustomerDealPersonLink.person` and
87
+ * `CustomerDealCompanyLink.company` (#6119): populating them under any other
88
+ * name makes MikroORM reject the query, so every write failed even for a deal
89
+ * with linked contacts.
90
+ *
91
+ * The link tables carry no `tenant_id` / `organization_id` column either, so the
92
+ * deal id is the whole criterion — the same query `api/deals/[id]/route.ts` runs.
93
+ * Callers pass a deal already returned by `loadDealForScope`; that check is what
94
+ * keeps the lookup inside the caller's tenant and organization.
85
95
  */
86
- async function resolveDealCommentEntityId(
87
- em: EntityManager,
88
- ctx: CustomersToolContext,
89
- tenantId: string,
90
- dealId: string,
91
- ): Promise<string | null> {
96
+ async function resolveDealLinkedEntityId(em: EntityManager, dealId: string): Promise<string | null> {
92
97
  const personLink = await em.findOne(
93
98
  CustomerDealPersonLink,
94
- { deal: dealId, tenantId } as never,
95
- { populate: ['personEntity'] as never },
99
+ { deal: dealId } as never,
100
+ {
101
+ populate: ['person'] as never,
102
+ orderBy: { isPrimary: 'DESC', createdAt: 'ASC', id: 'ASC' },
103
+ },
96
104
  )
97
- if (personLink) {
98
- const linked = (personLink as unknown as { personEntity?: { id?: string | null } | null }).personEntity
99
- if (linked && typeof linked === 'object' && typeof linked.id === 'string') return linked.id
100
- const raw = (personLink as unknown as { personEntity?: unknown }).personEntity
101
- if (typeof raw === 'string') return raw
102
- }
105
+ const personId = linkedEntityIdOf(personLink, 'person')
106
+ if (personId) return personId
103
107
  const companyLink = await em.findOne(
104
108
  CustomerDealCompanyLink,
105
- { deal: dealId, tenantId } as never,
106
- { populate: ['companyEntity'] as never },
109
+ { deal: dealId } as never,
110
+ {
111
+ populate: ['company'] as never,
112
+ orderBy: { createdAt: 'ASC', id: 'ASC' },
113
+ },
107
114
  )
108
- if (companyLink) {
109
- const linked = (companyLink as unknown as { companyEntity?: { id?: string | null } | null }).companyEntity
110
- if (linked && typeof linked === 'object' && typeof linked.id === 'string') return linked.id
111
- const raw = (companyLink as unknown as { companyEntity?: unknown }).companyEntity
112
- if (typeof raw === 'string') return raw
115
+ return linkedEntityIdOf(companyLink, 'company')
116
+ }
117
+
118
+ /** Id of a populated relation, or the raw foreign key when it was not loaded. */
119
+ function linkedEntityIdOf(row: unknown, relation: 'person' | 'company'): string | null {
120
+ if (!row || typeof row !== 'object') return null
121
+ const value = (row as Record<string, unknown>)[relation]
122
+ if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'string') {
123
+ return (value as { id: string }).id
113
124
  }
114
- return null
125
+ return typeof value === 'string' ? value : null
126
+ }
127
+
128
+ function dealHasNoLinkedContactMessage(dealId: string, what: 'comment' | 'activity'): string {
129
+ return `Deal "${dealId}" has no linked person or company. Link a contact to the deal in the backoffice before adding ${what === 'comment' ? 'a comment' : 'an activity'}, or post the ${what} directly on the person/company record instead.`
115
130
  }
116
131
 
117
132
  async function loadCommentForScope(
@@ -493,11 +508,9 @@ const manageDealCommentTool: CustomersAiToolDefinition = {
493
508
  // people/companies via two link tables. Resolve the first available
494
509
  // person, then fall back to the first linked company. Only fail the
495
510
  // operation when the deal has no linked contacts at all.
496
- const dealEntityId = await resolveDealCommentEntityId(em, ctx, tenantId, deal.id)
511
+ const dealEntityId = await resolveDealLinkedEntityId(em, deal.id)
497
512
  if (!dealEntityId) {
498
- throw new Error(
499
- `Deal "${deal.id}" has no linked person or company. Link a contact to the deal in the backoffice before adding a comment, or post the comment directly on the person/company record instead.`,
500
- )
513
+ throw new Error(dealHasNoLinkedContactMessage(deal.id, 'comment'))
501
514
  }
502
515
  const body: Record<string, unknown> = {
503
516
  tenantId,
@@ -656,15 +669,12 @@ const manageDealActivityTool: CustomersAiToolDefinition = {
656
669
  if (!deal) throw new Error(`Deal "${input.dealId}" is not accessible to the caller.`)
657
670
  const organizationId = deal.organizationId
658
671
  if (!organizationId) throw new Error(`Deal "${deal.id}" has no organization scope.`)
659
- const dealEntity = (deal as unknown as { entity?: unknown }).entity
660
- const entityId =
661
- dealEntity && typeof dealEntity === 'object'
662
- ? (dealEntity as { id?: string | null }).id ?? null
663
- : typeof dealEntity === 'string'
664
- ? dealEntity
665
- : null
672
+ // Same rule as the comment tool (#6119): `CustomerDeal` has no `.entity`,
673
+ // the activity's timeline owner is the deal's first linked person, then
674
+ // its first linked company.
675
+ const entityId = await resolveDealLinkedEntityId(em, deal.id)
666
676
  if (!entityId) {
667
- throw new Error(`Deal "${deal.id}" has no associated person/company; cannot attach an activity.`)
677
+ throw new Error(dealHasNoLinkedContactMessage(deal.id, 'activity'))
668
678
  }
669
679
  const body: Record<string, unknown> = {
670
680
  tenantId,
@@ -14,11 +14,22 @@ import {
14
14
  import { SearchInput } from '@open-mercato/ui/primitives/search-input'
15
15
  import { useT } from '@open-mercato/shared/lib/i18n/context'
16
16
  import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
17
- import { ViewTabsRow } from '../pipeline/components/ViewTabsRow'
17
+ import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
18
+ import {
19
+ ViewTabsRow,
20
+ VIEW_TABS_ROW_COMPONENT_ID,
21
+ type ViewTabsRowProps,
22
+ } from '../pipeline/components/ViewTabsRow'
18
23
  import { DealsMapView } from './components/DealsMapView'
19
24
 
20
25
  export default function DealsMapPage(): React.ReactElement {
21
26
  const t = useT()
27
+ // Resolved through the component registry so downstream apps can hide a view
28
+ // (or replace the whole switcher) without forking this page.
29
+ const DealsViewTabsRow = useRegisteredComponent<ViewTabsRowProps>(
30
+ VIEW_TABS_ROW_COMPONENT_ID,
31
+ ViewTabsRow,
32
+ )
22
33
  const [search, setSearch] = React.useState('')
23
34
 
24
35
  return (
@@ -68,7 +79,7 @@ export default function DealsMapPage(): React.ReactElement {
68
79
  </div>
69
80
  </div>
70
81
 
71
- <ViewTabsRow active="map" className="mt-4" />
82
+ <DealsViewTabsRow active="map" className="mt-4" />
72
83
 
73
84
  <DealsMapView search={search} />
74
85
  </PageBody>
@@ -30,7 +30,12 @@ import { SimpleTooltip } from '@open-mercato/ui/primitives/tooltip'
30
30
  import { Briefcase, AlertTriangle, X } from 'lucide-react'
31
31
  import { isLostDealStatus, isWonDealStatus } from '../../../lib/dealStatus'
32
32
  import { formatRelativeTime } from '@open-mercato/shared/lib/time'
33
- import { ViewTabsRow } from './pipeline/components/ViewTabsRow'
33
+ import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
34
+ import {
35
+ ViewTabsRow,
36
+ VIEW_TABS_ROW_COMPONENT_ID,
37
+ type ViewTabsRowProps,
38
+ } from './pipeline/components/ViewTabsRow'
34
39
  import { DealsKpiStrip } from '../../../components/DealsKpiStrip'
35
40
  import { E } from '#generated/entities.ids.generated'
36
41
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
@@ -195,6 +200,12 @@ function formatGroupedAmount(amount: number | null | undefined): string | null {
195
200
 
196
201
  export default function CustomersDealsPage() {
197
202
  const t = useT()
203
+ // Resolved through the component registry so downstream apps can hide a view
204
+ // (or replace the whole switcher) without forking this page.
205
+ const DealsViewTabsRow = useRegisteredComponent<ViewTabsRowProps>(
206
+ VIEW_TABS_ROW_COMPONENT_ID,
207
+ ViewTabsRow,
208
+ )
198
209
  const locale = useLocale()
199
210
  const { confirm, ConfirmDialogElement } = useConfirmDialog()
200
211
  const router = useRouter()
@@ -1064,7 +1075,7 @@ export default function CustomersDealsPage() {
1064
1075
  return (
1065
1076
  <Page>
1066
1077
  <PageBody>
1067
- <ViewTabsRow active="list" className="mb-4" />
1078
+ <DealsViewTabsRow active="list" className="mb-4" />
1068
1079
  <DealsKpiStrip
1069
1080
  ownerNames={ownerNames}
1070
1081
  stageDictionary={dictionaryMaps['pipeline-stages'] ?? {}}
@@ -7,24 +7,76 @@ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
7
7
 
8
8
  export type KanbanView = 'kanban' | 'list' | 'map'
9
9
 
10
- type ViewTabsRowProps = {
10
+ /**
11
+ * Canonical left-to-right order of the deals views. `visibleViews` selects a
12
+ * subset of these; it never reorders them, so a host that hides one view keeps
13
+ * the same tab order as every other deployment.
14
+ */
15
+ export const DEALS_VIEWS: readonly KanbanView[] = ['kanban', 'list', 'map']
16
+
17
+ export type ViewTabsRowProps = {
11
18
  active: KanbanView
12
19
  className?: string
20
+ /**
21
+ * Views exposed in the tab row. Defaults to all of `DEALS_VIEWS`; pass a
22
+ * subset to hide a view a deployment has no use for (e.g. omit `'map'` when
23
+ * deal addresses are never geocoded). Unknown and duplicated entries are
24
+ * ignored, and `active` is always rendered — a tab row that omits the page it
25
+ * is sitting on would show no selected tab at all.
26
+ */
27
+ visibleViews?: readonly KanbanView[]
28
+ }
29
+
30
+ /**
31
+ * Registered component id for the deals view switcher. Downstream apps can
32
+ * target it from `widgets/components.ts` to replace or wrap the row, or — the
33
+ * reason it is resolved through the registry at all — to transform its props
34
+ * from one place instead of forking each of the three pages that render it:
35
+ *
36
+ * ```ts
37
+ * export const componentOverrides: ComponentOverride[] = [{
38
+ * target: { componentId: 'section:customers.deals.viewTabs' },
39
+ * priority: 50,
40
+ * metadata: { module: 'my_module' },
41
+ * propsTransform: (props) => ({ ...props, visibleViews: ['kanban', 'list'] }),
42
+ * }]
43
+ * ```
44
+ *
45
+ * Hiding a tab does not unpublish its route; an app that also wants the page
46
+ * gone disables it through `entry.overrides.pages` (#5923).
47
+ */
48
+ export const VIEW_TABS_ROW_COMPONENT_ID = 'section:customers.deals.viewTabs'
49
+
50
+ const VIEW_HREFS: Record<KanbanView, string> = {
51
+ kanban: '/backend/customers/deals/pipeline',
52
+ list: '/backend/customers/deals',
53
+ map: '/backend/customers/deals/map',
13
54
  }
14
55
 
15
- const VIEW_KANBAN_HREF = '/backend/customers/deals/pipeline'
16
- const VIEW_LIST_HREF = '/backend/customers/deals'
17
- const VIEW_MAP_HREF = '/backend/customers/deals/map'
56
+ /**
57
+ * Narrows `visibleViews` to the tabs this row renders, in canonical order.
58
+ * Exported so the contract — canonical order, deduplication, unknown entries
59
+ * dropped, `active` always kept — is testable without rendering.
60
+ */
61
+ export function resolveVisibleViews(
62
+ active: KanbanView,
63
+ visibleViews?: readonly KanbanView[],
64
+ ): KanbanView[] {
65
+ if (!visibleViews) return [...DEALS_VIEWS]
66
+ const requested = new Set<KanbanView>(visibleViews.filter((view) => DEALS_VIEWS.includes(view)))
67
+ requested.add(active)
68
+ return DEALS_VIEWS.filter((view) => requested.has(view))
69
+ }
18
70
 
19
- export function ViewTabsRow({ active, className }: ViewTabsRowProps): React.ReactElement {
71
+ export function ViewTabsRow({ active, className, visibleViews }: ViewTabsRowProps): React.ReactElement {
20
72
  const t = useT()
21
- const labels = {
73
+ const labels: Record<KanbanView, string> = {
22
74
  kanban: translateWithFallback(t, 'customers.deals.kanban.view.kanban', 'Kanban'),
23
75
  list: translateWithFallback(t, 'customers.deals.kanban.view.list', 'List'),
24
76
  map: translateWithFallback(t, 'customers.deals.kanban.view.map', 'Map'),
25
77
  }
26
78
 
27
- // Link-based tab row (two routes), so the Tabs primitive (state-driven,
79
+ // Link-based tab row (three routes), so the Tabs primitive (state-driven,
28
80
  // onValueChange) does not fit — real <Link> semantics must stay. Classes
29
81
  // mirror the Tabs underline variant: accent-indigo active border,
30
82
  // shadow-focus halo.
@@ -33,14 +85,9 @@ export function ViewTabsRow({ active, className }: ViewTabsRowProps): React.Reac
33
85
  const activeTab = 'border-b-2 border-accent-indigo font-semibold text-foreground'
34
86
  const inactiveTab = 'border-b-2 border-transparent font-normal text-muted-foreground hover:text-foreground'
35
87
 
36
- // Renders the active tab as a non-navigating `<span>` and the inactive tab as a `<Link>`,
37
- // so the user can always round-trip between kanban and list from either page. Previously
38
- // the kanban tab was a hardcoded `<span>` because the row was only rendered on the kanban
39
- // page — adding it to the list page (item 7 of the SPEC-048 UX review) requires it to
40
- // navigate back to /pipeline.
41
- const isKanbanActive = active === 'kanban'
42
- const isListActive = active === 'list'
43
- const isMapActive = active === 'map'
88
+ // The active tab renders as a non-navigating `<span>` and every other tab as a
89
+ // `<Link>`, so the user can always round-trip between the views from any page.
90
+ const views = resolveVisibleViews(active, visibleViews)
44
91
 
45
92
  return (
46
93
  <div
@@ -48,60 +95,26 @@ export function ViewTabsRow({ active, className }: ViewTabsRowProps): React.Reac
48
95
  aria-label={translateWithFallback(t, 'customers.deals.kanban.view.tablistLabel', 'Deals views')}
49
96
  className={`flex items-end gap-1 border-b border-border ${className ?? ''}`.trim()}
50
97
  >
51
- {isKanbanActive ? (
52
- <span
53
- role="tab"
54
- aria-selected={true}
55
- className={`${baseTab} ${activeTab}`}
56
- >
57
- {labels.kanban}
58
- </span>
59
- ) : (
60
- <Link
61
- href={VIEW_KANBAN_HREF}
62
- role="tab"
63
- aria-selected={false}
64
- className={`${baseTab} ${inactiveTab}`}
65
- >
66
- {labels.kanban}
67
- </Link>
68
- )}
69
- {isListActive ? (
70
- <span
71
- role="tab"
72
- aria-selected={true}
73
- className={`${baseTab} ${activeTab}`}
74
- >
75
- {labels.list}
76
- </span>
77
- ) : (
78
- <Link
79
- href={VIEW_LIST_HREF}
80
- role="tab"
81
- aria-selected={false}
82
- className={`${baseTab} ${inactiveTab}`}
83
- >
84
- {labels.list}
85
- </Link>
86
- )}
87
- {isMapActive ? (
98
+ {views.map((view) => (view === active ? (
88
99
  <span
100
+ key={view}
89
101
  role="tab"
90
102
  aria-selected={true}
91
103
  className={`${baseTab} ${activeTab}`}
92
104
  >
93
- {labels.map}
105
+ {labels[view]}
94
106
  </span>
95
107
  ) : (
96
108
  <Link
97
- href={VIEW_MAP_HREF}
109
+ key={view}
110
+ href={VIEW_HREFS[view]}
98
111
  role="tab"
99
112
  aria-selected={false}
100
113
  className={`${baseTab} ${inactiveTab}`}
101
114
  >
102
- {labels.map}
115
+ {labels[view]}
103
116
  </Link>
104
- )}
117
+ )))}
105
118
  </div>
106
119
  )
107
120
  }
@@ -54,7 +54,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
54
54
  import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
55
55
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
56
56
  import type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'
57
- import { ViewTabsRow } from './components/ViewTabsRow'
57
+ import { ViewTabsRow, VIEW_TABS_ROW_COMPONENT_ID, type ViewTabsRowProps } from './components/ViewTabsRow'
58
58
  import { LANE_WIDTH_CLASS } from './components/constants'
59
59
  import { useCurrencyDictionary } from '../../../../components/detail/hooks/useCurrencyDictionary'
60
60
  import { FilterBarRow, type KanbanFilterChip } from './components/FilterBarRow'
@@ -419,6 +419,12 @@ export default function DealsKanbanPage(): React.ReactElement {
419
419
  QUICK_DEAL_DIALOG_COMPONENT_ID,
420
420
  DefaultQuickDealDialog,
421
421
  )
422
+ // Same reason: an app that does not use one of the three views hides it with a
423
+ // props override on this handle instead of forking all three deals pages.
424
+ const DealsViewTabsRow = useRegisteredComponent<ViewTabsRowProps>(
425
+ VIEW_TABS_ROW_COMPONENT_ID,
426
+ ViewTabsRow,
427
+ )
422
428
  const router = useRouter()
423
429
  const scopeVersion = useOrganizationScopeVersion()
424
430
  const queryClient = useQueryClient()
@@ -2558,7 +2564,7 @@ export default function DealsKanbanPage(): React.ReactElement {
2558
2564
  return (
2559
2565
  <Page>
2560
2566
  <PageBody>
2561
- <ViewTabsRow active="kanban" className="mb-4" />
2567
+ <DealsViewTabsRow active="kanban" className="mb-4" />
2562
2568
  <div className="flex flex-col gap-2">
2563
2569
  <Breadcrumb>
2564
2570
  <BreadcrumbList>
@@ -1035,6 +1035,14 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
1035
1035
  }
1036
1036
 
1037
1037
  await em.nativeUpdate(CustomerPersonProfile, { company: record }, { company: null })
1038
+ // Soft-deleted person links keep their `company_entity_id` foreign key, so the hard
1039
+ // delete below would violate it. The guard above already proved no active link
1040
+ // remains, and restricting the filter to `deletedAt != null` keeps that guarantee.
1041
+ await em.nativeDelete(CustomerPersonCompanyLink, {
1042
+ company: record,
1043
+ deletedAt: { $ne: null },
1044
+ ...dependentScope,
1045
+ })
1038
1046
  await em.nativeDelete(CustomerDealCompanyLink, { company: record })
1039
1047
  await em.nativeDelete(CustomerActivity, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
1040
1048
  await em.nativeDelete(CustomerInteraction, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })