@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.
Files changed (33) hide show
  1. package/dist/fulltext/drivers/meilisearch/index.js +14 -2
  2. package/dist/fulltext/drivers/meilisearch/index.js.map +2 -2
  3. package/dist/lib/merger.js +10 -3
  4. package/dist/lib/merger.js.map +2 -2
  5. package/dist/modules/search/api/search/global/route.js +3 -0
  6. package/dist/modules/search/api/search/global/route.js.map +2 -2
  7. package/dist/modules/search/api/search/route.js +3 -0
  8. package/dist/modules/search/api/search/route.js.map +2 -2
  9. package/dist/service.js +25 -1
  10. package/dist/service.js.map +2 -2
  11. package/dist/strategies/fulltext.strategy.js +6 -0
  12. package/dist/strategies/fulltext.strategy.js.map +2 -2
  13. package/dist/strategies/token.strategy.js +16 -4
  14. package/dist/strategies/token.strategy.js.map +2 -2
  15. package/dist/strategies/vector.strategy.js +16 -2
  16. package/dist/strategies/vector.strategy.js.map +2 -2
  17. package/dist/vector/drivers/pgvector/index.js +8 -2
  18. package/dist/vector/drivers/pgvector/index.js.map +2 -2
  19. package/dist/vector/types.js.map +2 -2
  20. package/package.json +4 -4
  21. package/src/__tests__/service.test.ts +44 -0
  22. package/src/fulltext/drivers/meilisearch/index.ts +16 -2
  23. package/src/fulltext/types.ts +2 -0
  24. package/src/lib/merger.ts +9 -2
  25. package/src/modules/search/api/__tests__/org-scoping.routes.test.ts +29 -1
  26. package/src/modules/search/api/search/global/route.ts +3 -0
  27. package/src/modules/search/api/search/route.ts +3 -0
  28. package/src/service.ts +32 -1
  29. package/src/strategies/fulltext.strategy.ts +9 -0
  30. package/src/strategies/token.strategy.ts +25 -4
  31. package/src/strategies/vector.strategy.ts +21 -3
  32. package/src/vector/drivers/pgvector/index.ts +14 -2
  33. 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 resolved org scope (all orgs, non-superadmin)', async () => {
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(merged, options.tenantId, options.organizationId)
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 (options.organizationId) {
81
- queryBuilder = queryBuilder.where('organization_id' as any, '=', options.organizationId)
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<{ entity_type: string; entity_id: string; match_count: string | number }>
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
- // Only add organizationId filter if it's a real org ID
81
- if (options.organizationId) {
82
- filter.organizationId = options.organizationId
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
- normalizedOrganizationId,
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
  )
@@ -50,6 +50,7 @@ export type VectorDriverQuery = {
50
50
  filter?: {
51
51
  entityIds?: EntityId[]
52
52
  organizationId?: string | null
53
+ organizationIds?: string[] | null
53
54
  tenantId: string
54
55
  }
55
56
  }