@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
|
@@ -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')
|