@open-mercato/core 0.6.6-develop.6176.1.4507b99c2f → 0.6.6-develop.6198.1.0822f97cc6

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 (39) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/frontend/login.js +5 -5
  3. package/dist/modules/auth/frontend/login.js.map +2 -2
  4. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js +3 -2
  5. package/dist/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.js.map +2 -2
  6. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +26 -14
  7. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  8. package/dist/modules/customers/api/deals/[id]/route.js +169 -149
  9. package/dist/modules/customers/api/deals/[id]/route.js.map +3 -3
  10. package/dist/modules/customers/components/detail/InlineEditors.js +9 -4
  11. package/dist/modules/customers/components/detail/InlineEditors.js.map +2 -2
  12. package/dist/modules/integrations/backend/integrations/[id]/page.js +13 -9
  13. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  14. package/dist/modules/integrations/backend/integrations/detail-page-refresh.js +17 -0
  15. package/dist/modules/integrations/backend/integrations/detail-page-refresh.js.map +7 -0
  16. package/dist/modules/sales/components/documents/SalesDocumentForm.js +299 -297
  17. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  18. package/dist/modules/workflows/components/EdgeEditDialog.js +120 -141
  19. package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
  20. package/dist/modules/workflows/components/NodeEditDialog.js +114 -118
  21. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  22. package/dist/modules/workflows/frontend/checkout-demo/page.js +155 -260
  23. package/dist/modules/workflows/frontend/checkout-demo/page.js.map +2 -2
  24. package/package.json +7 -7
  25. package/src/modules/auth/frontend/login.tsx +5 -5
  26. package/src/modules/catalog/backend/catalog/products/MerchandisingAssistantSheet.tsx +4 -3
  27. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +27 -17
  28. package/src/modules/customers/api/deals/[id]/route.ts +208 -174
  29. package/src/modules/customers/components/detail/InlineEditors.tsx +6 -10
  30. package/src/modules/integrations/backend/integrations/[id]/page.tsx +13 -9
  31. package/src/modules/integrations/backend/integrations/detail-page-refresh.ts +36 -0
  32. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +351 -327
  33. package/src/modules/workflows/components/EdgeEditDialog.tsx +132 -157
  34. package/src/modules/workflows/components/NodeEditDialog.tsx +116 -121
  35. package/src/modules/workflows/frontend/checkout-demo/page.tsx +172 -239
  36. package/src/modules/workflows/i18n/de.json +2 -0
  37. package/src/modules/workflows/i18n/en.json +2 -0
  38. package/src/modules/workflows/i18n/es.json +2 -0
  39. package/src/modules/workflows/i18n/pl.json +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6176.1.4507b99c2f",
3
+ "version": "0.6.6-develop.6198.1.0822f97cc6",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -246,16 +246,16 @@
246
246
  "zod": "^4.4.3"
247
247
  },
248
248
  "peerDependencies": {
249
- "@open-mercato/ai-assistant": "0.6.6-develop.6176.1.4507b99c2f",
250
- "@open-mercato/shared": "0.6.6-develop.6176.1.4507b99c2f",
251
- "@open-mercato/ui": "0.6.6-develop.6176.1.4507b99c2f",
249
+ "@open-mercato/ai-assistant": "0.6.6-develop.6198.1.0822f97cc6",
250
+ "@open-mercato/shared": "0.6.6-develop.6198.1.0822f97cc6",
251
+ "@open-mercato/ui": "0.6.6-develop.6198.1.0822f97cc6",
252
252
  "react": "^19.0.0",
253
253
  "react-dom": "^19.0.0"
254
254
  },
255
255
  "devDependencies": {
256
- "@open-mercato/ai-assistant": "0.6.6-develop.6176.1.4507b99c2f",
257
- "@open-mercato/shared": "0.6.6-develop.6176.1.4507b99c2f",
258
- "@open-mercato/ui": "0.6.6-develop.6176.1.4507b99c2f",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6198.1.0822f97cc6",
257
+ "@open-mercato/shared": "0.6.6-develop.6198.1.0822f97cc6",
258
+ "@open-mercato/ui": "0.6.6-develop.6198.1.0822f97cc6",
259
259
  "@testing-library/dom": "^10.4.1",
260
260
  "@testing-library/jest-dom": "^6.9.1",
261
261
  "@testing-library/react": "^16.3.1",
@@ -383,15 +383,15 @@ export default function LoginPage() {
383
383
  </div>
384
384
  ) : null}
