@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/api/deals/aggregate/route.js +85 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +5 -117
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -3
- package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
- package/dist/modules/customers/commands/deals.js +33 -9
- package/dist/modules/customers/commands/deals.js.map +3 -3
- package/dist/modules/customers/components/AddressEditor.js +71 -1
- package/dist/modules/customers/components/AddressEditor.js.map +2 -2
- package/dist/modules/customers/utils/addressFormat.js +18 -2
- package/dist/modules/customers/utils/addressFormat.js.map +2 -2
- package/dist/modules/sales/components/documents/AddressesSection.js +17 -2
- package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -24
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/components/documents/normalizeAddressDraft.js +29 -0
- package/dist/modules/sales/components/documents/normalizeAddressDraft.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +102 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/types.ts +0 -23
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +11 -172
- package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
- package/src/modules/customers/commands/deals.ts +84 -9
- package/src/modules/customers/components/AddressEditor.tsx +116 -1
- package/src/modules/customers/i18n/de.json +6 -0
- package/src/modules/customers/i18n/en.json +6 -0
- package/src/modules/customers/i18n/es.json +6 -0
- package/src/modules/customers/i18n/ko.json +6 -0
- package/src/modules/customers/i18n/pl.json +6 -0
- package/src/modules/customers/utils/addressFormat.tsx +59 -1
- package/src/modules/sales/components/documents/AddressesSection.tsx +48 -5
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +5 -23
- 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
|
-
/**
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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: () =>
|
|
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
|
-
|
|
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)
|