@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
@@ -157,6 +157,34 @@ async function deleteSearchTokensForRecord(db, params, options) {
157
157
  const executor = options?.trx ?? db;
158
158
  await executor.deleteFrom("search_tokens").where("entity_type", "=", params.entityType).where("entity_id", "=", String(params.recordId)).where(sql`organization_id is not distinct from ${organizationId}`).where(sql`tenant_id is not distinct from ${tenantId}`).execute();
159
159
  }
160
+ const SIGNATURE_SEPARATOR = String.fromCharCode(0);
161
+ function tokenSignature(row) {
162
+ return [
163
+ String(row.field ?? ""),
164
+ String(row.token_hash ?? ""),
165
+ row.token == null ? "" : String(row.token)
166
+ ].join(SIGNATURE_SEPARATOR);
167
+ }
168
+ function tallyEquals(a, b) {
169
+ const left = a ?? /* @__PURE__ */ new Map();
170
+ const right = b ?? /* @__PURE__ */ new Map();
171
+ if (left.size !== right.size) return false;
172
+ for (const [key, count] of left.entries()) {
173
+ if (right.get(key) !== count) return false;
174
+ }
175
+ return true;
176
+ }
177
+ function tallyTokenRows(rows, keyOf) {
178
+ const tallies = /* @__PURE__ */ new Map();
179
+ for (const row of rows) {
180
+ const key = keyOf(row);
181
+ const tally = tallies.get(key) ?? /* @__PURE__ */ new Map();
182
+ const signature = tokenSignature(row);
183
+ tally.set(signature, (tally.get(signature) ?? 0) + 1);
184
+ tallies.set(key, tally);
185
+ }
186
+ return tallies;
187
+ }
160
188
  async function replaceSearchTokensForBatch(db, payloads) {
161
189
  if (!payloads.length) return;
162
190
  const config = resolveSearchConfig();
@@ -179,12 +207,60 @@ async function replaceSearchTokensForBatch(db, payloads) {
179
207
  bucket.ids.add(String(payload.recordId));
180
208
  scopeBuckets.set(key, bucket);
181
209
  }
210
+ const recordKeyOf = (row) => `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`;
211
+ const builtTally = tallyTokenRows(rows, recordKeyOf);
212
+ const changedIdsByBucket = /* @__PURE__ */ new Map();
213
+ for (const [key, bucket] of scopeBuckets.entries()) {
214
+ const ids = Array.from(bucket.ids);
215
+ const builtCountById = /* @__PURE__ */ new Map();
216
+ for (const id of ids) {
217
+ let total = 0;
218
+ const tally = builtTally.get(`${key}|${id}`);
219
+ if (tally) for (const count of tally.values()) total += count;
220
+ builtCountById.set(id, total);
221
+ }
222
+ const storedCounts = await db.selectFrom("search_tokens").select(["entity_id", sql`count(*)`.as("token_count")]).where("entity_type", "=", payloads[0].entityType).where(sql`organization_id is not distinct from ${bucket.organizationId}`).where(sql`tenant_id is not distinct from ${bucket.tenantId}`).where("entity_id", "in", ids).groupBy("entity_id").execute();
223
+ const storedCountById = /* @__PURE__ */ new Map();
224
+ for (const row of storedCounts) {
225
+ storedCountById.set(String(row.entity_id), Number(row.token_count));
226
+ }
227
+ const changed = /* @__PURE__ */ new Set();
228
+ const contentCandidates = ids.filter((id) => {
229
+ const builtCount = builtCountById.get(id) ?? 0;
230
+ if ((storedCountById.get(id) ?? 0) !== builtCount) {
231
+ changed.add(id);
232
+ return false;
233
+ }
234
+ return builtCount > 0;
235
+ });
236
+ if (contentCandidates.length) {
237
+ const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0);
238
+ const stored = await db.selectFrom("search_tokens").select(["entity_id", "field", "token_hash", "token"]).where("entity_type", "=", payloads[0].entityType).where(sql`organization_id is not distinct from ${bucket.organizationId}`).where(sql`tenant_id is not distinct from ${bucket.tenantId}`).where("entity_id", "in", contentCandidates).limit(rowBudget).execute();
239
+ const storedTally = tallyTokenRows(stored, (row) => String(row.entity_id));
240
+ for (const id of contentCandidates) {
241
+ if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id);
242
+ }
243
+ }
244
+ changedIdsByBucket.set(key, changed);
245
+ }
246
+ const changedRecordKeys = /* @__PURE__ */ new Set();
247
+ for (const [key, changed] of changedIdsByBucket.entries()) {
248
+ for (const id of changed) changedRecordKeys.add(`${key}|${id}`);
249
+ }
250
+ debug("batch.skip", {
251
+ entityType: payloads[0].entityType,
252
+ recordCount: payloads.length,
253
+ changedCount: changedRecordKeys.size
254
+ });
255
+ if (!changedRecordKeys.size) return;
182
256
  await db.transaction().execute(async (trx) => {
183
- for (const [, bucket] of scopeBuckets.entries()) {
184
- const deleteQuery = trx.deleteFrom("search_tokens").where("entity_type", "=", payloads[0].entityType).where(sql`organization_id is not distinct from ${bucket.organizationId}`).where(sql`tenant_id is not distinct from ${bucket.tenantId}`).where("entity_id", "in", Array.from(bucket.ids));
257
+ for (const [key, bucket] of scopeBuckets.entries()) {
258
+ const changed = changedIdsByBucket.get(key);
259
+ if (!changed?.size) continue;
260
+ const deleteQuery = trx.deleteFrom("search_tokens").where("entity_type", "=", payloads[0].entityType).where(sql`organization_id is not distinct from ${bucket.organizationId}`).where(sql`tenant_id is not distinct from ${bucket.tenantId}`).where("entity_id", "in", Array.from(changed));
185
261
  await deleteQuery.execute();
186
262
  }
187
- const payloadWithTimestamps = rows.map((row) => ({ ...row, created_at: sql`now()` }));
263
+ const payloadWithTimestamps = rows.filter((row) => changedRecordKeys.has(recordKeyOf(row))).map((row) => ({ ...row, created_at: sql`now()` }));
188
264
  for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {
189
265
  await trx.insertInto("search_tokens").values(batch).execute();
190
266
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/query_index/lib/search-tokens.ts"],
4
- "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n await db.transaction().execute(async (trx) => {\n for (const [, bucket] of scopeBuckets.entries()) {\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(bucket.ids))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { type Kysely, type Transaction, sql } from 'kysely'\nimport {\n isSearchFieldBlocklisted,\n resolveSearchConfig,\n resolveSearchTokenLimits,\n type SearchConfig,\n} from '@open-mercato/shared/lib/search/config'\nimport { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'\nimport { parseBooleanToken } from '@open-mercato/shared/lib/boolean'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('query_index').child({ component: 'search-tokens' })\n\nconst INSERT_BATCH_SIZE = 500\n\nfunction chunk<T>(items: T[], size: number): T[][] {\n if (size <= 0) return [items]\n const out: T[][] = []\n for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))\n return out\n}\n\nexport type SearchTokenRow = {\n entity_type: string\n entity_id: string\n organization_id: string | null\n tenant_id: string | null\n field: string\n token_hash: string\n token?: string | null\n}\n\ntype BuildTokenOptions = {\n entityType: string\n recordId: string\n organizationId?: string | null\n tenantId?: string | null\n doc?: Record<string, unknown> | null\n config?: SearchConfig\n}\n\nconst DEFAULT_SCOPE = { organizationId: null, tenantId: null }\ntype EntityFieldPair = [string, string]\ntype SearchTokenExecutor = Kysely<any> | Transaction<any>\n\nexport const isSearchDebugEnabled = (): boolean => {\n return parseBooleanToken(process.env.OM_SEARCH_DEBUG ?? '') === true\n}\n\nconst debug = (event: string, payload: Record<string, unknown>) => {\n if (!isSearchDebugEnabled()) return\n try {\n logger.debug('Search token event', { event, payload })\n } catch {\n // ignore\n }\n}\n\nfunction collectTextValues(value: unknown): string[] {\n if (typeof value === 'string') return [value]\n if (Array.isArray(value)) {\n const out: string[] = []\n for (const entry of value) {\n if (typeof entry === 'string') out.push(entry)\n }\n return out\n }\n return []\n}\n\nfunction shouldIndexField(\n field: string,\n value: unknown,\n config: SearchConfig,\n entityType: string | null,\n): boolean {\n if (typeof value !== 'string' && !Array.isArray(value)) return false\n const lower = field.toLowerCase()\n if (lower === 'id' || lower.endsWith('_id') || lower.endsWith('.id')) return false\n if (lower.endsWith('_at')) return false\n if (['created_at', 'updated_at', 'deleted_at', 'tenant_id', 'organization_id'].includes(lower)) return false\n if (isSearchFieldBlocklisted(field, entityType, config)) return false\n return collectTextValues(value).some((text) => text.length > 0)\n}\n\nexport function buildSearchTokenRows(params: BuildTokenOptions): SearchTokenRow[] {\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return []\n if (!params.doc) return []\n const tokens: SearchTokenRow[] = []\n const capturePairs = isSearchDebugEnabled() && params.entityType === 'customers:customer_deal'\n const debugPairs: Array<{ field: string; hash: string }> = []\n const scope = {\n organizationId: params.organizationId ?? DEFAULT_SCOPE.organizationId,\n tenantId: params.tenantId ?? DEFAULT_SCOPE.tenantId,\n }\n const limits = resolveSearchTokenLimits(config)\n const recordLimit = limits.maxTokensPerRecord > 0 ? limits.maxTokensPerRecord : Number.POSITIVE_INFINITY\n const fieldLimit = limits.maxTokensPerField > 0 ? limits.maxTokensPerField : Number.POSITIVE_INFINITY\n\n for (const [field, rawValue] of Object.entries(params.doc)) {\n if (tokens.length >= recordLimit) break\n if (!shouldIndexField(field, rawValue, config, params.entityType)) continue\n const values = collectTextValues(rawValue)\n const seen = new Set<string>()\n let fieldTokenCount = 0\n for (const text of values) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const remainingLimit = Math.min(recordLimit - tokens.length, fieldLimit - fieldTokenCount)\n const candidateLimit = fieldTokenCount + remainingLimit\n const tokenConfig = Number.isFinite(candidateLimit)\n ? { ...config, maxTokensPerField: candidateLimit }\n : config\n const { tokens: textTokens, hashes } = tokenizeText(text, tokenConfig)\n for (let i = 0; i < textTokens.length; i += 1) {\n if (tokens.length >= recordLimit || fieldTokenCount >= fieldLimit) break\n const token = textTokens[i]\n const hash = hashes[i]\n const dedupeKey = `${field}|${hash}`\n if (seen.has(dedupeKey)) continue\n seen.add(dedupeKey)\n fieldTokenCount += 1\n debug('token.generated', { entityType: params.entityType, recordId: params.recordId, field, hash })\n tokens.push({\n entity_type: params.entityType,\n entity_id: String(params.recordId),\n organization_id: scope.organizationId,\n tenant_id: scope.tenantId,\n field,\n token_hash: hash,\n token: config.storeRawTokens ? token : null,\n })\n if (capturePairs) {\n debugPairs.push({ field, hash })\n }\n }\n }\n }\n if (capturePairs) {\n debug('deal.tokens', {\n entityType: params.entityType,\n recordId: params.recordId,\n tokenCount: debugPairs.length,\n tokens: debugPairs,\n })\n }\n debug('doc.completed', { entityType: params.entityType, recordId: params.recordId, tokenCount: tokens.length })\n\n return tokens\n}\n\nfunction buildFieldPairs(recordId: string, doc?: Record<string, unknown> | null): EntityFieldPair[] {\n if (!doc) return []\n const pairs: EntityFieldPair[] = []\n const dedupe = new Set<string>()\n for (const field of Object.keys(doc)) {\n const key = `${recordId}|${field}`\n if (dedupe.has(key)) continue\n dedupe.add(key)\n pairs.push([recordId, field])\n }\n return pairs\n}\n\nexport async function replaceSearchTokensForRecord(\n db: Kysely<any>,\n params: BuildTokenOptions,\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const rows = buildSearchTokenRows(params)\n const config = params.config ?? resolveSearchConfig()\n if (!config.enabled) return\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const fieldPairs = buildFieldPairs(String(params.recordId), params.doc)\n\n const writeTokens = async (executor: SearchTokenExecutor): Promise<void> => {\n let deleteQuery = executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n if (fieldPairs.length) {\n deleteQuery = deleteQuery.where((eb: any) => eb.or(\n fieldPairs.map(([rid, field]) => eb.and([\n eb('entity_id' as any, '=', rid),\n eb('field' as any, '=', field),\n ])),\n ))\n } else {\n deleteQuery = deleteQuery.where('entity_id' as any, '=', String(params.recordId))\n }\n await deleteQuery.execute()\n if (!rows.length) return\n const payloads = rows.map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloads, INSERT_BATCH_SIZE)) {\n await executor.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n }\n\n if (options?.trx) {\n await writeTokens(options.trx)\n return\n }\n\n await db.transaction().execute(writeTokens)\n}\n\nexport async function deleteSearchTokensForRecord(\n db: Kysely<any>,\n params: { entityType: string; recordId: string; organizationId?: string | null; tenantId?: string | null },\n options?: { trx?: SearchTokenExecutor },\n): Promise<void> {\n const organizationId = params.organizationId ?? null\n const tenantId = params.tenantId ?? null\n const executor = options?.trx ?? db\n await executor\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', params.entityType)\n .where('entity_id' as any, '=', String(params.recordId))\n .where(sql<boolean>`organization_id is not distinct from ${organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${tenantId}`)\n .execute()\n}\n\n// NUL, not a printable separator: a field name may itself contain a space, so `a b` + hash `c`\n// would otherwise sign identically to field `a` + hash `b c`.\nconst SIGNATURE_SEPARATOR = String.fromCharCode(0)\n\n// Identifies one token row for comparison. `token` is NULL unless `storeRawTokens` is on, and a\n// stored NULL has to sign the same as the `null` a freshly built row carries \u2014 otherwise every\n// record compares as changed and the skip never fires.\nfunction tokenSignature(row: { field?: unknown; token_hash?: unknown; token?: unknown }): string {\n return [\n String(row.field ?? ''),\n String(row.token_hash ?? ''),\n row.token == null ? '' : String(row.token),\n ].join(SIGNATURE_SEPARATOR)\n}\n\n// Multiplicities, not sets: #4681 reports token rows duplicated by the concurrent-replacement\n// defect, and a set comparison reads such a record as already correct and preserves the duplicates\n// forever. Counting sends it through a full rewrite, which collapses them.\nfunction tallyEquals(a: Map<string, number> | undefined, b: Map<string, number> | undefined): boolean {\n const left = a ?? new Map<string, number>()\n const right = b ?? new Map<string, number>()\n if (left.size !== right.size) return false\n for (const [key, count] of left.entries()) {\n if (right.get(key) !== count) return false\n }\n return true\n}\n\nfunction tallyTokenRows<TRow extends { field?: unknown; token_hash?: unknown; token?: unknown }>(\n rows: Iterable<TRow>,\n keyOf: (row: TRow) => string\n): Map<string, Map<string, number>> {\n const tallies = new Map<string, Map<string, number>>()\n for (const row of rows) {\n const key = keyOf(row)\n const tally = tallies.get(key) ?? new Map<string, number>()\n const signature = tokenSignature(row)\n tally.set(signature, (tally.get(signature) ?? 0) + 1)\n tallies.set(key, tally)\n }\n return tallies\n}\n\nexport async function replaceSearchTokensForBatch(\n db: Kysely<any>,\n payloads: Array<BuildTokenOptions & { doc: Record<string, unknown> }>\n): Promise<void> {\n if (!payloads.length) return\n const config = resolveSearchConfig()\n if (!config.enabled) return\n\n const rows = payloads.flatMap((payload) => buildSearchTokenRows({ ...payload, config }))\n if (!rows.length) {\n const entityType = payloads[0]?.entityType\n if (!entityType) return\n const ids = payloads.map((p) => String(p.recordId))\n await db\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', entityType)\n .where('entity_id' as any, 'in', ids)\n .execute()\n return\n }\n\n const scopeKey = (org: string | null, tenant: string | null) => `${org ?? '__null__'}|${tenant ?? '__null__'}`\n const scopeBuckets = new Map<string, { organizationId: string | null; tenantId: string | null; ids: Set<string> }>()\n\n for (const payload of payloads) {\n const org = payload.organizationId ?? null\n const tenant = payload.tenantId ?? null\n const key = scopeKey(org, tenant)\n const bucket = scopeBuckets.get(key) ?? { organizationId: org, tenantId: tenant, ids: new Set<string>() }\n bucket.ids.add(String(payload.recordId))\n scopeBuckets.set(key, bucket)\n }\n\n const recordKeyOf = (row: SearchTokenRow) =>\n `${scopeKey(row.organization_id ?? null, row.tenant_id ?? null)}|${String(row.entity_id)}`\n const builtTally = tallyTokenRows(rows, recordKeyOf)\n\n // Read outside the transaction, deliberately. The comparison decides only whether to skip a\n // rewrite, so a concurrent writer costs us at most a rewrite we declined \u2014 declined because the\n // table already held exactly the rows this call wanted to write. One ordering is worth naming\n // though: if the read matches and a concurrent writer then commits tokens built from a *staler*\n // doc, the unconditional rewrite this call used to perform would have overwritten them by\n // accident. It no longer does, so those stale rows survive until the record's next write. That\n // is a repair we lose, not a guarantee we break.\n const changedIdsByBucket = new Map<string, Set<string>>()\n for (const [key, bucket] of scopeBuckets.entries()) {\n const ids = Array.from(bucket.ids)\n const builtCountById = new Map<string, number>()\n for (const id of ids) {\n let total = 0\n const tally = builtTally.get(`${key}|${id}`)\n if (tally) for (const count of tally.values()) total += count\n builtCountById.set(id, total)\n }\n\n // Count probe first. Its result is one row per record in the batch, so it is bounded by the\n // batch size \u2014 unlike a bare row read, which would be bounded only by how many token rows the\n // table already holds for these ids, a quantity this function does not control and (per #4681)\n // has no reason to trust.\n const storedCounts = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, sql<number>`count(*)`.as('token_count') as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', ids)\n .groupBy('entity_id' as any)\n .execute()\n const storedCountById = new Map<string, number>()\n for (const row of storedCounts as any[]) {\n storedCountById.set(String(row.entity_id), Number(row.token_count))\n }\n\n const changed = new Set<string>()\n // A record whose stored row count already differs is changed, whatever the rows say \u2014 the\n // duplicate case from #4681 resolves here without ever materializing the duplicated rows.\n const contentCandidates = ids.filter((id) => {\n const builtCount = builtCountById.get(id) ?? 0\n if ((storedCountById.get(id) ?? 0) !== builtCount) {\n changed.add(id)\n return false\n }\n return builtCount > 0\n })\n\n if (contentCandidates.length) {\n const rowBudget = contentCandidates.reduce((sum, id) => sum + (builtCountById.get(id) ?? 0), 0)\n const stored = await db\n .selectFrom('search_tokens' as any)\n .select(['entity_id' as any, 'field' as any, 'token_hash' as any, 'token' as any])\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', contentCandidates)\n // Counts already match, so this cannot truncate \u2014 it bounds the damage if a concurrent\n // writer inserts between the probe and this read. A truncated read compares as changed,\n // which costs a rewrite rather than a wrong skip.\n .limit(rowBudget)\n .execute()\n const storedTally = tallyTokenRows(stored as any[], (row) => String(row.entity_id))\n for (const id of contentCandidates) {\n if (!tallyEquals(builtTally.get(`${key}|${id}`), storedTally.get(id))) changed.add(id)\n }\n }\n changedIdsByBucket.set(key, changed)\n }\n\n const changedRecordKeys = new Set<string>()\n for (const [key, changed] of changedIdsByBucket.entries()) {\n for (const id of changed) changedRecordKeys.add(`${key}|${id}`)\n }\n debug('batch.skip', {\n entityType: payloads[0].entityType,\n recordCount: payloads.length,\n changedCount: changedRecordKeys.size,\n })\n if (!changedRecordKeys.size) return\n\n await db.transaction().execute(async (trx) => {\n for (const [key, bucket] of scopeBuckets.entries()) {\n const changed = changedIdsByBucket.get(key)\n if (!changed?.size) continue\n // Delete by entity_id: a batch replaces all of a record's tokens, and a per-field OR over the\n // whole batch overflows the query compiler's call stack on large batches.\n const deleteQuery = trx\n .deleteFrom('search_tokens' as any)\n .where('entity_type' as any, '=', payloads[0].entityType)\n .where(sql<boolean>`organization_id is not distinct from ${bucket.organizationId}`)\n .where(sql<boolean>`tenant_id is not distinct from ${bucket.tenantId}`)\n .where('entity_id' as any, 'in', Array.from(changed))\n await deleteQuery.execute()\n }\n const payloadWithTimestamps = rows\n .filter((row) => changedRecordKeys.has(recordKeyOf(row)))\n .map((row) => ({ ...row, created_at: sql`now()` }))\n for (const batch of chunk(payloadWithTimestamps, INSERT_BATCH_SIZE)) {\n await trx.insertInto('search_tokens' as any).values(batch as any).execute()\n }\n })\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.8-develop.7012.1.1dbd6f5fbd",
3
+ "version": "0.6.8-develop.7015.1.af90a2ddc7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -254,16 +254,16 @@
254
254
  "zod": "^4.4.3"
255
255
  },
256
256
  "peerDependencies": {
257
- "@open-mercato/ai-assistant": "0.6.8-develop.7012.1.1dbd6f5fbd",
258
- "@open-mercato/shared": "0.6.8-develop.7012.1.1dbd6f5fbd",
259
- "@open-mercato/ui": "0.6.8-develop.7012.1.1dbd6f5fbd",
257
+ "@open-mercato/ai-assistant": "0.6.8-develop.7015.1.af90a2ddc7",
258
+ "@open-mercato/shared": "0.6.8-develop.7015.1.af90a2ddc7",
259
+ "@open-mercato/ui": "0.6.8-develop.7015.1.af90a2ddc7",
260
260
  "react": "^19.0.0",
261
261
  "react-dom": "^19.0.0"
262
262
  },
263
263
  "devDependencies": {
264
- "@open-mercato/ai-assistant": "0.6.8-develop.7012.1.1dbd6f5fbd",
265
- "@open-mercato/shared": "0.6.8-develop.7012.1.1dbd6f5fbd",
266
- "@open-mercato/ui": "0.6.8-develop.7012.1.1dbd6f5fbd",
264
+ "@open-mercato/ai-assistant": "0.6.8-develop.7015.1.af90a2ddc7",
265
+ "@open-mercato/shared": "0.6.8-develop.7015.1.af90a2ddc7",
266
+ "@open-mercato/ui": "0.6.8-develop.7015.1.af90a2ddc7",
267
267
  "@testing-library/dom": "^10.4.1",
268
268
  "@testing-library/jest-dom": "^7.0.0",
269
269
  "@testing-library/react": "^16.3.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,