385
385
  {showTenantInvalid ? (
386
- <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-xs text-red-700">
386
+ <div className="rounded-md border border-status-error-border bg-status-error-bg px-3 py-2 text-center text-xs text-status-error-text">
387
387
  <div className="font-medium">{translate('auth.login.errors.tenantInvalid', 'Tenant not found. Clear the tenant selection and try again.')}</div>
388
- <Button type="button" variant="outline" size="sm" className="mt-2 border-red-300 text-red-700" onClick={handleClearTenant}>
388
+ <Button type="button" variant="outline" size="sm" className="mt-2 border-status-error-border text-status-error-text hover:text-status-error-text" onClick={handleClearTenant}>
389
389
  <X className="mr-2 size-4" aria-hidden="true" />
390
390
  {translate('auth.login.tenantClear', 'Clear')}
391
391
  </Button>
392
392
  </div>
393
393
  ) : tenantId ? (
394
- <div className="rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-center text-xs text-emerald-900">
394
+ <div className="rounded-md border border-status-success-border bg-status-success-bg px-3 py-2 text-center text-xs text-status-success-text">
395
395
  <div className="font-medium">
396
396
  {tenantLoading
397
397
  ? translate('auth.login.tenantLoading', 'Loading tenant details...')
@@ -399,14 +399,14 @@ export default function LoginPage() {
399
399
  tenant: tenantName || tenantId,
400
400
  })}
401
401
  </div>
402
- <Button type="button" variant="outline" size="sm" className="mt-2 border-emerald-300 text-emerald-900" onClick={handleClearTenant}>
402
+ <Button type="button" variant="outline" size="sm" className="mt-2 border-status-success-border text-status-success-text hover:text-status-success-text" onClick={handleClearTenant}>
403
403
  <X className="mr-2 size-4" aria-hidden="true" />
404
404
  {translate('auth.login.tenantClear', 'Clear')}
405
405
  </Button>
406
406
  </div>
407
407
  ) : null}
408
408
  {error && !showTenantInvalid && (
409
- <div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-center text-sm text-red-700" role="alert" aria-live="polite">
409
+ <div className="rounded-md border border-status-error-border bg-status-error-bg px-3 py-2 text-center text-sm text-status-error-text" role="alert" aria-live="polite">
410
410
  {error}
411
411
  </div>
412
412
  )}
