@open-mercato/core 0.6.6-develop.6385.1.9a81faa5f0 → 0.6.6-develop.6387.1.f4896ad565
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/attachments/lib/scope.js +26 -0
- package/dist/modules/attachments/lib/scope.js.map +7 -0
- package/dist/modules/catalog/commands/variants.js +4 -2
- package/dist/modules/catalog/commands/variants.js.map +2 -2
- package/dist/modules/customers/api/deals/map/route.js +433 -0
- package/dist/modules/customers/api/deals/map/route.js.map +7 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
- package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
- package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
- package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
- package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
- package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
- package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
- package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
- package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
- package/dist/modules/customers/components/AddressEditor.js +34 -2
- package/dist/modules/customers/components/AddressEditor.js.map +2 -2
- package/dist/modules/customers/components/AddressTiles.js +43 -3
- package/dist/modules/customers/components/AddressTiles.js.map +2 -2
- package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
- package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
- package/dist/modules/customers/components/formConfig.js +10 -0
- package/dist/modules/customers/components/formConfig.js.map +2 -2
- package/dist/modules/customers/data/validators.js +3 -2
- package/dist/modules/customers/data/validators.js.map +2 -2
- package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
- package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
- package/dist/modules/messages/commands/messages.js +0 -1
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/lib/attachments.js +13 -7
- package/dist/modules/messages/lib/attachments.js.map +2 -2
- package/package.json +11 -7
- package/src/modules/attachments/lib/scope.ts +42 -0
- package/src/modules/catalog/commands/variants.ts +7 -2
- package/src/modules/customers/api/deals/map/route.ts +549 -0
- package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
- package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
- package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
- package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
- package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
- package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
- package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
- package/src/modules/customers/components/AddressEditor.tsx +35 -0
- package/src/modules/customers/components/AddressTiles.tsx +51 -1
- package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
- package/src/modules/customers/components/formConfig.tsx +10 -0
- package/src/modules/customers/data/validators.ts +13 -2
- package/src/modules/customers/i18n/de.json +24 -0
- package/src/modules/customers/i18n/en.json +24 -0
- package/src/modules/customers/i18n/es.json +24 -0
- package/src/modules/customers/i18n/pl.json +24 -0
- package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
- package/src/modules/messages/commands/messages.ts +0 -1
- package/src/modules/messages/lib/attachments.ts +15 -7
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
4
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
6
|
+
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
7
|
+
import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
8
|
+
import { SortDir, type QueryEngine } from '@open-mercato/shared/lib/query/types'
|
|
9
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
10
|
+
import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
11
|
+
import {
|
|
12
|
+
CustomerAddress,
|
|
13
|
+
CustomerDealCompanyLink,
|
|
14
|
+
CustomerDealPersonLink,
|
|
15
|
+
} from '../../../data/entities'
|
|
16
|
+
import { buildDealListFilters, dealListQuerySchema } from '../route'
|
|
17
|
+
import { applyEntityIdRestriction, findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'
|
|
18
|
+
import { createPagedListResponseSchema } from '../../openapi'
|
|
19
|
+
import {
|
|
20
|
+
resolveDealLocations,
|
|
21
|
+
type DealMapAddress,
|
|
22
|
+
type DealMapLink,
|
|
23
|
+
type DealMapLocation,
|
|
24
|
+
} from '../../../lib/dealsMapLocation'
|
|
25
|
+
import { E } from '#generated/entities.ids.generated'
|
|
26
|
+
|
|
27
|
+
export const metadata = {
|
|
28
|
+
GET: { requireAuth: true, requireFeatures: ['customers.deals.view', 'customers.activities.view'] },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const mapSortFields = ['createdAt', 'updatedAt', 'title', 'value', 'probability', 'expectedCloseAt'] as const
|
|
32
|
+
|
|
33
|
+
const sortFieldMap: Record<(typeof mapSortFields)[number], string> = {
|
|
34
|
+
createdAt: 'created_at',
|
|
35
|
+
updatedAt: 'updated_at',
|
|
36
|
+
title: 'title',
|
|
37
|
+
value: 'value_amount',
|
|
38
|
+
probability: 'probability',
|
|
39
|
+
expectedCloseAt: 'expected_close_at',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const querySchema = dealListQuerySchema.extend({
|
|
43
|
+
pageSize: z.coerce.number().min(1).max(100).default(100),
|
|
44
|
+
sortField: z.enum(mapSortFields).optional(),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const dealMapAssociationSchema = z.object({
|
|
48
|
+
id: z.string().uuid(),
|
|
49
|
+
label: z.string().nullable(),
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const dealMapLocationSchema = z.object({
|
|
53
|
+
latitude: z.number(),
|
|
54
|
+
longitude: z.number(),
|
|
55
|
+
city: z.string().nullable(),
|
|
56
|
+
region: z.string().nullable(),
|
|
57
|
+
country: z.string().nullable(),
|
|
58
|
+
source: z.enum(['company', 'person']),
|
|
59
|
+
entityId: z.string().uuid(),
|
|
60
|
+
addressId: z.string().uuid(),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const dealMapItemSchema = z.object({
|
|
64
|
+
id: z.string().uuid(),
|
|
65
|
+
title: z.string().nullable(),
|
|
66
|
+
status: z.string().nullable(),
|
|
67
|
+
pipelineId: z.string().uuid().nullable(),
|
|
68
|
+
pipelineStageId: z.string().uuid().nullable(),
|
|
69
|
+
pipelineStage: z.string().nullable(),
|
|
70
|
+
valueAmount: z.number().nullable(),
|
|
71
|
+
valueCurrency: z.string().nullable(),
|
|
72
|
+
probability: z.number().nullable(),
|
|
73
|
+
expectedCloseAt: z.string().nullable(),
|
|
74
|
+
ownerUserId: z.string().uuid().nullable(),
|
|
75
|
+
updatedAt: z.string().nullable(),
|
|
76
|
+
companies: z.array(dealMapAssociationSchema),
|
|
77
|
+
people: z.array(dealMapAssociationSchema),
|
|
78
|
+
location: dealMapLocationSchema.nullable(),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const mapErrorSchema = z.object({
|
|
82
|
+
error: z.string(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
export const openApi: OpenApiRouteDoc = {
|
|
86
|
+
tag: 'Customers',
|
|
87
|
+
summary: 'Deals map listing with resolved locations',
|
|
88
|
+
methods: {
|
|
89
|
+
GET: {
|
|
90
|
+
summary: 'Paginated deals that have a resolvable map location',
|
|
91
|
+
description:
|
|
92
|
+
'Returns a page of deals that have a coordinate-bearing linked company/person address, each enriched with one resolved location (company primary first, then earliest created; person addresses as fallback). Deals with no coordinate-bearing address are excluded entirely, so every item carries a non-null location in normal operation; the schema keeps location nullable only for the rare case where the address is deleted between the located-deal resolution and the page fetch.',
|
|
93
|
+
query: querySchema,
|
|
94
|
+
responses: [
|
|
95
|
+
{ status: 200, description: 'Paged located deals', schema: createPagedListResponseSchema(dealMapItemSchema) },
|
|
96
|
+
],
|
|
97
|
+
errors: [
|
|
98
|
+
{ status: 400, description: 'Invalid query parameters', schema: mapErrorSchema },
|
|
99
|
+
{ status: 401, description: 'Unauthorized', schema: mapErrorSchema },
|
|
100
|
+
{ status: 403, description: 'Missing required features', schema: mapErrorSchema },
|
|
101
|
+
],
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type DealMapAssociation = z.infer<typeof dealMapAssociationSchema>
|
|
107
|
+
|
|
108
|
+
type LinkRow = {
|
|
109
|
+
dealId: string
|
|
110
|
+
entityId: string
|
|
111
|
+
label: string
|
|
112
|
+
createdAt: Date | string | null
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readEntityRef(ref: unknown): { id: string | null; record: Record<string, unknown> | null } {
|
|
116
|
+
if (typeof ref === 'string') return { id: ref, record: null }
|
|
117
|
+
if (ref && typeof ref === 'object') {
|
|
118
|
+
const record = ref as Record<string, unknown>
|
|
119
|
+
return { id: typeof record.id === 'string' ? record.id : null, record }
|
|
120
|
+
}
|
|
121
|
+
return { id: null, record: null }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readLinkRow(link: { deal: unknown; createdAt?: Date | string | null }, linkedRef: unknown): LinkRow | null {
|
|
125
|
+
const { id: dealId } = readEntityRef(link.deal)
|
|
126
|
+
if (!dealId) return null
|
|
127
|
+
const { id: entityId, record } = readEntityRef(linkedRef)
|
|
128
|
+
if (!entityId) return null
|
|
129
|
+
const label = record && typeof record.displayName === 'string' ? record.displayName : ''
|
|
130
|
+
return { dealId, entityId, label, createdAt: link.createdAt ?? null }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function groupAssociations(rows: LinkRow[]): Map<string, DealMapAssociation[]> {
|
|
134
|
+
const byDeal = new Map<string, DealMapAssociation[]>()
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
const bucket = byDeal.get(row.dealId) ?? []
|
|
137
|
+
if (!bucket.some((entry) => entry.id === row.entityId)) {
|
|
138
|
+
bucket.push({ id: row.entityId, label: row.label })
|
|
139
|
+
byDeal.set(row.dealId, bucket)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return byDeal
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toStringOrNull(value: unknown): string | null {
|
|
146
|
+
return typeof value === 'string' && value.length > 0 ? value : null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function toFiniteNumberOrNull(value: unknown): number | null {
|
|
150
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
151
|
+
if (typeof value === 'string' && value.trim().length > 0) {
|
|
152
|
+
const parsed = Number(value)
|
|
153
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function toIsoStringOrNull(value: unknown): string | null {
|
|
159
|
+
if (value instanceof Date) return value.toISOString()
|
|
160
|
+
return toStringOrNull(value)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {
|
|
164
|
+
const all = searchParams.getAll(key)
|
|
165
|
+
if (!all.length) return null
|
|
166
|
+
const trimmed = all.flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean)
|
|
167
|
+
return trimmed.length ? trimmed : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectRefIds(rows: unknown[], key: string): string[] {
|
|
171
|
+
const ids = new Set<string>()
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
if (!row || typeof row !== 'object') continue
|
|
174
|
+
const { id } = readEntityRef((row as Record<string, unknown>)[key])
|
|
175
|
+
if (id) ids.add(id)
|
|
176
|
+
}
|
|
177
|
+
return Array.from(ids)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// The deal-list filter builder may already restrict `id` (search, association, stuck, or advanced
|
|
181
|
+
// filters). `applyEntityIdRestriction` overwrites an existing `$in`, so intersect the located-deal
|
|
182
|
+
// set with whatever allow-list is already present before re-applying it.
|
|
183
|
+
function readFilterIdAllowlist(filters: Record<string, unknown>): string[] | null {
|
|
184
|
+
const idFilter = filters.id
|
|
185
|
+
if (!idFilter || typeof idFilter !== 'object' || Array.isArray(idFilter)) return null
|
|
186
|
+
const record = idFilter as { $eq?: unknown; $in?: unknown }
|
|
187
|
+
if (typeof record.$eq === 'string') return [record.$eq]
|
|
188
|
+
if (Array.isArray(record.$in)) return record.$in.filter((value): value is string => typeof value === 'string')
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function emptyMapResponse(page: number, pageSize: number) {
|
|
193
|
+
return NextResponse.json({ items: [], total: 0, page, pageSize, totalPages: 0 })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function GET(req: Request) {
|
|
197
|
+
const auth = await getAuthFromRequest(req)
|
|
198
|
+
if (!auth?.tenantId) {
|
|
199
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const url = new URL(req.url)
|
|
203
|
+
const params = url.searchParams
|
|
204
|
+
const parsed = querySchema.safeParse({
|
|
205
|
+
page: params.get('page') ?? undefined,
|
|
206
|
+
pageSize: params.get('pageSize') ?? undefined,
|
|
207
|
+
search: params.get('search') ?? undefined,
|
|
208
|
+
status: readArrayParam(params, 'status') ?? undefined,
|
|
209
|
+
pipelineId: readArrayParam(params, 'pipelineId') ?? undefined,
|
|
210
|
+
pipelineStageId: params.get('pipelineStageId') ?? undefined,
|
|
211
|
+
ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,
|
|
212
|
+
personId: readArrayParam(params, 'personId') ?? undefined,
|
|
213
|
+
companyId: readArrayParam(params, 'companyId') ?? undefined,
|
|
214
|
+
valueCurrency: readArrayParam(params, 'valueCurrency') ?? undefined,
|
|
215
|
+
expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,
|
|
216
|
+
expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,
|
|
217
|
+
isStuck: params.get('isStuck') ?? undefined,
|
|
218
|
+
isOverdue: params.get('isOverdue') ?? undefined,
|
|
219
|
+
sortField: params.get('sortField') ?? undefined,
|
|
220
|
+
sortDir: params.get('sortDir') ?? undefined,
|
|
221
|
+
})
|
|
222
|
+
if (!parsed.success) {
|
|
223
|
+
return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
|
|
224
|
+
}
|
|
225
|
+
const query = parsed.data
|
|
226
|
+
|
|
227
|
+
const container = await createRequestContainer()
|
|
228
|
+
const em = container.resolve<EntityManager>('em')
|
|
229
|
+
const queryEngine = container.resolve<QueryEngine>('queryEngine')
|
|
230
|
+
|
|
231
|
+
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
232
|
+
const effectiveTenantId = scope.tenantId ?? auth.tenantId
|
|
233
|
+
// `null` = unrestricted access (superadmin or an "all organizations" grant): aggregate tenant-wide,
|
|
234
|
+
// exactly like the deals List route (`makeCrudRoute`) and the query_index status route. A populated
|
|
235
|
+
// array bounds the query to the caller's visible orgs; an empty array means no org visibility → 401.
|
|
236
|
+
// The previous guard hard-required `auth.orgId`, which is empty under the header "All organizations"
|
|
237
|
+
// scope, so the map 401'd (and hung on the loading spinner) while List/Kanban aggregated fine (#3481).
|
|
238
|
+
const orgScopeIds: string[] | null =
|
|
239
|
+
scope.filterIds === null
|
|
240
|
+
? null
|
|
241
|
+
: Array.isArray(scope.filterIds) && scope.filterIds.length > 0
|
|
242
|
+
? Array.from(new Set(scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)))
|
|
243
|
+
: auth.orgId
|
|
244
|
+
? [auth.orgId]
|
|
245
|
+
: []
|
|
246
|
+
if (!effectiveTenantId || (Array.isArray(orgScopeIds) && orgScopeIds.length === 0)) {
|
|
247
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Bounded `$in` when the caller is org-restricted; omitted entirely under unrestricted access so
|
|
251
|
+
// every query stays tenant-scoped only. `fallbackOrgId` is undefined when unrestricted.
|
|
252
|
+
const orgScopeFilter = orgScopeIds ? { organizationId: { $in: orgScopeIds } } : {}
|
|
253
|
+
const fallbackOrgId = orgScopeIds && orgScopeIds.length > 0 ? orgScopeIds[0] : undefined
|
|
254
|
+
|
|
255
|
+
// `findWithDecryption` decrypts each row with the row's OWN tenant/organization (the encryption
|
|
256
|
+
// subscriber's resolveScope(target) wins); this scope is only a fallback for rows that carry no
|
|
257
|
+
// scope columns. CustomerAddress always carries organization_id, so every org's rows still decrypt
|
|
258
|
+
// correctly under a multi-org / unrestricted scope even though `fallbackOrgId` may be undefined.
|
|
259
|
+
const decryptionScope = { tenantId: effectiveTenantId, organizationId: fallbackOrgId }
|
|
260
|
+
|
|
261
|
+
// Located-only, resolved in two stages so the per-request cost stays page-bounded:
|
|
262
|
+
// 1) LIGHT id-only queries (FK columns only, no decryption) determine which deals have a
|
|
263
|
+
// coordinate-bearing linked company/person, so pagination + `total` operate on located deals.
|
|
264
|
+
// 2) The HEAVY decrypted/populated fetch (labels + address coordinates) runs only for the deals
|
|
265
|
+
// that actually land on the requested page — never the whole located universe.
|
|
266
|
+
const coordinateEntityRows = await findWithDecryption(
|
|
267
|
+
em,
|
|
268
|
+
CustomerAddress,
|
|
269
|
+
{
|
|
270
|
+
latitude: { $ne: null },
|
|
271
|
+
longitude: { $ne: null },
|
|
272
|
+
tenantId: effectiveTenantId,
|
|
273
|
+
...orgScopeFilter,
|
|
274
|
+
},
|
|
275
|
+
{ fields: ['entity'] },
|
|
276
|
+
decryptionScope,
|
|
277
|
+
)
|
|
278
|
+
const locatedEntityIds = collectRefIds(coordinateEntityRows, 'entity')
|
|
279
|
+
if (locatedEntityIds.length === 0) {
|
|
280
|
+
return emptyMapResponse(query.page, query.pageSize)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const [locatedCompanyLinkRows, locatedPersonLinkRows] = await Promise.all([
|
|
284
|
+
findWithDecryption(
|
|
285
|
+
em,
|
|
286
|
+
CustomerDealCompanyLink,
|
|
287
|
+
{ company: { $in: locatedEntityIds } },
|
|
288
|
+
{ fields: ['deal'] },
|
|
289
|
+
decryptionScope,
|
|
290
|
+
),
|
|
291
|
+
findWithDecryption(
|
|
292
|
+
em,
|
|
293
|
+
CustomerDealPersonLink,
|
|
294
|
+
{ person: { $in: locatedEntityIds } },
|
|
295
|
+
{ fields: ['deal'] },
|
|
296
|
+
decryptionScope,
|
|
297
|
+
),
|
|
298
|
+
])
|
|
299
|
+
const locatedDealIds = Array.from(
|
|
300
|
+
new Set([
|
|
301
|
+
...collectRefIds(locatedCompanyLinkRows, 'deal'),
|
|
302
|
+
...collectRefIds(locatedPersonLinkRows, 'deal'),
|
|
303
|
+
]),
|
|
304
|
+
)
|
|
305
|
+
if (locatedDealIds.length === 0) {
|
|
306
|
+
return emptyMapResponse(query.page, query.pageSize)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const ctx: CrudCtx = {
|
|
310
|
+
container,
|
|
311
|
+
auth,
|
|
312
|
+
organizationScope: scope,
|
|
313
|
+
selectedOrganizationId: fallbackOrgId ?? null,
|
|
314
|
+
organizationIds: orgScopeIds,
|
|
315
|
+
request: req,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const filters = await buildDealListFilters(query, ctx)
|
|
319
|
+
const existingAllowlist = readFilterIdAllowlist(filters)
|
|
320
|
+
|
|
321
|
+
// The map card headline is the linked company/person name, which the deal-field token search
|
|
322
|
+
// (title/description/status/…) used by `buildDealListFilters` does NOT cover — so searching a
|
|
323
|
+
// company/person name a user can plainly see on a card would otherwise return nothing. When a
|
|
324
|
+
// search term is present, also resolve located deals whose linked company/person ENTITY name
|
|
325
|
+
// matches, and union them with the deal-field matches. The sentinel "no match" id from the
|
|
326
|
+
// deal-field search drops out naturally at the located intersection below, so no special-casing.
|
|
327
|
+
let searchAllowlist = existingAllowlist
|
|
328
|
+
if (query.search && locatedEntityIds.length > 0) {
|
|
329
|
+
const nameMatchedEntityIds = await findMatchingEntityIdsBySearchTokensAcrossSources({
|
|
330
|
+
ctx,
|
|
331
|
+
query: query.search,
|
|
332
|
+
sources: [
|
|
333
|
+
{
|
|
334
|
+
entityType: E.customers.customer_entity,
|
|
335
|
+
fields: ['display_name', 'primary_email', 'primary_phone', 'description'],
|
|
336
|
+
},
|
|
337
|
+
],
|
|
338
|
+
})
|
|
339
|
+
const locatedEntitySet = new Set(locatedEntityIds)
|
|
340
|
+
const locatedMatchedEntityIds = (nameMatchedEntityIds ?? []).filter((id) => locatedEntitySet.has(id))
|
|
341
|
+
if (locatedMatchedEntityIds.length > 0) {
|
|
342
|
+
const [companyNameLinkRows, personNameLinkRows] = await Promise.all([
|
|
343
|
+
findWithDecryption(
|
|
344
|
+
em,
|
|
345
|
+
CustomerDealCompanyLink,
|
|
346
|
+
{ company: { $in: locatedMatchedEntityIds } },
|
|
347
|
+
{ fields: ['deal'] },
|
|
348
|
+
decryptionScope,
|
|
349
|
+
),
|
|
350
|
+
findWithDecryption(
|
|
351
|
+
em,
|
|
352
|
+
CustomerDealPersonLink,
|
|
353
|
+
{ person: { $in: locatedMatchedEntityIds } },
|
|
354
|
+
{ fields: ['deal'] },
|
|
355
|
+
decryptionScope,
|
|
356
|
+
),
|
|
357
|
+
])
|
|
358
|
+
let nameMatchedDealIds = [
|
|
359
|
+
...collectRefIds(companyNameLinkRows, 'deal'),
|
|
360
|
+
...collectRefIds(personNameLinkRows, 'deal'),
|
|
361
|
+
]
|
|
362
|
+
if (nameMatchedDealIds.length > 0) {
|
|
363
|
+
// The deal-field search in `buildDealListFilters` is intersected with the People/Companies
|
|
364
|
+
// association (and stuck/`?id`) filters — they all land together in `filters.id`. The name
|
|
365
|
+
// matches above carry NO such constraint, so unioning them raw would let a co-active
|
|
366
|
+
// People/Companies filter be bypassed (over-broad results). Recover exactly the non-search id
|
|
367
|
+
// allowlist by rebuilding the deal filters WITHOUT the search term, and intersect the name
|
|
368
|
+
// matches with it so the active filter is honored: located ∩ nonSearch ∩ (dealField ∪ name).
|
|
369
|
+
// (The map's query carries no advanced `filter[...]` params, so the consume-mutation in
|
|
370
|
+
// buildDealListFilters does not affect this second build.)
|
|
371
|
+
const nonSearchAllowlist = readFilterIdAllowlist(
|
|
372
|
+
await buildDealListFilters({ ...query, search: undefined }, ctx),
|
|
373
|
+
)
|
|
374
|
+
if (nonSearchAllowlist) {
|
|
375
|
+
const nonSearchLookup = new Set(nonSearchAllowlist)
|
|
376
|
+
nameMatchedDealIds = nameMatchedDealIds.filter((id) => nonSearchLookup.has(id))
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (nameMatchedDealIds.length > 0) {
|
|
380
|
+
searchAllowlist = Array.from(new Set([...(existingAllowlist ?? []), ...nameMatchedDealIds]))
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const allowlistLookup = searchAllowlist ? new Set(searchAllowlist) : null
|
|
386
|
+
const restrictedDealIds = allowlistLookup
|
|
387
|
+
? locatedDealIds.filter((id) => allowlistLookup.has(id))
|
|
388
|
+
: locatedDealIds
|
|
389
|
+
// `restrictedDealIds` is the final located ∩ (deal-field ∪ company/person-name) set — it already
|
|
390
|
+
// captures every id constraint `buildDealListFilters` expressed (read back via
|
|
391
|
+
// `readFilterIdAllowlist`). Clear the stale `filters.id` first so `applyEntityIdRestriction` does
|
|
392
|
+
// not re-intersect against the deal-field "no match" sentinel and drop the name-matched deals.
|
|
393
|
+
delete (filters as Record<string, unknown>).id
|
|
394
|
+
applyEntityIdRestriction(filters, restrictedDealIds)
|
|
395
|
+
|
|
396
|
+
const sortColumn = query.sortField ? sortFieldMap[query.sortField] : 'id'
|
|
397
|
+
const sortDir = query.sortDir === 'desc' ? SortDir.Desc : SortDir.Asc
|
|
398
|
+
// Always append `id` as a stable tiebreaker — non-unique sort columns (title, value,
|
|
399
|
+
// probability, stage, or colliding timestamps) otherwise reorder between OFFSET pages and the
|
|
400
|
+
// client's multi-page accumulation would skip or duplicate deals at page boundaries.
|
|
401
|
+
const sort = sortColumn === 'id'
|
|
402
|
+
? [{ field: 'id', dir: sortDir }]
|
|
403
|
+
: [{ field: sortColumn, dir: sortDir }, { field: 'id', dir: SortDir.Asc }]
|
|
404
|
+
|
|
405
|
+
const res = await queryEngine.query(E.customers.customer_deal, {
|
|
406
|
+
fields: [
|
|
407
|
+
'id',
|
|
408
|
+
// `organization_id` is projected (never exposed in the response) so the query engine can decrypt
|
|
409
|
+
// each row's encrypted fields (e.g. `title`) with the row's OWN org. Without it, an unrestricted
|
|
410
|
+
// "All organizations" scope has no single fallback org to decrypt with and titles come back as
|
|
411
|
+
// ciphertext — the List route projects it for the same reason (#3481).
|
|
412
|
+
'organization_id',
|
|
413
|
+
'title',
|
|
414
|
+
'status',
|
|
415
|
+
'pipeline_id',
|
|
416
|
+
'pipeline_stage_id',
|
|
417
|
+
'pipeline_stage',
|
|
418
|
+
'value_amount',
|
|
419
|
+
'value_currency',
|
|
420
|
+
'probability',
|
|
421
|
+
'expected_close_at',
|
|
422
|
+
'owner_user_id',
|
|
423
|
+
'updated_at',
|
|
424
|
+
],
|
|
425
|
+
sort,
|
|
426
|
+
page: { page: query.page, pageSize: query.pageSize },
|
|
427
|
+
filters,
|
|
428
|
+
tenantId: effectiveTenantId,
|
|
429
|
+
organizationId: fallbackOrgId,
|
|
430
|
+
organizationIds: orgScopeIds ?? undefined,
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
const rows = (Array.isArray(res.items) ? res.items : []).filter(
|
|
434
|
+
(row): row is Record<string, unknown> => !!row && typeof row === 'object',
|
|
435
|
+
)
|
|
436
|
+
const dealIds = rows
|
|
437
|
+
.map((row) => (typeof row.id === 'string' && row.id.trim().length ? row.id : null))
|
|
438
|
+
.filter((value): value is string => value !== null)
|
|
439
|
+
|
|
440
|
+
// Heavy fetch, page-bounded: pull decrypted links (for labels) and coordinate-bearing addresses
|
|
441
|
+
// only for the deals on this page.
|
|
442
|
+
const [companyLinks, personLinks] = dealIds.length
|
|
443
|
+
? await Promise.all([
|
|
444
|
+
findWithDecryption(
|
|
445
|
+
em,
|
|
446
|
+
CustomerDealCompanyLink,
|
|
447
|
+
{ deal: { $in: dealIds } },
|
|
448
|
+
{ populate: ['company'] },
|
|
449
|
+
decryptionScope,
|
|
450
|
+
),
|
|
451
|
+
findWithDecryption(
|
|
452
|
+
em,
|
|
453
|
+
CustomerDealPersonLink,
|
|
454
|
+
{ deal: { $in: dealIds } },
|
|
455
|
+
{ populate: ['person'] },
|
|
456
|
+
decryptionScope,
|
|
457
|
+
),
|
|
458
|
+
])
|
|
459
|
+
: [[], []]
|
|
460
|
+
|
|
461
|
+
const companyRows = companyLinks
|
|
462
|
+
.map((link) => readLinkRow(link, link.company))
|
|
463
|
+
.filter((row): row is LinkRow => row !== null)
|
|
464
|
+
const personRows = personLinks
|
|
465
|
+
.map((link) => readLinkRow(link, link.person))
|
|
466
|
+
.filter((row): row is LinkRow => row !== null)
|
|
467
|
+
const companiesByDeal = groupAssociations(companyRows)
|
|
468
|
+
const peopleByDeal = groupAssociations(personRows)
|
|
469
|
+
|
|
470
|
+
const linkedEntityIds = Array.from(
|
|
471
|
+
new Set([...companyRows, ...personRows].map((row) => row.entityId)),
|
|
472
|
+
)
|
|
473
|
+
const addresses = linkedEntityIds.length
|
|
474
|
+
? await findWithDecryption(
|
|
475
|
+
em,
|
|
476
|
+
CustomerAddress,
|
|
477
|
+
{
|
|
478
|
+
entity: { $in: linkedEntityIds },
|
|
479
|
+
latitude: { $ne: null },
|
|
480
|
+
longitude: { $ne: null },
|
|
481
|
+
tenantId: effectiveTenantId,
|
|
482
|
+
...orgScopeFilter,
|
|
483
|
+
},
|
|
484
|
+
{},
|
|
485
|
+
decryptionScope,
|
|
486
|
+
)
|
|
487
|
+
: []
|
|
488
|
+
|
|
489
|
+
const addressRows: DealMapAddress[] = []
|
|
490
|
+
for (const address of addresses) {
|
|
491
|
+
const { id: entityId } = readEntityRef(address.entity)
|
|
492
|
+
if (!entityId) continue
|
|
493
|
+
addressRows.push({
|
|
494
|
+
id: address.id,
|
|
495
|
+
entityId,
|
|
496
|
+
isPrimary: address.isPrimary === true,
|
|
497
|
+
latitude: address.latitude ?? null,
|
|
498
|
+
longitude: address.longitude ?? null,
|
|
499
|
+
city: address.city ?? null,
|
|
500
|
+
region: address.region ?? null,
|
|
501
|
+
country: address.country ?? null,
|
|
502
|
+
createdAt: address.createdAt ?? null,
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const toLink = (row: LinkRow): DealMapLink => ({
|
|
507
|
+
dealId: row.dealId,
|
|
508
|
+
entityId: row.entityId,
|
|
509
|
+
createdAt: row.createdAt,
|
|
510
|
+
})
|
|
511
|
+
const locationByDeal: Map<string, DealMapLocation | null> = resolveDealLocations(
|
|
512
|
+
dealIds,
|
|
513
|
+
companyRows.map(toLink),
|
|
514
|
+
personRows.map(toLink),
|
|
515
|
+
addressRows,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
const items = rows
|
|
519
|
+
.map((row) => {
|
|
520
|
+
const id = typeof row.id === 'string' && row.id.trim().length ? row.id : null
|
|
521
|
+
if (!id) return null
|
|
522
|
+
return {
|
|
523
|
+
id,
|
|
524
|
+
title: toStringOrNull(row.title),
|
|
525
|
+
status: toStringOrNull(row.status),
|
|
526
|
+
pipelineId: toStringOrNull(row.pipeline_id),
|
|
527
|
+
pipelineStageId: toStringOrNull(row.pipeline_stage_id),
|
|
528
|
+
pipelineStage: toStringOrNull(row.pipeline_stage),
|
|
529
|
+
valueAmount: toFiniteNumberOrNull(row.value_amount),
|
|
530
|
+
valueCurrency: toStringOrNull(row.value_currency),
|
|
531
|
+
probability: toFiniteNumberOrNull(row.probability),
|
|
532
|
+
expectedCloseAt: toIsoStringOrNull(row.expected_close_at),
|
|
533
|
+
ownerUserId: toStringOrNull(row.owner_user_id),
|
|
534
|
+
updatedAt: toIsoStringOrNull(row.updated_at),
|
|
535
|
+
companies: companiesByDeal.get(id) ?? [],
|
|
536
|
+
people: peopleByDeal.get(id) ?? [],
|
|
537
|
+
location: locationByDeal.get(id) ?? null,
|
|
538
|
+
}
|
|
539
|
+
})
|
|
540
|
+
.filter((item): item is NonNullable<typeof item> => item !== null)
|
|
541
|
+
|
|
542
|
+
return NextResponse.json({
|
|
543
|
+
items,
|
|
544
|
+
total: res.total,
|
|
545
|
+
page: query.page,
|
|
546
|
+
pageSize: query.pageSize,
|
|
547
|
+
totalPages: Math.ceil(res.total / (query.pageSize || 1)),
|
|
548
|
+
})
|
|
549
|
+
}
|
|
@@ -103,6 +103,8 @@ export default function CreateCompanyPage() {
|
|
|
103
103
|
if (postalCode !== undefined) body.postalCode = postalCode
|
|
104
104
|
const country = normalize(entry.country)
|
|
105
105
|
if (country !== undefined) body.country = country.toUpperCase()
|
|
106
|
+
if (typeof entry.latitude === 'number') body.latitude = entry.latitude
|
|
107
|
+
if (typeof entry.longitude === 'number') body.longitude = entry.longitude
|
|
106
108
|
try {
|
|
107
109
|
await createCrud('customers/addresses', body)
|
|
108
110
|
} catch (addressErr) {
|