@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.
- 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/dist/modules/query_index/lib/search-tokens.js +79 -3
- package/dist/modules/query_index/lib/search-tokens.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
- 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
|
-
|
|
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>
|
|
@@ -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
|
-
|
|
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')
|