@open-mercato/core 0.6.8-develop.7013.1.48f7adca80 → 0.6.8-develop.7015.1.af90a2ddc7
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/dist/modules/customers/api/companies/[id]/people/route.js +19 -63
- package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
- package/dist/modules/customers/api/companies/[id]/route.js +9 -85
- package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +4 -3
- package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
- package/dist/modules/customers/commands/personCompanyLinks.js +131 -2
- package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -1
- package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
- package/dist/modules/customers/components/detail/PersonCompaniesSection.js +3 -1
- package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
- package/dist/modules/customers/data/validators.js +10 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/events.js +5 -0
- package/dist/modules/customers/events.js.map +2 -2
- package/dist/modules/customers/lib/personCompanies.js +100 -2
- package/dist/modules/customers/lib/personCompanies.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
- package/src/modules/customers/api/companies/[id]/route.ts +11 -97
- package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
- package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
- package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
- package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
- package/src/modules/customers/data/validators.ts +18 -3
- package/src/modules/customers/events.ts +5 -0
- package/src/modules/customers/lib/personCompanies.ts +136 -1
|
@@ -7,17 +7,10 @@ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
|
7
7
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
8
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
9
9
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
10
|
-
import { findOneWithDecryption
|
|
10
|
+
import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
11
|
import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
CustomerPersonCompanyLink,
|
|
15
|
-
CustomerPersonProfile,
|
|
16
|
-
} from '../../../../data/entities'
|
|
17
|
-
import {
|
|
18
|
-
filterActivePersonCompanyLinks,
|
|
19
|
-
withActiveCustomerPersonCompanyLinkFilter,
|
|
20
|
-
} from '../../../../lib/personCompanyLinkTable'
|
|
12
|
+
import { CustomerEntity } from '../../../../data/entities'
|
|
13
|
+
import { loadCompanyPeopleUnion } from '../../../../lib/personCompanies'
|
|
21
14
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
22
15
|
|
|
23
16
|
const logger = createLogger('customers')
|
|
@@ -124,68 +117,23 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
|
|
|
124
117
|
}
|
|
125
118
|
|
|
126
119
|
const entityScope = { tenantId: auth.tenantId, organizationId: company.organizationId }
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
const personIds = links
|
|
147
|
-
.map((link) => link.person?.id)
|
|
148
|
-
.filter((personId): personId is string => typeof personId === 'string' && personId.length > 0)
|
|
149
|
-
|
|
150
|
-
const profiles = personIds.length > 0
|
|
151
|
-
? await findWithDecryption(
|
|
152
|
-
em,
|
|
153
|
-
CustomerPersonProfile,
|
|
154
|
-
{
|
|
155
|
-
entity: { $in: personIds },
|
|
156
|
-
tenantId: company.tenantId,
|
|
157
|
-
organizationId: company.organizationId,
|
|
158
|
-
},
|
|
159
|
-
{},
|
|
160
|
-
entityScope,
|
|
161
|
-
)
|
|
162
|
-
: []
|
|
163
|
-
const profileByPersonId = new Map(
|
|
164
|
-
profiles.map((profile) => [(profile.entity as { id: string }).id, profile]),
|
|
165
|
-
)
|
|
166
|
-
|
|
167
|
-
const items = links
|
|
168
|
-
.map((link) => {
|
|
169
|
-
const person = link.person
|
|
170
|
-
if (!person?.id) return null
|
|
171
|
-
const profile = profileByPersonId.get(person.id) ?? null
|
|
172
|
-
return {
|
|
173
|
-
id: person.id,
|
|
174
|
-
displayName: person.displayName ?? person.primaryEmail ?? person.id,
|
|
175
|
-
primaryEmail: person.primaryEmail ?? null,
|
|
176
|
-
primaryPhone: person.primaryPhone ?? null,
|
|
177
|
-
status: person.status ?? null,
|
|
178
|
-
lifecycleStage: person.lifecycleStage ?? null,
|
|
179
|
-
jobTitle: profile?.jobTitle ?? null,
|
|
180
|
-
department: profile?.department ?? null,
|
|
181
|
-
createdAt: person.createdAt.toISOString(),
|
|
182
|
-
organizationId: person.organizationId,
|
|
183
|
-
temperature: person.temperature ?? null,
|
|
184
|
-
source: person.source ?? null,
|
|
185
|
-
linkedAt: link.createdAt ? link.createdAt.toISOString() : null,
|
|
186
|
-
} satisfies CompanyPersonItem
|
|
187
|
-
})
|
|
188
|
-
.filter((item): item is CompanyPersonItem => item !== null)
|
|
120
|
+
const union = await loadCompanyPeopleUnion(em, company, entityScope)
|
|
121
|
+
|
|
122
|
+
const items = union.map(({ entity: person, profile, linkedAt }) => ({
|
|
123
|
+
id: person.id,
|
|
124
|
+
displayName: person.displayName ?? person.primaryEmail ?? person.id,
|
|
125
|
+
primaryEmail: person.primaryEmail ?? null,
|
|
126
|
+
primaryPhone: person.primaryPhone ?? null,
|
|
127
|
+
status: person.status ?? null,
|
|
128
|
+
lifecycleStage: person.lifecycleStage ?? null,
|
|
129
|
+
jobTitle: profile?.jobTitle ?? null,
|
|
130
|
+
department: profile?.department ?? null,
|
|
131
|
+
createdAt: person.createdAt.toISOString(),
|
|
132
|
+
organizationId: person.organizationId,
|
|
133
|
+
temperature: person.temperature ?? null,
|
|
134
|
+
source: person.source ?? null,
|
|
135
|
+
linkedAt,
|
|
136
|
+
} satisfies CompanyPersonItem))
|
|
189
137
|
|
|
190
138
|
const filtered = query.search?.trim().length ? items.filter((item) => matchesSearch(item, query.search ?? '')) : items
|
|
191
139
|
const sorted = sortItems(filtered, query.sort)
|
|
@@ -17,8 +17,6 @@ import {
|
|
|
17
17
|
CustomerDealCompanyLink,
|
|
18
18
|
CustomerDeal,
|
|
19
19
|
CustomerTodoLink,
|
|
20
|
-
CustomerPersonCompanyLink,
|
|
21
|
-
CustomerPersonProfile,
|
|
22
20
|
CustomerInteraction,
|
|
23
21
|
} from '../../../data/entities'
|
|
24
22
|
import { User } from '@open-mercato/core/modules/auth/data/entities'
|
|
@@ -47,9 +45,10 @@ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
|
47
45
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
48
46
|
import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
|
|
49
47
|
import {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
countCompanyPeopleUnion,
|
|
49
|
+
loadCompanyPeopleUnion,
|
|
50
|
+
type CompanyPersonUnionEntry,
|
|
51
|
+
} from '../../../lib/personCompanies'
|
|
53
52
|
import { normalizeCustomerDetailCustomFields } from '../../detailCustomFields'
|
|
54
53
|
import { isOrganizationReadAccessAllowed } from '@open-mercato/core/modules/directory/utils/organizationScopeGuard'
|
|
55
54
|
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
@@ -801,79 +800,13 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
|
|
|
801
800
|
deal.organizationId === company.organizationId,
|
|
802
801
|
)
|
|
803
802
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
803
|
+
const peopleUnionScope = {
|
|
804
|
+
tenantId: company.tenantId ?? auth.tenantId ?? null,
|
|
805
|
+
organizationId: company.organizationId ?? scope?.selectedId ?? auth.orgId ?? null,
|
|
806
|
+
}
|
|
807
|
+
let relatedPeople: CompanyPersonUnionEntry[] = []
|
|
809
808
|
if (includePeople) {
|
|
810
|
-
|
|
811
|
-
tenantId: company.tenantId ?? auth.tenantId ?? null,
|
|
812
|
-
organizationId: company.organizationId ?? scope?.selectedId ?? auth.orgId ?? null,
|
|
813
|
-
}
|
|
814
|
-
const relatedPeopleById = new Map<
|
|
815
|
-
string,
|
|
816
|
-
{ entity: CustomerEntity; profile: CustomerPersonProfile | null; linkedAt: string | null }
|
|
817
|
-
>()
|
|
818
|
-
const companyLinkWhere = await withActiveCustomerPersonCompanyLinkFilter(
|
|
819
|
-
em,
|
|
820
|
-
{
|
|
821
|
-
company: company.id,
|
|
822
|
-
organizationId: company.organizationId,
|
|
823
|
-
tenantId: company.tenantId,
|
|
824
|
-
},
|
|
825
|
-
'customers.companies.GET',
|
|
826
|
-
)
|
|
827
|
-
const companyLinks = filterActivePersonCompanyLinks(
|
|
828
|
-
await findWithDecryption(
|
|
829
|
-
em,
|
|
830
|
-
CustomerPersonCompanyLink,
|
|
831
|
-
companyLinkWhere,
|
|
832
|
-
{
|
|
833
|
-
populate: ['person', 'person.personProfile'],
|
|
834
|
-
orderBy: { isPrimary: 'desc', createdAt: 'asc' },
|
|
835
|
-
},
|
|
836
|
-
peopleDecryptionScope,
|
|
837
|
-
),
|
|
838
|
-
)
|
|
839
|
-
companyLinks.forEach((link) => {
|
|
840
|
-
const entity = typeof link.person === 'string' ? null : link.person
|
|
841
|
-
if (!entity || entity.kind !== 'person' || entity.deletedAt) return
|
|
842
|
-
const personProfile =
|
|
843
|
-
entity.personProfile && typeof entity.personProfile !== 'string'
|
|
844
|
-
? entity.personProfile
|
|
845
|
-
: null
|
|
846
|
-
relatedPeopleById.set(entity.id, {
|
|
847
|
-
entity,
|
|
848
|
-
profile: personProfile,
|
|
849
|
-
linkedAt: link.createdAt instanceof Date ? link.createdAt.toISOString() : null,
|
|
850
|
-
})
|
|
851
|
-
})
|
|
852
|
-
|
|
853
|
-
const profiles = await findWithDecryption(
|
|
854
|
-
em,
|
|
855
|
-
CustomerPersonProfile,
|
|
856
|
-
{
|
|
857
|
-
company: company.id,
|
|
858
|
-
tenantId: company.tenantId,
|
|
859
|
-
organizationId: company.organizationId,
|
|
860
|
-
entity: { deletedAt: null },
|
|
861
|
-
},
|
|
862
|
-
{ populate: ['entity'] },
|
|
863
|
-
peopleDecryptionScope,
|
|
864
|
-
)
|
|
865
|
-
profiles.forEach((entry) => {
|
|
866
|
-
const entity = entry.entity as CustomerEntity | null
|
|
867
|
-
if (!entity || entity.kind !== 'person' || entity.deletedAt) return
|
|
868
|
-
if (!relatedPeopleById.has(entity.id)) {
|
|
869
|
-
relatedPeopleById.set(entity.id, {
|
|
870
|
-
entity,
|
|
871
|
-
profile: entry ?? null,
|
|
872
|
-
linkedAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : null,
|
|
873
|
-
})
|
|
874
|
-
}
|
|
875
|
-
})
|
|
876
|
-
relatedPeople = Array.from(relatedPeopleById.values())
|
|
809
|
+
relatedPeople = await loadCompanyPeopleUnion(em, company, peopleUnionScope)
|
|
877
810
|
}
|
|
878
811
|
|
|
879
812
|
// Entity custom fields, profile custom fields, and the routing lookup do not
|
|
@@ -919,26 +852,7 @@ export async function GET(_req: Request, ctx: { params?: { id?: string } }) {
|
|
|
919
852
|
// instead of a waterfall (issue #3203).
|
|
920
853
|
const peopleCountQuery = includePeople
|
|
921
854
|
? Promise.resolve(relatedPeople.length)
|
|
922
|
-
: (
|
|
923
|
-
const peopleLinkWhere = await withActiveCustomerPersonCompanyLinkFilter(
|
|
924
|
-
em,
|
|
925
|
-
{
|
|
926
|
-
company: company.id,
|
|
927
|
-
organizationId: company.organizationId,
|
|
928
|
-
tenantId: company.tenantId,
|
|
929
|
-
},
|
|
930
|
-
'customers.companies.GET',
|
|
931
|
-
)
|
|
932
|
-
return filterActivePersonCompanyLinks(
|
|
933
|
-
await findWithDecryption(
|
|
934
|
-
em,
|
|
935
|
-
CustomerPersonCompanyLink,
|
|
936
|
-
peopleLinkWhere,
|
|
937
|
-
{},
|
|
938
|
-
{ tenantId: company.tenantId, organizationId: company.organizationId },
|
|
939
|
-
),
|
|
940
|
-
).length
|
|
941
|
-
})()
|
|
855
|
+
: countCompanyPeopleUnion(em, company)
|
|
942
856
|
const [
|
|
943
857
|
activityCount,
|
|
944
858
|
interactionCount,
|
|
@@ -234,7 +234,7 @@ export async function DELETE(req: Request, ctx: { params?: { id?: string; linkId
|
|
|
234
234
|
const { translate } = await resolveTranslations()
|
|
235
235
|
try {
|
|
236
236
|
const { id, linkId } = paramsSchema.parse({ id: ctx.params?.id, linkId: ctx.params?.linkId })
|
|
237
|
-
const { container, auth, selectedOrganizationId, person } = await loadPersonContext(req, id)
|
|
237
|
+
const { container, auth, selectedOrganizationId, person, profile } = await loadPersonContext(req, id)
|
|
238
238
|
if (!selectedOrganizationId) {
|
|
239
239
|
throw new CrudHttpError(400, { error: translate('customers.errors.organization_required', 'Organization context is required') })
|
|
240
240
|
}
|
|
@@ -262,18 +262,26 @@ export async function DELETE(req: Request, ctx: { params?: { id?: string; linkId
|
|
|
262
262
|
auth.tenantId,
|
|
263
263
|
selectedOrganizationId,
|
|
264
264
|
)
|
|
265
|
-
|
|
265
|
+
// No `CustomerPersonCompanyLink` row resolves for the profile-only association case
|
|
266
|
+
// (`CustomerPersonProfile.company` set without a link row, e.g. from a CRM migration).
|
|
267
|
+
// The delete command accepts that shape too, so it is dispatched instead of 404ing and
|
|
268
|
+
// the detach keeps its audit entry, undo token and cache invalidation (#5114).
|
|
269
|
+
const isProfileOnlyMatch =
|
|
270
|
+
profile.company && typeof profile.company !== 'string' && profile.company.id === linkId
|
|
271
|
+
if (!resolvedLinkId && !isProfileOnlyMatch) {
|
|
266
272
|
throw notFound(translate('customers.errors.person_company_link_not_found', 'Person-company link not found'))
|
|
267
273
|
}
|
|
268
274
|
|
|
269
275
|
const commandInput = personCompanyLinkDeleteSchema.parse({
|
|
270
|
-
|
|
276
|
+
...(resolvedLinkId
|
|
277
|
+
? { linkId: resolvedLinkId }
|
|
278
|
+
: { personEntityId: person.id, companyEntityId: linkId }),
|
|
271
279
|
tenantId: auth.tenantId,
|
|
272
280
|
organizationId: selectedOrganizationId,
|
|
273
281
|
} satisfies PersonCompanyLinkDeleteInput)
|
|
274
282
|
|
|
275
283
|
const commandBus = container.resolve('commandBus') as CommandBus
|
|
276
|
-
const { result, logEntry } = await commandBus.execute<PersonCompanyLinkDeleteInput, { linkId: string }>(
|
|
284
|
+
const { result, logEntry } = await commandBus.execute<PersonCompanyLinkDeleteInput, { linkId: string | null }>(
|
|
277
285
|
'customers.personCompanyLinks.delete',
|
|
278
286
|
{
|
|
279
287
|
input: commandInput,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { registerCommand } from '@open-mercato/shared/lib/commands'
|
|
2
|
-
import type { CommandHandler } from '@open-mercato/shared/lib/commands'
|
|
2
|
+
import type { CommandHandler, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
3
3
|
import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
|
|
4
4
|
import { emitCrudSideEffects, emitCrudUndoSideEffects } from '@open-mercato/shared/lib/commands/helpers'
|
|
5
5
|
import { CrudHttpError, notFound } from '@open-mercato/shared/lib/crud/errors'
|
|
@@ -24,14 +24,27 @@ import {
|
|
|
24
24
|
findDeletedPersonCompanyLink,
|
|
25
25
|
loadPersonCompanyLinks,
|
|
26
26
|
promoteFallbackPrimaryLink,
|
|
27
|
+
removePersonCompanyLink,
|
|
27
28
|
} from '../lib/personCompanies'
|
|
28
29
|
import {
|
|
30
|
+
emitQueryIndexUpsertEvents,
|
|
29
31
|
ensureOrganizationScope,
|
|
30
32
|
ensureTenantScope,
|
|
31
33
|
extractUndoPayload,
|
|
34
|
+
type QueryIndexEventEntry,
|
|
32
35
|
} from './shared'
|
|
36
|
+
import type { CustomersEventId } from '../events'
|
|
37
|
+
import { E } from '#generated/entities.ids.generated'
|
|
38
|
+
import type { EventBus } from '@open-mercato/events'
|
|
33
39
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
34
40
|
import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
|
|
41
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
42
|
+
|
|
43
|
+
const logger = createLogger('customers').child({ component: 'personCompanyLinks' })
|
|
44
|
+
|
|
45
|
+
// Typed against the module's declared event ids so removing or renaming the declaration in
|
|
46
|
+
// `events.ts` fails the build here rather than emitting an undeclared event at runtime.
|
|
47
|
+
const PROFILE_ONLY_DETACHED_EVENT: CustomersEventId = 'customers.person.company_assignment.detached'
|
|
35
48
|
|
|
36
49
|
type PersonCompanyLinkSnapshot = {
|
|
37
50
|
id: string
|
|
@@ -48,6 +61,65 @@ type PersonCompanyLinkUndoPayload = {
|
|
|
48
61
|
after?: PersonCompanyLinkSnapshot | null
|
|
49
62
|
}
|
|
50
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Snapshot of a legacy profile-only company assignment — `customer_person_profiles.company_id`
|
|
66
|
+
* set with no backing link row (#5114). It carries no link id because none exists; `kind`
|
|
67
|
+
* discriminates it from `PersonCompanyLinkSnapshot` in the delete command's undo payload.
|
|
68
|
+
*/
|
|
69
|
+
type PersonCompanyProfileAssignmentSnapshot = {
|
|
70
|
+
kind: 'profile-only'
|
|
71
|
+
personEntityId: string
|
|
72
|
+
companyEntityId: string
|
|
73
|
+
tenantId: string
|
|
74
|
+
organizationId: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type PersonCompanyLinkDeleteSnapshot = PersonCompanyLinkSnapshot | PersonCompanyProfileAssignmentSnapshot
|
|
78
|
+
|
|
79
|
+
type PersonCompanyLinkDeleteUndoPayload = {
|
|
80
|
+
before?: PersonCompanyLinkDeleteSnapshot | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type PersonCompanyLinkDeleteResult = {
|
|
84
|
+
linkId: string | null
|
|
85
|
+
personEntityId: string | null
|
|
86
|
+
companyEntityId: string | null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isProfileAssignmentSnapshot(
|
|
90
|
+
snapshot: PersonCompanyLinkDeleteSnapshot | null | undefined,
|
|
91
|
+
): snapshot is PersonCompanyProfileAssignmentSnapshot {
|
|
92
|
+
return Boolean(snapshot) && (snapshot as PersonCompanyProfileAssignmentSnapshot).kind === 'profile-only'
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function resolveProfileOnlyTarget(
|
|
96
|
+
parsed: PersonCompanyLinkDeleteInput,
|
|
97
|
+
): { personEntityId: string; companyEntityId: string } | null {
|
|
98
|
+
if (parsed.linkId) return null
|
|
99
|
+
if (!parsed.personEntityId || !parsed.companyEntityId) return null
|
|
100
|
+
return { personEntityId: parsed.personEntityId, companyEntityId: parsed.companyEntityId }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function personQueryIndexEntries(
|
|
104
|
+
person: CustomerEntity,
|
|
105
|
+
profile: CustomerPersonProfile,
|
|
106
|
+
): QueryIndexEventEntry[] {
|
|
107
|
+
return [
|
|
108
|
+
{
|
|
109
|
+
entityType: E.customers.customer_entity,
|
|
110
|
+
recordId: person.id,
|
|
111
|
+
tenantId: person.tenantId,
|
|
112
|
+
organizationId: person.organizationId,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
entityType: E.customers.customer_person_profile,
|
|
116
|
+
recordId: profile.id,
|
|
117
|
+
tenantId: profile.tenantId,
|
|
118
|
+
organizationId: profile.organizationId,
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
}
|
|
122
|
+
|
|
51
123
|
const personCompanyLinkCrudEvents: CrudEventsConfig = {
|
|
52
124
|
module: 'customers',
|
|
53
125
|
entity: 'person_company_link',
|
|
@@ -576,12 +648,144 @@ const updatePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkUpdateInpu
|
|
|
576
648
|
},
|
|
577
649
|
}
|
|
578
650
|
|
|
579
|
-
|
|
651
|
+
/**
|
|
652
|
+
* Detach a legacy profile-only company assignment: `customer_person_profiles.company_id`
|
|
653
|
+
* points at the company but no `customer_person_company_links` row was ever created, so
|
|
654
|
+
* there is no link row for the id-based branch to soft-delete (#5114). Running it as part
|
|
655
|
+
* of the same command keeps the audit entry, the undo token and the CRUD cache
|
|
656
|
+
* invalidation identical to a link-backed detach.
|
|
657
|
+
*/
|
|
658
|
+
async function detachProfileOnlyCompany(
|
|
659
|
+
em: EntityManager,
|
|
660
|
+
ctx: CommandRuntimeContext,
|
|
661
|
+
parsed: PersonCompanyLinkDeleteInput,
|
|
662
|
+
target: { personEntityId: string; companyEntityId: string },
|
|
663
|
+
): Promise<PersonCompanyLinkDeleteResult> {
|
|
664
|
+
const person = await requirePersonEntity(em, target.personEntityId, parsed.tenantId, parsed.organizationId)
|
|
665
|
+
const profile = await requirePersonProfile(em, person)
|
|
666
|
+
const assignedCompany = profile.company
|
|
667
|
+
const assignedCompanyId = typeof assignedCompany === 'string' ? assignedCompany : assignedCompany?.id ?? null
|
|
668
|
+
if (assignedCompanyId !== target.companyEntityId) {
|
|
669
|
+
throw notFound('Company link not found')
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
await withAtomicFlush(em, [
|
|
673
|
+
() => removePersonCompanyLink(em, person, profile, target.companyEntityId),
|
|
674
|
+
], { transaction: true })
|
|
675
|
+
|
|
676
|
+
// There is no link entity to hand to `emitCrudSideEffects`; what changed is the
|
|
677
|
+
// person's own company assignment, so reindex the person and its profile instead.
|
|
678
|
+
await emitQueryIndexUpsertEvents(ctx, personQueryIndexEntries(person, profile))
|
|
679
|
+
await emitProfileOnlyDetachedEvent(ctx, person, target.companyEntityId)
|
|
680
|
+
|
|
681
|
+
return { linkId: null, personEntityId: person.id, companyEntityId: target.companyEntityId }
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Live-refresh signal for the company People tab and the person Companies tab, which the
|
|
686
|
+
* link-backed branch gets from `customers.person_company_link.deleted` via `emitCrudSideEffects`.
|
|
687
|
+
* The profile-only branch has no link row to emit that for, so it publishes the sibling
|
|
688
|
+
* `clientBroadcast` event instead (#5114). `tenantId` must travel in the payload: the event bus
|
|
689
|
+
* only forwards a broadcast event cross-process when the payload carries a tenant scope. The
|
|
690
|
+
* write is already committed, so a failed refresh signal must never fail the detach.
|
|
691
|
+
*/
|
|
692
|
+
async function emitProfileOnlyDetachedEvent(
|
|
693
|
+
ctx: CommandRuntimeContext,
|
|
694
|
+
person: CustomerEntity,
|
|
695
|
+
companyEntityId: string,
|
|
696
|
+
): Promise<void> {
|
|
697
|
+
try {
|
|
698
|
+
const bus = ctx.container.resolve<EventBus>('eventBus')
|
|
699
|
+
await bus.emitEvent(
|
|
700
|
+
PROFILE_ONLY_DETACHED_EVENT,
|
|
701
|
+
{
|
|
702
|
+
linkId: null,
|
|
703
|
+
personEntityId: person.id,
|
|
704
|
+
companyEntityId,
|
|
705
|
+
tenantId: person.tenantId,
|
|
706
|
+
organizationId: person.organizationId,
|
|
707
|
+
},
|
|
708
|
+
{ tenantId: person.tenantId, organizationId: person.organizationId },
|
|
709
|
+
)
|
|
710
|
+
} catch (err) {
|
|
711
|
+
logger.warn('Profile-only company detach broadcast failed', {
|
|
712
|
+
command: 'customers.personCompanyLinks.delete',
|
|
713
|
+
personEntityId: person.id,
|
|
714
|
+
companyEntityId,
|
|
715
|
+
err,
|
|
716
|
+
})
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Undo counterpart of `detachProfileOnlyCompany`: re-point the person's legacy
|
|
722
|
+
* `profile.company` at the company it was detached from. The lookups run in the first
|
|
723
|
+
* flush phase and the assignment in the second so the encryption subscriber's
|
|
724
|
+
* re-baseline on find cannot drop the pending change (#2507).
|
|
725
|
+
*/
|
|
726
|
+
async function restoreProfileOnlyCompany(
|
|
727
|
+
ctx: CommandRuntimeContext,
|
|
728
|
+
before: PersonCompanyProfileAssignmentSnapshot,
|
|
729
|
+
): Promise<void> {
|
|
730
|
+
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
731
|
+
let person: CustomerEntity | null = null
|
|
732
|
+
let profile: CustomerPersonProfile | null = null
|
|
733
|
+
let company: CustomerEntity | null = null
|
|
734
|
+
|
|
735
|
+
await withAtomicFlush(em, [
|
|
736
|
+
async () => {
|
|
737
|
+
person = await findOneWithDecryption(
|
|
738
|
+
em,
|
|
739
|
+
CustomerEntity,
|
|
740
|
+
{ id: before.personEntityId, kind: 'person', tenantId: before.tenantId, organizationId: before.organizationId, deletedAt: null },
|
|
741
|
+
undefined,
|
|
742
|
+
{ tenantId: before.tenantId, organizationId: before.organizationId },
|
|
743
|
+
)
|
|
744
|
+
if (!person) return
|
|
745
|
+
profile = await findOneWithDecryption(
|
|
746
|
+
em,
|
|
747
|
+
CustomerPersonProfile,
|
|
748
|
+
{ entity: person },
|
|
749
|
+
{ populate: ['company'] },
|
|
750
|
+
{ tenantId: before.tenantId, organizationId: before.organizationId },
|
|
751
|
+
)
|
|
752
|
+
if (!profile) return
|
|
753
|
+
company = await findOneWithDecryption(
|
|
754
|
+
em,
|
|
755
|
+
CustomerEntity,
|
|
756
|
+
{ id: before.companyEntityId, kind: 'company', tenantId: before.tenantId, organizationId: before.organizationId, deletedAt: null },
|
|
757
|
+
undefined,
|
|
758
|
+
{ tenantId: before.tenantId, organizationId: before.organizationId },
|
|
759
|
+
)
|
|
760
|
+
},
|
|
761
|
+
() => {
|
|
762
|
+
if (profile && company) profile.company = company
|
|
763
|
+
},
|
|
764
|
+
], { transaction: true })
|
|
765
|
+
|
|
766
|
+
if (person && profile) {
|
|
767
|
+
await emitQueryIndexUpsertEvents(ctx, personQueryIndexEntries(person, profile))
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const deletePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkDeleteInput, PersonCompanyLinkDeleteResult> = {
|
|
580
772
|
id: 'customers.personCompanyLinks.delete',
|
|
581
773
|
async prepare(rawInput, ctx) {
|
|
582
774
|
const parsed = personCompanyLinkDeleteSchema.parse(rawInput)
|
|
775
|
+
const profileOnlyTarget = resolveProfileOnlyTarget(parsed)
|
|
776
|
+
if (profileOnlyTarget) {
|
|
777
|
+
return {
|
|
778
|
+
before: {
|
|
779
|
+
kind: 'profile-only',
|
|
780
|
+
personEntityId: profileOnlyTarget.personEntityId,
|
|
781
|
+
companyEntityId: profileOnlyTarget.companyEntityId,
|
|
782
|
+
tenantId: parsed.tenantId,
|
|
783
|
+
organizationId: parsed.organizationId,
|
|
784
|
+
} satisfies PersonCompanyProfileAssignmentSnapshot,
|
|
785
|
+
}
|
|
786
|
+
}
|
|
583
787
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
584
|
-
const snapshot = await loadPersonCompanyLinkSnapshot(em, parsed.linkId, {
|
|
788
|
+
const snapshot = await loadPersonCompanyLinkSnapshot(em, parsed.linkId as string, {
|
|
585
789
|
tenantId: ctx.auth?.tenantId ?? null,
|
|
586
790
|
organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
|
|
587
791
|
})
|
|
@@ -593,11 +797,16 @@ const deletePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkDeleteInpu
|
|
|
593
797
|
ensureOrganizationScope(ctx, parsed.organizationId)
|
|
594
798
|
|
|
595
799
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
800
|
+
const profileOnlyTarget = resolveProfileOnlyTarget(parsed)
|
|
801
|
+
if (profileOnlyTarget) {
|
|
802
|
+
return await detachProfileOnlyCompany(em, ctx, parsed, profileOnlyTarget)
|
|
803
|
+
}
|
|
804
|
+
|
|
596
805
|
const link = await findOneWithDecryption(
|
|
597
806
|
em,
|
|
598
807
|
CustomerPersonCompanyLink,
|
|
599
808
|
{
|
|
600
|
-
id: parsed.linkId,
|
|
809
|
+
id: parsed.linkId as string,
|
|
601
810
|
tenantId: parsed.tenantId,
|
|
602
811
|
organizationId: parsed.organizationId,
|
|
603
812
|
deletedAt: null,
|
|
@@ -647,13 +856,15 @@ const deletePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkDeleteInpu
|
|
|
647
856
|
indexer: { entityType: 'customers:customer_person_company_link' },
|
|
648
857
|
})
|
|
649
858
|
|
|
650
|
-
return { linkId: link.id }
|
|
859
|
+
return { linkId: link.id, personEntityId: personId, companyEntityId: companyId }
|
|
651
860
|
},
|
|
652
861
|
buildLog: async ({ result, snapshots }) => {
|
|
653
862
|
const { translate } = await resolveTranslations()
|
|
654
|
-
const before = snapshots.before as
|
|
863
|
+
const before = snapshots.before as PersonCompanyLinkDeleteSnapshot | undefined
|
|
655
864
|
return {
|
|
656
865
|
actionLabel: translate('customers.audit.personCompanyLinks.delete', 'Remove company link'),
|
|
866
|
+
// Kept as `customers.personCompanyLink` for the profile-only shape too: the company
|
|
867
|
+
// detail cache tags this resource kind, so both detach shapes must invalidate it.
|
|
657
868
|
resourceKind: 'customers.personCompanyLink',
|
|
658
869
|
resourceId: result.linkId,
|
|
659
870
|
parentResourceKind: 'customers.person',
|
|
@@ -664,15 +875,20 @@ const deletePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkDeleteInpu
|
|
|
664
875
|
payload: {
|
|
665
876
|
undo: {
|
|
666
877
|
before: before ?? null,
|
|
667
|
-
} satisfies
|
|
878
|
+
} satisfies PersonCompanyLinkDeleteUndoPayload,
|
|
668
879
|
},
|
|
669
880
|
}
|
|
670
881
|
},
|
|
671
882
|
undo: async ({ logEntry, ctx }) => {
|
|
672
|
-
const payload = extractUndoPayload<
|
|
883
|
+
const payload = extractUndoPayload<PersonCompanyLinkDeleteUndoPayload>(logEntry)
|
|
673
884
|
const before = payload?.before
|
|
674
885
|
if (!before) return
|
|
675
886
|
|
|
887
|
+
if (isProfileAssignmentSnapshot(before)) {
|
|
888
|
+
await restoreProfileOnlyCompany(ctx, before)
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
|
|
676
892
|
const em = (ctx.container.resolve('em') as EntityManager).fork()
|
|
677
893
|
const link = await findOneWithDecryption(
|
|
678
894
|
em,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
15
|
import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
16
16
|
import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
|
|
17
|
+
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
17
18
|
import type { SectionAction, TabEmptyStateConfig, Translator } from './types'
|
|
18
19
|
import { CreatePersonDialog } from './CreatePersonDialog'
|
|
19
20
|
import { PersonCard } from './PersonCard'
|
|
@@ -322,13 +323,19 @@ export function CompanyPeopleSection({
|
|
|
322
323
|
void loadVisiblePeople()
|
|
323
324
|
}, [loadVisiblePeople])
|
|
324
325
|
|
|
325
|
-
|
|
326
|
+
const reloadOnCompanyDetach = React.useCallback((event: AppEventPayload) => {
|
|
326
327
|
const payload = event.payload as { companyEntityId?: string | null } | null | undefined
|
|
327
328
|
if (payload && payload.companyEntityId === companyId) {
|
|
328
329
|
void loadVisiblePeople()
|
|
329
330
|
}
|
|
330
331
|
}, [companyId, loadVisiblePeople])
|
|
331
332
|
|
|
333
|
+
useAppEvent('customers.person_company_link.deleted', reloadOnCompanyDetach, [reloadOnCompanyDetach])
|
|
334
|
+
// Legacy profile-only assignments have no link row, so their detach broadcasts this sibling
|
|
335
|
+
// event instead of `customers.person_company_link.deleted` (#5114). Without it, other viewers
|
|
336
|
+
// of the same company keep listing a person who is already gone.
|
|
337
|
+
useAppEvent('customers.person.company_assignment.detached', reloadOnCompanyDetach, [reloadOnCompanyDetach])
|
|
338
|
+
|
|
332
339
|
React.useEffect(() => {
|
|
333
340
|
setListPage(1)
|
|
334
341
|
}, [searchQuery, sortMode])
|
|
@@ -10,6 +10,7 @@ import { Button } from '@open-mercato/ui/primitives/button'
|
|
|
10
10
|
import { Input } from '@open-mercato/ui/primitives/input'
|
|
11
11
|
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
12
12
|
import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
|
|
13
|
+
import type { AppEventPayload } from '@open-mercato/shared/modules/widgets/injection'
|
|
13
14
|
import { CompanyCard, type EnrichedCompanyData } from './CompanyCard'
|
|
14
15
|
import { useCustomerDictionary } from './hooks/useCustomerDictionary'
|
|
15
16
|
import { LinkEntityDialog, type LinkEntityOption } from '../linking/LinkEntityDialog'
|
|
@@ -369,13 +370,19 @@ export function PersonCompaniesSection({
|
|
|
369
370
|
[_personName, confirm, loadData, onChanged, personId, runWriteMutation, t, unlinkingId],
|
|
370
371
|
)
|
|
371
372
|
|
|
372
|
-
|
|
373
|
+
const reloadOnPersonDetach = React.useCallback((event: AppEventPayload) => {
|
|
373
374
|
const payload = event.payload as { personEntityId?: string | null } | null | undefined
|
|
374
375
|
if (payload && payload.personEntityId === personId) {
|
|
375
376
|
void loadData({ showLoading: false })
|
|
376
377
|
}
|
|
377
378
|
}, [personId, loadData])
|
|
378
379
|
|
|
380
|
+
useAppEvent('customers.person_company_link.deleted', reloadOnPersonDetach, [reloadOnPersonDetach])
|
|
381
|
+
// Legacy profile-only assignments have no link row, so their detach broadcasts this sibling
|
|
382
|
+
// event instead of `customers.person_company_link.deleted` (#5114). Without it, other viewers
|
|
383
|
+
// of the same person keep listing a company that is already detached.
|
|
384
|
+
useAppEvent('customers.person.company_assignment.detached', reloadOnPersonDetach, [reloadOnPersonDetach])
|
|
385
|
+
|
|
379
386
|
return (
|
|
380
387
|
<>
|
|
381
388
|
<div className="space-y-4">
|
|
@@ -774,9 +774,24 @@ export const personCompanyLinkUpdateSchema = scopedSchema.extend({
|
|
|
774
774
|
isPrimary: z.boolean(),
|
|
775
775
|
})
|
|
776
776
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
777
|
+
// Two shapes are accepted, because a person can belong to a company in two ways:
|
|
778
|
+
// through a `customer_person_company_links` row (`linkId`), or through a legacy
|
|
779
|
+
// profile-only assignment where `customer_person_profiles.company_id` is set and no
|
|
780
|
+
// link row was ever created (migrated CRM data, #5114). Both detaches go through the
|
|
781
|
+
// same command so audit, undo and cache invalidation stay consistent.
|
|
782
|
+
export const personCompanyLinkDeleteSchema = scopedSchema
|
|
783
|
+
.extend({
|
|
784
|
+
linkId: uuid().optional(),
|
|
785
|
+
personEntityId: uuid().optional(),
|
|
786
|
+
companyEntityId: uuid().optional(),
|
|
787
|
+
})
|
|
788
|
+
.refine(
|
|
789
|
+
(payload) => Boolean(payload.linkId) || Boolean(payload.personEntityId && payload.companyEntityId),
|
|
790
|
+
{
|
|
791
|
+
message: 'Provide either linkId or both personEntityId and companyEntityId.',
|
|
792
|
+
path: ['linkId'],
|
|
793
|
+
}
|
|
794
|
+
)
|
|
780
795
|
|
|
781
796
|
export type PersonCompanyLinkCreateInput = z.infer<typeof personCompanyLinkCreateSchema>
|
|
782
797
|
export type PersonCompanyLinkUpdateInput = z.infer<typeof personCompanyLinkUpdateSchema>
|