@@ -428,12 +428,13 @@ export function MerchandisingAssistantSheet({
428
428
  </div>
429
429
  <div className="flex flex-col gap-0.5">
430
430
  {agents.map((agent) => (
431
- <button
431
+ <Button
432
432
  key={agent.id}
433
433
  type="button"
434
+ variant="ghost"
434
435
  onClick={() => handleSelectAgent(agent.id)}
435
436
  data-ai-merchandising-agent-option={agent.id}
436
- className="flex items-start gap-2 rounded-sm px-2 py-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
437
+ className="h-auto w-full items-start justify-start gap-2 whitespace-normal rounded-sm px-2 py-2 text-left text-sm font-normal"
437
438
  >
438
439
  <span className="mt-0.5 inline-flex size-6 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
439
440
  {agent.icon}
@@ -444,7 +445,7 @@ export function MerchandisingAssistantSheet({
444
445
  {agent.description}
445
446
  </span>
446
447
  </span>
447
- </button>
448
+ </Button>
448
449
  ))}
449
450
  </div>
450
451
  </PopoverContent>
@@ -3,6 +3,8 @@ import * as React from 'react'
3
3
  import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
4
4
  import { subscribeProductSeoValidation } from './state'
5
5
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
7
+ import { Alert } from '@open-mercato/ui/primitives/alert'
6
8
 
7
9
  type SeoData = {
8
10
  title?: string | null
@@ -14,6 +16,8 @@ type ValidationState = { ok: boolean; issues: string[]; message?: string }
14
16
 
15
17
  type IssueKey = 'addTitle' | 'titleTooShort' | 'titleTooLong' | 'addDescription' | 'descriptionTooShort'
16
18
 
19
+ type SeoScore = { text: string; variant: StatusBadgeVariant }
20
+
17
21
  function computeIssueKeys(title: string, description: string): IssueKey[] {
18
22
  const issues: IssueKey[] = []
19
23
  if (!title) {
@@ -51,27 +55,27 @@ export default function ProductSeoWidget({ data }: InjectionWidgetComponentProps
51
55
  })
52
56
  }, [])
53
57
 
54
- const titleScore = React.useMemo(() => {
55
- if (!title) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' }
56
- if (title.length < 10) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-200' }
57
- if (title.length > 60) return { text: t('catalog.products.create.seoWidget.tooLong', 'Too long'), color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-200' }
58
- return { text: t('catalog.products.create.seoWidget.good', 'Good'), color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-200' }
58
+ const titleScore = React.useMemo<SeoScore>(() => {
59
+ if (!title) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), variant: 'error' }
60
+ if (title.length < 10) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), variant: 'warning' }
61
+ if (title.length > 60) return { text: t('catalog.products.create.seoWidget.tooLong', 'Too long'), variant: 'warning' }
62
+ return { text: t('catalog.products.create.seoWidget.good', 'Good'), variant: 'success' }
59
63
  }, [title, t])
60
64
 
61
- const descScore = React.useMemo(() => {
62
- if (!description) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), color: 'text-red-600', bg: 'bg-red-50', border: 'border-red-200' }
63
- if (description.length < 50) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), color: 'text-amber-600', bg: 'bg-amber-50', border: 'border-amber-200' }
64
- return { text: t('catalog.products.create.seoWidget.good', 'Good'), color: 'text-green-600', bg: 'bg-green-50', border: 'border-green-200' }
65
+ const descScore = React.useMemo<SeoScore>(() => {
66
+ if (!description) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), variant: 'error' }
67
+ if (description.length < 50) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), variant: 'warning' }
68
+ return { text: t('catalog.products.create.seoWidget.good', 'Good'), variant: 'success' }
65
69
  }, [description, t])
66
70
 
67
71
  const statusBadge = validation.ok ? (
68
- <span className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700 border border-emerald-200">
72
+ <StatusBadge variant="success">
69
73
  {t('catalog.products.create.seoWidget.ready', 'Ready')}
70
- </span>
74
+ </StatusBadge>
71
75
  ) : (
72
- <span className="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700 border border-amber-200">
76
+ <StatusBadge variant="warning">
73
77
  {t('catalog.products.create.seoWidget.needsAttention', 'Needs attention')}
74
- </span>
78
+ </StatusBadge>
75
79
  )
76
80
 
77
81
  const translateIssue = (issueKey: string): string => {
@@ -89,7 +93,13 @@ export default function ProductSeoWidget({ data }: InjectionWidgetComponentProps
89
93
  </div>
90
94
 
91
95
  {validation.message || validation.issues.length ? (
92
- <div className={`rounded-md border p-3 text-xs ${validation.ok ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : 'border-amber-200 bg-amber-50 text-amber-900'}`}>
96
+ <Alert
97
+ status={validation.ok ? 'success' : 'warning'}
98
+ style="lighter"
99
+ size="sm"
100
+ showIcon={false}
101
+ className="text-xs"
102
+ >
93
103
  {validation.message ? <div className="font-medium">{validation.message}</div> : null}
94
104
  {validation.issues.length ? (
95
105
  <ul className="ml-4 list-disc space-y-1 pt-1">
@@ -98,17 +108,17 @@ export default function ProductSeoWidget({ data }: InjectionWidgetComponentProps
98
108
  ))}
99
109
  </ul>
100
110
  ) : null}
101
- </div>
111
+ </Alert>
102
112
  ) : null}
103
113
 
104
114
  <div className="rounded border bg-muted/30 p-3 text-sm">
