@open-mercato/core 0.6.6-develop.6386.1.391a1afb9e → 0.6.6-develop.6388.1.b9b9f5e778

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 (60) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/api/deals/map/route.js +433 -0
  3. package/dist/modules/customers/api/deals/map/route.js.map +7 -0
  4. package/dist/modules/customers/backend/customers/companies/create/page.js +2 -0
  5. package/dist/modules/customers/backend/customers/companies/create/page.js.map +2 -2
  6. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js +159 -0
  7. package/dist/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.js.map +7 -0
  8. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js +26 -0
  9. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.js.map +7 -0
  10. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js +321 -0
  11. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.js.map +7 -0
  12. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js +533 -0
  13. package/dist/modules/customers/backend/customers/deals/map/components/DealsMapView.js.map +7 -0
  14. package/dist/modules/customers/backend/customers/deals/map/page.js +55 -0
  15. package/dist/modules/customers/backend/customers/deals/map/page.js.map +7 -0
  16. package/dist/modules/customers/backend/customers/deals/map/page.meta.js +38 -0
  17. package/dist/modules/customers/backend/customers/deals/map/page.meta.js.map +7 -0
  18. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js +38 -47
  19. package/dist/modules/customers/backend/customers/deals/pipeline/components/DealCard.js.map +2 -2
  20. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js +3 -3
  21. package/dist/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.js.map +2 -2
  22. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +22 -1
  23. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  24. package/dist/modules/customers/backend/customers/people/create/page.js +2 -0
  25. package/dist/modules/customers/backend/customers/people/create/page.js.map +2 -2
  26. package/dist/modules/customers/components/AddressEditor.js +34 -2
  27. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  28. package/dist/modules/customers/components/AddressTiles.js +43 -3
  29. package/dist/modules/customers/components/AddressTiles.js.map +2 -2
  30. package/dist/modules/customers/components/detail/AddressesSection.js +4 -1
  31. package/dist/modules/customers/components/detail/AddressesSection.js.map +2 -2
  32. package/dist/modules/customers/components/formConfig.js +10 -0
  33. package/dist/modules/customers/components/formConfig.js.map +2 -2
  34. package/dist/modules/customers/data/validators.js +3 -2
  35. package/dist/modules/customers/data/validators.js.map +2 -2
  36. package/dist/modules/customers/lib/dealsMapLocation.js +75 -0
  37. package/dist/modules/customers/lib/dealsMapLocation.js.map +7 -0
  38. package/package.json +11 -7
  39. package/src/modules/customers/api/deals/map/route.ts +549 -0
  40. package/src/modules/customers/backend/customers/companies/create/page.tsx +2 -0
  41. package/src/modules/customers/backend/customers/deals/map/components/DealsLocationPanel.tsx +213 -0
  42. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvas.tsx +79 -0
  43. package/src/modules/customers/backend/customers/deals/map/components/DealsMapCanvasImpl.tsx +407 -0
  44. package/src/modules/customers/backend/customers/deals/map/components/DealsMapView.tsx +667 -0
  45. package/src/modules/customers/backend/customers/deals/map/page.meta.ts +36 -0
  46. package/src/modules/customers/backend/customers/deals/map/page.tsx +77 -0
  47. package/src/modules/customers/backend/customers/deals/pipeline/components/DealCard.tsx +57 -42
  48. package/src/modules/customers/backend/customers/deals/pipeline/components/FilterBarRow.tsx +3 -3
  49. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +22 -1
  50. package/src/modules/customers/backend/customers/people/create/page.tsx +2 -0
  51. package/src/modules/customers/components/AddressEditor.tsx +35 -0
  52. package/src/modules/customers/components/AddressTiles.tsx +51 -1
  53. package/src/modules/customers/components/detail/AddressesSection.tsx +3 -0
  54. package/src/modules/customers/components/formConfig.tsx +10 -0
  55. package/src/modules/customers/data/validators.ts +13 -2
  56. package/src/modules/customers/i18n/de.json +24 -0
  57. package/src/modules/customers/i18n/en.json +24 -0
  58. package/src/modules/customers/i18n/es.json +24 -0
  59. package/src/modules/customers/i18n/pl.json +24 -0
  60. package/src/modules/customers/lib/dealsMapLocation.ts +125 -0
@@ -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) {