@open-mercato/search 0.6.4-develop.4239.1.4a264a5828 → 0.6.4-develop.4264.1.53368d85fe
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/dist/fulltext/drivers/meilisearch/index.js +14 -2
- package/dist/fulltext/drivers/meilisearch/index.js.map +2 -2
- package/dist/lib/merger.js +10 -3
- package/dist/lib/merger.js.map +2 -2
- package/dist/modules/search/api/search/global/route.js +3 -0
- package/dist/modules/search/api/search/global/route.js.map +2 -2
- package/dist/modules/search/api/search/route.js +3 -0
- package/dist/modules/search/api/search/route.js.map +2 -2
- package/dist/service.js +25 -1
- package/dist/service.js.map +2 -2
- package/dist/strategies/fulltext.strategy.js +6 -0
- package/dist/strategies/fulltext.strategy.js.map +2 -2
- package/dist/strategies/token.strategy.js +16 -4
- package/dist/strategies/token.strategy.js.map +2 -2
- package/dist/strategies/vector.strategy.js +16 -2
- package/dist/strategies/vector.strategy.js.map +2 -2
- package/dist/vector/drivers/pgvector/index.js +8 -2
- package/dist/vector/drivers/pgvector/index.js.map +2 -2
- package/dist/vector/types.js.map +2 -2
- package/package.json +4 -4
- package/src/__tests__/service.test.ts +44 -0
- package/src/fulltext/drivers/meilisearch/index.ts +16 -2
- package/src/fulltext/types.ts +2 -0
- package/src/lib/merger.ts +9 -2
- package/src/modules/search/api/__tests__/org-scoping.routes.test.ts +29 -1
- package/src/modules/search/api/search/global/route.ts +3 -0
- package/src/modules/search/api/search/route.ts +3 -0
- package/src/service.ts +32 -1
- package/src/strategies/fulltext.strategy.ts +9 -0
- package/src/strategies/token.strategy.ts +25 -4
- package/src/strategies/vector.strategy.ts +21 -3
- package/src/vector/drivers/pgvector/index.ts +14 -2
- package/src/vector/types.ts +1 -0
|
@@ -71,12 +71,13 @@ describe('Search API organizationId scoping', () => {
|
|
|
71
71
|
|
|
72
72
|
const passedOptions = (searchService.search as jest.Mock).mock.calls[0][1] as Record<string, unknown>
|
|
73
73
|
expect(passedOptions.organizationId).toEqual('org-A')
|
|
74
|
+
expect(passedOptions.organizationIds).toEqual(['org-A'])
|
|
74
75
|
|
|
75
76
|
const body = await res.json()
|
|
76
77
|
expect(Array.isArray(body.results)).toBe(true)
|
|
77
78
|
})
|
|
78
79
|
|
|
79
|
-
test('/api/search/search/global uses
|
|
80
|
+
test('/api/search/search/global uses full allowed org scope when no single org is selected', async () => {
|
|
80
81
|
mockGetAuthFromRequest.mockResolvedValue({ tenantId: 't1', orgId: 'org-A', sub: 'user-1', isSuperAdmin: false })
|
|
81
82
|
|
|
82
83
|
const searchService = {
|
|
@@ -99,6 +100,33 @@ describe('Search API organizationId scoping', () => {
|
|
|
99
100
|
|
|
100
101
|
const passedOptions = (searchService.search as jest.Mock).mock.calls[0][1] as Record<string, unknown>
|
|
101
102
|
expect(passedOptions.organizationId).toBeUndefined()
|
|
103
|
+
expect(passedOptions.organizationIds).toEqual(['org-A', 'org-B'])
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('/api/search/search uses full allowed org scope when restricted user has no selected org', async () => {
|
|
107
|
+
mockGetAuthFromRequest.mockResolvedValue({ tenantId: 't1', orgId: 'org-A', sub: 'user-1', isSuperAdmin: false })
|
|
108
|
+
|
|
109
|
+
const searchService = {
|
|
110
|
+
search: jest.fn().mockResolvedValue([]),
|
|
111
|
+
}
|
|
112
|
+
const container = {
|
|
113
|
+
resolve: jest.fn((name: string) => (name === 'searchService' ? searchService : undefined)),
|
|
114
|
+
dispose: jest.fn(),
|
|
115
|
+
}
|
|
116
|
+
mockCreateRequestContainer.mockResolvedValue(container)
|
|
117
|
+
mockResolveOrganizationScopeForRequest.mockResolvedValue({
|
|
118
|
+
selectedId: null,
|
|
119
|
+
filterIds: ['org-A', 'org-B'],
|
|
120
|
+
allowedIds: ['org-A', 'org-B'],
|
|
121
|
+
tenantId: 't1',
|
|
122
|
+
} satisfies MockOrganizationScope)
|
|
123
|
+
|
|
124
|
+
const req = new Request('http://localhost/api/search/search?q=test')
|
|
125
|
+
await hybridSearchGet(req)
|
|
126
|
+
|
|
127
|
+
const passedOptions = (searchService.search as jest.Mock).mock.calls[0][1] as Record<string, unknown>
|
|
128
|
+
expect(passedOptions.organizationId).toBeUndefined()
|
|
129
|
+
expect(passedOptions.organizationIds).toEqual(['org-A', 'org-B'])
|
|
102
130
|
})
|
|
103
131
|
|
|
104
132
|
test('/api/search/index list respects resolved org scope and does not default to org NULL', async () => {
|
|
@@ -3,6 +3,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
6
7
|
import type { SearchService } from '@open-mercato/search'
|
|
7
8
|
import type { EmbeddingService } from '../../../../../vector'
|
|
8
9
|
import { resolveEmbeddingConfig } from '../../../lib/embedding-config'
|
|
@@ -93,11 +94,13 @@ export async function GET(req: Request) {
|
|
|
93
94
|
})
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
const scopeFilter = resolveOrganizationScopeFilter(scope, auth)
|
|
96
98
|
const organizationId =
|
|
97
99
|
typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined
|
|
98
100
|
const searchOptions = {
|
|
99
101
|
tenantId: auth.tenantId,
|
|
100
102
|
organizationId,
|
|
103
|
+
organizationIds: scopeFilter.organizationIds,
|
|
101
104
|
limit,
|
|
102
105
|
strategies,
|
|
103
106
|
entityTypes,
|
|
@@ -3,6 +3,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
3
3
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
4
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
6
7
|
import type { SearchService } from '@open-mercato/search'
|
|
7
8
|
import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
8
9
|
import type { EmbeddingService } from '../../../../vector'
|
|
@@ -90,11 +91,13 @@ export async function GET(req: Request) {
|
|
|
90
91
|
})
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
const scopeFilter = resolveOrganizationScopeFilter(scope, auth)
|
|
93
95
|
const organizationId =
|
|
94
96
|
typeof scope.selectedId === 'string' && scope.selectedId.trim().length > 0 ? scope.selectedId.trim() : undefined
|
|
95
97
|
const searchOptions = {
|
|
96
98
|
tenantId: auth.tenantId,
|
|
97
99
|
organizationId,
|
|
100
|
+
organizationIds: scopeFilter.organizationIds,
|
|
98
101
|
limit,
|
|
99
102
|
strategies,
|
|
100
103
|
entityTypes,
|
package/src/service.ts
CHANGED
|
@@ -26,6 +26,31 @@ const DEFAULT_MERGE_CONFIG: ResultMergeConfig = {
|
|
|
26
26
|
*/
|
|
27
27
|
const STRATEGY_AVAILABILITY_CACHE_TTL_MS = 2_000
|
|
28
28
|
|
|
29
|
+
function normalizeOrganizationFilter(options: SearchOptions): string[] | null {
|
|
30
|
+
const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''
|
|
31
|
+
if (single) return [single]
|
|
32
|
+
if (!Array.isArray(options.organizationIds)) return null
|
|
33
|
+
|
|
34
|
+
const values = Array.from(new Set(
|
|
35
|
+
options.organizationIds
|
|
36
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
37
|
+
.filter((value) => value.length > 0),
|
|
38
|
+
))
|
|
39
|
+
return values
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function filterResultsByOrganizationScope(results: SearchResult[], options: SearchOptions): SearchResult[] {
|
|
43
|
+
const organizationIds = normalizeOrganizationFilter(options)
|
|
44
|
+
if (!organizationIds) return results
|
|
45
|
+
if (organizationIds.length === 0) return []
|
|
46
|
+
|
|
47
|
+
const allowed = new Set(organizationIds)
|
|
48
|
+
return results.filter((result) => {
|
|
49
|
+
const organizationId = typeof result.organizationId === 'string' ? result.organizationId.trim() : ''
|
|
50
|
+
return organizationId.length > 0 && allowed.has(organizationId)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
29
54
|
/**
|
|
30
55
|
* SearchService orchestrates multiple search strategies, executing searches in parallel
|
|
31
56
|
* and merging results using the RRF algorithm.
|
|
@@ -87,6 +112,11 @@ export class SearchService {
|
|
|
87
112
|
* @returns Merged and ranked search results
|
|
88
113
|
*/
|
|
89
114
|
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
115
|
+
const organizationIds = normalizeOrganizationFilter(options)
|
|
116
|
+
if (organizationIds && organizationIds.length === 0) {
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
|
|
90
120
|
const strategyIds = options.strategies ?? this.defaultStrategies
|
|
91
121
|
const activeStrategies = await this.getAvailableStrategies(strategyIds)
|
|
92
122
|
|
|
@@ -126,9 +156,10 @@ export class SearchService {
|
|
|
126
156
|
|
|
127
157
|
// Merge and rank results
|
|
128
158
|
const merged = mergeAndRankResults(allResults, this.mergeConfig)
|
|
159
|
+
const scoped = filterResultsByOrganizationScope(merged, options)
|
|
129
160
|
|
|
130
161
|
// Enrich results missing presenter or navigation metadata
|
|
131
|
-
return this.enrichResultsWithPresenter(
|
|
162
|
+
return this.enrichResultsWithPresenter(scoped, options.tenantId, options.organizationId)
|
|
132
163
|
}
|
|
133
164
|
|
|
134
165
|
/**
|
|
@@ -34,9 +34,17 @@ export class FullTextSearchStrategy implements SearchStrategy {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
37
|
+
if (Array.isArray(options.organizationIds)) {
|
|
38
|
+
const organizationIds = options.organizationIds
|
|
39
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
40
|
+
.filter((value) => value.length > 0)
|
|
41
|
+
if (!options.organizationId && organizationIds.length === 0) return []
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
const hits = await this.driver.search(query, {
|
|
38
45
|
tenantId: options.tenantId,
|
|
39
46
|
organizationId: options.organizationId,
|
|
47
|
+
organizationIds: options.organizationIds,
|
|
40
48
|
entityTypes: options.entityTypes,
|
|
41
49
|
limit: options.limit,
|
|
42
50
|
offset: options.offset,
|
|
@@ -133,6 +141,7 @@ export class FullTextSearchStrategy implements SearchStrategy {
|
|
|
133
141
|
recordId: hit.recordId,
|
|
134
142
|
score: hit.score,
|
|
135
143
|
source: this.id,
|
|
144
|
+
organizationId: hit.organizationId ?? null,
|
|
136
145
|
presenter: hit.presenter,
|
|
137
146
|
url: hit.url,
|
|
138
147
|
links: hit.links,
|
|
@@ -18,6 +18,17 @@ export type TokenStrategyConfig = {
|
|
|
18
18
|
defaultLimit?: number
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function normalizeOrganizationIds(options: SearchOptions): string[] | null {
|
|
22
|
+
const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''
|
|
23
|
+
if (single) return [single]
|
|
24
|
+
if (!Array.isArray(options.organizationIds)) return null
|
|
25
|
+
return Array.from(new Set(
|
|
26
|
+
options.organizationIds
|
|
27
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
28
|
+
.filter((value) => value.length > 0),
|
|
29
|
+
))
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
/**
|
|
22
33
|
* TokenSearchStrategy provides hash-based search using the existing search_tokens table.
|
|
23
34
|
* This strategy is always available and serves as a fallback when other strategies fail.
|
|
@@ -50,6 +61,9 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
64
|
+
const organizationIds = normalizeOrganizationIds(options)
|
|
65
|
+
if (organizationIds && organizationIds.length === 0) return []
|
|
66
|
+
|
|
53
67
|
// Dynamically import tokenization to avoid circular dependencies
|
|
54
68
|
const { tokenizeText } = await import('@open-mercato/shared/lib/search/tokenize')
|
|
55
69
|
const { resolveSearchConfig } = await import('@open-mercato/shared/lib/search/config')
|
|
@@ -68,24 +82,30 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
68
82
|
.select([
|
|
69
83
|
'entity_type' as any,
|
|
70
84
|
'entity_id' as any,
|
|
85
|
+
'organization_id' as any,
|
|
71
86
|
sql<string>`count(*)`.as('match_count'),
|
|
72
87
|
])
|
|
73
88
|
.where('token_hash' as any, 'in', hashes)
|
|
74
89
|
.where('tenant_id' as any, '=', options.tenantId)
|
|
75
|
-
.groupBy(['entity_type' as any, 'entity_id' as any])
|
|
90
|
+
.groupBy(['entity_type' as any, 'entity_id' as any, 'organization_id' as any])
|
|
76
91
|
.having(sql<SqlBool>`count(distinct token_hash) >= ${minMatches}`)
|
|
77
92
|
.orderBy(sql`count(distinct token_hash) desc`)
|
|
78
93
|
.limit(limit)
|
|
79
94
|
|
|
80
|
-
if (
|
|
81
|
-
queryBuilder = queryBuilder.where('organization_id' as any, '
|
|
95
|
+
if (organizationIds) {
|
|
96
|
+
queryBuilder = queryBuilder.where('organization_id' as any, 'in', organizationIds)
|
|
82
97
|
}
|
|
83
98
|
|
|
84
99
|
if (options.entityTypes?.length) {
|
|
85
100
|
queryBuilder = queryBuilder.where('entity_type' as any, 'in', options.entityTypes)
|
|
86
101
|
}
|
|
87
102
|
|
|
88
|
-
const rows = await queryBuilder.execute() as Array<{
|
|
103
|
+
const rows = await queryBuilder.execute() as Array<{
|
|
104
|
+
entity_type: string
|
|
105
|
+
entity_id: string
|
|
106
|
+
organization_id: string | null
|
|
107
|
+
match_count: string | number
|
|
108
|
+
}>
|
|
89
109
|
|
|
90
110
|
return rows.map((row) => {
|
|
91
111
|
const matchCount = typeof row.match_count === 'string'
|
|
@@ -99,6 +119,7 @@ export class TokenSearchStrategy implements SearchStrategy {
|
|
|
99
119
|
recordId: row.entity_id,
|
|
100
120
|
score,
|
|
101
121
|
source: this.id,
|
|
122
|
+
organizationId: row.organization_id ?? null,
|
|
102
123
|
}
|
|
103
124
|
})
|
|
104
125
|
}
|
|
@@ -26,6 +26,17 @@ export type VectorStrategyConfig = {
|
|
|
26
26
|
defaultLimit?: number
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function normalizeOrganizationIds(options: SearchOptions): string[] | null {
|
|
30
|
+
const single = typeof options.organizationId === 'string' ? options.organizationId.trim() : ''
|
|
31
|
+
if (single) return [single]
|
|
32
|
+
if (!Array.isArray(options.organizationIds)) return null
|
|
33
|
+
return Array.from(new Set(
|
|
34
|
+
options.organizationIds
|
|
35
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
36
|
+
.filter((value) => value.length > 0),
|
|
37
|
+
))
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/**
|
|
30
41
|
* VectorSearchStrategy provides semantic search using embeddings.
|
|
31
42
|
* It wraps the existing vector module infrastructure.
|
|
@@ -62,6 +73,9 @@ export class VectorSearchStrategy implements SearchStrategy {
|
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
async search(query: string, options: SearchOptions): Promise<SearchResult[]> {
|
|
76
|
+
const organizationIds = normalizeOrganizationIds(options)
|
|
77
|
+
if (organizationIds && organizationIds.length === 0) return []
|
|
78
|
+
|
|
65
79
|
await this.ensureReady()
|
|
66
80
|
const embedding = await this.embeddingService.createEmbedding(query)
|
|
67
81
|
|
|
@@ -71,15 +85,18 @@ export class VectorSearchStrategy implements SearchStrategy {
|
|
|
71
85
|
const filter: {
|
|
72
86
|
tenantId: string
|
|
73
87
|
organizationId?: string | null
|
|
88
|
+
organizationIds?: string[] | null
|
|
74
89
|
entityIds?: EntityId[]
|
|
75
90
|
} = {
|
|
76
91
|
tenantId: options.tenantId,
|
|
77
92
|
entityIds: options.entityTypes as EntityId[],
|
|
78
93
|
}
|
|
79
94
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
if (organizationIds) {
|
|
96
|
+
filter.organizationIds = organizationIds
|
|
97
|
+
if (organizationIds.length === 1) {
|
|
98
|
+
filter.organizationId = organizationIds[0]
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
|
|
85
102
|
const results = await this.vectorDriver.query({
|
|
@@ -93,6 +110,7 @@ export class VectorSearchStrategy implements SearchStrategy {
|
|
|
93
110
|
recordId: hit.recordId,
|
|
94
111
|
score: hit.score,
|
|
95
112
|
source: this.id,
|
|
113
|
+
organizationId: hit.organizationId ?? null,
|
|
96
114
|
presenter: hit.presenter ?? undefined,
|
|
97
115
|
url: hit.primaryLinkHref ?? hit.url ?? undefined,
|
|
98
116
|
links: hit.links?.map((link) => ({
|
|
@@ -318,11 +318,23 @@ export function createPgVectorDriver(opts: PgVectorDriverOptions = {}): VectorDr
|
|
|
318
318
|
typeof filter.organizationId === 'string' && filter.organizationId.trim().length > 0
|
|
319
319
|
? filter.organizationId.trim()
|
|
320
320
|
: null
|
|
321
|
+
const normalizedOrganizationIds = normalizedOrganizationId
|
|
322
|
+
? [normalizedOrganizationId]
|
|
323
|
+
: Array.isArray(filter.organizationIds)
|
|
324
|
+
? Array.from(new Set(
|
|
325
|
+
filter.organizationIds
|
|
326
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
327
|
+
.filter((value) => value.length > 0),
|
|
328
|
+
))
|
|
329
|
+
: null
|
|
330
|
+
if (normalizedOrganizationIds && normalizedOrganizationIds.length === 0) {
|
|
331
|
+
return []
|
|
332
|
+
}
|
|
321
333
|
const params: any[] = [
|
|
322
334
|
vectorLiteral,
|
|
323
335
|
DRIVER_ID,
|
|
324
336
|
filter.tenantId,
|
|
325
|
-
|
|
337
|
+
normalizedOrganizationIds,
|
|
326
338
|
Array.isArray(filter.entityIds) && filter.entityIds.length ? filter.entityIds : null,
|
|
327
339
|
input.limit ?? 20,
|
|
328
340
|
]
|
|
@@ -365,7 +377,7 @@ export function createPgVectorDriver(opts: PgVectorDriverOptions = {}): VectorDr
|
|
|
365
377
|
FROM ${tableIdent}
|
|
366
378
|
WHERE driver_id = $2
|
|
367
379
|
AND tenant_id = $3::uuid
|
|
368
|
-
AND ($4::uuid IS NULL OR organization_id = $4::uuid)
|
|
380
|
+
AND ($4::uuid[] IS NULL OR organization_id = ANY($4::uuid[]))
|
|
369
381
|
AND (
|
|
370
382
|
$5::text[] IS NULL OR entity_id = ANY($5::text[])
|
|
371
383
|
)
|