105
115
  <div className="flex items-center justify-between">
106
116
  <span className="text-muted-foreground">{t('catalog.products.create.seoWidget.titleLabel', 'Title ({{count}} chars)', { count: title.length })}</span>
107
- <span className={`font-medium ${titleScore.color}`}>{titleScore.text}</span>
117
+ <StatusBadge variant={titleScore.variant}>{titleScore.text}</StatusBadge>
108
118
  </div>
109
119
  <div className="mt-2 flex items-center justify-between">
110
120
  <span className="text-muted-foreground">{t('catalog.products.create.seoWidget.descriptionLabel', 'Description ({{count}} chars)', { count: description.length })}</span>
111
- <span className={`font-medium ${descScore.color}`}>{descScore.text}</span>
121
+ <StatusBadge variant={descScore.variant}>{descScore.text}</StatusBadge>
112
122
  </div>
113
123
  </div>
114
124
 
@@ -386,103 +386,119 @@ export async function GET(request: Request, context: { params?: Record<string, u
386
386
  tenantId: deal.tenantId ?? auth.tenantId ?? null,
387
387
  organizationId: deal.organizationId ?? auth.orgId ?? null,
388
388
  }
389
- let linkedPersonIds: string[] = []
390
- let linkedCompanyIds: string[] = []
391
- let people: DealAssociation[] = []
392
- let companies: DealAssociation[] = []
393
-
394
- if (liteView) {
395
- const personLinkRows = await findWithDecryption(
396
- em,
397
- CustomerDealPersonLink,
398
- { deal: deal.id },
399
- { orderBy: { createdAt: 'ASC' } },
400
- decryptionScope,
401
- )
402
- const companyLinkRows = await findWithDecryption(
403
- em,
404
- CustomerDealCompanyLink,
405
- { deal: deal.id },
406
- { orderBy: { createdAt: 'ASC' } },
407
- decryptionScope,
408
- )
409
-
410
- linkedPersonIds = Array.from(
411
- new Set(
412
- personLinkRows
413
- .map((link) => {
414
- const personRef = link.person
415
- if (!personRef) return null
416
- if (typeof personRef === 'string') return personRef
417
- const personIdValue = personRef.id
418
- return typeof personIdValue === 'string' ? personIdValue : null
419
- })
420
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0),
421
- ),
422
- )
423
- linkedCompanyIds = Array.from(
424
- new Set(
425
- companyLinkRows
426
- .map((link) => {
427
- const companyRef = link.company
428
- if (!companyRef) return null
429
- if (typeof companyRef === 'string') return companyRef
430
- const companyIdValue = companyRef.id
431
- return typeof companyIdValue === 'string' ? companyIdValue : null
432
- })
433
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0),
434
- ),
435
- )
436
-
437
- const previewPeople = linkedPersonIds.length
438
- ? await findWithDecryption(
389
+ // Issue #3175: the reads below only depend on the already-resolved deal +
390
+ // organization scope + decryption scope, so they are grouped into Promise.all
391
+ // batches instead of being awaited one after another. True dependencies stay
392
+ // ordered: preview/decrypt run after their link rows, pipeline reads after the
393
+ // effective stage is known, and the appearance map / audit fallback after the
394
+ // pipeline stages load. This mirrors the parallel-read pattern already used
395
+ // across the customers list/interactions/activities routes.
396
+ const resolveAssociations = async (): Promise<{
397
+ people: DealAssociation[]
398
+ companies: DealAssociation[]
399
+ linkedPersonIds: string[]
400
+ linkedCompanyIds: string[]
401
+ }> => {
402
+ if (liteView) {
403
+ const [personLinkRows, companyLinkRows] = await Promise.all([
404
+ findWithDecryption(
439
405
  em,
440
- CustomerEntity,
441
- { id: { $in: linkedPersonIds.slice(0, 3) } },
442
- { populate: ['personProfile'] },
406
+ CustomerDealPersonLink,
407
+ { deal: deal.id },
408
+ { orderBy: { createdAt: 'ASC' } },
443
409
  decryptionScope,
444
- )
445
- : []
446
- const previewCompanies = linkedCompanyIds.length
447
- ? await findWithDecryption(
410
+ ),
411
+ findWithDecryption(
448
412
  em,
449
- CustomerEntity,
450
- { id: { $in: linkedCompanyIds.slice(0, 3) } },
451
- { populate: ['companyProfile'] },
413
+ CustomerDealCompanyLink,
414
+ { deal: deal.id },
415
+ { orderBy: { createdAt: 'ASC' } },
452
416
  decryptionScope,
453
- )
454
- : []
455
- const previewPeopleMap = new Map(previewPeople.map((entity) => [entity.id, entity]))
456
- const previewCompaniesMap = new Map(previewCompanies.map((entity) => [entity.id, entity]))
457
- people = linkedPersonIds.slice(0, 3).reduce<DealAssociation[]>((acc, personId) => {
458
- const entity = previewPeopleMap.get(personId) ?? null
459
- if (!entity || entity.deletedAt) return acc
460
- const { label, subtitle } = normalizePersonAssociation(entity)
461
- acc.push({ id: entity.id, label, subtitle, kind: 'person' })
462
- return acc
463
- }, [])
464
- companies = linkedCompanyIds.slice(0, 3).reduce<DealAssociation[]>((acc, companyId) => {
465
- const entity = previewCompaniesMap.get(companyId) ?? null
466
- if (!entity || entity.deletedAt) return acc
467
- const { label, subtitle } = normalizeCompanyAssociation(entity)
468
- acc.push({ id: entity.id, label, subtitle, kind: 'company' })
469
- return acc
470
- }, [])
471
- } else {
472
- const personLinks = await findWithDecryption(
473
- em,
474
- CustomerDealPersonLink,
475
- { deal: deal.id },
476
- { populate: ['person', 'person.personProfile'] },
477
- decryptionScope,
478
- )
479
- const companyLinks = await findWithDecryption(
480
- em,
481
- CustomerDealCompanyLink,
482
- { deal: deal.id },
483
- { populate: ['company', 'company.companyProfile'] },
484
- decryptionScope,
485
- )
417
+ ),
418
+ ])
419
+
420
+ const linkedPersonIds = Array.from(
421
+ new Set(
422
+ personLinkRows
423
+ .map((link) => {
424
+ const personRef = link.person
425
+ if (!personRef) return null
426
+ if (typeof personRef === 'string') return personRef
427
+ const personIdValue = personRef.id
428
+ return typeof personIdValue === 'string' ? personIdValue : null
429
+ })
430
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0),
431
+ ),
432
+ )
433
+ const linkedCompanyIds = Array.from(
434
+ new Set(
435
+ companyLinkRows
436
+ .map((link) => {
437
+ const companyRef = link.company
438
+ if (!companyRef) return null
439
+ if (typeof companyRef === 'string') return companyRef
440
+ const companyIdValue = companyRef.id
441
+ return typeof companyIdValue === 'string' ? companyIdValue : null
442
+ })
443
+ .filter((value): value is string => typeof value === 'string' && value.trim().length > 0),
444
+ ),
445
+ )
446
+
447
+ const [previewPeople, previewCompanies] = await Promise.all([
448
+ linkedPersonIds.length
449
+ ? findWithDecryption(
450
+ em,
451
+ CustomerEntity,
452
+ { id: { $in: linkedPersonIds.slice(0, 3) } },
453
+ { populate: ['personProfile'] },
454
+ decryptionScope,
455
+ )
456
+ : [],
457
+ linkedCompanyIds.length
458
+ ? findWithDecryption(
459
+ em,
460
+ CustomerEntity,
461
+ { id: { $in: linkedCompanyIds.slice(0, 3) } },
462
+ { populate: ['companyProfile'] },
463
+ decryptionScope,
464
+ )
465
+ : [],
466
+ ])
467
+ const previewPeopleMap = new Map(previewPeople.map((entity) => [entity.id, entity]))
468
+ const previewCompaniesMap = new Map(previewCompanies.map((entity) => [entity.id, entity]))
469
+ const people = linkedPersonIds.slice(0, 3).reduce<DealAssociation[]>((acc, personId) => {
470
+ const entity = previewPeopleMap.get(personId) ?? null
471
+ if (!entity || entity.deletedAt) return acc
472
+ const { label, subtitle } = normalizePersonAssociation(entity)
473
+ acc.push({ id: entity.id, label, subtitle, kind: 'person' })
474
+ return acc
475
+ }, [])
476
+ const companies = linkedCompanyIds.slice(0, 3).reduce<DealAssociation[]>((acc, companyId) => {
477
+ const entity = previewCompaniesMap.get(companyId) ?? null
478
+ if (!entity || entity.deletedAt) return acc
479
+ const { label, subtitle } = normalizeCompanyAssociation(entity)
480
+ acc.push({ id: entity.id, label, subtitle, kind: 'company' })
481
+ return acc
482
+ }, [])
483
+ return { people, companies, linkedPersonIds, linkedCompanyIds }
484
+ }
485
+
486
+ const [personLinks, companyLinks] = await Promise.all([
487
+ findWithDecryption(
488
+ em,
489
+ CustomerDealPersonLink,
490
+ { deal: deal.id },
491
+ { populate: ['person', 'person.personProfile'] },
492
+ decryptionScope,
493
+ ),
494
+ findWithDecryption(
495
+ em,
496
+ CustomerDealCompanyLink,
497
+ { deal: deal.id },
498
+ { populate: ['company', 'company.companyProfile'] },
499
+ decryptionScope,
500
+ ),
501
+ ])
486
502
  const fallbackTenantId = deal.tenantId ?? auth.tenantId ?? null
