@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.
Files changed (28) hide show
  1. package/dist/modules/customers/api/companies/[id]/people/route.js +19 -63
  2. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  3. package/dist/modules/customers/api/companies/[id]/route.js +9 -85
  4. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  5. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js +4 -3
  6. package/dist/modules/customers/api/people/[id]/companies/[linkId]/route.js.map +2 -2
  7. package/dist/modules/customers/commands/personCompanyLinks.js +131 -2
  8. package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
  9. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +3 -1
  10. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  11. package/dist/modules/customers/components/detail/PersonCompaniesSection.js +3 -1
  12. package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
  13. package/dist/modules/customers/data/validators.js +10 -2
  14. package/dist/modules/customers/data/validators.js.map +2 -2
  15. package/dist/modules/customers/events.js +5 -0
  16. package/dist/modules/customers/events.js.map +2 -2
  17. package/dist/modules/customers/lib/personCompanies.js +100 -2
  18. package/dist/modules/customers/lib/personCompanies.js.map +2 -2
  19. package/package.json +7 -7
  20. package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
  21. package/src/modules/customers/api/companies/[id]/route.ts +11 -97
  22. package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
  23. package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
  24. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
  25. package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
  26. package/src/modules/customers/data/validators.ts +18 -3
  27. package/src/modules/customers/events.ts +5 -0
  28. 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, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
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
- CustomerEntity,
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 linkWhere = await withActiveCustomerPersonCompanyLinkFilter(
128
- em,
129
- {
130
- company: company.id,
131
- tenantId: company.tenantId,
132
- organizationId: company.organizationId,
133
- },
134
- 'customers.companies.people.GET',
135
- )
136
- const links = filterActivePersonCompanyLinks(
137
- await findWithDecryption(
138
- em,
139
- CustomerPersonCompanyLink,
140
- linkWhere,
141
- { populate: ['person'] },
142
- entityScope,
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
- filterActivePersonCompanyLinks,
51
- withActiveCustomerPersonCompanyLinkFilter,
52
- } from '../../../lib/personCompanyLinkTable'
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
- let relatedPeople: Array<{
805
- entity: CustomerEntity
806
- profile: CustomerPersonProfile | null
807
- linkedAt: string | null
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
- const peopleDecryptionScope = {
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
- : (async () => {
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
- if (!resolvedLinkId) {
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
- linkId: resolvedLinkId,
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
- const deletePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkDeleteInput, { linkId: string }> = {
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 PersonCompanyLinkSnapshot | undefined
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 PersonCompanyLinkUndoPayload,
878
+ } satisfies PersonCompanyLinkDeleteUndoPayload,
668
879
  },
669
880
  }
670
881
  },
671
882
  undo: async ({ logEntry, ctx }) => {
672
- const payload = extractUndoPayload<PersonCompanyLinkUndoPayload>(logEntry)
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
- useAppEvent('customers.person_company_link.deleted', (event) => {
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
- useAppEvent('customers.person_company_link.deleted', (event) => {
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
- export const personCompanyLinkDeleteSchema = scopedSchema.extend({
778
- linkId: uuid(),
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>