@open-mercato/core 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7130.1.fef2396fd8

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 (35) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/deals/aggregate/route.js +85 -0
  3. package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
  4. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +5 -117
  5. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  6. package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -3
  7. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  8. package/dist/modules/customers/commands/deals.js +33 -9
  9. package/dist/modules/customers/commands/deals.js.map +3 -3
  10. package/dist/modules/customers/components/AddressEditor.js +71 -1
  11. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  12. package/dist/modules/customers/utils/addressFormat.js +18 -2
  13. package/dist/modules/customers/utils/addressFormat.js.map +2 -2
  14. package/dist/modules/sales/components/documents/AddressesSection.js +17 -2
  15. package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
  16. package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -24
  17. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  18. package/dist/modules/sales/components/documents/normalizeAddressDraft.js +29 -0
  19. package/dist/modules/sales/components/documents/normalizeAddressDraft.js.map +7 -0
  20. package/package.json +7 -7
  21. package/src/modules/customers/api/deals/aggregate/route.ts +102 -0
  22. package/src/modules/customers/backend/customers/deals/[id]/hooks/types.ts +0 -23
  23. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +11 -172
  24. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
  25. package/src/modules/customers/commands/deals.ts +84 -9
  26. package/src/modules/customers/components/AddressEditor.tsx +116 -1
  27. package/src/modules/customers/i18n/de.json +6 -0
  28. package/src/modules/customers/i18n/en.json +6 -0
  29. package/src/modules/customers/i18n/es.json +6 -0
  30. package/src/modules/customers/i18n/ko.json +6 -0
  31. package/src/modules/customers/i18n/pl.json +6 -0
  32. package/src/modules/customers/utils/addressFormat.tsx +59 -1
  33. package/src/modules/sales/components/documents/AddressesSection.tsx +48 -5
  34. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +5 -23
  35. package/src/modules/sales/components/documents/normalizeAddressDraft.ts +29 -0
@@ -1,7 +1,9 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { NextResponse } from 'next/server'
2
3
  import { z } from 'zod'
3
4
  import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
4
5
  import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
6
+ import { runWithCacheTenant } from '@open-mercato/cache'
5
7
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
6
8
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
7
9
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
@@ -11,6 +13,12 @@ import type { ExchangeRateService } from '@open-mercato/core/modules/currencies/
11
13
  import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
12
14
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
13
15
  import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
16
+ import {
17
+ buildCollectionTags,
18
+ debugCrudCache,
19
+ isCrudCacheEnabled,
20
+ resolveCrudCache,
21
+ } from '@open-mercato/shared/lib/crud/cache'
14
22
  import { readQueryParamList } from '@open-mercato/shared/lib/crud/query-params'
15
23
  import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
16
24
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
@@ -21,6 +29,8 @@ import { E } from '#generated/entities.ids.generated'
21
29
  import { createLogger } from '@open-mercato/shared/lib/logger'
22
30
 
23
31
  const logger = createLogger('customers')
32
+ const DEALS_AGGREGATE_CACHE_RESOURCE = 'customers.deal'
33
+ const DEALS_AGGREGATE_CACHE_TTL_MS = 30_000
24
34
 
25
35
  export const metadata = {
26
36
  GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },
@@ -45,6 +55,40 @@ const querySchema = z.object({
45
55
  expectedCloseAtTo: z.string().optional(),
46
56
  })
47
57
 
