@open-mercato/search 0.6.8-develop.7100.1.fbf66fca35 → 0.7.0
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/AGENTS.md +0 -1
- package/dist/indexer/search-indexer.js +0 -66
- package/dist/indexer/search-indexer.js.map +2 -2
- package/dist/lib/presenter-enricher.js +1 -71
- package/dist/lib/presenter-enricher.js.map +2 -2
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js +0 -143
- package/dist/modules/search/__integration__/TC-SEARCH-006.spec.js.map +2 -2
- package/dist/modules/search/lib/entity-access.js +43 -1
- package/dist/modules/search/lib/entity-access.js.map +2 -2
- package/dist/modules/search/workers/fulltext-index.worker.js +24 -7
- package/dist/modules/search/workers/fulltext-index.worker.js.map +2 -2
- package/dist/service.js +1 -18
- package/dist/service.js.map +2 -2
- package/dist/strategies/token.strategy.js +2 -8
- package/dist/strategies/token.strategy.js.map +2 -2
- package/package.json +5 -6
- package/src/__tests__/presenter-enricher.test.ts +0 -234
- package/src/__tests__/service.test.ts +0 -24
- package/src/__tests__/workers.test.ts +17 -46
- package/src/indexer/search-indexer.ts +0 -84
- package/src/lib/presenter-enricher.ts +1 -92
- package/src/modules/search/__integration__/TC-SEARCH-006.spec.ts +2 -190
- package/src/modules/search/api/__tests__/global-search.routes.test.ts +0 -107
- package/src/modules/search/lib/entity-access.ts +130 -4
- package/src/modules/search/workers/fulltext-index.worker.ts +29 -13
- package/src/service.ts +2 -37
- package/src/strategies/token.strategy.ts +2 -15
- package/src/__tests__/search-indexer-batch.test.ts +0 -214
- package/src/__tests__/token-strategy-entity-exclusion.test.ts +0 -99
|
@@ -104,95 +104,6 @@ type EnrichmentResult = {
|
|
|
104
104
|
links?: SearchResultLink[]
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function primaryNavigationHref(result: SearchResult): string | null {
|
|
108
|
-
if (typeof result.url === 'string' && result.url.trim().length > 0) {
|
|
109
|
-
return result.url.trim()
|
|
110
|
-
}
|
|
111
|
-
const primaryLink = result.links?.find((link) => link.kind === 'primary' && link.href.trim().length > 0)
|
|
112
|
-
return primaryLink?.href.trim() ?? null
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function directNavigationRecordId(href: string): string | null {
|
|
116
|
-
try {
|
|
117
|
-
const url = new URL(href, 'http://search.local')
|
|
118
|
-
if (url.search || url.hash) return null
|
|
119
|
-
const segments = url.pathname.split('/').filter(Boolean)
|
|
120
|
-
const lastSegment = segments.at(-1)
|
|
121
|
-
return lastSegment ? decodeURIComponent(lastSegment) : null
|
|
122
|
-
} catch {
|
|
123
|
-
return null
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function presenterTitle(result: SearchResult): string | null {
|
|
128
|
-
const title = result.presenter?.title?.trim()
|
|
129
|
-
return title?.length ? title : null
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function resultScopeKey(result: SearchResult, recordId: string): string {
|
|
133
|
-
return `${result.organizationId ?? ''}:${recordId}`
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function mergeResultMetadata(
|
|
137
|
-
targetMetadata: SearchResult['metadata'],
|
|
138
|
-
linkedMetadata: SearchResult['metadata'],
|
|
139
|
-
): SearchResult['metadata'] {
|
|
140
|
-
if (!targetMetadata && !linkedMetadata) return undefined
|
|
141
|
-
return {
|
|
142
|
-
...targetMetadata,
|
|
143
|
-
...linkedMetadata,
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function mergeLinkedDuplicateResults(results: SearchResult[]): SearchResult[] {
|
|
148
|
-
const indexesByRecord = new Map<string, number[]>()
|
|
149
|
-
for (let index = 0; index < results.length; index += 1) {
|
|
150
|
-
const result = results[index]
|
|
151
|
-
const key = resultScopeKey(result, result.recordId)
|
|
152
|
-
const indexes = indexesByRecord.get(key) ?? []
|
|
153
|
-
indexes.push(index)
|
|
154
|
-
indexesByRecord.set(key, indexes)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const replacements = new Map<number, SearchResult>()
|
|
158
|
-
const removedIndexes = new Set<number>()
|
|
159
|
-
|
|
160
|
-
for (let index = 0; index < results.length; index += 1) {
|
|
161
|
-
const linkedResult = results[index]
|
|
162
|
-
const href = primaryNavigationHref(linkedResult)
|
|
163
|
-
const targetRecordId = href ? directNavigationRecordId(href) : null
|
|
164
|
-
if (!targetRecordId || targetRecordId === linkedResult.recordId) continue
|
|
165
|
-
|
|
166
|
-
const targetIndexes = (indexesByRecord.get(resultScopeKey(linkedResult, targetRecordId)) ?? [])
|
|
167
|
-
.filter((candidateIndex) => candidateIndex !== index && !removedIndexes.has(candidateIndex))
|
|
168
|
-
if (targetIndexes.length !== 1) continue
|
|
169
|
-
|
|
170
|
-
const targetIndex = targetIndexes[0]
|
|
171
|
-
const targetResult = replacements.get(targetIndex) ?? results[targetIndex]
|
|
172
|
-
if (primaryNavigationHref(targetResult)) continue
|
|
173
|
-
|
|
174
|
-
const linkedTitle = presenterTitle(linkedResult)
|
|
175
|
-
const targetTitle = presenterTitle(targetResult)
|
|
176
|
-
if (!linkedTitle || linkedTitle !== targetTitle) continue
|
|
177
|
-
|
|
178
|
-
replacements.set(targetIndex, {
|
|
179
|
-
...targetResult,
|
|
180
|
-
score: Math.max(targetResult.score, linkedResult.score),
|
|
181
|
-
source: linkedResult.score > targetResult.score ? linkedResult.source : targetResult.source,
|
|
182
|
-
presenter: linkedResult.presenter ?? targetResult.presenter,
|
|
183
|
-
url: linkedResult.url ?? targetResult.url,
|
|
184
|
-
links: linkedResult.links ?? targetResult.links,
|
|
185
|
-
metadata: mergeResultMetadata(targetResult.metadata, linkedResult.metadata),
|
|
186
|
-
})
|
|
187
|
-
removedIndexes.add(index)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return results
|
|
191
|
-
.map((result, index) => replacements.get(index) ?? result)
|
|
192
|
-
.filter((_, index) => !removedIndexes.has(index))
|
|
193
|
-
.sort((left, right) => right.score - left.score)
|
|
194
|
-
}
|
|
195
|
-
|
|
196
107
|
/**
|
|
197
108
|
* Compute presenter, URL, and links for a single doc using config or fallback.
|
|
198
109
|
* Returns presenter (null if cannot be computed), and optionally URL/links from config.
|
|
@@ -374,7 +285,7 @@ export function createPresenterEnricher(
|
|
|
374
285
|
}
|
|
375
286
|
|
|
376
287
|
// Enrich results with computed presenter, URL, and links
|
|
377
|
-
|
|
288
|
+
return results.map((result) => {
|
|
378
289
|
if (!shouldEnrich(result)) return result
|
|
379
290
|
const key = `${result.entityId}:${result.recordId}`
|
|
380
291
|
const enriched = enrichmentMap.get(key)
|
|
@@ -386,7 +297,5 @@ export function createPresenterEnricher(
|
|
|
386
297
|
links: enriched.links ?? result.links,
|
|
387
298
|
}
|
|
388
299
|
})
|
|
389
|
-
|
|
390
|
-
return mergeLinkedDuplicateResults(enrichedResults)
|
|
391
300
|
}
|
|
392
301
|
}
|
|
@@ -1,75 +1,12 @@
|
|
|
1
|
-
import { expect, test
|
|
1
|
+
import { expect, test } from '@playwright/test'
|
|
2
2
|
import { apiRequest, getAuthToken } from '@open-mercato/core/helpers/integration/api'
|
|
3
3
|
import { readJsonSafe } from '@open-mercato/core/helpers/integration/generalFixtures'
|
|
4
|
-
import {
|
|
5
|
-
createCompanyFixture,
|
|
6
|
-
createPersonFixture,
|
|
7
|
-
deleteEntityIfExists,
|
|
8
|
-
} from '@open-mercato/core/helpers/integration/crmFixtures'
|
|
9
4
|
|
|
10
5
|
type GlobalSearchSettings = { enabledStrategies?: string[] }
|
|
11
6
|
type GlobalSearchUpdate = { ok?: boolean; enabledStrategies?: string[] }
|
|
12
|
-
type
|
|
13
|
-
entityId?: string
|
|
14
|
-
recordId?: string
|
|
15
|
-
presenter?: { title?: string } | null
|
|
16
|
-
url?: string | null
|
|
17
|
-
}
|
|
18
|
-
type GlobalSearchResponse = { strategiesEnabled?: string[]; results?: SearchResultItem[] }
|
|
19
|
-
type SearchQueryResult = { ok: boolean; status: number; results: SearchResultItem[] }
|
|
7
|
+
type GlobalSearchResponse = { strategiesEnabled?: string[] }
|
|
20
8
|
|
|
21
9
|
const DEFAULT_STRATEGIES = ['fulltext', 'vector', 'tokens']
|
|
22
|
-
const CUSTOMER_ENTITY = 'customers:customer_entity'
|
|
23
|
-
const PERSON_PROFILE = 'customers:customer_person_profile'
|
|
24
|
-
const COMPANY_PROFILE = 'customers:customer_company_profile'
|
|
25
|
-
|
|
26
|
-
function presenterTitle(result: SearchResultItem): string | null {
|
|
27
|
-
const title = result.presenter?.title
|
|
28
|
-
return typeof title === 'string' && title.trim().length > 0 ? title.trim() : null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
async function searchResults(
|
|
32
|
-
request: APIRequestContext,
|
|
33
|
-
token: string,
|
|
34
|
-
path: string,
|
|
35
|
-
): Promise<SearchQueryResult> {
|
|
36
|
-
const response = await apiRequest(request, 'GET', path, { token })
|
|
37
|
-
if (!response.ok()) return { ok: false, status: response.status(), results: [] }
|
|
38
|
-
const body = (await readJsonSafe<GlobalSearchResponse>(response)) ?? {}
|
|
39
|
-
return {
|
|
40
|
-
ok: true,
|
|
41
|
-
status: response.status(),
|
|
42
|
-
results: Array.isArray(body.results) ? body.results : [],
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function customerSearchPath(query: string, entityType: string): string {
|
|
47
|
-
const params = new URLSearchParams({
|
|
48
|
-
q: query,
|
|
49
|
-
limit: '20',
|
|
50
|
-
strategies: 'tokens',
|
|
51
|
-
entityTypes: entityType,
|
|
52
|
-
})
|
|
53
|
-
return `/api/search/search?${params.toString()}`
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function globalSearchPath(query: string): string {
|
|
57
|
-
const params = new URLSearchParams({ q: query, limit: '20' })
|
|
58
|
-
return `/api/search/search/global?${params.toString()}`
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* A profile result navigates to the customer's v2 detail page, whose path segment is the base
|
|
63
|
-
* customer entity id — not the profile's own `recordId`. So this asserts the shape of a direct
|
|
64
|
-
* detail link (prefix + one non-empty id segment, no query string or anchor) rather than
|
|
65
|
-
* equality with `recordId`.
|
|
66
|
-
*/
|
|
67
|
-
function hasCanonicalNavigation(result: SearchResultItem, expectedPrefix: string): boolean {
|
|
68
|
-
if (typeof result.url !== 'string') return false
|
|
69
|
-
if (!result.url.startsWith(`${expectedPrefix}/`)) return false
|
|
70
|
-
const target = result.url.slice(expectedPrefix.length + 1)
|
|
71
|
-
return target.length > 0 && !/[/?#]/.test(target)
|
|
72
|
-
}
|
|
73
10
|
|
|
74
11
|
/**
|
|
75
12
|
* TC-SEARCH-006: global (Cmd+K) search honors the saved strategy config over a
|
|
@@ -134,129 +71,4 @@ test.describe('TC-SEARCH-006: global search honors saved strategy config over UR
|
|
|
134
71
|
}
|
|
135
72
|
}
|
|
136
73
|
})
|
|
137
|
-
|
|
138
|
-
test('returns one navigable profile result per customer and no base-entity duplicate', async ({ request }) => {
|
|
139
|
-
test.slow()
|
|
140
|
-
test.setTimeout(120_000)
|
|
141
|
-
|
|
142
|
-
const stamp = Date.now()
|
|
143
|
-
const personName = `QASRCH006P${stamp}`
|
|
144
|
-
const companyName = `QASRCH006C${stamp}`
|
|
145
|
-
let token: string | null = null
|
|
146
|
-
let originalStrategies: string[] | null = DEFAULT_STRATEGIES
|
|
147
|
-
let personId: string | null = null
|
|
148
|
-
let companyId: string | null = null
|
|
149
|
-
let personGlobalResults: SearchResultItem[] = []
|
|
150
|
-
let companyGlobalResults: SearchResultItem[] = []
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
token = await getAuthToken(request, 'admin')
|
|
154
|
-
|
|
155
|
-
const currentRes = await apiRequest(request, 'GET', '/api/search/settings/global-search', { token })
|
|
156
|
-
expect(currentRes.ok(), 'GET global-search settings should succeed').toBeTruthy()
|
|
157
|
-
const current = (await readJsonSafe<GlobalSearchSettings>(currentRes)) ?? {}
|
|
158
|
-
originalStrategies =
|
|
159
|
-
Array.isArray(current.enabledStrategies) && current.enabledStrategies.length > 0
|
|
160
|
-
? current.enabledStrategies
|
|
161
|
-
: DEFAULT_STRATEGIES
|
|
162
|
-
|
|
163
|
-
const updateRes = await apiRequest(request, 'POST', '/api/search/settings/global-search', {
|
|
164
|
-
token,
|
|
165
|
-
data: { enabledStrategies: ['tokens'] },
|
|
166
|
-
})
|
|
167
|
-
expect(updateRes.status(), 'POST global-search settings should return 200').toBe(200)
|
|
168
|
-
|
|
169
|
-
personId = await createPersonFixture(request, token, {
|
|
170
|
-
firstName: 'QA',
|
|
171
|
-
lastName: `Search 006 ${stamp}`,
|
|
172
|
-
displayName: personName,
|
|
173
|
-
})
|
|
174
|
-
companyId = await createCompanyFixture(request, token, companyName)
|
|
175
|
-
|
|
176
|
-
await expect
|
|
177
|
-
.poll(
|
|
178
|
-
async () => {
|
|
179
|
-
const [personEntity, personProfile, companyEntity, companyProfile, personGlobal, companyGlobal] =
|
|
180
|
-
await Promise.all([
|
|
181
|
-
searchResults(request, token!, customerSearchPath(personName, CUSTOMER_ENTITY)),
|
|
182
|
-
searchResults(request, token!, customerSearchPath(personName, PERSON_PROFILE)),
|
|
183
|
-
searchResults(request, token!, customerSearchPath(companyName, CUSTOMER_ENTITY)),
|
|
184
|
-
searchResults(request, token!, customerSearchPath(companyName, COMPANY_PROFILE)),
|
|
185
|
-
searchResults(request, token!, globalSearchPath(personName)),
|
|
186
|
-
searchResults(request, token!, globalSearchPath(companyName)),
|
|
187
|
-
])
|
|
188
|
-
|
|
189
|
-
const queries = [
|
|
190
|
-
['person-entity', personEntity],
|
|
191
|
-
['person-profile', personProfile],
|
|
192
|
-
['company-entity', companyEntity],
|
|
193
|
-
['company-profile', companyProfile],
|
|
194
|
-
['person-global', personGlobal],
|
|
195
|
-
['company-global', companyGlobal],
|
|
196
|
-
] as const
|
|
197
|
-
const failedQuery = queries.find(([, result]) => !result.ok)
|
|
198
|
-
if (failedQuery) return `${failedQuery[0]}:status:${failedQuery[1].status}`
|
|
199
|
-
|
|
200
|
-
const indexedQueries = [
|
|
201
|
-
['person-profile', personProfile.results, personName, PERSON_PROFILE],
|
|
202
|
-
['company-profile', companyProfile.results, companyName, COMPANY_PROFILE],
|
|
203
|
-
] as const
|
|
204
|
-
for (const [label, results, expectedTitle, expectedEntityId] of indexedQueries) {
|
|
205
|
-
const matches = results.filter(
|
|
206
|
-
(result) => presenterTitle(result) === expectedTitle && result.entityId === expectedEntityId,
|
|
207
|
-
)
|
|
208
|
-
if (matches.length === 0) return `${label}:matches:0`
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
// Under the default OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=false the token strategy
|
|
212
|
-
// refuses to return base customer rows, so an explicit query for that entity type is
|
|
213
|
-
// empty even though the same customers' profiles are already indexed above. (The rows
|
|
214
|
-
// themselves stay in search_tokens — the list-search id lookup still needs them.)
|
|
215
|
-
const baseEntityQueries = [
|
|
216
|
-
['person-entity', personEntity.results, personName],
|
|
217
|
-
['company-entity', companyEntity.results, companyName],
|
|
218
|
-
] as const
|
|
219
|
-
for (const [label, results, expectedTitle] of baseEntityQueries) {
|
|
220
|
-
const matches = results.filter((result) => presenterTitle(result) === expectedTitle)
|
|
221
|
-
if (matches.length !== 0) return `${label}:matches:${matches.length}`
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
personGlobalResults = personGlobal.results.filter((result) => presenterTitle(result) === personName)
|
|
225
|
-
companyGlobalResults = companyGlobal.results.filter((result) => presenterTitle(result) === companyName)
|
|
226
|
-
if (personGlobalResults.length !== 1) return `person-global:matches:${personGlobalResults.length}`
|
|
227
|
-
if (companyGlobalResults.length !== 1) return `company-global:matches:${companyGlobalResults.length}`
|
|
228
|
-
if (personGlobalResults[0]?.entityId !== PERSON_PROFILE) {
|
|
229
|
-
return `person-global:entity:${personGlobalResults[0]?.entityId ?? 'missing'}`
|
|
230
|
-
}
|
|
231
|
-
if (companyGlobalResults[0]?.entityId !== COMPANY_PROFILE) {
|
|
232
|
-
return `company-global:entity:${companyGlobalResults[0]?.entityId ?? 'missing'}`
|
|
233
|
-
}
|
|
234
|
-
if (!hasCanonicalNavigation(personGlobalResults[0], '/backend/customers/people-v2')) {
|
|
235
|
-
return `person-global:navigation:${personGlobalResults[0]?.url ?? 'missing'}`
|
|
236
|
-
}
|
|
237
|
-
if (!hasCanonicalNavigation(companyGlobalResults[0], '/backend/customers/companies-v2')) {
|
|
238
|
-
return `company-global:navigation:${companyGlobalResults[0]?.url ?? 'missing'}`
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return 'ready'
|
|
242
|
-
},
|
|
243
|
-
{ timeout: 10_000 },
|
|
244
|
-
)
|
|
245
|
-
.toBe('ready')
|
|
246
|
-
|
|
247
|
-
expect(personGlobalResults).toHaveLength(1)
|
|
248
|
-
expect(personGlobalResults[0]?.entityId).toBe(PERSON_PROFILE)
|
|
249
|
-
expect(companyGlobalResults).toHaveLength(1)
|
|
250
|
-
expect(companyGlobalResults[0]?.entityId).toBe(COMPANY_PROFILE)
|
|
251
|
-
} finally {
|
|
252
|
-
await deleteEntityIfExists(request, token, '/api/customers/people', personId)
|
|
253
|
-
await deleteEntityIfExists(request, token, '/api/customers/companies', companyId)
|
|
254
|
-
if (token && originalStrategies) {
|
|
255
|
-
await apiRequest(request, 'POST', '/api/search/settings/global-search', {
|
|
256
|
-
token,
|
|
257
|
-
data: { enabledStrategies: originalStrategies },
|
|
258
|
-
}).catch(() => undefined)
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
})
|
|
262
74
|
})
|
|
@@ -198,113 +198,6 @@ describe('GET /api/search/search/global presenter localization', () => {
|
|
|
198
198
|
expect(result.links?.[0]?.label).toBe('Otwórz osobę')
|
|
199
199
|
}
|
|
200
200
|
})
|
|
201
|
-
|
|
202
|
-
// Covers OM_SEARCH_CUSTOMERS_INDEX_BASE_ENTITY=true, where base customer rows are tokenized and
|
|
203
|
-
// the route can therefore receive both halves of a pair. Under the default the token strategy
|
|
204
|
-
// never returns the base rows at all (see token-strategy-entity-exclusion.test.ts); the strategy
|
|
205
|
-
// here is a stub that yields them regardless, so this pins the merge itself.
|
|
206
|
-
it('merges a customer entity and profile pair into one navigable result when both are indexed', async () => {
|
|
207
|
-
const rows = [
|
|
208
|
-
{
|
|
209
|
-
entity_type: 'customers:customer_entity',
|
|
210
|
-
entity_id: 'person-entity',
|
|
211
|
-
doc: { id: 'person-entity', display_name: 'Ada Lovelace', kind: 'person' },
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
entity_type: 'customers:customer_person_profile',
|
|
215
|
-
entity_id: 'person-profile',
|
|
216
|
-
doc: { id: 'person-profile', entity_id: 'person-entity', display_name: 'Ada Lovelace' },
|
|
217
|
-
},
|
|
218
|
-
{
|
|
219
|
-
entity_type: 'customers:customer_entity',
|
|
220
|
-
entity_id: 'company-entity',
|
|
221
|
-
doc: { id: 'company-entity', display_name: 'Analytical Engines', kind: 'company' },
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
entity_type: 'customers:customer_company_profile',
|
|
225
|
-
entity_id: 'company-profile',
|
|
226
|
-
doc: { id: 'company-profile', entity_id: 'company-entity', display_name: 'Analytical Engines' },
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
entity_type: 'orders:order',
|
|
230
|
-
entity_id: 'order-1',
|
|
231
|
-
doc: { id: 'order-1', title: 'Order 1' },
|
|
232
|
-
},
|
|
233
|
-
]
|
|
234
|
-
const personEntityId = 'customers:customer_person_profile' as EntityId
|
|
235
|
-
const companyEntityId = 'customers:customer_company_profile' as EntityId
|
|
236
|
-
const configMap = new Map<EntityId, SearchEntityConfig>([
|
|
237
|
-
[personEntityId, {
|
|
238
|
-
entityId: personEntityId,
|
|
239
|
-
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
240
|
-
resolveUrl: async (context) => `/backend/customers/people-v2/${String(context.record.entity_id)}`,
|
|
241
|
-
}],
|
|
242
|
-
[companyEntityId, {
|
|
243
|
-
entityId: companyEntityId,
|
|
244
|
-
formatResult: async (context) => ({ title: String(context.record.display_name) }),
|
|
245
|
-
resolveUrl: async (context) => `/backend/customers/companies-v2/${String(context.record.entity_id)}`,
|
|
246
|
-
}],
|
|
247
|
-
])
|
|
248
|
-
const scoresByRecordId: Record<string, number> = {
|
|
249
|
-
'person-profile': 0.95,
|
|
250
|
-
'company-profile': 0.85,
|
|
251
|
-
'order-1': 0.5,
|
|
252
|
-
'person-entity': 0.2,
|
|
253
|
-
'company-entity': 0.1,
|
|
254
|
-
}
|
|
255
|
-
const results: SearchResult[] = rows
|
|
256
|
-
.map((row) => ({
|
|
257
|
-
entityId: row.entity_type as EntityId,
|
|
258
|
-
recordId: row.entity_id,
|
|
259
|
-
organizationId: 'org-1',
|
|
260
|
-
score: scoresByRecordId[row.entity_id] ?? 0,
|
|
261
|
-
source: 'tokens' as const,
|
|
262
|
-
}))
|
|
263
|
-
.sort((left, right) => right.score - left.score)
|
|
264
|
-
const strategy: SearchStrategy = {
|
|
265
|
-
id: 'tokens',
|
|
266
|
-
name: 'tokens',
|
|
267
|
-
priority: 10,
|
|
268
|
-
isAvailable: async () => true,
|
|
269
|
-
ensureReady: async () => undefined,
|
|
270
|
-
search: async () => results,
|
|
271
|
-
index: async () => undefined,
|
|
272
|
-
delete: async () => undefined,
|
|
273
|
-
}
|
|
274
|
-
const searchService = new SearchService({
|
|
275
|
-
strategies: [strategy],
|
|
276
|
-
defaultStrategies: ['tokens'],
|
|
277
|
-
presenterEnricher: createPresenterEnricher(createDatabase(rows), configMap),
|
|
278
|
-
})
|
|
279
|
-
mockCreateRequestContainer.mockResolvedValue(
|
|
280
|
-
createContainer(searchService, configMap, { features: ['search.global'], isSuperAdmin: true }),
|
|
281
|
-
)
|
|
282
|
-
|
|
283
|
-
const response = await GET(new Request('http://localhost/api/search/search/global?q=customer'))
|
|
284
|
-
const body = await response.json() as { results: SearchResult[] }
|
|
285
|
-
|
|
286
|
-
expect(response.status).toBe(200)
|
|
287
|
-
expect(body.results).toEqual([
|
|
288
|
-
expect.objectContaining({
|
|
289
|
-
entityId: 'customers:customer_entity',
|
|
290
|
-
recordId: 'person-entity',
|
|
291
|
-
presenter: expect.objectContaining({ title: 'Ada Lovelace' }),
|
|
292
|
-
url: '/backend/customers/people-v2/person-entity',
|
|
293
|
-
}),
|
|
294
|
-
expect.objectContaining({
|
|
295
|
-
entityId: 'customers:customer_entity',
|
|
296
|
-
recordId: 'company-entity',
|
|
297
|
-
presenter: expect.objectContaining({ title: 'Analytical Engines' }),
|
|
298
|
-
url: '/backend/customers/companies-v2/company-entity',
|
|
299
|
-
}),
|
|
300
|
-
expect.objectContaining({
|
|
301
|
-
entityId: 'orders:order',
|
|
302
|
-
recordId: 'order-1',
|
|
303
|
-
}),
|
|
304
|
-
])
|
|
305
|
-
expect(body.results[0]?.score).toBeGreaterThan(body.results[1]?.score ?? 0)
|
|
306
|
-
expect(body.results[1]?.score).toBeGreaterThan(body.results[2]?.score ?? 0)
|
|
307
|
-
})
|
|
308
201
|
})
|
|
309
202
|
|
|
310
203
|
describe('GET /api/search/search/global per-entity access control', () => {
|
|
@@ -1,6 +1,132 @@
|
|
|
1
|
+
import type { SearchEntityConfig } from '@open-mercato/shared/modules/search'
|
|
2
|
+
import { authorizeFeatures } from '@open-mercato/shared/security/featurePolicy'
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
+
* Minimal shape of the `searchIndexer` DI service consumed by per-entity ACL
|
|
6
|
+
* resolution. Kept structural so callers and tests can pass a plain object
|
|
7
|
+
* instead of constructing a full `SearchIndexer`.
|
|
5
8
|
*/
|
|
6
|
-
export
|
|
9
|
+
export type SearchEntityConfigLookup = {
|
|
10
|
+
getEntityConfig: (entityId: string) => SearchEntityConfig | undefined
|
|
11
|
+
getAllEntityConfigs: () => SearchEntityConfig[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type SearchEntityAccessSubject = {
|
|
15
|
+
grantedFeatures: readonly string[]
|
|
16
|
+
isSuperAdmin?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type SearchEntityDenyReason =
|
|
20
|
+
/** No module declares this entity in a `search.ts` config. */
|
|
21
|
+
| 'unconfigured'
|
|
22
|
+
/** The entity is configured for search but declares no `aclFeatures`. */
|
|
23
|
+
| 'no-acl-features'
|
|
24
|
+
/** The caller does not hold the entity's declared view feature(s). */
|
|
25
|
+
| 'insufficient-features'
|
|
26
|
+
|
|
27
|
+
export type SearchEntityAccessOptions = {
|
|
28
|
+
/**
|
|
29
|
+
* Called once per denied entity type. Exists so a silent drop is diagnosable:
|
|
30
|
+
* results disappearing because a module forgot to declare `aclFeatures` looks
|
|
31
|
+
* identical, from the palette, to results that simply did not match.
|
|
32
|
+
*/
|
|
33
|
+
onDeny?: (entityId: string, reason: SearchEntityDenyReason) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decide whether a caller may see results for one entity type.
|
|
38
|
+
*
|
|
39
|
+
* The single `search.global` gate on the palette only says "this user may use
|
|
40
|
+
* global search"; it says nothing about which records they may read. Each entity
|
|
41
|
+
* declares the owning module's view feature(s) in `aclFeatures`, and those are
|
|
42
|
+
* what actually authorize the read — the same rule the `search_get` /
|
|
43
|
+
* `search_aggregate` AI tools already apply.
|
|
44
|
+
*
|
|
45
|
+
* Fails closed: an entity that is not registered for search, or that declares no
|
|
46
|
+
* `aclFeatures`, is never exposed to a non-superadmin caller.
|
|
47
|
+
*/
|
|
48
|
+
export function canReadSearchEntity(
|
|
49
|
+
entityId: string,
|
|
50
|
+
lookup: SearchEntityConfigLookup,
|
|
51
|
+
subject: SearchEntityAccessSubject,
|
|
52
|
+
options: SearchEntityAccessOptions = {},
|
|
53
|
+
): boolean {
|
|
54
|
+
if (subject.isSuperAdmin) return true
|
|
55
|
+
|
|
56
|
+
const config = lookup.getEntityConfig(entityId)
|
|
57
|
+
if (!config) {
|
|
58
|
+
options.onDeny?.(entityId, 'unconfigured')
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const required = config.aclFeatures
|
|
63
|
+
if (!required || required.length === 0) {
|
|
64
|
+
options.onDeny?.(entityId, 'no-acl-features')
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const allowed = authorizeFeatures(required, {
|
|
69
|
+
grantedFeatures: subject.grantedFeatures,
|
|
70
|
+
unrestricted: false,
|
|
71
|
+
})
|
|
72
|
+
if (!allowed) options.onDeny?.(entityId, 'insufficient-features')
|
|
73
|
+
return allowed
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The entity types this caller may read, narrowed to `requestedEntityTypes` when
|
|
78
|
+
* the caller asked for specific ones.
|
|
79
|
+
*
|
|
80
|
+
* Restricting the query up front is what keeps `limit` meaningful. Filtering only
|
|
81
|
+
* after the search would spend the whole result budget on records the caller
|
|
82
|
+
* cannot see: an employee granted just `customers.people.view` would get the top
|
|
83
|
+
* 50 hits across every entity type, then watch most of them be dropped, and the
|
|
84
|
+
* palette would look empty even with hundreds of matching people behind it.
|
|
85
|
+
*
|
|
86
|
+
* Returns `undefined` when no restriction applies (superadmin with no explicit
|
|
87
|
+
* request), and an empty array when nothing is readable — callers should
|
|
88
|
+
* short-circuit on that rather than pass it down as "no filter".
|
|
89
|
+
*/
|
|
90
|
+
export function resolveReadableEntityTypes(
|
|
91
|
+
lookup: SearchEntityConfigLookup,
|
|
92
|
+
subject: SearchEntityAccessSubject,
|
|
93
|
+
requestedEntityTypes?: string[],
|
|
94
|
+
): string[] | undefined {
|
|
95
|
+
if (subject.isSuperAdmin) return requestedEntityTypes
|
|
96
|
+
|
|
97
|
+
const readable = lookup
|
|
98
|
+
.getAllEntityConfigs()
|
|
99
|
+
.filter((config) => config.enabled !== false)
|
|
100
|
+
.map((config) => config.entityId)
|
|
101
|
+
.filter((entityId) => canReadSearchEntity(entityId, lookup, subject))
|
|
102
|
+
|
|
103
|
+
if (!requestedEntityTypes) return readable
|
|
104
|
+
const requested = new Set(requestedEntityTypes)
|
|
105
|
+
return readable.filter((entityId) => requested.has(entityId))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Drop the results whose entity type the caller is not allowed to read.
|
|
110
|
+
*
|
|
111
|
+
* Filtering happens server-side so an under-privileged caller never receives the
|
|
112
|
+
* presenter title, subtitle or deep link of a record they cannot open. Decisions
|
|
113
|
+
* are memoized per entity type because a single response commonly mixes dozens of
|
|
114
|
+
* results across a handful of types.
|
|
115
|
+
*/
|
|
116
|
+
export function filterSearchResultsByEntityAccess<T extends { entityId: string }>(
|
|
117
|
+
results: readonly T[],
|
|
118
|
+
lookup: SearchEntityConfigLookup,
|
|
119
|
+
subject: SearchEntityAccessSubject,
|
|
120
|
+
options: SearchEntityAccessOptions = {},
|
|
121
|
+
): T[] {
|
|
122
|
+
if (subject.isSuperAdmin) return [...results]
|
|
123
|
+
|
|
124
|
+
const decisions = new Map<string, boolean>()
|
|
125
|
+
return results.filter((result) => {
|
|
126
|
+
const cached = decisions.get(result.entityId)
|
|
127
|
+
if (cached !== undefined) return cached
|
|
128
|
+
const allowed = canReadSearchEntity(result.entityId, lookup, subject, options)
|
|
129
|
+
decisions.set(result.entityId, allowed)
|
|
130
|
+
return allowed
|
|
131
|
+
})
|
|
132
|
+
}
|
|
@@ -78,9 +78,8 @@ async function advanceFulltextReindexProgress(params: {
|
|
|
78
78
|
* This handler processes single record indexing, batch indexing, deletion, and purge
|
|
79
79
|
* operations for the fulltext search strategy.
|
|
80
80
|
*
|
|
81
|
-
*
|
|
82
|
-
* fresh data
|
|
83
|
-
* through a single bulk write instead of one write per record.
|
|
81
|
+
* All indexing operations (single and batch) use searchIndexer.indexRecordById() to load
|
|
82
|
+
* fresh data, ensuring consistency with the vector worker pattern.
|
|
84
83
|
*
|
|
85
84
|
* @param job - The queued job containing payload
|
|
86
85
|
* @param jobCtx - Queue job context with job ID and attempt info
|
|
@@ -203,7 +202,7 @@ export async function handleFulltextIndexJob(
|
|
|
203
202
|
return
|
|
204
203
|
}
|
|
205
204
|
|
|
206
|
-
// ========== BATCH-INDEX:
|
|
205
|
+
// ========== BATCH-INDEX: Use searchIndexer.indexRecordById() for fresh data ==========
|
|
207
206
|
if (jobType === 'batch-index') {
|
|
208
207
|
const { records, organizationId } = job.payload
|
|
209
208
|
if (!records || records.length === 0) {
|
|
@@ -217,13 +216,30 @@ export async function handleFulltextIndexJob(
|
|
|
217
216
|
throw new Error('searchIndexer not available for batch indexing')
|
|
218
217
|
}
|
|
219
218
|
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
// Process each record using indexRecordById (same pattern as vector worker)
|
|
220
|
+
let successCount = 0
|
|
221
|
+
let failCount = 0
|
|
222
|
+
|
|
223
|
+
for (const { entityId, recordId } of records) {
|
|
224
|
+
try {
|
|
225
|
+
const result = await searchIndexer.indexRecordById({
|
|
226
|
+
entityId: entityId as EntityId,
|
|
227
|
+
recordId,
|
|
228
|
+
tenantId,
|
|
229
|
+
organizationId,
|
|
230
|
+
})
|
|
231
|
+
if (result.action === 'indexed') {
|
|
232
|
+
successCount++
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
failCount++
|
|
236
|
+
searchDebugWarn('fulltext-index.worker', 'Failed to index record in batch', {
|
|
237
|
+
entityId,
|
|
238
|
+
recordId,
|
|
239
|
+
error: error instanceof Error ? error.message : error,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
}
|
|
227
243
|
|
|
228
244
|
await advanceFulltextReindexProgress({
|
|
229
245
|
db,
|
|
@@ -239,7 +255,7 @@ export async function handleFulltextIndexJob(
|
|
|
239
255
|
tenantId,
|
|
240
256
|
requestedCount: records.length,
|
|
241
257
|
successCount,
|
|
242
|
-
|
|
258
|
+
failCount,
|
|
243
259
|
})
|
|
244
260
|
|
|
245
261
|
await recordIndexerLog(
|
|
@@ -249,7 +265,7 @@ export async function handleFulltextIndexJob(
|
|
|
249
265
|
handler: 'worker:fulltext:batch-index',
|
|
250
266
|
message: `Indexed ${successCount}/${records.length} records to fulltext`,
|
|
251
267
|
tenantId,
|
|
252
|
-
details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount,
|
|
268
|
+
details: { jobId: jobCtx.jobId, requestedCount: records.length, successCount, failCount },
|
|
253
269
|
},
|
|
254
270
|
)
|
|
255
271
|
return
|