@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/attachments/lib/storage.js +15 -1
- package/dist/modules/attachments/lib/storage.js.map +2 -2
- package/dist/modules/auth/api/reset/confirm.js +3 -2
- package/dist/modules/auth/api/reset/confirm.js.map +2 -2
- package/dist/modules/auth/api/reset.js +2 -1
- package/dist/modules/auth/api/reset.js.map +2 -2
- package/dist/modules/auth/di.js +6 -6
- package/dist/modules/auth/di.js.map +2 -2
- package/dist/modules/auth/lib/tenantHint.js +29 -0
- package/dist/modules/auth/lib/tenantHint.js.map +7 -0
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +11 -3
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/customer_accounts/api/admin/users.js +27 -14
- package/dist/modules/customer_accounts/api/admin/users.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +9 -0
- package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js +8 -8
- package/dist/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.js.map +2 -2
- package/dist/modules/customer_accounts/di.js +3 -3
- package/dist/modules/customer_accounts/di.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerAuth.js +18 -7
- package/dist/modules/customer_accounts/lib/customerAuth.js.map +2 -2
- package/dist/modules/customer_accounts/services/customerSessionService.js +23 -0
- package/dist/modules/customer_accounts/services/customerSessionService.js.map +2 -2
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js +28 -24
- package/dist/modules/customers/ai-tools/activities-tasks-pack.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/map/page.js +10 -2
- package/dist/modules/customers/backend/customers/deals/map/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +10 -2
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +41 -66
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +6 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +2 -2
- package/dist/modules/customers/commands/companies.js +5 -0
- package/dist/modules/customers/commands/companies.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +3 -3
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -1
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-engine.js +3 -7
- package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
- package/dist/modules/data_sync/lib/sync-schedule-service.js +52 -3
- package/dist/modules/data_sync/lib/sync-schedule-service.js.map +2 -2
- package/dist/modules/progress/lib/progressServiceImpl.js +1 -1
- package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/lib/storage.ts +24 -1
- package/src/modules/auth/api/reset/confirm.ts +3 -2
- package/src/modules/auth/api/reset.ts +2 -1
- package/src/modules/auth/di.ts +6 -6
- package/src/modules/auth/lib/tenantHint.ts +49 -0
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +19 -5
- package/src/modules/catalog/i18n/de.json +1 -0
- package/src/modules/catalog/i18n/en.json +1 -0
- package/src/modules/catalog/i18n/es.json +1 -0
- package/src/modules/catalog/i18n/ko.json +1 -0
- package/src/modules/catalog/i18n/pl.json +1 -0
- package/src/modules/customer_accounts/api/admin/users.ts +30 -14
- package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +11 -0
- package/src/modules/customer_accounts/backend/customer_accounts/users/[id]/PortalUserDetailPageClient.tsx +11 -11
- package/src/modules/customer_accounts/di.ts +3 -3
- package/src/modules/customer_accounts/i18n/de.json +1 -0
- package/src/modules/customer_accounts/i18n/en.json +1 -0
- package/src/modules/customer_accounts/i18n/es.json +1 -0
- package/src/modules/customer_accounts/i18n/ko.json +1 -0
- package/src/modules/customer_accounts/i18n/pl.json +1 -0
- package/src/modules/customer_accounts/lib/customerAuth.ts +36 -7
- package/src/modules/customer_accounts/services/customerSessionService.ts +28 -0
- package/src/modules/customers/ai-tools/activities-tasks-pack.ts +49 -39
- package/src/modules/customers/backend/customers/deals/map/page.tsx +13 -2
- package/src/modules/customers/backend/customers/deals/page.tsx +13 -2
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +69 -56
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +8 -2
- package/src/modules/customers/commands/companies.ts +8 -0
- package/src/modules/customers/components/formConfig.tsx +3 -3
- package/src/modules/customers/data/validators.ts +2 -1
- package/src/modules/customers/i18n/de.json +1 -0
- package/src/modules/customers/i18n/en.json +1 -0
- package/src/modules/customers/i18n/es.json +1 -0
- package/src/modules/customers/i18n/ko.json +1 -0
- package/src/modules/customers/i18n/pl.json +1 -0
- package/src/modules/data_sync/AGENTS.md +1 -1
- package/src/modules/data_sync/lib/sync-engine.ts +23 -12
- package/src/modules/data_sync/lib/sync-schedule-service.ts +54 -3
- 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<{
|
|
263
|
-
if (!cancelled && call.ok && call.result) {
|
|
264
|
-
setPersonName(
|
|
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;
|
|
272
|
-
if (!cancelled && call.ok && call.result) {
|
|
273
|
-
setCompanyName(call.result.
|
|
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;
|
|
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.
|
|
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;
|
|
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.
|
|
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
|
|
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
|
-
:
|
|
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
|
|
81
|
-
* `entity_id` (the timeline owner) so this helper resolves the
|
|
82
|
-
*
|
|
83
|
-
* neither exists, the caller MUST instruct the operator to link a
|
|
84
|
-
* before
|
|
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
|
|
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
|
|
95
|
-
{
|
|
99
|
+
{ deal: dealId } as never,
|
|
100
|
+
{
|
|
101
|
+
populate: ['person'] as never,
|
|
102
|
+
orderBy: { isPrimary: 'DESC', createdAt: 'ASC', id: 'ASC' },
|
|
103
|
+
},
|
|
96
104
|
)
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
106
|
-
{
|
|
109
|
+
{ deal: dealId } as never,
|
|
110
|
+
{
|
|
111
|
+
populate: ['company'] as never,
|
|
112
|
+
orderBy: { createdAt: 'ASC', id: 'ASC' },
|
|
113
|
+
},
|
|
107
114
|
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 (
|
|
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
|
-
//
|
|
37
|
-
// so the user can always round-trip between
|
|
38
|
-
|
|
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
|
-
{
|
|
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
|
|
105
|
+
{labels[view]}
|
|
94
106
|
</span>
|
|
95
107
|
) : (
|
|
96
108
|
<Link
|
|
97
|
-
|
|
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
|
|
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
|
-
<
|
|
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 })
|