487
503
  const fallbackOrgId = deal.organizationId ?? auth.orgId ?? null
488
504
  await decryptEntitiesWithFallbackScope(personLinks, {
@@ -496,7 +512,7 @@ export async function GET(request: Request, context: { params?: Record<string, u
496
512
  organizationId: fallbackOrgId,
497
513
  })
498
514
 
499
- people = personLinks.reduce<DealAssociation[]>((acc, link) => {
515
+ const people = personLinks.reduce<DealAssociation[]>((acc, link) => {
500
516
  const entity = link.person as CustomerEntity | null
501
517
  if (!entity || entity.deletedAt) return acc
502
518
  const { label, subtitle } = normalizePersonAssociation(entity)
@@ -504,55 +520,89 @@ export async function GET(request: Request, context: { params?: Record<string, u
504
520
  return acc
505
521
  }, [])
506
522
 
507
- companies = companyLinks.reduce<DealAssociation[]>((acc, link) => {
523
+ const companies = companyLinks.reduce<DealAssociation[]>((acc, link) => {
508
524
  const entity = link.company as CustomerEntity | null
509
525
  if (!entity || entity.deletedAt) return acc
510
526
  const { label, subtitle } = normalizeCompanyAssociation(entity)
511
527
  acc.push({ id: entity.id, label, subtitle, kind: 'company' })
512
528
  return acc
513
529
  }, [])
514
- linkedPersonIds = people.map((entry) => entry.id)
515
- linkedCompanyIds = companies.map((entry) => entry.id)
530
+ return {
531
+ people,
532
+ companies,
533
+ linkedPersonIds: people.map((entry) => entry.id),
534
+ linkedCompanyIds: companies.map((entry) => entry.id),
535
+ }
516
536
  }
517
537
 
518
- const customFieldValues = await loadCustomFieldValues({
519
- em,
520
- entityId: E.customers.customer_deal,
521
- recordIds: [deal.id],
522
- tenantIdByRecord: { [deal.id]: deal.tenantId ?? null },
523
- organizationIdByRecord: { [deal.id]: deal.organizationId ?? null },
524
- tenantFallbacks: [deal.tenantId ?? auth.tenantId ?? null].filter((value): value is string => !!value),
525
- })
526
- const customFields = normalizeCustomFieldResponse(customFieldValues[deal.id]) ?? {}
527
-
528
538
  const viewerUserId = auth.isApiKey ? null : auth.sub ?? null
529
- let viewerName: string | null = null
530
- let viewerEmail: string | null = auth.email ?? null
531
- if (viewerUserId) {
532
- const viewerScope = {
533
- tenantId: auth.tenantId ?? null,
534
- organizationId: auth.orgId ?? null,
539
+ const resolveViewer = async (): Promise<{ name: string | null; email: string | null }> => {
540
+ if (!viewerUserId) {
541
+ return { name: null, email: auth.email ?? null }
535
542
  }
536
543
  const viewer = await findOneWithDecryption(
537
544
  em,
538
545
  User,
539
546
  { id: viewerUserId, tenantId: auth.tenantId ?? null },
540
547
  {},
541
- viewerScope,
548
+ { tenantId: auth.tenantId ?? null, organizationId: auth.orgId ?? null },
542
549
  )
543
- viewerName = viewer?.name ?? null
544
- viewerEmail = viewer?.email ?? viewerEmail ?? null
550
+ return {
551
+ name: viewer?.name ?? null,
552
+ email: viewer?.email ?? auth.email ?? null,
553
+ }
545
554
  }
546
555
 
547
- const owner = deal.ownerUserId
548
- ? await findOneWithDecryption(
556
+ const resolveOwner = async () =>
557
+ deal.ownerUserId
558
+ ? await findOneWithDecryption(
559
+ em,
560
+ User,
561
+ { id: deal.ownerUserId, tenantId: deal.tenantId ?? auth.tenantId ?? null },
562
+ {},
563
+ decryptionScope,
564
+ )
565
+ : null
566
+
567
+ const resolveStageTransitions = async (): Promise<CustomerDealStageTransition[]> => {
568
+ if (!includeStages) return []
569
+ try {
570
+ return await findWithDecryption(
571
+ em,
572
+ CustomerDealStageTransition,
573
+ { deal: deal.id, deletedAt: null },
574
+ { orderBy: { stageOrder: 'ASC', transitionedAt: 'ASC' } },
575
+ decryptionScope,
576
+ )
577
+ } catch (error) {
578
+ if (!isMissingDealStageTransitionTable(error)) {
579
+ throw error
580
+ }
581
+ warnMissingDealStageTransitionTable('customers.api.deals.detail.GET')
582
+ return []
583
+ }
584
+ }
585
+
586
+ const [associations, customFieldValues, viewerInfo, owner, effectiveStage, stageTransitions] = await Promise.all([
587
+ resolveAssociations(),
588
+ loadCustomFieldValues({
549
589
  em,
550
- User,
551
- { id: deal.ownerUserId, tenantId: deal.tenantId ?? auth.tenantId ?? null },
552
- {},
553
- decryptionScope,
554
- )
555
- : null
590
+ entityId: E.customers.customer_deal,
591
+ recordIds: [deal.id],
592
+ tenantIdByRecord: { [deal.id]: deal.tenantId ?? null },
593
+ organizationIdByRecord: { [deal.id]: deal.organizationId ?? null },
594
+ tenantFallbacks: [deal.tenantId ?? auth.tenantId ?? null].filter((value): value is string => !!value),
595
+ }),
596
+ resolveViewer(),
597
+ resolveOwner(),
598
+ includeStages ? resolveEffectivePipelineStage(em, deal, decryptionScope) : Promise.resolve(null),
599
+ resolveStageTransitions(),
600
+ ])
601
+
602
+ const { people, companies, linkedPersonIds, linkedCompanyIds } = associations
603
+ const customFields = normalizeCustomFieldResponse(customFieldValues[deal.id]) ?? {}
604
+ const viewerName = viewerInfo.name
605
+ const viewerEmail = viewerInfo.email
556
606
  const ownerPayload = owner
557
607
  ? {
558
608
  id: owner.id,
@@ -561,69 +611,53 @@ export async function GET(request: Request, context: { params?: Record<string, u
561
611
  }
562
612
  : null
563
613
 
564
- const effectiveStage = includeStages
565
- ? await resolveEffectivePipelineStage(em, deal, decryptionScope)
566
- : null
567
614
  const effectivePipelineId = deal.pipelineId ?? effectiveStage?.pipelineId ?? null
568
615
  const effectivePipelineStageId = deal.pipelineStageId ?? effectiveStage?.id ?? null
569
616
  const effectivePipelineStageLabel = deal.pipelineStage ?? effectiveStage?.label ?? null
570
617
 
571
- const pipelineStages = includeStages && effectivePipelineId
572
- ? await findWithDecryption(
573
- em,
574
- CustomerPipelineStage,
575
- {
576
- pipelineId: effectivePipelineId,
577
- organizationId: deal.organizationId,
578
- tenantId: deal.tenantId,
579
- },
580
- { orderBy: { order: 'ASC' } },
581
- decryptionScope,
582
- )
583
- : []
584
- const pipeline = effectivePipelineId
585
- ? await findOneWithDecryption(
586
- em,
587
- CustomerPipeline,
588
- {
589
- id: effectivePipelineId,
590
- organizationId: deal.organizationId,
591
- tenantId: deal.tenantId,
592
- },
593
- {},
594
- decryptionScope,
595
- )
596
- : null
597
- const pipelineStageAppearanceMap = pipelineStages.length
598
- ? await loadPipelineStageAppearanceMap(em, pipelineStages, deal.organizationId, deal.tenantId)
599
- : new Map<string, CustomerDictionaryEntry>()
600
- let stageTransitions: CustomerDealStageTransition[] = []
601
- if (includeStages) {
602
- try {
603
- stageTransitions = await findWithDecryption(
618
+ const [pipelineStages, pipeline] = await Promise.all([
619
+ includeStages && effectivePipelineId
620
+ ? findWithDecryption(
604
621
  em,
605
- CustomerDealStageTransition,
606
- { deal: deal.id, deletedAt: null },
607
- { orderBy: { stageOrder: 'ASC', transitionedAt: 'ASC' } },
622
+ CustomerPipelineStage,
623
+ {
624
+ pipelineId: effectivePipelineId,
625
+ organizationId: deal.organizationId,
626
+ tenantId: deal.tenantId,
627
+ },
628
+ { orderBy: { order: 'ASC' } },
608
629
  decryptionScope,
609
630
  )
610
- } catch (error) {
611
- if (!isMissingDealStageTransitionTable(error)) {
612
- throw error
613
- }
614
- warnMissingDealStageTransitionTable('customers.api.deals.detail.GET')
615
- stageTransitions = []
616
- }
617
- }
631
+ : [],
632
+ effectivePipelineId
633
+ ? findOneWithDecryption(
634
+ em,
635
+ CustomerPipeline,
636
+ {
637
+ id: effectivePipelineId,
638
+ organizationId: deal.organizationId,
639
+ tenantId: deal.tenantId,
640
+ },
641
+ {},
642
+ decryptionScope,
643
+ )
644
+ : null,
645
+ ])
646
+
618
647
  const persistedStageTransitions = stageTransitions.map((transition) => ({
619
648
  stageId: transition.stageId,
620
649
  stageLabel: transition.stageLabel,
621
650
  stageOrder: transition.stageOrder,
622
651
  transitionedAt: transition.transitionedAt.toISOString(),
623
652
  }))
624
- const recoveredStageTransitions = includeStages && persistedStageTransitions.length === 0
625
- ? await loadAuditStageTransitionsFallback({ container, deal, pipelineStages })
626
- : []
653
+ const [pipelineStageAppearanceMap, recoveredStageTransitions] = await Promise.all([
654
+ pipelineStages.length
655
+ ? loadPipelineStageAppearanceMap(em, pipelineStages, deal.organizationId, deal.tenantId)
656
+ : new Map<string, CustomerDictionaryEntry>(),
657
+ includeStages && persistedStageTransitions.length === 0
658
+ ? loadAuditStageTransitionsFallback({ container, deal, pipelineStages })
659
+ : [],
660
+ ])
627
661
  const effectiveCurrentStage = (() => {
628
662
  if (!effectivePipelineStageId) return null
629
663
  const matchingStage = pipelineStages.find((stage) => stage.id === effectivePipelineStageId)