58
+ type AggregateQuery = z.infer<typeof querySchema>
59
+
60
+ function normalizeStringSet(values: string[] | undefined): string[] {
61
+ return Array.from(new Set((values ?? []).map((value) => value.trim())))
62
+ .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
63
+ }
64
+
65
+ function buildDealsAggregateCacheKey(params: {
66
+ tenantId: string
67
+ currencyScopeOrganizationId: string | null
68
+ organizationIds: string[]
69
+ query: AggregateQuery
70
+ }): string {
71
+ const signature = {
72
+ tenantId: params.tenantId,
73
+ currencyScopeOrganizationId: params.currencyScopeOrganizationId,
74
+ organizationIds: normalizeStringSet(params.organizationIds),
75
+ pipelineId: params.query.pipelineId ?? null,
76
+ status: normalizeStringSet(params.query.status),
77
+ ownerUserId: normalizeStringSet(params.query.ownerUserId),
78
+ personId: normalizeStringSet(params.query.personId),
79
+ companyId: normalizeStringSet(params.query.companyId),
80
+ expectedCloseAtFrom: params.query.expectedCloseAtFrom ?? null,
81
+ expectedCloseAtTo: params.query.expectedCloseAtTo ?? null,
82
+ isOverdue: params.query.isOverdue === true,
83
+ }
84
+ const digest = createHash('sha256').update(JSON.stringify(signature)).digest('hex')
85
+ return `customers:deal:aggregate:v1:${digest}`
86
+ }
87
+
88
+ function isDealsAggregateCacheEligible(searchParams: URLSearchParams): boolean {
89
+ return !searchParams.has('search') && !searchParams.has('isStuck')
90
+ }
91
+
48
92
  type StageBreakdownByCurrency = {
49
93
  currency: string
50
94
  total: number
@@ -175,6 +219,42 @@ export async function GET(req: Request) {
175
219
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
176
220
  }
177
221
  const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })
222
+ const aggregateCache = isDealsAggregateCacheEligible(params) && isCrudCacheEnabled()
223
+ ? resolveCrudCache(container)
224
+ : null
225
+ const aggregateCacheKey = aggregateCache
226
+ ? buildDealsAggregateCacheKey({
227
+ tenantId: effectiveTenantId,
228
+ currencyScopeOrganizationId: orgFilterIds[0] ?? null,
229
+ organizationIds: orgFilterIds,
230
+ query: parsed.data,
231
+ })
232
+ : null
233
+
234
+ if (aggregateCache && aggregateCacheKey) {
235
+ try {
236
+ const cached = await runWithCacheTenant(
237
+ effectiveTenantId,
238
+ () => aggregateCache.get(aggregateCacheKey),
239
+ )
240
+ const cachedResponse = aggregateResponseSchema.safeParse(cached)
241
+ if (cachedResponse.success) {
242
+ return NextResponse.json(cachedResponse.data)
243
+ }
244
+ if (cached !== null && cached !== undefined) {
245
+ debugCrudCache('get-invalid', {
246
+ resource: DEALS_AGGREGATE_CACHE_RESOURCE,
247
+ key: aggregateCacheKey,
248
+ })
249
+ }
250
+ } catch (err) {
251
+ debugCrudCache('get', {
252
+ resource: DEALS_AGGREGATE_CACHE_RESOURCE,
253
+ key: aggregateCacheKey,
254
+ error: err instanceof Error ? err.message : String(err),
255
+ })
256
+ }
257
+ }
178
258
 
179
259
  // Raw SQL is used here intentionally — the route only projects non-encrypted columns
180
260
  // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It
@@ -443,5 +523,27 @@ export async function GET(req: Request) {
443
523
  perStage: Array.from(stageMap.values()),
444
524
  }
445
525
 
526
+ if (aggregateCache && aggregateCacheKey) {
527
+ try {
528
+ await runWithCacheTenant(
529
+ effectiveTenantId,
530
+ () => aggregateCache.set(aggregateCacheKey, response, {
531
+ ttl: DEALS_AGGREGATE_CACHE_TTL_MS,
532
+ tags: buildCollectionTags(
533
+ DEALS_AGGREGATE_CACHE_RESOURCE,
534
+ effectiveTenantId,
535
+ normalizeStringSet(orgFilterIds),
536
+ ),
537
+ }),
538
+ )
539
+ } catch (err) {
540
+ debugCrudCache('store', {
541
+ resource: DEALS_AGGREGATE_CACHE_RESOURCE,
542
+ key: aggregateCacheKey,
543
+ error: err instanceof Error ? err.message : String(err),
544
+ })
545
+ }
546
+ }
547
+
446
548
  return NextResponse.json(response)
447
549
  }
@@ -6,29 +6,6 @@ export type DealAssociation = {
6
6
  isPrimary?: boolean
7
7
  }
