@open-mercato/search 0.4.2-canary-c02407ff85
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 +678 -0
- package/build.mjs +92 -0
- package/dist/di.js +157 -0
- package/dist/di.js.map +7 -0
- package/dist/fulltext/drivers/index.js +21 -0
- package/dist/fulltext/drivers/index.js.map +7 -0
- package/dist/fulltext/drivers/meilisearch/index.js +320 -0
- package/dist/fulltext/drivers/meilisearch/index.js.map +7 -0
- package/dist/fulltext/index.js +7 -0
- package/dist/fulltext/index.js.map +7 -0
- package/dist/fulltext/types.js +1 -0
- package/dist/fulltext/types.js.map +7 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +7 -0
- package/dist/indexer/index.js +8 -0
- package/dist/indexer/index.js.map +7 -0
- package/dist/indexer/search-indexer.js +848 -0
- package/dist/indexer/search-indexer.js.map +7 -0
- package/dist/indexer/subscribers/delete.js +41 -0
- package/dist/indexer/subscribers/delete.js.map +7 -0
- package/dist/lib/debug.js +34 -0
- package/dist/lib/debug.js.map +7 -0
- package/dist/lib/fallback-presenter.js +107 -0
- package/dist/lib/fallback-presenter.js.map +7 -0
- package/dist/lib/field-policy.js +75 -0
- package/dist/lib/field-policy.js.map +7 -0
- package/dist/lib/index.js +19 -0
- package/dist/lib/index.js.map +7 -0
- package/dist/lib/merger.js +93 -0
- package/dist/lib/merger.js.map +7 -0
- package/dist/lib/presenter-enricher.js +192 -0
- package/dist/lib/presenter-enricher.js.map +7 -0
- package/dist/modules/search/acl.js +14 -0
- package/dist/modules/search/acl.js.map +7 -0
- package/dist/modules/search/ai-tools.js +284 -0
- package/dist/modules/search/ai-tools.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/embeddings/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/reindex/route.js +165 -0
- package/dist/modules/search/api/embeddings/reindex/route.js.map +7 -0
- package/dist/modules/search/api/embeddings/route.js +246 -0
- package/dist/modules/search/api/embeddings/route.js.map +7 -0
- package/dist/modules/search/api/index/route.js +245 -0
- package/dist/modules/search/api/index/route.js.map +7 -0
- package/dist/modules/search/api/reindex/cancel/route.js +65 -0
- package/dist/modules/search/api/reindex/cancel/route.js.map +7 -0
- package/dist/modules/search/api/reindex/route.js +332 -0
- package/dist/modules/search/api/reindex/route.js.map +7 -0
- package/dist/modules/search/api/search/global/route.js +100 -0
- package/dist/modules/search/api/search/global/route.js.map +7 -0
- package/dist/modules/search/api/search/route.js +101 -0
- package/dist/modules/search/api/search/route.js.map +7 -0
- package/dist/modules/search/api/settings/fulltext/route.js +55 -0
- package/dist/modules/search/api/settings/fulltext/route.js.map +7 -0
- package/dist/modules/search/api/settings/global-search/route.js +80 -0
- package/dist/modules/search/api/settings/global-search/route.js.map +7 -0
- package/dist/modules/search/api/settings/route.js +118 -0
- package/dist/modules/search/api/settings/route.js.map +7 -0
- package/dist/modules/search/api/settings/vector-store/route.js +77 -0
- package/dist/modules/search/api/settings/vector-store/route.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.js +10 -0
- package/dist/modules/search/backend/config/search/page.js.map +7 -0
- package/dist/modules/search/backend/config/search/page.meta.js +24 -0
- package/dist/modules/search/backend/config/search/page.meta.js.map +7 -0
- package/dist/modules/search/cli.js +698 -0
- package/dist/modules/search/cli.js.map +7 -0
- package/dist/modules/search/di.js +32 -0
- package/dist/modules/search/di.js.map +7 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js +357 -0
- package/dist/modules/search/frontend/components/GlobalSearchDialog.js.map +7 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js +343 -0
- package/dist/modules/search/frontend/components/HybridSearchTable.js.map +7 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js +303 -0
- package/dist/modules/search/frontend/components/SearchSettingsPageClient.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js +360 -0
- package/dist/modules/search/frontend/components/sections/FulltextSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js +101 -0
- package/dist/modules/search/frontend/components/sections/GlobalSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js +608 -0
- package/dist/modules/search/frontend/components/sections/VectorSearchSection.js.map +7 -0
- package/dist/modules/search/frontend/index.js +9 -0
- package/dist/modules/search/frontend/index.js.map +7 -0
- package/dist/modules/search/frontend/utils.js +41 -0
- package/dist/modules/search/frontend/utils.js.map +7 -0
- package/dist/modules/search/i18n/de.json +61 -0
- package/dist/modules/search/i18n/en.json +72 -0
- package/dist/modules/search/i18n/es.json +61 -0
- package/dist/modules/search/i18n/pl.json +61 -0
- package/dist/modules/search/index.js +11 -0
- package/dist/modules/search/index.js.map +7 -0
- package/dist/modules/search/lib/auto-indexing.js +29 -0
- package/dist/modules/search/lib/auto-indexing.js.map +7 -0
- package/dist/modules/search/lib/embedding-config.js +131 -0
- package/dist/modules/search/lib/embedding-config.js.map +7 -0
- package/dist/modules/search/lib/global-search-config.js +45 -0
- package/dist/modules/search/lib/global-search-config.js.map +7 -0
- package/dist/modules/search/lib/reindex-lock.js +99 -0
- package/dist/modules/search/lib/reindex-lock.js.map +7 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js +64 -0
- package/dist/modules/search/subscribers/fulltext_upsert.js.map +7 -0
- package/dist/modules/search/subscribers/vector_delete.js +58 -0
- package/dist/modules/search/subscribers/vector_delete.js.map +7 -0
- package/dist/modules/search/subscribers/vector_purge.js +142 -0
- package/dist/modules/search/subscribers/vector_purge.js.map +7 -0
- package/dist/modules/search/subscribers/vector_upsert.js +58 -0
- package/dist/modules/search/subscribers/vector_upsert.js.map +7 -0
- package/dist/modules/search/workers/fulltext-index.worker.js +240 -0
- package/dist/modules/search/workers/fulltext-index.worker.js.map +7 -0
- package/dist/modules/search/workers/vector-index.worker.js +234 -0
- package/dist/modules/search/workers/vector-index.worker.js.map +7 -0
- package/dist/queue/fulltext-indexing.js +15 -0
- package/dist/queue/fulltext-indexing.js.map +7 -0
- package/dist/queue/index.js +3 -0
- package/dist/queue/index.js.map +7 -0
- package/dist/queue/vector-indexing.js +15 -0
- package/dist/queue/vector-indexing.js.map +7 -0
- package/dist/service.js +286 -0
- package/dist/service.js.map +7 -0
- package/dist/strategies/fulltext.strategy.js +116 -0
- package/dist/strategies/fulltext.strategy.js.map +7 -0
- package/dist/strategies/index.js +12 -0
- package/dist/strategies/index.js.map +7 -0
- package/dist/strategies/token.strategy.js +80 -0
- package/dist/strategies/token.strategy.js.map +7 -0
- package/dist/strategies/vector.strategy.js +137 -0
- package/dist/strategies/vector.strategy.js.map +7 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +7 -0
- package/dist/vector/drivers/chromadb/index.js +44 -0
- package/dist/vector/drivers/chromadb/index.js.map +7 -0
- package/dist/vector/drivers/index.js +9 -0
- package/dist/vector/drivers/index.js.map +7 -0
- package/dist/vector/drivers/pgvector/index.js +509 -0
- package/dist/vector/drivers/pgvector/index.js.map +7 -0
- package/dist/vector/drivers/qdrant/index.js +44 -0
- package/dist/vector/drivers/qdrant/index.js.map +7 -0
- package/dist/vector/index.js +4 -0
- package/dist/vector/index.js.map +7 -0
- package/dist/vector/lib/vector-logs.js +33 -0
- package/dist/vector/lib/vector-logs.js.map +7 -0
- package/dist/vector/services/checksum.js +20 -0
- package/dist/vector/services/checksum.js.map +7 -0
- package/dist/vector/services/embedding.js +222 -0
- package/dist/vector/services/embedding.js.map +7 -0
- package/dist/vector/services/index.js +4 -0
- package/dist/vector/services/index.js.map +7 -0
- package/dist/vector/services/vector-index.service.js +960 -0
- package/dist/vector/services/vector-index.service.js.map +7 -0
- package/dist/vector/types/pg.d.js +1 -0
- package/dist/vector/types/pg.d.js.map +7 -0
- package/dist/vector/types.js +75 -0
- package/dist/vector/types.js.map +7 -0
- package/jest.config.cjs +19 -0
- package/package.json +142 -0
- package/src/__tests__/queue.test.ts +148 -0
- package/src/__tests__/service.test.ts +345 -0
- package/src/__tests__/workers.test.ts +319 -0
- package/src/di.ts +291 -0
- package/src/fulltext/drivers/index.ts +41 -0
- package/src/fulltext/drivers/meilisearch/index.ts +410 -0
- package/src/fulltext/index.ts +13 -0
- package/src/fulltext/types.ts +115 -0
- package/src/index.ts +36 -0
- package/src/indexer/index.ts +13 -0
- package/src/indexer/search-indexer.ts +1141 -0
- package/src/indexer/subscribers/delete.ts +49 -0
- package/src/lib/debug.ts +46 -0
- package/src/lib/fallback-presenter.ts +106 -0
- package/src/lib/field-policy.ts +169 -0
- package/src/lib/index.ts +13 -0
- package/src/lib/merger.ts +159 -0
- package/src/lib/presenter-enricher.ts +323 -0
- package/src/modules/search/README.md +694 -0
- package/src/modules/search/acl.ts +10 -0
- package/src/modules/search/ai-tools.ts +467 -0
- package/src/modules/search/api/embeddings/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/embeddings/reindex/route.ts +197 -0
- package/src/modules/search/api/embeddings/route.ts +304 -0
- package/src/modules/search/api/index/route.ts +297 -0
- package/src/modules/search/api/reindex/cancel/route.ts +77 -0
- package/src/modules/search/api/reindex/route.ts +419 -0
- package/src/modules/search/api/search/global/route.ts +120 -0
- package/src/modules/search/api/search/route.ts +121 -0
- package/src/modules/search/api/settings/fulltext/route.ts +82 -0
- package/src/modules/search/api/settings/global-search/route.ts +91 -0
- package/src/modules/search/api/settings/route.ts +187 -0
- package/src/modules/search/api/settings/vector-store/route.ts +105 -0
- package/src/modules/search/backend/config/search/page.meta.ts +22 -0
- package/src/modules/search/backend/config/search/page.tsx +12 -0
- package/src/modules/search/cli.ts +818 -0
- package/src/modules/search/di.ts +50 -0
- package/src/modules/search/frontend/components/GlobalSearchDialog.tsx +436 -0
- package/src/modules/search/frontend/components/HybridSearchTable.tsx +418 -0
- package/src/modules/search/frontend/components/SearchSettingsPageClient.tsx +476 -0
- package/src/modules/search/frontend/components/sections/FulltextSearchSection.tsx +624 -0
- package/src/modules/search/frontend/components/sections/GlobalSearchSection.tsx +124 -0
- package/src/modules/search/frontend/components/sections/VectorSearchSection.tsx +943 -0
- package/src/modules/search/frontend/index.ts +3 -0
- package/src/modules/search/frontend/utils.ts +82 -0
- package/src/modules/search/i18n/de.json +61 -0
- package/src/modules/search/i18n/en.json +72 -0
- package/src/modules/search/i18n/es.json +61 -0
- package/src/modules/search/i18n/pl.json +61 -0
- package/src/modules/search/index.ts +9 -0
- package/src/modules/search/lib/auto-indexing.ts +35 -0
- package/src/modules/search/lib/embedding-config.ts +161 -0
- package/src/modules/search/lib/global-search-config.ts +69 -0
- package/src/modules/search/lib/reindex-lock.ts +201 -0
- package/src/modules/search/subscribers/fulltext_upsert.ts +83 -0
- package/src/modules/search/subscribers/vector_delete.ts +75 -0
- package/src/modules/search/subscribers/vector_purge.ts +161 -0
- package/src/modules/search/subscribers/vector_upsert.ts +75 -0
- package/src/modules/search/workers/fulltext-index.worker.ts +318 -0
- package/src/modules/search/workers/vector-index.worker.ts +292 -0
- package/src/queue/fulltext-indexing.ts +87 -0
- package/src/queue/index.ts +2 -0
- package/src/queue/vector-indexing.ts +66 -0
- package/src/service.ts +397 -0
- package/src/strategies/fulltext.strategy.ts +155 -0
- package/src/strategies/index.ts +17 -0
- package/src/strategies/token.strategy.ts +153 -0
- package/src/strategies/vector.strategy.ts +234 -0
- package/src/types.ts +38 -0
- package/src/vector/drivers/chromadb/index.ts +49 -0
- package/src/vector/drivers/index.ts +4 -0
- package/src/vector/drivers/pgvector/index.ts +627 -0
- package/src/vector/drivers/qdrant/index.ts +49 -0
- package/src/vector/index.ts +3 -0
- package/src/vector/lib/vector-logs.ts +46 -0
- package/src/vector/services/checksum.ts +18 -0
- package/src/vector/services/embedding.ts +275 -0
- package/src/vector/services/index.ts +3 -0
- package/src/vector/services/vector-index.service.ts +1234 -0
- package/src/vector/types/pg.d.ts +1 -0
- package/src/vector/types.ts +220 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import type { SearchStrategy } from '@open-mercato/shared/modules/search'
|
|
6
|
+
import type { SearchIndexer } from '@open-mercato/search/indexer'
|
|
7
|
+
import type { EntityId } from '@open-mercato/shared/modules/entities'
|
|
8
|
+
import { recordIndexerLog } from '@open-mercato/shared/lib/indexers/status-log'
|
|
9
|
+
import { recordIndexerError } from '@open-mercato/shared/lib/indexers/error-log'
|
|
10
|
+
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
11
|
+
import type { Knex } from 'knex'
|
|
12
|
+
import { searchDebug, searchError } from '../../../../lib/debug'
|
|
13
|
+
import {
|
|
14
|
+
acquireReindexLock,
|
|
15
|
+
clearReindexLock,
|
|
16
|
+
getReindexLockStatus,
|
|
17
|
+
} from '../../lib/reindex-lock'
|
|
18
|
+
|
|
19
|
+
/** Strategy with optional stats support */
|
|
20
|
+
type StrategyWithStats = SearchStrategy & {
|
|
21
|
+
getIndexStats?: (tenantId: string) => Promise<Record<string, unknown> | null>
|
|
22
|
+
clearIndex?: (tenantId: string) => Promise<void>
|
|
23
|
+
recreateIndex?: (tenantId: string) => Promise<void>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Collect stats from all strategies that support it */
|
|
27
|
+
async function collectStrategyStats(
|
|
28
|
+
strategies: StrategyWithStats[],
|
|
29
|
+
tenantId: string
|
|
30
|
+
): Promise<Record<string, Record<string, unknown> | null>> {
|
|
31
|
+
const stats: Record<string, Record<string, unknown> | null> = {}
|
|
32
|
+
for (const strategy of strategies) {
|
|
33
|
+
if (typeof strategy.getIndexStats === 'function') {
|
|
34
|
+
try {
|
|
35
|
+
const isAvailable = await strategy.isAvailable()
|
|
36
|
+
if (isAvailable) {
|
|
37
|
+
stats[strategy.id] = await strategy.getIndexStats(tenantId)
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Skip strategy if stats collection fails
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return stats
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const metadata = {
|
|
48
|
+
POST: { requireAuth: true, requireFeatures: ['search.reindex'] },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type ReindexAction = 'clear' | 'recreate' | 'reindex'
|
|
52
|
+
|
|
53
|
+
const toJson = (payload: Record<string, unknown>, init?: ResponseInit) => NextResponse.json(payload, init)
|
|
54
|
+
|
|
55
|
+
const unauthorized = async () => {
|
|
56
|
+
const { t } = await resolveTranslations()
|
|
57
|
+
return NextResponse.json({ error: t('api.errors.unauthorized', 'Unauthorized') }, { status: 401 })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function POST(req: Request) {
|
|
61
|
+
const { t } = await resolveTranslations()
|
|
62
|
+
const auth = await getAuthFromRequest(req)
|
|
63
|
+
if (!auth?.tenantId) {
|
|
64
|
+
return await unauthorized()
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Capture tenantId as non-null for TypeScript (we checked above)
|
|
68
|
+
const tenantId = auth.tenantId
|
|
69
|
+
|
|
70
|
+
let payload: { action?: ReindexAction; entityId?: string; useQueue?: boolean } = {}
|
|
71
|
+
try {
|
|
72
|
+
payload = await req.json()
|
|
73
|
+
} catch {
|
|
74
|
+
// Default to reindex
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const action: ReindexAction =
|
|
78
|
+
payload.action === 'clear' ? 'clear' :
|
|
79
|
+
payload.action === 'recreate' ? 'recreate' : 'reindex'
|
|
80
|
+
const entityId = typeof payload.entityId === 'string' ? payload.entityId : undefined
|
|
81
|
+
// Use queue by default (requires queue workers to be running), can be disabled with useQueue: false
|
|
82
|
+
const useQueue = payload.useQueue !== false
|
|
83
|
+
|
|
84
|
+
const container = await createRequestContainer()
|
|
85
|
+
const em = container.resolve('em') as EntityManager
|
|
86
|
+
const knex = (em.getConnection() as unknown as { getKnex: () => Knex }).getKnex()
|
|
87
|
+
|
|
88
|
+
// Check if another fulltext reindex operation is already in progress
|
|
89
|
+
const existingLock = await getReindexLockStatus(knex, tenantId, { type: 'fulltext' })
|
|
90
|
+
if (existingLock) {
|
|
91
|
+
const startedAt = new Date(existingLock.startedAt)
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{
|
|
94
|
+
error: t('search.api.errors.reindexInProgress', 'A reindex operation is already in progress'),
|
|
95
|
+
lock: {
|
|
96
|
+
type: existingLock.type,
|
|
97
|
+
action: existingLock.action,
|
|
98
|
+
startedAt: existingLock.startedAt,
|
|
99
|
+
elapsedMinutes: Math.round((Date.now() - startedAt.getTime()) / 60000),
|
|
100
|
+
processedCount: existingLock.processedCount,
|
|
101
|
+
totalCount: existingLock.totalCount,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{ status: 409 }
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Acquire lock before starting the operation
|
|
109
|
+
const { acquired: lockAcquired } = await acquireReindexLock(knex, {
|
|
110
|
+
type: 'fulltext',
|
|
111
|
+
action,
|
|
112
|
+
tenantId: tenantId,
|
|
113
|
+
organizationId: auth.orgId ?? null,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (!lockAcquired) {
|
|
117
|
+
return NextResponse.json(
|
|
118
|
+
{ error: t('search.api.errors.lockFailed', 'Failed to acquire reindex lock') },
|
|
119
|
+
{ status: 409 }
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// Get all search strategies
|
|
125
|
+
const searchStrategies = (container.resolve('searchStrategies') as StrategyWithStats[] | undefined) ?? []
|
|
126
|
+
|
|
127
|
+
// Find a strategy that supports index management (clear/recreate)
|
|
128
|
+
const indexableStrategy = searchStrategies.find(
|
|
129
|
+
(s) => typeof s.clearIndex === 'function' || typeof s.recreateIndex === 'function'
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if (!indexableStrategy) {
|
|
133
|
+
return toJson(
|
|
134
|
+
{ error: t('search.api.errors.noIndexableStrategy', 'No indexable search strategy is configured') },
|
|
135
|
+
{ status: 503 }
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check if strategy is available
|
|
140
|
+
const isAvailable = await indexableStrategy.isAvailable()
|
|
141
|
+
if (!isAvailable) {
|
|
142
|
+
return toJson(
|
|
143
|
+
{ error: t('search.api.errors.strategyUnavailable', 'Search strategy is not available') },
|
|
144
|
+
{ status: 503 }
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Perform the requested action
|
|
149
|
+
if (action === 'reindex') {
|
|
150
|
+
// Full reindex: recreate index and re-index all data
|
|
151
|
+
const searchIndexer = container.resolve('searchIndexer') as SearchIndexer | undefined
|
|
152
|
+
if (!searchIndexer) {
|
|
153
|
+
return toJson(
|
|
154
|
+
{ error: t('search.api.errors.indexerUnavailable', 'Search indexer is not available') },
|
|
155
|
+
{ status: 503 }
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let result
|
|
160
|
+
const orgId = typeof auth.orgId === 'string' ? auth.orgId : null
|
|
161
|
+
|
|
162
|
+
// Debug: List enabled entities
|
|
163
|
+
const enabledEntities = searchIndexer.listEnabledEntities()
|
|
164
|
+
searchDebug('search.reindex', 'Starting reindex', {
|
|
165
|
+
tenantId: tenantId,
|
|
166
|
+
orgId,
|
|
167
|
+
enabledEntities,
|
|
168
|
+
entityId: entityId ?? 'all',
|
|
169
|
+
useQueue,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Log reindex started
|
|
173
|
+
await recordIndexerLog(
|
|
174
|
+
{ em },
|
|
175
|
+
{
|
|
176
|
+
source: 'fulltext',
|
|
177
|
+
handler: 'api:search.reindex',
|
|
178
|
+
message: entityId
|
|
179
|
+
? `Starting Meilisearch reindex for ${entityId}`
|
|
180
|
+
: `Starting Meilisearch reindex for all entities (${enabledEntities.join(', ')})`,
|
|
181
|
+
entityType: entityId ?? null,
|
|
182
|
+
tenantId: tenantId,
|
|
183
|
+
organizationId: orgId,
|
|
184
|
+
details: { enabledEntities, useQueue },
|
|
185
|
+
},
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if (entityId) {
|
|
189
|
+
// Reindex specific entity
|
|
190
|
+
result = await searchIndexer.reindexEntityToFulltext({
|
|
191
|
+
entityId: entityId as EntityId,
|
|
192
|
+
tenantId: tenantId,
|
|
193
|
+
organizationId: orgId,
|
|
194
|
+
recreateIndex: true,
|
|
195
|
+
useQueue,
|
|
196
|
+
onProgress: async (progress) => {
|
|
197
|
+
searchDebug('search.reindex', 'Progress', progress)
|
|
198
|
+
// Note: Heartbeat is updated by workers during job processing, not during enqueueing
|
|
199
|
+
},
|
|
200
|
+
})
|
|
201
|
+
searchDebug('search.reindex', 'Reindexed entity to Meilisearch', {
|
|
202
|
+
entityId,
|
|
203
|
+
tenantId: tenantId,
|
|
204
|
+
recordsIndexed: result.recordsIndexed,
|
|
205
|
+
jobsEnqueued: result.jobsEnqueued,
|
|
206
|
+
errors: result.errors,
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Log to indexer status logs
|
|
210
|
+
await recordIndexerLog(
|
|
211
|
+
{ em },
|
|
212
|
+
{
|
|
213
|
+
source: 'fulltext',
|
|
214
|
+
handler: 'api:search.reindex',
|
|
215
|
+
message: useQueue
|
|
216
|
+
? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of ${entityId}`
|
|
217
|
+
: `Reindexed ${result.recordsIndexed} records to Meilisearch for ${entityId}`,
|
|
218
|
+
entityType: entityId,
|
|
219
|
+
tenantId: tenantId,
|
|
220
|
+
organizationId: orgId,
|
|
221
|
+
details: {
|
|
222
|
+
recordsIndexed: result.recordsIndexed,
|
|
223
|
+
jobsEnqueued: result.jobsEnqueued,
|
|
224
|
+
useQueue,
|
|
225
|
+
errors: result.errors.length > 0 ? result.errors : undefined,
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
// Log any batch errors to error logs
|
|
231
|
+
for (const err of result.errors) {
|
|
232
|
+
await recordIndexerError(
|
|
233
|
+
{ em },
|
|
234
|
+
{
|
|
235
|
+
source: 'fulltext',
|
|
236
|
+
handler: 'api:search.reindex',
|
|
237
|
+
error: new Error(err.error),
|
|
238
|
+
entityType: err.entityId,
|
|
239
|
+
tenantId: tenantId,
|
|
240
|
+
organizationId: orgId,
|
|
241
|
+
payload: { action, useQueue },
|
|
242
|
+
},
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
// Reindex all entities
|
|
247
|
+
result = await searchIndexer.reindexAllToFulltext({
|
|
248
|
+
tenantId: tenantId,
|
|
249
|
+
organizationId: orgId,
|
|
250
|
+
recreateIndex: true,
|
|
251
|
+
useQueue,
|
|
252
|
+
onProgress: async (progress) => {
|
|
253
|
+
searchDebug('search.reindex', 'Progress', progress)
|
|
254
|
+
// Note: Heartbeat is updated by workers during job processing, not during enqueueing
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
searchDebug('search.reindex', 'Reindexed all entities to Meilisearch', {
|
|
258
|
+
tenantId: tenantId,
|
|
259
|
+
entitiesProcessed: result.entitiesProcessed,
|
|
260
|
+
recordsIndexed: result.recordsIndexed,
|
|
261
|
+
jobsEnqueued: result.jobsEnqueued,
|
|
262
|
+
errors: result.errors,
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Log to indexer status logs
|
|
266
|
+
await recordIndexerLog(
|
|
267
|
+
{ em },
|
|
268
|
+
{
|
|
269
|
+
source: 'fulltext',
|
|
270
|
+
handler: 'api:search.reindex',
|
|
271
|
+
message: useQueue
|
|
272
|
+
? `Enqueued ${result.jobsEnqueued ?? 0} jobs for Meilisearch reindex of all entities`
|
|
273
|
+
: `Reindexed ${result.recordsIndexed} records to Meilisearch for ${result.entitiesProcessed} entities`,
|
|
274
|
+
tenantId: tenantId,
|
|
275
|
+
organizationId: orgId,
|
|
276
|
+
details: {
|
|
277
|
+
entitiesProcessed: result.entitiesProcessed,
|
|
278
|
+
recordsIndexed: result.recordsIndexed,
|
|
279
|
+
jobsEnqueued: result.jobsEnqueued,
|
|
280
|
+
useQueue,
|
|
281
|
+
errors: result.errors.length > 0 ? result.errors : undefined,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
// Log any batch errors to error logs
|
|
287
|
+
for (const err of result.errors) {
|
|
288
|
+
await recordIndexerError(
|
|
289
|
+
{ em },
|
|
290
|
+
{
|
|
291
|
+
source: 'fulltext',
|
|
292
|
+
handler: 'api:search.reindex',
|
|
293
|
+
error: new Error(err.error),
|
|
294
|
+
entityType: err.entityId,
|
|
295
|
+
tenantId: tenantId,
|
|
296
|
+
organizationId: orgId,
|
|
297
|
+
payload: { action, useQueue },
|
|
298
|
+
},
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Get updated stats from all strategies
|
|
304
|
+
const stats = await collectStrategyStats(searchStrategies, tenantId)
|
|
305
|
+
|
|
306
|
+
return toJson({
|
|
307
|
+
ok: result.success,
|
|
308
|
+
action,
|
|
309
|
+
entityId: entityId ?? null,
|
|
310
|
+
useQueue,
|
|
311
|
+
result: {
|
|
312
|
+
entitiesProcessed: result.entitiesProcessed,
|
|
313
|
+
recordsIndexed: result.recordsIndexed,
|
|
314
|
+
jobsEnqueued: result.jobsEnqueued ?? 0,
|
|
315
|
+
errors: result.errors.length > 0 ? result.errors : undefined,
|
|
316
|
+
},
|
|
317
|
+
stats,
|
|
318
|
+
})
|
|
319
|
+
} else if (entityId) {
|
|
320
|
+
// Purge specific entity
|
|
321
|
+
await indexableStrategy.purge?.(entityId as EntityId, tenantId)
|
|
322
|
+
searchDebug('search.reindex', 'Purged entity', { strategyId: indexableStrategy.id, entityId, tenantId: tenantId })
|
|
323
|
+
|
|
324
|
+
await recordIndexerLog(
|
|
325
|
+
{ em },
|
|
326
|
+
{
|
|
327
|
+
source: 'fulltext',
|
|
328
|
+
handler: 'api:search.reindex',
|
|
329
|
+
message: `Purged entity ${entityId} from Meilisearch`,
|
|
330
|
+
entityType: entityId,
|
|
331
|
+
tenantId: tenantId,
|
|
332
|
+
organizationId: auth.orgId ?? null,
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
} else if (action === 'clear') {
|
|
336
|
+
// Clear all documents but keep index
|
|
337
|
+
if (indexableStrategy.clearIndex) {
|
|
338
|
+
await indexableStrategy.clearIndex(tenantId)
|
|
339
|
+
searchDebug('search.reindex', 'Cleared index', { strategyId: indexableStrategy.id, tenantId: tenantId })
|
|
340
|
+
|
|
341
|
+
await recordIndexerLog(
|
|
342
|
+
{ em },
|
|
343
|
+
{
|
|
344
|
+
source: 'fulltext',
|
|
345
|
+
handler: 'api:search.reindex',
|
|
346
|
+
message: 'Cleared all documents from Meilisearch index',
|
|
347
|
+
tenantId: tenantId,
|
|
348
|
+
organizationId: auth.orgId ?? null,
|
|
349
|
+
},
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
// Recreate the entire index
|
|
354
|
+
if (indexableStrategy.recreateIndex) {
|
|
355
|
+
await indexableStrategy.recreateIndex(tenantId)
|
|
356
|
+
searchDebug('search.reindex', 'Recreated index', { strategyId: indexableStrategy.id, tenantId: tenantId })
|
|
357
|
+
|
|
358
|
+
await recordIndexerLog(
|
|
359
|
+
{ em },
|
|
360
|
+
{
|
|
361
|
+
source: 'fulltext',
|
|
362
|
+
handler: 'api:search.reindex',
|
|
363
|
+
message: 'Recreated Meilisearch index',
|
|
364
|
+
tenantId: tenantId,
|
|
365
|
+
organizationId: auth.orgId ?? null,
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Get updated stats from all strategies
|
|
372
|
+
const stats = await collectStrategyStats(searchStrategies, tenantId)
|
|
373
|
+
|
|
374
|
+
return toJson({
|
|
375
|
+
ok: true,
|
|
376
|
+
action,
|
|
377
|
+
entityId: entityId ?? null,
|
|
378
|
+
stats,
|
|
379
|
+
})
|
|
380
|
+
} catch (error: unknown) {
|
|
381
|
+
// Log full error details server-side only
|
|
382
|
+
searchError('search.reindex', 'Failed', {
|
|
383
|
+
error: error instanceof Error ? error.message : error,
|
|
384
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
385
|
+
tenantId: tenantId,
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// Record error to indexer error logs
|
|
389
|
+
await recordIndexerError(
|
|
390
|
+
{ em },
|
|
391
|
+
{
|
|
392
|
+
source: 'fulltext',
|
|
393
|
+
handler: 'api:search.reindex',
|
|
394
|
+
error,
|
|
395
|
+
entityType: entityId ?? null,
|
|
396
|
+
tenantId: tenantId,
|
|
397
|
+
organizationId: auth.orgId ?? null,
|
|
398
|
+
payload: { action, entityId, useQueue },
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
// Return generic message to client - don't expose internal error details
|
|
403
|
+
return toJson(
|
|
404
|
+
{ error: t('search.api.errors.reindexFailed', 'Reindex operation failed. Please try again or contact support.') },
|
|
405
|
+
{ status: 500 }
|
|
406
|
+
)
|
|
407
|
+
} finally {
|
|
408
|
+
// Only clear lock immediately if NOT using queue mode
|
|
409
|
+
// When using queue mode, workers update heartbeat and stale detection handles cleanup
|
|
410
|
+
if (!useQueue) {
|
|
411
|
+
await clearReindexLock(knex, tenantId, 'fulltext', auth.orgId ?? null)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const disposable = container as unknown as { dispose?: () => Promise<void> }
|
|
415
|
+
if (typeof disposable.dispose === 'function') {
|
|
416
|
+
await disposable.dispose()
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import type { SearchService } from '@open-mercato/search'
|
|
6
|
+
import type { EmbeddingService } from '../../../../../vector'
|
|
7
|
+
import { resolveEmbeddingConfig } from '../../../lib/embedding-config'
|
|
8
|
+
import { resolveGlobalSearchStrategies } from '../../../lib/global-search-config'
|
|
9
|
+
import { searchError } from '../../../../../lib/debug'
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
GET: { requireAuth: true, requireFeatures: ['search.view'] },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseLimit(value: string | null): number {
|
|
16
|
+
if (!value) return 50
|
|
17
|
+
const parsed = Number.parseInt(value, 10)
|
|
18
|
+
if (Number.isNaN(parsed) || parsed <= 0) return 50
|
|
19
|
+
return Math.min(parsed, 100)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseEntityTypes(value: string | null): string[] | undefined {
|
|
23
|
+
if (!value) return undefined
|
|
24
|
+
const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
25
|
+
return entityTypes.length > 0 ? entityTypes : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Global search endpoint for Cmd+K.
|
|
30
|
+
* Always uses saved global search settings - does NOT accept strategies from URL.
|
|
31
|
+
*/
|
|
32
|
+
export async function GET(req: Request) {
|
|
33
|
+
const { t } = await resolveTranslations()
|
|
34
|
+
const url = new URL(req.url)
|
|
35
|
+
const query = (url.searchParams.get('q') || '').trim()
|
|
36
|
+
const limit = parseLimit(url.searchParams.get('limit'))
|
|
37
|
+
const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))
|
|
38
|
+
|
|
39
|
+
if (!query) {
|
|
40
|
+
return NextResponse.json(
|
|
41
|
+
{ error: t('search.api.errors.missingQuery', 'Missing query') },
|
|
42
|
+
{ status: 400 }
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const auth = await getAuthFromRequest(req)
|
|
47
|
+
if (!auth?.tenantId) {
|
|
48
|
+
return NextResponse.json(
|
|
49
|
+
{ error: t('api.errors.unauthorized', 'Unauthorized') },
|
|
50
|
+
{ status: 401 }
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const container = await createRequestContainer()
|
|
55
|
+
try {
|
|
56
|
+
const searchService = container.resolve('searchService') as SearchService | undefined
|
|
57
|
+
if (!searchService) {
|
|
58
|
+
return NextResponse.json(
|
|
59
|
+
{ error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },
|
|
60
|
+
{ status: 503 }
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fetch saved global search strategies
|
|
65
|
+
const strategies = await resolveGlobalSearchStrategies(container)
|
|
66
|
+
|
|
67
|
+
// Load embedding config for vector strategy (only if vector is enabled)
|
|
68
|
+
if (strategies.includes('vector')) {
|
|
69
|
+
try {
|
|
70
|
+
const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
|
|
71
|
+
if (embeddingConfig) {
|
|
72
|
+
const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')
|
|
73
|
+
embeddingService.updateConfig(embeddingConfig)
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Embedding config not available, vector strategy may not work
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const startTime = Date.now()
|
|
81
|
+
|
|
82
|
+
const searchOptions = {
|
|
83
|
+
tenantId: auth.tenantId,
|
|
84
|
+
organizationId: null,
|
|
85
|
+
limit,
|
|
86
|
+
strategies,
|
|
87
|
+
entityTypes,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const results = await searchService.search(query, searchOptions)
|
|
91
|
+
|
|
92
|
+
const timing = Date.now() - startTime
|
|
93
|
+
|
|
94
|
+
// Collect unique strategies that returned results
|
|
95
|
+
const strategiesUsed = [...new Set(results.map((r) => r.source))]
|
|
96
|
+
|
|
97
|
+
return NextResponse.json({
|
|
98
|
+
results,
|
|
99
|
+
strategiesUsed,
|
|
100
|
+
strategiesEnabled: strategies,
|
|
101
|
+
timing,
|
|
102
|
+
query,
|
|
103
|
+
limit,
|
|
104
|
+
})
|
|
105
|
+
} catch (error: unknown) {
|
|
106
|
+
searchError('search.api.global', 'failed', {
|
|
107
|
+
error: error instanceof Error ? error.message : error,
|
|
108
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
109
|
+
})
|
|
110
|
+
return NextResponse.json(
|
|
111
|
+
{ error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },
|
|
112
|
+
{ status: 500 }
|
|
113
|
+
)
|
|
114
|
+
} finally {
|
|
115
|
+
const disposable = container as unknown as { dispose?: () => Promise<void> }
|
|
116
|
+
if (typeof disposable.dispose === 'function') {
|
|
117
|
+
await disposable.dispose()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
5
|
+
import type { SearchService } from '@open-mercato/search'
|
|
6
|
+
import type { SearchStrategyId } from '@open-mercato/shared/modules/search'
|
|
7
|
+
import type { EmbeddingService } from '../../../../vector'
|
|
8
|
+
import { resolveEmbeddingConfig } from '../../lib/embedding-config'
|
|
9
|
+
import { searchError } from '../../../../lib/debug'
|
|
10
|
+
|
|
11
|
+
export const metadata = {
|
|
12
|
+
GET: { requireAuth: true, requireFeatures: ['search.view'] },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseLimit(value: string | null): number {
|
|
16
|
+
if (!value) return 50
|
|
17
|
+
const parsed = Number.parseInt(value, 10)
|
|
18
|
+
if (Number.isNaN(parsed) || parsed <= 0) return 50
|
|
19
|
+
return Math.min(parsed, 100)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseStrategies(value: string | null): SearchStrategyId[] | undefined {
|
|
23
|
+
if (!value) return undefined
|
|
24
|
+
const strategies = value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
25
|
+
return strategies.length > 0 ? strategies : undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseEntityTypes(value: string | null): string[] | undefined {
|
|
29
|
+
if (!value) return undefined
|
|
30
|
+
const entityTypes = value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
31
|
+
return entityTypes.length > 0 ? entityTypes : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function GET(req: Request) {
|
|
35
|
+
const { t } = await resolveTranslations()
|
|
36
|
+
const url = new URL(req.url)
|
|
37
|
+
const query = (url.searchParams.get('q') || '').trim()
|
|
38
|
+
const limit = parseLimit(url.searchParams.get('limit'))
|
|
39
|
+
const strategies = parseStrategies(url.searchParams.get('strategies'))
|
|
40
|
+
const entityTypes = parseEntityTypes(url.searchParams.get('entityTypes'))
|
|
41
|
+
|
|
42
|
+
if (!query) {
|
|
43
|
+
return NextResponse.json(
|
|
44
|
+
{ error: t('search.api.errors.missingQuery', 'Missing query') },
|
|
45
|
+
{ status: 400 }
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const auth = await getAuthFromRequest(req)
|
|
50
|
+
if (!auth?.tenantId) {
|
|
51
|
+
return NextResponse.json(
|
|
52
|
+
{ error: t('api.errors.unauthorized', 'Unauthorized') },
|
|
53
|
+
{ status: 401 }
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const container = await createRequestContainer()
|
|
58
|
+
try {
|
|
59
|
+
const searchService = container.resolve('searchService') as SearchService | undefined
|
|
60
|
+
if (!searchService) {
|
|
61
|
+
return NextResponse.json(
|
|
62
|
+
{ error: t('search.api.errors.serviceUnavailable', 'Search service unavailable') },
|
|
63
|
+
{ status: 503 }
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Load embedding config for vector strategy (same as Vector Search playground)
|
|
68
|
+
try {
|
|
69
|
+
const embeddingConfig = await resolveEmbeddingConfig(container, { defaultValue: null })
|
|
70
|
+
if (embeddingConfig) {
|
|
71
|
+
const embeddingService = container.resolve<EmbeddingService>('vectorEmbeddingService')
|
|
72
|
+
embeddingService.updateConfig(embeddingConfig)
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Embedding config not available, vector strategy may not work
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const startTime = Date.now()
|
|
79
|
+
|
|
80
|
+
// Don't filter by organization in the playground - show all results
|
|
81
|
+
// Both strategies handle null as "no organization filter"
|
|
82
|
+
const searchOptions = {
|
|
83
|
+
tenantId: auth.tenantId,
|
|
84
|
+
organizationId: null,
|
|
85
|
+
limit,
|
|
86
|
+
strategies,
|
|
87
|
+
entityTypes,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const results = await searchService.search(query, searchOptions)
|
|
91
|
+
|
|
92
|
+
const timing = Date.now() - startTime
|
|
93
|
+
|
|
94
|
+
// Collect unique strategies that returned results
|
|
95
|
+
const strategiesUsed = [...new Set(results.map((r) => r.source))]
|
|
96
|
+
|
|
97
|
+
return NextResponse.json({
|
|
98
|
+
results,
|
|
99
|
+
strategiesUsed,
|
|
100
|
+
timing,
|
|
101
|
+
query,
|
|
102
|
+
limit,
|
|
103
|
+
})
|
|
104
|
+
} catch (error: unknown) {
|
|
105
|
+
// Log full error details server-side only
|
|
106
|
+
searchError('search.api.search', 'failed', {
|
|
107
|
+
error: error instanceof Error ? error.message : error,
|
|
108
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
109
|
+
})
|
|
110
|
+
// Return generic message to client - don't expose internal error details
|
|
111
|
+
return NextResponse.json(
|
|
112
|
+
{ error: t('search.api.errors.searchFailed', 'Search failed. Please try again.') },
|
|
113
|
+
{ status: 500 }
|
|
114
|
+
)
|
|
115
|
+
} finally {
|
|
116
|
+
const disposable = container as unknown as { dispose?: () => Promise<void> }
|
|
117
|
+
if (typeof disposable.dispose === 'function') {
|
|
118
|
+
await disposable.dispose()
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|