@open-mercato/core 0.6.8-develop.7012.1.1dbd6f5fbd → 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 (31) 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/dist/modules/query_index/lib/search-tokens.js +79 -3
  20. package/dist/modules/query_index/lib/search-tokens.js.map +2 -2
  21. package/package.json +7 -7
  22. package/src/modules/customers/api/companies/[id]/people/route.ts +20 -72
  23. package/src/modules/customers/api/companies/[id]/route.ts +11 -97
  24. package/src/modules/customers/api/people/[id]/companies/[linkId]/route.ts +12 -4
  25. package/src/modules/customers/commands/personCompanyLinks.ts +224 -8
  26. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -1
  27. package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +8 -1
  28. package/src/modules/customers/data/validators.ts +18 -3
  29. package/src/modules/customers/events.ts +5 -0
  30. package/src/modules/customers/lib/personCompanies.ts +136 -1
  31. package/src/modules/query_index/lib/search-tokens.ts +135 -3
@@ -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>
@@ -78,6 +78,11 @@ const events = [
78
78
  { id: 'customers.person_company_link.created', label: 'Person Linked To Company', entity: 'person_company_link', category: 'crud', clientBroadcast: true },
79
79
  { id: 'customers.person_company_link.updated', label: 'Person-Company Link Updated', entity: 'person_company_link', category: 'crud', clientBroadcast: true },
80
80
  { id: 'customers.person_company_link.deleted', label: 'Person Unlinked From Company', entity: 'person_company_link', category: 'crud', clientBroadcast: true },
81
+ // Legacy profile-only company assignments (`customer_person_profiles.company_id` set with no
82
+ // backing link row, #5114) have no link entity, so detaching one cannot honestly emit
83
+ // `customers.person_company_link.deleted` — that event's payload promises a link id that never
84
+ // existed. This sibling carries the same live-refresh signal for that shape instead.
85
+ { id: 'customers.person.company_assignment.detached', label: 'Legacy Company Assignment Detached', entity: 'person_company_link', category: 'lifecycle', clientBroadcast: true },
81
86
 
82
87
  // ── Email integration (2026-05-27) ────────────────────────────────────────
83
88
  { id: 'customers.email.linked', label: 'Email Linked To Person', entity: 'email_link', category: 'lifecycle', clientBroadcast: true },
@@ -56,6 +56,136 @@ async function requireCompany(
56
56
  return company
57
57
  }
58
58
 
59
+ export type CompanyPersonUnionEntry = {
60
+ entity: CustomerEntity
61
+ profile: CustomerPersonProfile | null
62
+ linkedAt: string | null
63
+ }
64
+
65
+ /**
66
+ * Resolve "people belonging to this company" as the union of active
67
+ * `CustomerPersonCompanyLink` rows and `CustomerPersonProfile.company`
68
+ * profile-only assignments (data that never got a link row, e.g. from
69
+ * migrated CRM imports). This is the single source of truth for both the
70
+ * company People tab list and its badge count — see #5114.
71
+ */
72
+ export async function loadCompanyPeopleUnion(
73
+ em: EntityManager,
74
+ company: CustomerEntity,
75
+ scope: { tenantId?: string | null; organizationId?: string | null },
76
+ ): Promise<CompanyPersonUnionEntry[]> {
77
+ const decryptionScope = {
78
+ tenantId: scope.tenantId ?? company.tenantId ?? null,
79
+ organizationId: scope.organizationId ?? company.organizationId ?? null,
80
+ }
81
+ const relatedPeopleById = new Map<string, CompanyPersonUnionEntry>()
82
+
83
+ const companyLinkWhere = await withActiveCustomerPersonCompanyLinkFilter(
84
+ em,
85
+ { company: company.id, organizationId: company.organizationId, tenantId: company.tenantId },
86
+ 'customers.companies.loadCompanyPeopleUnion',
87
+ )
88
+ const companyLinks = filterActivePersonCompanyLinks(
89
+ await findWithDecryption(
90
+ em,
91
+ CustomerPersonCompanyLink,
92
+ companyLinkWhere,
93
+ { populate: ['person', 'person.personProfile'], orderBy: { isPrimary: 'desc', createdAt: 'asc' } },
94
+ decryptionScope,
95
+ ),
96
+ )
97
+ companyLinks.forEach((link) => {
98
+ const entity = typeof link.person === 'string' ? null : link.person
99
+ if (!entity || entity.kind !== 'person' || entity.deletedAt) return
100
+ const personProfile =
101
+ entity.personProfile && typeof entity.personProfile !== 'string' ? entity.personProfile : null
102
+ relatedPeopleById.set(entity.id, {
103
+ entity,
104
+ profile: personProfile,
105
+ linkedAt: link.createdAt instanceof Date ? link.createdAt.toISOString() : null,
106
+ })
107
+ })
108
+
109
+ const profiles = await findWithDecryption(
110
+ em,
111
+ CustomerPersonProfile,
112
+ {
113
+ company: company.id,
114
+ tenantId: company.tenantId,
115
+ organizationId: company.organizationId,
116
+ entity: { deletedAt: null },
117
+ },
118
+ { populate: ['entity'] },
119
+ decryptionScope,
120
+ )
121
+ profiles.forEach((entry) => {
122
+ const entity = entry.entity as CustomerEntity | null
123
+ if (!entity || entity.kind !== 'person' || entity.deletedAt) return
124
+ if (!relatedPeopleById.has(entity.id)) {
125
+ relatedPeopleById.set(entity.id, {
126
+ entity,
127
+ profile: entry ?? null,
128
+ linkedAt: entry.createdAt instanceof Date ? entry.createdAt.toISOString() : null,
129
+ })
130
+ }
131
+ })
132
+
133
+ return Array.from(relatedPeopleById.values())
134
+ }
135
+
136
+ /**
137
+ * Count-only sibling of `loadCompanyPeopleUnion` for the company People badge, which
138
+ * needs the size of the union and nothing else. Both queries project just the foreign
139
+ * key column and push the `kind: 'person'` / `deletedAt: null` predicates into the
140
+ * WHERE clause, so no person row is hydrated and no PII is decrypted to produce a
141
+ * number — `findWithDecryption` is deliberately not used here because the projections
142
+ * select no encrypted column.
143
+ */
144
+ export async function countCompanyPeopleUnion(
145
+ em: EntityManager,
146
+ company: CustomerEntity,
147
+ ): Promise<number> {
148
+ const personIds = new Set<string>()
149
+
150
+ const companyLinkWhere = await withActiveCustomerPersonCompanyLinkFilter(
151
+ em,
152
+ {
153
+ company: company.id,
154
+ organizationId: company.organizationId,
155
+ tenantId: company.tenantId,
156
+ person: { kind: 'person', deletedAt: null },
157
+ },
158
+ 'customers.companies.countCompanyPeopleUnion',
159
+ )
160
+ const companyLinks = filterActivePersonCompanyLinks(
161
+ await em.find(CustomerPersonCompanyLink, companyLinkWhere as any, {
162
+ fields: ['person', 'deletedAt'],
163
+ } as any),
164
+ )
165
+ companyLinks.forEach((link) => {
166
+ const personId = typeof link.person === 'string' ? link.person : link.person?.id
167
+ if (personId) personIds.add(personId)
168
+ })
169
+
170
+ const profiles = await em.find(
171
+ CustomerPersonProfile,
172
+ {
173
+ company: company.id,
174
+ tenantId: company.tenantId,
175
+ organizationId: company.organizationId,
176
+ entity: { kind: 'person', deletedAt: null },
177
+ } as any,
178
+ { fields: ['entity'] } as any,
179
+ )
180
+ profiles.forEach((entry) => {
181
+ const entity = entry.entity as CustomerEntity | string | null
182
+ const entityId = typeof entity === 'string' ? entity : entity?.id
183
+ if (entityId) personIds.add(entityId)
184
+ })
185
+
186
+ return personIds.size
187
+ }
188
+
59
189
  export async function loadPersonCompanyLinks(
60
190
  em: EntityManager,
61
191
  person: CustomerEntity,
@@ -298,7 +428,12 @@ export async function removePersonCompanyLink(
298
428
 
299
429
  if (!link) {
300
430
  if (profile.company && typeof profile.company !== 'string' && profile.company.id === linkId) {
301
- profile.company = null
431
+ // Profile-only assignment: no link row backs it, so only the legacy field is
432
+ // cleared. It must keep mirroring the primary link the way every other detach
433
+ // path leaves it, so promote a remaining primary link instead of nulling blindly.
434
+ const primary = existingLinks.find((entry) => entry.isPrimary) ?? null
435
+ const primaryCompany = primary ? resolveLinkedCompany(primary) : null
436
+ profile.company = primaryCompany ?? null
302
437
  return
303
438
  }
304
439
  throw notFound('Company link not found')