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