@open-mercato/core 0.6.3-develop.3651.1.d3a548a5f1 → 0.6.3-develop.3660.1.53b73d5aa4
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/generated/entities/customer_settings/index.js +2 -0
- package/dist/generated/entities/customer_settings/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +332 -0
- package/dist/modules/customers/api/deals/aggregate/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-owner/route.js.map +7 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js +188 -0
- package/dist/modules/customers/api/deals/bulk-update-stage/route.js.map +7 -0
- package/dist/modules/customers/api/deals/route.js +255 -122
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/api/settings/stuck-threshold/route.js +169 -0
- package/dist/modules/customers/api/settings/stuck-threshold/route.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js +181 -0
- package/dist/modules/customers/backend/config/customers/deals/page.js.map +7 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js +38 -0
- package/dist/modules/customers/backend/config/customers/deals/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/create/page.js +17 -6
- package/dist/modules/customers/backend/customers/deals/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/page.js +2 -0
- package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js +217 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js +335 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js +29 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js +160 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js +157 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js +114 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js +36 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ChipButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js +233 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js +31 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +389 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js +181 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js +199 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +44 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js +70 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +278 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js +135 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js +83 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js +155 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js +318 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js +138 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +126 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +71 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/constants.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js +2330 -415
- package/dist/modules/customers/backend/customers/deals/pipeline/page.js.map +3 -3
- package/dist/modules/customers/commands/deals.js +6 -0
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/commands/pipeline-stages.js +48 -13
- package/dist/modules/customers/commands/pipeline-stages.js.map +3 -3
- package/dist/modules/customers/commands/settings.js +45 -5
- package/dist/modules/customers/commands/settings.js.map +2 -2
- package/dist/modules/customers/data/enrichers.js +130 -1
- package/dist/modules/customers/data/enrichers.js.map +2 -2
- package/dist/modules/customers/data/entities.js +4 -0
- package/dist/modules/customers/data/entities.js.map +2 -2
- package/dist/modules/customers/data/validators.js +29 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/bulkDeals.js +150 -0
- package/dist/modules/customers/lib/bulkDeals.js.map +7 -0
- package/dist/modules/customers/lib/kysely.js +12 -0
- package/dist/modules/customers/lib/kysely.js.map +7 -0
- package/dist/modules/customers/lib/stuckDeals.js +32 -0
- package/dist/modules/customers/lib/stuckDeals.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js +13 -0
- package/dist/modules/customers/migrations/Migration20260513203311_customers.js.map +7 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js +52 -0
- package/dist/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-owner.js.map +7 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js +41 -0
- package/dist/modules/customers/workers/deals-bulk-update-stage.js.map +7 -0
- package/generated/entities/customer_settings/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +438 -0
- package/src/modules/customers/api/deals/bulk-update-owner/route.ts +216 -0
- package/src/modules/customers/api/deals/bulk-update-stage/route.ts +228 -0
- package/src/modules/customers/api/deals/route.ts +330 -135
- package/src/modules/customers/api/settings/stuck-threshold/route.ts +195 -0
- package/src/modules/customers/backend/config/customers/deals/page.meta.ts +36 -0
- package/src/modules/customers/backend/config/customers/deals/page.tsx +205 -0
- package/src/modules/customers/backend/customers/deals/create/page.tsx +25 -6
- package/src/modules/customers/backend/customers/deals/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ActivityComposerDialog.tsx +277 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageDialog.tsx +425 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/AddStageLane.tsx +31 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/BulkActionsBar.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeOwnerDialog.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChangeStageDialog.tsx +138 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ChipButton.tsx +47 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CloseDateFilterPopover.tsx +232 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyBreakdownTable.tsx +110 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CurrencyFilterPopover.tsx +310 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/CustomizeViewDialog.tsx +169 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DashedTileButton.tsx +54 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +562 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/DealCardMenu.tsx +214 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/EntityFilterPopover.tsx +257 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +62 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterPopoverShell.tsx +101 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +378 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneCurrencyBreakdown.tsx +192 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/LaneResizeHandle.tsx +107 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/PipelineFilterPopover.tsx +196 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/QuickDealDialog.tsx +408 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/SortByPopover.tsx +164 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +171 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +84 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/constants.ts +16 -0
- package/src/modules/customers/backend/customers/deals/pipeline/page.tsx +2841 -550
- package/src/modules/customers/commands/deals.ts +19 -0
- package/src/modules/customers/commands/pipeline-stages.ts +68 -13
- package/src/modules/customers/commands/settings.ts +64 -5
- package/src/modules/customers/data/enrichers.ts +209 -2
- package/src/modules/customers/data/entities.ts +4 -1
- package/src/modules/customers/data/validators.ts +36 -2
- package/src/modules/customers/i18n/de.json +244 -0
- package/src/modules/customers/i18n/en.json +244 -0
- package/src/modules/customers/i18n/es.json +244 -0
- package/src/modules/customers/i18n/pl.json +244 -0
- package/src/modules/customers/lib/bulkDeals.ts +231 -0
- package/src/modules/customers/lib/kysely.ts +60 -0
- package/src/modules/customers/lib/stuckDeals.ts +88 -0
- package/src/modules/customers/migrations/.snapshot-open-mercato.json +16 -0
- package/src/modules/customers/migrations/Migration20260513203311_customers.ts +13 -0
- package/src/modules/customers/migrations/Migration20260519120000_pipeline_stage_color_tones.ts +71 -0
- package/src/modules/customers/workers/deals-bulk-update-owner.ts +46 -0
- package/src/modules/customers/workers/deals-bulk-update-stage.ts +46 -0
|
@@ -5,6 +5,7 @@ import { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink } from '.
|
|
|
5
5
|
import { dealCreateSchema, dealUpdateSchema } from '../../data/validators'
|
|
6
6
|
import { E } from '#generated/entities.ids.generated'
|
|
7
7
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
8
|
+
import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
|
|
8
9
|
import {
|
|
9
10
|
applyEntityIdRestriction,
|
|
10
11
|
findMatchingEntityIdsWithQueryEngine,
|
|
@@ -18,24 +19,48 @@ import {
|
|
|
18
19
|
defaultOkResponseSchema,
|
|
19
20
|
} from '../openapi'
|
|
20
21
|
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
22
|
+
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
21
23
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
22
24
|
import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'
|
|
25
|
+
import { fetchStuckDealIds } from '../../lib/stuckDeals'
|
|
23
26
|
|
|
24
27
|
const rawBodySchema = z.object({}).passthrough()
|
|
25
28
|
|
|
26
|
-
const
|
|
29
|
+
const stringOrStringArray = z.union([z.string(), z.array(z.string())])
|
|
30
|
+
const booleanQueryParam = z.preprocess((value) => {
|
|
31
|
+
const parsed = parseBooleanFromUnknown(value)
|
|
32
|
+
return parsed === null ? value : parsed
|
|
33
|
+
}, z.boolean()).optional()
|
|
34
|
+
|
|
35
|
+
export const dealListQuerySchema = z
|
|
27
36
|
.object({
|
|
28
37
|
page: z.coerce.number().min(1).default(1),
|
|
29
38
|
pageSize: z.coerce.number().min(1).max(100).default(50),
|
|
30
39
|
search: z.string().optional(),
|
|
31
|
-
status:
|
|
40
|
+
status: stringOrStringArray.optional(),
|
|
32
41
|
pipelineStage: z.string().optional(),
|
|
33
|
-
pipelineId:
|
|
34
|
-
pipelineStageId: z.string().uuid().optional(),
|
|
42
|
+
pipelineId: stringOrStringArray.optional(),
|
|
43
|
+
pipelineStageId: z.union([z.string().uuid(), z.literal('__unassigned')]).optional(),
|
|
44
|
+
ownerUserId: stringOrStringArray.optional(),
|
|
45
|
+
expectedCloseAtFrom: z.string().optional(),
|
|
46
|
+
expectedCloseAtTo: z.string().optional(),
|
|
47
|
+
isStuck: booleanQueryParam,
|
|
48
|
+
isOverdue: booleanQueryParam,
|
|
49
|
+
valueCurrency: stringOrStringArray.optional(),
|
|
35
50
|
sortField: z.string().optional(),
|
|
36
51
|
sortDir: z.enum(['asc', 'desc']).optional(),
|
|
37
|
-
|
|
38
|
-
|
|
52
|
+
/**
|
|
53
|
+
* @deprecated Use `personId` instead. The `personEntityId` alias is kept for backward
|
|
54
|
+
* compatibility with older clients and will be removed in a future minor release.
|
|
55
|
+
*/
|
|
56
|
+
personEntityId: z.string().uuid().optional().describe('Deprecated; use personId'),
|
|
57
|
+
/**
|
|
58
|
+
* @deprecated Use `companyId` instead. The `companyEntityId` alias is kept for backward
|
|
59
|
+
* compatibility with older clients and will be removed in a future minor release.
|
|
60
|
+
*/
|
|
61
|
+
companyEntityId: z.string().uuid().optional().describe('Deprecated; use companyId'),
|
|
62
|
+
personId: stringOrStringArray.optional(),
|
|
63
|
+
companyId: stringOrStringArray.optional(),
|
|
39
64
|
})
|
|
40
65
|
.passthrough()
|
|
41
66
|
|
|
@@ -48,7 +73,7 @@ const routeMetadata = {
|
|
|
48
73
|
|
|
49
74
|
export const metadata = routeMetadata
|
|
50
75
|
|
|
51
|
-
type DealListQuery = z.infer<typeof
|
|
76
|
+
export type DealListQuery = z.infer<typeof dealListQuerySchema>
|
|
52
77
|
|
|
53
78
|
function parseUuid(value: unknown): string | null {
|
|
54
79
|
if (typeof value !== 'string') return null
|
|
@@ -58,6 +83,97 @@ function parseUuid(value: unknown): string | null {
|
|
|
58
83
|
return result.success ? trimmed : null
|
|
59
84
|
}
|
|
60
85
|
|
|
86
|
+
function normalizeStringList(value: unknown): string[] {
|
|
87
|
+
const set = new Set<string>()
|
|
88
|
+
const visit = (entry: unknown) => {
|
|
89
|
+
if (entry == null) return
|
|
90
|
+
if (Array.isArray(entry)) {
|
|
91
|
+
entry.forEach(visit)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
if (typeof entry !== 'string') return
|
|
95
|
+
entry
|
|
96
|
+
.split(',')
|
|
97
|
+
.map((token) => token.trim())
|
|
98
|
+
.filter((token) => token.length > 0)
|
|
99
|
+
.forEach((token) => set.add(token))
|
|
100
|
+
}
|
|
101
|
+
visit(value)
|
|
102
|
+
return Array.from(set)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseDateInput(value: unknown): Date | null {
|
|
106
|
+
if (!(typeof value === 'string') || value.trim().length === 0) return null
|
|
107
|
+
const date = new Date(value)
|
|
108
|
+
return Number.isNaN(date.getTime()) ? null : date
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Pre-pagination ID narrowing for person/company filters.
|
|
113
|
+
*
|
|
114
|
+
* Mirrors the SQL the aggregate route uses (`EXISTS ... IN (...)`) so the list endpoint
|
|
115
|
+
* and the lane-header aggregate return a consistent set of deals for the same filters.
|
|
116
|
+
* Semantics: OR within a category (any selected person/company matches), AND across
|
|
117
|
+
* categories (deal must match at least one person AND at least one company when both
|
|
118
|
+
* filter lists are provided).
|
|
119
|
+
*
|
|
120
|
+
* Returns the matched deal IDs (UUIDs). An empty array means "no deals match" — callers
|
|
121
|
+
* must intersect with `restrictedIds` (which will collapse the list to zero).
|
|
122
|
+
*/
|
|
123
|
+
async function fetchDealIdsMatchingAssociations(
|
|
124
|
+
em: EntityManager,
|
|
125
|
+
organizationId: string,
|
|
126
|
+
tenantId: string,
|
|
127
|
+
personIds: string[],
|
|
128
|
+
companyIds: string[],
|
|
129
|
+
): Promise<string[]> {
|
|
130
|
+
if (!personIds.length && !companyIds.length) return []
|
|
131
|
+
const where: string[] = [
|
|
132
|
+
'organization_id = ?',
|
|
133
|
+
'tenant_id = ?',
|
|
134
|
+
'deleted_at IS NULL',
|
|
135
|
+
]
|
|
136
|
+
const values: Array<string | number> = [organizationId, tenantId]
|
|
137
|
+
if (personIds.length) {
|
|
138
|
+
const placeholders = personIds.map(() => '?').join(',')
|
|
139
|
+
where.push(
|
|
140
|
+
`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`,
|
|
141
|
+
)
|
|
142
|
+
values.push(...personIds)
|
|
143
|
+
}
|
|
144
|
+
if (companyIds.length) {
|
|
145
|
+
const placeholders = companyIds.map(() => '?').join(',')
|
|
146
|
+
where.push(
|
|
147
|
+
`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`,
|
|
148
|
+
)
|
|
149
|
+
values.push(...companyIds)
|
|
150
|
+
}
|
|
151
|
+
const rows = await em.getConnection().execute<Array<{ id: string }>>(
|
|
152
|
+
`SELECT id FROM customer_deals WHERE ${where.join(' AND ')}`,
|
|
153
|
+
values,
|
|
154
|
+
)
|
|
155
|
+
return rows.map((row) => row.id)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeCurrencyList(value: unknown): string[] {
|
|
159
|
+
const set = new Set<string>()
|
|
160
|
+
const visit = (entry: unknown) => {
|
|
161
|
+
if (entry == null) return
|
|
162
|
+
if (Array.isArray(entry)) {
|
|
163
|
+
entry.forEach(visit)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (typeof entry !== 'string') return
|
|
167
|
+
entry
|
|
168
|
+
.split(',')
|
|
169
|
+
.map((token) => token.trim().toUpperCase())
|
|
170
|
+
.filter((token) => /^[A-Z]{3}$/.test(token))
|
|
171
|
+
.forEach((token) => set.add(token))
|
|
172
|
+
}
|
|
173
|
+
visit(value)
|
|
174
|
+
return Array.from(set)
|
|
175
|
+
}
|
|
176
|
+
|
|
61
177
|
function normalizeUuidList(values: Array<unknown>): string[] {
|
|
62
178
|
const set = new Set<string>()
|
|
63
179
|
values.forEach((candidate) => {
|
|
@@ -84,6 +200,186 @@ function normalizeUuidList(values: Array<unknown>): string[] {
|
|
|
84
200
|
return Array.from(set)
|
|
85
201
|
}
|
|
86
202
|
|
|
203
|
+
export async function buildDealListFilters(query: DealListQuery, ctx?: import('@open-mercato/shared/lib/crud/factory').CrudCtx) {
|
|
204
|
+
const advancedFilterTree = consumeAdvancedFilterState(query)
|
|
205
|
+
const filters: Record<string, unknown> = {}
|
|
206
|
+
let restrictedIds: string[] | null = null
|
|
207
|
+
|
|
208
|
+
const intersectIds = (ids: string[]) => {
|
|
209
|
+
if (restrictedIds === null) {
|
|
210
|
+
restrictedIds = ids
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const lookup = new Set(ids)
|
|
214
|
+
restrictedIds = restrictedIds.filter((id) => lookup.has(id))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (query.search) {
|
|
218
|
+
const matchingIds = ctx
|
|
219
|
+
? await findMatchingEntityIdsBySearchTokensAcrossSources({
|
|
220
|
+
ctx,
|
|
221
|
+
query: query.search,
|
|
222
|
+
sources: [
|
|
223
|
+
{
|
|
224
|
+
entityType: E.customers.customer_deal,
|
|
225
|
+
fields: [
|
|
226
|
+
'title',
|
|
227
|
+
'description',
|
|
228
|
+
'status',
|
|
229
|
+
'pipeline_stage',
|
|
230
|
+
'source',
|
|
231
|
+
'value_amount',
|
|
232
|
+
'value_currency',
|
|
233
|
+
'cf:competitive_risk',
|
|
234
|
+
'cf:implementation_complexity',
|
|
235
|
+
],
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
: null
|
|
240
|
+
if (matchingIds !== null && matchingIds.length > 0) {
|
|
241
|
+
intersectIds(matchingIds)
|
|
242
|
+
} else if (isTenantDataEncryptionEnabled()) {
|
|
243
|
+
// `customers:customer_deal.title` and `.description` are encrypted at rest
|
|
244
|
+
// (see `encryption.ts`). The `$ilike` fallback below would silently match
|
|
245
|
+
// nothing on the ciphertext and produce empty pages without disclosing why —
|
|
246
|
+
// collapse the result to "no matches" instead so the kanban + list views
|
|
247
|
+
// agree, and lane aggregates stay consistent with the list endpoint.
|
|
248
|
+
intersectIds([])
|
|
249
|
+
} else {
|
|
250
|
+
const searchPattern = `%${escapeLikePattern(query.search)}%`
|
|
251
|
+
filters.$or = [
|
|
252
|
+
{ title: { $ilike: searchPattern } },
|
|
253
|
+
{ description: { $ilike: searchPattern } },
|
|
254
|
+
]
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const statusList = query.status ? normalizeStringList(query.status) : []
|
|
259
|
+
if (statusList.length > 0) {
|
|
260
|
+
filters.status = statusList.length === 1 ? { $eq: statusList[0] } : { $in: statusList }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (query.pipelineStage) {
|
|
264
|
+
filters.pipeline_stage = { $eq: query.pipelineStage }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const pipelineIds = query.pipelineId ? normalizeUuidList([query.pipelineId]) : []
|
|
268
|
+
if (pipelineIds.length > 0) {
|
|
269
|
+
filters.pipeline_id = pipelineIds.length === 1 ? { $eq: pipelineIds[0] } : { $in: pipelineIds }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (query.pipelineStageId === '__unassigned') {
|
|
273
|
+
filters.pipeline_stage_id = { $eq: null }
|
|
274
|
+
} else if (query.pipelineStageId) {
|
|
275
|
+
filters.pipeline_stage_id = { $eq: query.pipelineStageId }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const ownerUserIds = query.ownerUserId ? normalizeUuidList([query.ownerUserId]) : []
|
|
279
|
+
if (ownerUserIds.length > 0) {
|
|
280
|
+
filters.owner_user_id =
|
|
281
|
+
ownerUserIds.length === 1 ? { $eq: ownerUserIds[0] } : { $in: ownerUserIds }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Currency filter (additive; sourced by the kanban Currency popover). Codes are
|
|
285
|
+
// normalised to upper-case so `usd` / `USD` / mixed-case query params all hit the same
|
|
286
|
+
// stored value (the deals API persists `value_currency` upper-case already).
|
|
287
|
+
const currencyCodes = query.valueCurrency ? normalizeCurrencyList(query.valueCurrency) : []
|
|
288
|
+
if (currencyCodes.length > 0) {
|
|
289
|
+
filters.value_currency =
|
|
290
|
+
currencyCodes.length === 1 ? { $eq: currencyCodes[0] } : { $in: currencyCodes }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const expectedCloseFrom = parseDateInput(query.expectedCloseAtFrom)
|
|
294
|
+
const expectedCloseTo = parseDateInput(query.expectedCloseAtTo)
|
|
295
|
+
if (expectedCloseFrom || expectedCloseTo) {
|
|
296
|
+
const range: Record<string, Date> = {}
|
|
297
|
+
if (expectedCloseFrom) range.$gte = expectedCloseFrom
|
|
298
|
+
if (expectedCloseTo) range.$lte = expectedCloseTo
|
|
299
|
+
filters.expected_close_at = range
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (query.isOverdue) {
|
|
303
|
+
const today = new Date()
|
|
304
|
+
today.setHours(0, 0, 0, 0)
|
|
305
|
+
if (statusList.length === 0) {
|
|
306
|
+
filters.status = { $eq: 'open' }
|
|
307
|
+
}
|
|
308
|
+
const existingRange =
|
|
309
|
+
filters.expected_close_at && typeof filters.expected_close_at === 'object'
|
|
310
|
+
? (filters.expected_close_at as Record<string, Date>)
|
|
311
|
+
: {}
|
|
312
|
+
existingRange.$lt = today
|
|
313
|
+
filters.expected_close_at = existingRange
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (query.isStuck && ctx) {
|
|
317
|
+
const tenantId = ctx.auth?.tenantId
|
|
318
|
+
// CrudCtx.auth carries `orgId` (not `organizationId`). The previous code referenced
|
|
319
|
+
// `organizationId` which is always `undefined`, so the typeof check below silently
|
|
320
|
+
// skipped the entire isStuck branch — `?isStuck=true` was a no-op on this endpoint.
|
|
321
|
+
const organizationId = ctx.auth?.orgId
|
|
322
|
+
if (typeof tenantId === 'string' && typeof organizationId === 'string') {
|
|
323
|
+
const em = ctx.container.resolve<EntityManager>('em')
|
|
324
|
+
const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)
|
|
325
|
+
intersectIds(stuckIds)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Pre-pagination association filter. Must run on the FULL dataset (before pagination),
|
|
330
|
+
// otherwise matching deals on later pages disappear and `total` would be wrong. Read the
|
|
331
|
+
// raw URL too so legacy `?personEntityId=` / `?companyEntityId=` keep working alongside the
|
|
332
|
+
// canonical `?personId=` / `?companyId=`.
|
|
333
|
+
const url = ctx?.request ? new URL(ctx.request.url) : null
|
|
334
|
+
const personCandidates: unknown[] = [query.personId, query.personEntityId]
|
|
335
|
+
const companyCandidates: unknown[] = [query.companyId, query.companyEntityId]
|
|
336
|
+
if (url) {
|
|
337
|
+
personCandidates.push(url.searchParams.getAll('personId'))
|
|
338
|
+
personCandidates.push(url.searchParams.getAll('personEntityId'))
|
|
339
|
+
companyCandidates.push(url.searchParams.getAll('companyId'))
|
|
340
|
+
companyCandidates.push(url.searchParams.getAll('companyEntityId'))
|
|
341
|
+
}
|
|
342
|
+
const selectedPersonIds = normalizeUuidList(personCandidates)
|
|
343
|
+
const selectedCompanyIds = normalizeUuidList(companyCandidates)
|
|
344
|
+
if ((selectedPersonIds.length > 0 || selectedCompanyIds.length > 0) && ctx) {
|
|
345
|
+
const tenantId = ctx.auth?.tenantId
|
|
346
|
+
// `ctx.auth` exposes `orgId` (see AuthContext in @open-mercato/shared/lib/auth/server).
|
|
347
|
+
// Read it under the correct key — the previous code's `organizationId` would always be
|
|
348
|
+
// `undefined`, silently disabling association filtering on the deals list endpoint.
|
|
349
|
+
const organizationId = ctx.auth?.orgId
|
|
350
|
+
if (typeof tenantId === 'string' && typeof organizationId === 'string') {
|
|
351
|
+
const em = ctx.container.resolve<EntityManager>('em')
|
|
352
|
+
const matchedIds = await fetchDealIdsMatchingAssociations(
|
|
353
|
+
em,
|
|
354
|
+
organizationId,
|
|
355
|
+
tenantId,
|
|
356
|
+
selectedPersonIds,
|
|
357
|
+
selectedCompanyIds,
|
|
358
|
+
)
|
|
359
|
+
// intersectIds with empty array → no rows; collapses the page to zero, total stays correct.
|
|
360
|
+
intersectIds(matchedIds)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (ctx && advancedFilterTree) {
|
|
365
|
+
const advancedFilters = mergeAdvancedFilterTree({ ...filters }, advancedFilterTree)
|
|
366
|
+
const matchedIds = await findMatchingEntityIdsWithQueryEngine({
|
|
367
|
+
ctx,
|
|
368
|
+
entityId: E.customers.customer_deal,
|
|
369
|
+
filters: advancedFilters,
|
|
370
|
+
})
|
|
371
|
+
if (matchedIds !== null) {
|
|
372
|
+
intersectIds(matchedIds)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (restrictedIds !== null) {
|
|
377
|
+
applyEntityIdRestriction(filters, restrictedIds)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return filters
|
|
381
|
+
}
|
|
382
|
+
|
|
87
383
|
const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
88
384
|
metadata: routeMetadata,
|
|
89
385
|
orm: {
|
|
@@ -96,8 +392,9 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
96
392
|
indexer: {
|
|
97
393
|
entityType: E.customers.customer_deal,
|
|
98
394
|
},
|
|
395
|
+
enrichers: { entityId: 'customers.deal' },
|
|
99
396
|
list: {
|
|
100
|
-
schema:
|
|
397
|
+
schema: dealListQuerySchema,
|
|
101
398
|
entityId: E.customers.customer_deal,
|
|
102
399
|
fields: [
|
|
103
400
|
'id',
|
|
@@ -129,68 +426,10 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
129
426
|
updatedAt: 'updated_at',
|
|
130
427
|
title: 'title',
|
|
131
428
|
value: 'value_amount',
|
|
429
|
+
probability: 'probability',
|
|
430
|
+
expectedCloseAt: 'expected_close_at',
|
|
132
431
|
},
|
|
133
|
-
|
|
134
|
-
buildFilters: async (query: any, ctx) => {
|
|
135
|
-
const advancedFilterTree = consumeAdvancedFilterState(query)
|
|
136
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
137
|
-
const filters: Record<string, any> = {}
|
|
138
|
-
if (query.search) {
|
|
139
|
-
const matchingIds = ctx
|
|
140
|
-
? await findMatchingEntityIdsBySearchTokensAcrossSources({
|
|
141
|
-
ctx,
|
|
142
|
-
query: query.search,
|
|
143
|
-
sources: [
|
|
144
|
-
{
|
|
145
|
-
entityType: E.customers.customer_deal,
|
|
146
|
-
fields: [
|
|
147
|
-
'title',
|
|
148
|
-
'description',
|
|
149
|
-
'status',
|
|
150
|
-
'pipeline_stage',
|
|
151
|
-
'source',
|
|
152
|
-
'value_amount',
|
|
153
|
-
'value_currency',
|
|
154
|
-
'cf:competitive_risk',
|
|
155
|
-
'cf:implementation_complexity',
|
|
156
|
-
],
|
|
157
|
-
},
|
|
158
|
-
],
|
|
159
|
-
})
|
|
160
|
-
: null
|
|
161
|
-
if (matchingIds !== null && matchingIds.length > 0) {
|
|
162
|
-
applyEntityIdRestriction(filters, matchingIds)
|
|
163
|
-
} else {
|
|
164
|
-
const searchPattern = `%${escapeLikePattern(query.search)}%`
|
|
165
|
-
filters.$or = [
|
|
166
|
-
{ title: { $ilike: searchPattern } },
|
|
167
|
-
{ description: { $ilike: searchPattern } },
|
|
168
|
-
]
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
if (query.status) {
|
|
172
|
-
filters.status = { $eq: query.status }
|
|
173
|
-
}
|
|
174
|
-
if (query.pipelineStage) {
|
|
175
|
-
filters.pipeline_stage = { $eq: query.pipelineStage }
|
|
176
|
-
}
|
|
177
|
-
if (query.pipelineId) {
|
|
178
|
-
filters.pipeline_id = { $eq: query.pipelineId }
|
|
179
|
-
}
|
|
180
|
-
if (query.pipelineStageId) {
|
|
181
|
-
filters.pipeline_stage_id = { $eq: query.pipelineStageId }
|
|
182
|
-
}
|
|
183
|
-
if (ctx && advancedFilterTree) {
|
|
184
|
-
const advancedFilters = mergeAdvancedFilterTree({ ...filters }, advancedFilterTree)
|
|
185
|
-
const matchedIds = await findMatchingEntityIdsWithQueryEngine({
|
|
186
|
-
ctx,
|
|
187
|
-
entityId: E.customers.customer_deal,
|
|
188
|
-
filters: advancedFilters,
|
|
189
|
-
})
|
|
190
|
-
applyEntityIdRestriction(filters, matchedIds)
|
|
191
|
-
}
|
|
192
|
-
return filters
|
|
193
|
-
},
|
|
432
|
+
buildFilters: buildDealListFilters,
|
|
194
433
|
},
|
|
195
434
|
actions: {
|
|
196
435
|
create: {
|
|
@@ -229,50 +468,18 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
229
468
|
},
|
|
230
469
|
},
|
|
231
470
|
hooks: {
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const allPersonCandidates: unknown[] = []
|
|
237
|
-
const allCompanyCandidates: unknown[] = []
|
|
238
|
-
if (legacyPersonId) allPersonCandidates.push(legacyPersonId)
|
|
239
|
-
if (legacyCompanyId) allCompanyCandidates.push(legacyCompanyId)
|
|
240
|
-
if (url) {
|
|
241
|
-
const personParams = url.searchParams.getAll('personId')
|
|
242
|
-
const companyParams = url.searchParams.getAll('companyId')
|
|
243
|
-
if (personParams.length) allPersonCandidates.push(...personParams)
|
|
244
|
-
if (companyParams.length) allCompanyCandidates.push(...companyParams)
|
|
245
|
-
const legacyRepeatPerson = url.searchParams.getAll('personEntityId')
|
|
246
|
-
const legacyRepeatCompany = url.searchParams.getAll('companyEntityId')
|
|
247
|
-
if (legacyRepeatPerson.length) allPersonCandidates.push(...legacyRepeatPerson)
|
|
248
|
-
if (legacyRepeatCompany.length) allCompanyCandidates.push(...legacyRepeatCompany)
|
|
249
|
-
}
|
|
250
|
-
const personIds = normalizeUuidList(allPersonCandidates)
|
|
251
|
-
const companyIds = normalizeUuidList(allCompanyCandidates)
|
|
252
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
253
|
-
;(ctx as any).__dealsFilters = {
|
|
254
|
-
personIds,
|
|
255
|
-
companyIds,
|
|
256
|
-
}
|
|
257
|
-
},
|
|
471
|
+
// afterList only DECORATES results with `people`/`companies` arrays — it must not filter,
|
|
472
|
+
// because filtering after pagination would drop deals on later pages and rewrite `total`
|
|
473
|
+
// to a misleading value. Association filtering happens pre-pagination in `buildFilters`
|
|
474
|
+
// via `fetchDealIdsMatchingAssociations`.
|
|
258
475
|
afterList: async (payload, ctx) => {
|
|
259
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
260
|
-
const filters = ((ctx as any).__dealsFilters || {}) as {
|
|
261
|
-
personIds?: string[]
|
|
262
|
-
companyIds?: string[]
|
|
263
|
-
}
|
|
264
|
-
const selectedPersonIds = Array.isArray(filters.personIds) ? filters.personIds : []
|
|
265
|
-
const selectedCompanyIds = Array.isArray(filters.companyIds) ? filters.companyIds : []
|
|
266
|
-
|
|
267
476
|
const items = Array.isArray(payload.items) ? payload.items : []
|
|
268
477
|
if (!items.length) return
|
|
269
478
|
const scopeSource = (items[0] ?? {}) as Record<string, unknown>
|
|
270
479
|
const tenantIdRaw = scopeSource.tenantId ?? scopeSource.tenant_id
|
|
271
|
-
|
|
272
|
-
const fallbackTenantId = (typeof tenantIdRaw === 'string' && tenantIdRaw.trim().length ? tenantIdRaw : null) ?? (ctx as any)?.auth?.tenantId ?? null
|
|
480
|
+
const fallbackTenantId = (typeof tenantIdRaw === 'string' && tenantIdRaw.trim().length ? tenantIdRaw : null) ?? ctx.auth?.tenantId ?? null
|
|
273
481
|
const orgIdRaw = scopeSource.organizationId ?? scopeSource.organization_id
|
|
274
|
-
|
|
275
|
-
const fallbackOrganizationId = (typeof orgIdRaw === 'string' && orgIdRaw.trim().length ? orgIdRaw : null) ?? (ctx as any)?.auth?.orgId ?? null
|
|
482
|
+
const fallbackOrganizationId = (typeof orgIdRaw === 'string' && orgIdRaw.trim().length ? orgIdRaw : null) ?? ctx.auth?.orgId ?? null
|
|
276
483
|
const ids = items
|
|
277
484
|
.map((item: unknown) => {
|
|
278
485
|
if (!item || typeof item !== 'object') return null
|
|
@@ -305,7 +512,6 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
305
512
|
])
|
|
306
513
|
|
|
307
514
|
const personAssignments = new Map<string, { id: string; label: string }[]>()
|
|
308
|
-
const personMemberships = new Map<string, Set<string>>()
|
|
309
515
|
allPersonLinks.forEach((link) => {
|
|
310
516
|
const deal = link.deal
|
|
311
517
|
const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null
|
|
@@ -327,13 +533,9 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
327
533
|
bucket.push({ id: personId, label })
|
|
328
534
|
personAssignments.set(dealId, bucket)
|
|
329
535
|
}
|
|
330
|
-
const membership = personMemberships.get(dealId) ?? new Set<string>()
|
|
331
|
-
membership.add(personId)
|
|
332
|
-
personMemberships.set(dealId, membership)
|
|
333
536
|
})
|
|
334
537
|
|
|
335
538
|
const companyAssignments = new Map<string, { id: string; label: string }[]>()
|
|
336
|
-
const companyMemberships = new Map<string, Set<string>>()
|
|
337
539
|
allCompanyLinks.forEach((link) => {
|
|
338
540
|
const deal = link.deal
|
|
339
541
|
const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null
|
|
@@ -355,20 +557,8 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
355
557
|
bucket.push({ id: companyId, label })
|
|
356
558
|
companyAssignments.set(dealId, bucket)
|
|
357
559
|
}
|
|
358
|
-
const membership = companyMemberships.get(dealId) ?? new Set<string>()
|
|
359
|
-
membership.add(companyId)
|
|
360
|
-
companyMemberships.set(dealId, membership)
|
|
361
560
|
})
|
|
362
561
|
|
|
363
|
-
const hasPersonFilter = selectedPersonIds.length > 0
|
|
364
|
-
const hasCompanyFilter = selectedCompanyIds.length > 0
|
|
365
|
-
const matchesAll = (selected: string[], memberships: Map<string, Set<string>>, dealId: string) => {
|
|
366
|
-
if (!selected.length) return true
|
|
367
|
-
const membership = memberships.get(dealId)
|
|
368
|
-
if (!membership || membership.size === 0) return false
|
|
369
|
-
return selected.every((id) => membership.has(id))
|
|
370
|
-
}
|
|
371
|
-
|
|
372
562
|
const enhancedItems = items
|
|
373
563
|
.map((item: unknown) => {
|
|
374
564
|
if (!item || typeof item !== 'object') return null
|
|
@@ -377,9 +567,6 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
377
567
|
if (!candidate || !candidate.trim().length) return null
|
|
378
568
|
const people = personAssignments.get(candidate) ?? []
|
|
379
569
|
const companies = companyAssignments.get(candidate) ?? []
|
|
380
|
-
const matchesPerson = matchesAll(selectedPersonIds, personMemberships, candidate)
|
|
381
|
-
const matchesCompany = matchesAll(selectedCompanyIds, companyMemberships, candidate)
|
|
382
|
-
if (!matchesPerson || !matchesCompany) return null
|
|
383
570
|
const tenantIdRaw =
|
|
384
571
|
typeof data.tenantId === 'string'
|
|
385
572
|
? data.tenantId
|
|
@@ -409,14 +596,22 @@ const crud = makeCrudRoute<unknown, unknown, DealListQuery>({
|
|
|
409
596
|
)
|
|
410
597
|
|
|
411
598
|
payload.items = enhancedItems
|
|
412
|
-
if (hasPersonFilter || hasCompanyFilter) {
|
|
413
|
-
payload.total = enhancedItems.length
|
|
414
|
-
payload.totalPages = 1
|
|
415
|
-
payload.page = 1
|
|
416
|
-
}
|
|
417
599
|
} catch (err) {
|
|
418
|
-
|
|
419
|
-
//
|
|
600
|
+
// We swallow rather than fail the request because the kanban is still useful without
|
|
601
|
+
// people/companies labels (cards just lose their company pill). Tag every item with
|
|
602
|
+
// `_associations: { ok: false }` so a future surface can render a degraded-state hint
|
|
603
|
+
// instead of silently showing cards without company badges.
|
|
604
|
+
console.warn('[customers.deals] failed to decorate items with person/company links', err)
|
|
605
|
+
payload.items = items.map((item: unknown) => {
|
|
606
|
+
if (!item || typeof item !== 'object') return item
|
|
607
|
+
return {
|
|
608
|
+
...(item as Record<string, unknown>),
|
|
609
|
+
_associations: {
|
|
610
|
+
ok: false,
|
|
611
|
+
reason: err instanceof Error ? err.message : 'unknown',
|
|
612
|
+
},
|
|
613
|
+
}
|
|
614
|
+
})
|
|
420
615
|
}
|
|
421
616
|
},
|
|
422
617
|
},
|
|
@@ -466,7 +661,7 @@ const dealCreateResponseSchema = z.object({
|
|
|
466
661
|
|
|
467
662
|
export const openApi = createCustomersCrudOpenApi({
|
|
468
663
|
resourceName: 'Deal',
|
|
469
|
-
querySchema:
|
|
664
|
+
querySchema: dealListQuerySchema,
|
|
470
665
|
listResponseSchema: createPagedListResponseSchema(dealListItemSchema),
|
|
471
666
|
create: {
|
|
472
667
|
schema: dealCreateSchema,
|