8
8
 
9
- export type PersonAssociationApiRecord = {
10
- id?: string
11
- displayName?: string | null
12
- display_name?: string | null
13
- primaryEmail?: string | null
14
- primary_email?: string | null
15
- primaryPhone?: string | null
16
- primary_phone?: string | null
17
- personProfile?: { jobTitle?: string | null } | null
18
- person_profile?: { jobTitle?: string | null } | null
19
- }
20
-
21
- export type CompanyAssociationApiRecord = {
22
- id?: string
23
- displayName?: string | null
24
- display_name?: string | null
25
- domain?: string | null
26
- websiteUrl?: string | null
27
- website_url?: string | null
28
- companyProfile?: { domain?: string | null; websiteUrl?: string | null } | null
29
- company_profile?: { domain?: string | null; websiteUrl?: string | null } | null
30
- }
31
-
32
9
  export type PipelineStageInfo = {
33
10
  id: string
34
11
  label: string
@@ -6,81 +6,11 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
6
6
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import type {
9
- CompanyAssociationApiRecord,
10
9
  DealAssociation,
11
10
  DealDetailPayload,
12
11
  GuardedMutationRunner,
13
- PersonAssociationApiRecord,
14
12
  } from './types'
15
13
 
16
- export function normalizePersonAssociationRecord(
17
- record: PersonAssociationApiRecord,
18
- fallbackId: string,
19
- ): DealAssociation {
20
- const displayName =
21
- typeof record.displayName === 'string' && record.displayName.trim().length
22
- ? record.displayName.trim()
23
- : typeof record.display_name === 'string' && record.display_name.trim().length
24
- ? record.display_name.trim()
25
- : null
26
- const email =
27
- typeof record.primaryEmail === 'string' && record.primaryEmail.trim().length
28
- ? record.primaryEmail.trim()
29
- : typeof record.primary_email === 'string' && record.primary_email.trim().length
30
- ? record.primary_email.trim()
31
- : null
32
- const phone =
33
- typeof record.primaryPhone === 'string' && record.primaryPhone.trim().length
34
- ? record.primaryPhone.trim()
35
- : typeof record.primary_phone === 'string' && record.primary_phone.trim().length
36
- ? record.primary_phone.trim()
37
- : null
38
- const profile = record.personProfile ?? record.person_profile ?? null
39
- const jobTitle =
40
- profile && typeof profile.jobTitle === 'string' && profile.jobTitle.trim().length
41
- ? profile.jobTitle.trim()
42
- : null
43
- return {
44
- id: typeof record.id === 'string' ? record.id : fallbackId,
45
- label: displayName ?? email ?? phone ?? fallbackId,
46
- subtitle: jobTitle ?? email ?? phone ?? null,
47
- kind: 'person',
48
- }
49
- }
50
-
51
- export function normalizeCompanyAssociationRecord(
52
- record: CompanyAssociationApiRecord,
53
- fallbackId: string,
54
- ): DealAssociation {
55
- const displayName =
56
- typeof record.displayName === 'string' && record.displayName.trim().length
57
- ? record.displayName.trim()
58
- : typeof record.display_name === 'string' && record.display_name.trim().length
59
- ? record.display_name.trim()
60
- : null
61
- const profile = record.companyProfile ?? record.company_profile ?? null
62
- const domain =
63
- typeof record.domain === 'string' && record.domain.trim().length
64
- ? record.domain.trim()
65
- : profile && typeof profile.domain === 'string' && profile.domain.trim().length
66
- ? profile.domain.trim()
67
- : null
68
- const website =
69
- typeof record.websiteUrl === 'string' && record.websiteUrl.trim().length
70
- ? record.websiteUrl.trim()
71
- : typeof record.website_url === 'string' && record.website_url.trim().length
72
- ? record.website_url.trim()
73
- : profile && typeof profile.websiteUrl === 'string' && profile.websiteUrl.trim().length
74
- ? profile.websiteUrl.trim()
75
- : null
76
- return {
77
- id: typeof record.id === 'string' ? record.id : fallbackId,
78
- label: displayName ?? domain ?? website ?? fallbackId,
79
- subtitle: domain ?? website ?? null,
80
- kind: 'company',
81
- }
82
- }
83
-
84
14
  function sameIdList(left: string[], right: string[]): boolean {
85
15
  if (left.length !== right.length) return false
86
16
  return left.every((value, index) => value === right[index])
@@ -97,8 +27,12 @@ type UseDealAssociationsOptions = {
97
27
  data: DealDetailPayload | null
98
28
  setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>
99
29
  runMutationWithContext: GuardedMutationRunner
100
- /** Re-fetch the deal detail; wired into the conflict bar's refresh action on a 409. */
101
- onRefresh?: (() => void) | null
30
+ /**
31
+ * Re-fetch the deal detail. Required, not optional: this hook no longer patches the list
32
+ * itself on success, so a caller without it would show a stale list *and* keep a superseded
33
+ * lock token that 409s on the next save. It also backs the conflict bar's refresh on a 409.
34
+ */
35
+ onRefresh: () => void | Promise<void>
102
36
  }
103
37
 
104
38
  type UseDealAssociationsResult = {
@@ -130,80 +64,6 @@ export function useDealAssociations({
130
64
  setCompaniesEditorIds(data?.linkedCompanyIds ?? [])
131
65
  }, [data?.linkedCompanyIds, data?.linkedPersonIds])
132
66
 
133
- const loadPeopleAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {
134
- const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))
135
- if (!uniqueIds.length) return []
136
- try {
137
- const params = new URLSearchParams({
138
- ids: uniqueIds.join(','),
139
- pageSize: String(Math.max(uniqueIds.length, 1)),
140
- })
141
- const payload = await readApiResultOrThrow<{ items?: PersonAssociationApiRecord[] }>(
142
- `/api/customers/people?${params.toString()}`,
143
- )
144
- const items = Array.isArray(payload.items) ? payload.items : []
145
- const byId = new Map<string, PersonAssociationApiRecord>()
146
- items.forEach((record) => {
147
- if (record && typeof record.id === 'string') byId.set(record.id, record)
148
- })
149
- return uniqueIds.map((personId) => {
150
- const record = byId.get(personId)
151
- return record
152
- ? normalizePersonAssociationRecord(record, personId)
153
- : {
154
- id: personId,
155
- label: personId,
156
- subtitle: null,
157
- kind: 'person' as const,
158
- }
159
- })
160
- } catch {
161
- return uniqueIds.map((personId) => ({
162
- id: personId,
163
- label: personId,
164
- subtitle: null,
165
- kind: 'person' as const,
166
- }))
167
- }
168
- }, [])
169
-
170
- const loadCompanyAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {
171
- const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))
172
- if (!uniqueIds.length) return []
173
- try {
174
- const params = new URLSearchParams({
175
- ids: uniqueIds.join(','),
176
- pageSize: String(Math.max(uniqueIds.length, 1)),
177
- })
178
- const payload = await readApiResultOrThrow<{ items?: CompanyAssociationApiRecord[] }>(
179
- `/api/customers/companies?${params.toString()}`,
180
- )
181
- const items = Array.isArray(payload.items) ? payload.items : []
182
- const byId = new Map<string, CompanyAssociationApiRecord>()
183
- items.forEach((record) => {
184
- if (record && typeof record.id === 'string') byId.set(record.id, record)
185
- })
186
- return uniqueIds.map((companyId) => {
187
- const record = byId.get(companyId)
188
- return record
189
- ? normalizeCompanyAssociationRecord(record, companyId)
190
- : {
191
- id: companyId,
192
- label: companyId,
193
- subtitle: null,
194
- kind: 'company' as const,
195
- }
196
- })
197
- } catch {
198
- return uniqueIds.map((companyId) => ({
199
- id: companyId,
200
- label: companyId,
201
- subtitle: null,
202
- kind: 'company' as const,
203
- }))
204
- }
205
- }, [])
206
-
207
67
  const loadLinkedPeoplePage = React.useCallback(
208
68
  async (page: number, query: string): Promise<LinkedPageResult> => {
209
69
  if (!currentDealId) {
@@ -274,17 +134,7 @@ export function useDealAssociations({
274
134
  ),
275
135
  { id: currentDealId, personIds: nextIds, operation: 'updateDealPeople' },
276
136
  )
277
- const nextPeople = await loadPeopleAssociations(nextIds.slice(0, 3))
278
- setData((prev) =>
279
- prev
280
- ? {
281
- ...prev,
282
- people: nextPeople,
283
- linkedPersonIds: nextIds,
284
- counts: { ...prev.counts, people: nextIds.length },
285
- }
286
- : prev,
287
- )
137
+ await onRefresh()
288
138
  } catch (error) {
289
139
  setPeopleEditorIds(previousIds)
290
140
  setData((prev) =>
@@ -299,14 +149,14 @@ export function useDealAssociations({
299
149
  )
300
150
  // runMutationWithContext already surfaces the conflict bar on a 409; only
301
151
  // fall back to the generic flash when this is not a record conflict.
302
- if (!surfaceRecordConflict(error, t, { onRefresh: onRefresh ?? null })) {
152
+ if (!surfaceRecordConflict(error, t, { onRefresh })) {
303
153
  flash(t('customers.deals.detail.peopleUpdateError', 'Failed to update linked people.'), 'error')
304
154
  }
305
155
  } finally {
306
156
  setPeopleSaving(false)
307
157
  }
308
158
  },
309
- [currentDealId, data?.deal.updatedAt, data?.people, loadPeopleAssociations, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],
159
+ [currentDealId, data?.deal.updatedAt, data?.people, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],
310
160
  )
311
161
 
312
162
  const handleCompaniesAssociationsChange = React.useCallback(
@@ -325,17 +175,7 @@ export function useDealAssociations({
325
175
  ),
326
176
  { id: currentDealId, companyIds: nextIds, operation: 'updateDealCompanies' },
327
177
  )
328
- const nextCompanies = await loadCompanyAssociations(nextIds.slice(0, 3))
329
- setData((prev) =>
330
- prev
331
- ? {
332
- ...prev,
333
- companies: nextCompanies,
334
- linkedCompanyIds: nextIds,
335
- counts: { ...prev.counts, companies: nextIds.length },
336
- }
337
- : prev,
338
- )
178
+ await onRefresh()
339
179
  } catch (error) {
340
180
  setCompaniesEditorIds(previousIds)
341
181
  setData((prev) =>
@@ -350,7 +190,7 @@ export function useDealAssociations({
350
190
  )
351
191
  // runMutationWithContext already surfaces the conflict bar on a 409; only
352
192
  // fall back to the generic flash when this is not a record conflict.
353
- if (!surfaceRecordConflict(error, t, { onRefresh: onRefresh ?? null })) {
193
+ if (!surfaceRecordConflict(error, t, { onRefresh })) {
354
194
  flash(t('customers.deals.detail.companiesUpdateError', 'Failed to update linked companies.'), 'error')
355
195
  }
356
196
  } finally {
@@ -362,7 +202,6 @@ export function useDealAssociations({
362
202
  currentDealId,
363
203
  data?.companies,
364
204
  data?.deal.updatedAt,
365
- loadCompanyAssociations,
366
205
  onRefresh,
367
206
  runMutationWithContext,
368
207
  setData,
@@ -198,7 +198,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
198
198
  data,
199
199
  setData,
200
200
  runMutationWithContext,
201
- onRefresh: () => { void loadData() },
201
+ onRefresh: () => loadData(),
202
202
  })
203
203
 
204
204
  const { isStageSaving, handleStageChange } = useDealPipeline({
@@ -383,12 +383,49 @@ function toNumericString(value: number | null | undefined): string | null {
383
383
  return value.toString()
384
384
  }
385
385
 
386
+ function sameLinkIdSet(next: Set<string>, current: Set<string>): boolean {
387
+ if (next.size !== current.size) return false
388
+ for (const id of next) {
389
+ if (!current.has(id)) return false
390
+ }
391
+ return true
392
+ }
393
+
394
+ /**
395
+ * The deal's optimistic-lock token is `customer_deals.updated_at`, and `CustomerDeal.updatedAt`
396
+ * is declared `onUpdate`-only — it advances only when the deal entity itself enters the change
397
+ * set. A `{ id, personIds }` or `{ id, companyIds }` payload mutates link rows exclusively, so
398
+ * without this explicit touch the token never moves: two clients editing the same deal's links
399
+ * from the same base version both pass the version check and the later stale whole-set payload
400
+ * silently reinstates what the earlier one removed.
401
+ *
402
+ * Assigning the property is what dirties the entity; the `onUpdate` hook then supplies the
403
+ * committed value, so the two do not fight. Same pattern as the profile-only branch in
404
+ * `people.ts`, which touches its parent for exactly this reason.
405
+ *
406
+ * Callers MUST only invoke this when the links actually changed — stamping on a no-op write
407
+ * would invalidate every other session's token on an idle save.
408
+ *
409
+ * ORDERING: this MUST be the last thing a sync helper does. MikroORM v7 discards a pending
410
+ * scalar change on a managed entity when a query runs on the same EntityManager before the
411
+ * flush (SPEC-018) — the same footgun the CRITICAL comment in `updateDealCommand` guards
412
+ * against, and these helpers are exactly the queries it names. Touching before
413
+ * `requireCustomerEntity` runs would silently drop the UPDATE and leave the token frozen.
414
+ */
415
+ function touchDealLockToken(deal: CustomerDeal): void {
416
+ deal.updatedAt = new Date()
417
+ }
418
+
386
419
  async function syncDealPeople(
387
420
  em: EntityManager,
388
421
  deal: CustomerDeal,
389
422
  personIds: string[] | undefined | null,
390
- primaryPersonEntityId?: string | null
423
+ primaryPersonEntityId?: string | null,
424
+ options?: { stampLockToken?: boolean }
391
425
  ): Promise<void> {
426
+ // A freshly created deal has no other session holding a token to invalidate, and stamping
427
+ // there would only make `updated_at` overtake `created_at` on every new deal with links.
428
+ const stampLockToken = options?.stampLockToken !== false
392
429
  if (personIds === undefined) {
393
430
  if (primaryPersonEntityId === undefined) return
394
431
  const links = await em.find(CustomerDealPersonLink, { deal })
@@ -401,6 +438,18 @@ async function syncDealPeople(
401
438
  ),
402
439
  })
403
440
  }
441
+ const currentPrimaryId = links.find((link) => link.isPrimary)?.person?.id ?? null
442
+ // Nothing changed — same invariant the whole-set branch below enforces. Without this the
443
+ // clear loop and its flush would rewrite every link row just to put the flag back on the
444
+ // row it was already on, and would briefly leave the deal with no primary at all inside
445
+ // the transaction. Only one row can carry the flag (partial unique index on
446
+ // `deal_id where is_primary`), so there is no second stale flag left to clean up here.
447
+ if (currentPrimaryId === primaryPersonEntityId) return
448
+ // Safe here: only assignments and the explicit flush below follow — no query runs
449
+ // between this touch and the flush that persists it.
450
+ if (stampLockToken) {
451
+ touchDealLockToken(deal)
452
+ }
404
453
  for (const link of links) {
405
454
  link.isPrimary = false
406
455
  }
@@ -421,13 +470,30 @@ async function syncDealPeople(
421
470
  ),
422
471
  })
423
472
  }
473
+ // Read the current links once: this both resolves the inherited primary (as the previous
474
+ // `findOne(..., { isPrimary: true })` did) and lets us tell a real change from a no-op save,
475
+ // which the lock stamp below depends on.
476
+ const existingLinks = await em.find(CustomerDealPersonLink, { deal })
477
+ const currentPersonIds = new Set(existingLinks.map((link) => link.person.id))
478
+ const currentPrimaryId = existingLinks.find((link) => link.isPrimary)?.person?.id ?? null
479
+
424
480
  let effectivePrimaryId = primaryPersonEntityId
425
481
  if (effectivePrimaryId === undefined) {
426
- const existingPrimary = await em.findOne(CustomerDealPersonLink, { deal, isPrimary: true })
427
- effectivePrimaryId = existingPrimary?.person?.id ?? null
482
+ effectivePrimaryId = currentPrimaryId
428
483
  }
484
+ const linksChanged =
485
+ !sameLinkIdSet(new Set(unique), currentPersonIds) || effectivePrimaryId !== currentPrimaryId
486
+ // Nothing to do. Returning here also stops a no-op save from deleting and recreating every
487
+ // row, which would otherwise discard `participant_role`, `created_at` and the link ids while
488
+ // this function reports the write as a no-op to every other session.
489
+ //
490
+ // NOTE: this only covers the pure no-op. A genuine change below still deletes and recreates
491
+ // every row, so surviving participants do lose those columns — pre-existing behaviour that
492
+ // the set-diff in PR 3 of the linked-people parity spec removes. It is out of scope here
493
+ // because the linked date it corrupts is not rendered until that PR.
494
+ if (!linksChanged) return
495
+
429
496
  await em.nativeDelete(CustomerDealPersonLink, { deal })
430
- if (!unique.length) return
431
497
  for (const personId of unique) {
432
498
  const person = await requireCustomerEntity(em, personId, { tenantId: deal.tenantId, organizationId: deal.organizationId }, 'person', 'Person not found')
433
499
  ensureSameScope(person, deal.organizationId, deal.tenantId)
@@ -438,17 +504,24 @@ async function syncDealPeople(
438
504
  })
439
505
  em.persist(link)
440
506
  }
507
+ // Last statement on purpose — see the ORDERING note on `touchDealLockToken`.
508
+ if (stampLockToken) touchDealLockToken(deal)
441
509
  }
442
510
 
443
511
  async function syncDealCompanies(
444
512
  em: EntityManager,
445
513
  deal: CustomerDeal,
446
- companyIds: string[] | undefined | null
514
+ companyIds: string[] | undefined | null,
515
+ options?: { stampLockToken?: boolean }
447
516
  ): Promise<void> {
448
517
  if (companyIds === undefined) return
518
+ const stampLockToken = options?.stampLockToken !== false
519
+ const unique = Array.from(new Set(companyIds ?? []))
520
+ const existingLinks = await em.find(CustomerDealCompanyLink, { deal })
521
+ const currentCompanyIds = new Set(existingLinks.map((link) => link.company.id))
522
+ if (sameLinkIdSet(new Set(unique), currentCompanyIds)) return
523
+
449
524
  await em.nativeDelete(CustomerDealCompanyLink, { deal })
450
- if (!companyIds || !companyIds.length) return
451
- const unique = Array.from(new Set(companyIds))
452
525
  for (const companyId of unique) {
453
526
  const company = await requireCustomerEntity(em, companyId, { tenantId: deal.tenantId, organizationId: deal.organizationId }, 'company', 'Company not found')
454
527
  ensureSameScope(company, deal.organizationId, deal.tenantId)
@@ -458,6 +531,8 @@ async function syncDealCompanies(
458
531
  })
459
532
  em.persist(link)
460
533
  }
534
+ // Last statement on purpose — see the ORDERING note on `touchDealLockToken`.
535
+ if (stampLockToken) touchDealLockToken(deal)
461
536
  }
462
537
 
463
538
  const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
@@ -530,8 +605,8 @@ const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
530
605
  transitionedByUserId: normalizedTransitionAuthorUserId,
531
606
  })
532
607
  },
533
- () => syncDealPeople(em, deal, parsed.personIds ?? [], parsed.primaryPersonEntityId),
534
- () => syncDealCompanies(em, deal, parsed.companyIds ?? []),
608
+ () => syncDealPeople(em, deal, parsed.personIds ?? [], parsed.primaryPersonEntityId, { stampLockToken: false }),
609
+ () => syncDealCompanies(em, deal, parsed.companyIds ?? [], { stampLockToken: false }),
535
610
  ], { transaction: true })
536
611
 
537
612
  const de = (ctx.container.resolve('dataEngine') as DataEngine)