1
28import { createHash } from "node:crypto";
29import { Inject, Injectable } from "@nestjs/common";
30import { z } from "zod";
31
32
33
34import { SearchClient, type SearchSort, type SearchType, type ViewerContext } from "@factory/search";
35import { m } from "@factory/observability";
36import { loadEnv } from "../../config/env.js";
37import { ActorContext } from "../../common/actor-context.js";
38import { ApiError } from "../../common/errors.js";
39import { logger } from "../../common/logger.js";
40import { PrismaService } from "../../common/prisma.service.js";
41import { RedisService } from "../../common/redis.service.js";
42import { CacheService } from "../../common/cache.js";
43
44const RECENT_KEY = (userId: string) => `search:recent:${userId}`;
45const RECENT_MAX = 20;
46
47const LOG_QUERY_PREFIX_CHARS = 64;
48
49const RECENT_WRITE_LIMIT = 30;
50const RECENT_WRITE_WINDOW_MS = 60_000;
51
52const SUGGEST_DEFAULT_SIZE = 8;
53
54const SUGGEST_MAX_SIZE = 20;
55
56export interface SearchInput {
57 q: string;
58 types?: SearchType[];
59 workspaceId?: string;
60 teamId?: string;
61 createdBy?: string;
62 updatedAfter?: string;
63 updatedBefore?: string;
64
65 sortBy?: SearchSort;
66 cursor?: string;
67 limit?: number;
68}
69
70export interface SearchEnvelope {
71 data: import("@factory/search").SearchResult[];
72 nextCursor: string | null;
73 tookMs: number;
74}
75
76export interface Suggestion {
77 id: string;
78 type: string;
79 title: string;
80}
81
82
83interface CallerScope {
84
85 workspaceIds: string[];
86
87 teamIds: string[];
88}
89
90type SearchResult = import("@factory/search").SearchResult;
91
92
100const SEARCH_RESULT_CACHE = z.object({
101 docId: z.string(),
102 type: z.string(),
103 id: z.string(),
104 title: z.string(),
105 snippet: z.string().optional(),
106 url: z.string(),
107 icon: z.string().optional(),
108 createdByName: z.string().optional(),
109 updatedAt: z.string(),
110 pageId: z.string().optional(),
111 blockId: z.string().optional(),
112 databaseId: z.string().optional(),
113 recordId: z.string().optional(),
114 workspaceId: z.string(),
115 score: z.number(),
116 source: z.string()
117});
118const SEARCH_ENVELOPE_CACHE = z.object({
119 data: z.array(SEARCH_RESULT_CACHE),
120 nextCursor: z.string().nullable(),
121 tookMs: z.number()
122});
123
124@Injectable()
125export class SearchService {
126
130 private clientOverride: SearchClient | null = null;
131 private lazyClient: SearchClient | null = null;
132
133 constructor(
134 @Inject(PrismaService) private readonly prisma: PrismaService,
135 @Inject(RedisService) private readonly redis: RedisService,
136 @Inject(CacheService) private readonly cache: CacheService,
137 ) {}
138
139
140 setClientForTesting(client: SearchClient | null): void {
141 this.clientOverride = client;
142 this.lazyClient = null;
143 }
144
145 private client(): SearchClient {
146 if (this.clientOverride) return this.clientOverride;
147 if (!this.lazyClient) {
148 const env = loadEnv();
149 this.lazyClient = new SearchClient({
150 baseUrl: env.SEARCH_URL,
151 ...(env.SEARCH_PASSWORD
152 ? { auth: { username: env.SEARCH_USERNAME, password: env.SEARCH_PASSWORD } }
153 : {}),
154 });
155 }
156 return this.lazyClient;
157 }
158
159
165 searchClient(): SearchClient {
166 return this.client();
167 }
168
169
174 async resolveScope(userId: string): Promise<CallerScope> {
175
176
177
178
179 const actor = ActorContext.get();
180 if (actor?.kind === "api" && actor.workspaceId) {
181 const active = await this.filterActiveWorkspaces([actor.workspaceId]);
182 let teamIds: string[] = [];
183 if (active.length > 0) {
184 const teamRows = await this.prisma.team.findMany({
185 where: { workspaceId: actor.workspaceId },
186 select: { id: true },
187 });
188 teamIds = teamRows.map((t) => t.id);
189 }
190 return { workspaceIds: active, teamIds };
191 }
192 const members = await this.prisma.workspaceMember.findMany({
193 where: { userId },
194 select: { workspaceId: true },
195 });
196 const workspaceIds = await this.filterActiveWorkspaces(members.map((m) => m.workspaceId));
197 let teamIds: string[] = [];
198 if (workspaceIds.length > 0) {
199 const teamRows = await this.prisma.teamMember.findMany({
200 where: { userId, team: { workspaceId: { in: workspaceIds } } },
201 select: { teamId: true },
202 });
203 teamIds = [...new Set(teamRows.map((t) => t.teamId))];
204 }
205 return { workspaceIds, teamIds };
206 }
207
208
215 async viewerForWorkspace(userId: string, workspaceId: string): Promise<ViewerContext | null> {
216 const scope = await this.resolveScope(userId);
217 return this.viewerFor(userId, scope, workspaceId);
218 }
219
220
221 private async filterActiveWorkspaces(ids: string[]): Promise<string[]> {
222 if (ids.length === 0) return [];
223 const rows = await this.prisma.workspace.findMany({
224 where: { id: { in: ids }, deletedAt: null },
225 select: { id: true },
226 });
227 return rows.map((w) => w.id);
228 }
229
230
236 private async viewerFor(
237 userId: string,
238 scope: CallerScope,
239 workspaceId?: string,
240 ): Promise<ViewerContext | null> {
241 const actor = ActorContext.get();
242 const isKey = actor?.kind === "api";
243 let workspaceIds: string[];
244 if (isKey) {
245
246
247
248 if (!workspaceId || workspaceId !== actor.workspaceId) {
249 throw ApiError.notFound("Resource not found.");
250 }
251 workspaceIds = [workspaceId];
252 } else if (workspaceId) {
253 if (!scope.workspaceIds.includes(workspaceId)) {
254
255
256 throw ApiError.notFound("Resource not found.");
257 }
258 workspaceIds = [workspaceId];
259 } else {
260 workspaceIds = scope.workspaceIds;
261 if (workspaceIds.length === 0) return null;
262 }
263
264
265
266
267 return {
268 userId: isKey ? `apikey-${actor!.apiKeyId ?? "unknown"}` : userId,
269 workspaceIds,
270 teamIds: scope.teamIds,
271 };
272 }
273
274
281 async search(userId: string, input: SearchInput): Promise<SearchEnvelope> {
282 const cacheKeyInput = {
283 q: input.q.trim(),
284 types: input.types,
285 workspaceId: input.workspaceId,
286 teamId: input.teamId,
287 createdBy: input.createdBy,
288 updatedAfter: input.updatedAfter,
289 updatedBefore: input.updatedBefore,
290
291
292 sortBy: input.sortBy,
293 cursor: input.cursor,
294 limit: input.limit
295 };
296 const { data } = await this.cache.getSearch(
297 input.workspaceId,
298 userId,
299 cacheKeyInput,
300 () => this.searchLive(userId, input),
301 SEARCH_ENVELOPE_CACHE
302 );
303 return data as SearchEnvelope;
304 }
305
306
307 private async searchLive(userId: string, input: SearchInput): Promise<SearchEnvelope> {
308 const q = input.q.trim();
309 const scope = await this.resolveScope(userId);
310 const viewer = await this.viewerFor(userId, scope, input.workspaceId);
311 if (!viewer) {
312
313 this.logSearch(userId, { q, types: input.types, workspaceId: input.workspaceId }, 0, 0);
314 return { data: [], nextCursor: null, tookMs: 0 };
315 }
316
317
318
319
320
321 const pageSize = clampLimit(input.limit);
322 const started = Date.now();
323 const res = await this.tryClient(() =>
324 this.client().search(
325 {
326 q,
327 ...(input.types && input.types.length > 0 ? { types: input.types } : {}),
328
329 ...(input.sortBy && input.sortBy !== "relevance" ? { sortBy: input.sortBy } : {}),
330 filters: {
331 ...(input.teamId ? { teams: [input.teamId] } : {}),
332 ...(input.createdBy ? { createdBy: input.createdBy } : {}),
333 ...(input.updatedAfter ? { updatedAfter: input.updatedAfter } : {}),
334 ...(input.updatedBefore ? { updatedBefore: input.updatedBefore } : {}),
335 },
336 page: input.cursor,
337 pageSize,
338 },
339 viewer,
340 ),
341 );
342 const tookMs = Date.now() - started;
343
344
345
346
347 m.search.record({ type: "bm25" }, tookMs / 1000);
348 this.logSearch(
349 userId,
350 { q, types: input.types, workspaceId: input.workspaceId },
351 res.total,
352 tookMs,
353 );
354 return { data: res.results, nextCursor: res.cursor ?? null, tookMs };
355 }
356
357
363 async query(userId: string, input: SearchInput): Promise<SearchResult[]> {
364 const q = input.q.trim();
365 const scope = await this.resolveScope(userId);
366 const viewer = await this.viewerFor(userId, scope, input.workspaceId);
367 if (!viewer) return [];
368
369
370 const pageSize = clampLimit(input.limit);
371 const res = await this.tryClient(() =>
372 this.client().search(
373 {
374 q,
375 ...(input.types && input.types.length > 0 ? { types: input.types } : {}),
376
377 ...(input.sortBy && input.sortBy !== "relevance" ? { sortBy: input.sortBy } : {}),
378 filters: {
379 ...(input.teamId ? { teams: [input.teamId] } : {}),
380 ...(input.createdBy ? { createdBy: input.createdBy } : {}),
381 ...(input.updatedAfter ? { updatedAfter: input.updatedAfter } : {}),
382 ...(input.updatedBefore ? { updatedBefore: input.updatedBefore } : {}),
383 },
384 page: input.cursor,
385 pageSize,
386 },
387 viewer,
388 ),
389 );
390
391
392 m.search.record({ type: "bm25" }, res.latencyMs / 1000);
393 this.logSearch(
394 userId,
395 { q, types: input.types, workspaceId: input.workspaceId },
396 res.total,
397 res.latencyMs,
398 );
399 return res.results;
400 }
401
402
409 async suggest(userId: string, q: string, limit?: number): Promise<Suggestion[]> {
410 const scope = await this.resolveScope(userId);
411 const viewer = await this.viewerFor(userId, scope);
412 if (!viewer) return [];
413
414 const size = clampSuggest(limit);
415 const started = Date.now();
416 const items = await this.tryClient(() => this.client().suggest(q, viewer, size));
417 this.logSearch(userId, { q, suggest: true }, items.length, Date.now() - started);
418 return items;
419 }
420
421
422 private async tryClient<T>(fn: () => Promise<T>): Promise<T> {
423 try {
424 return await fn();
425 } catch (err) {
426 this.mapSharedError(err);
427 }
428 }
429
430
443 private mapSharedError(err: unknown): never {
444 const code: unknown = (err as { code?: unknown })?.code;
445 const status: unknown = (err as { status?: unknown })?.status;
446 const msg = err instanceof Error ? err.message : "search request failed";
447 if (code === "SEARCH_UNAVAILABLE") {
448 logger.warn("opensearch unavailable", { code, status, message: msg });
449 throw new ApiError(
450 "SEARCH_UNAVAILABLE",
451 typeof status === "number" ? status : 503,
452 "Search is temporarily unavailable.",
453 );
454 }
455
456
457
458 logger.warn("search backend error", {
459 code: code ?? null,
460 status: status ?? null,
461 message: msg,
462 });
463 throw new ApiError("SEARCH_UNAVAILABLE", 503, "Search is temporarily unavailable.");
464 }
465
466
467 private logSearch(
468 userId: string,
469 meta: { q: string; types?: SearchType[]; workspaceId?: string; suggest?: boolean },
470 total: number,
471 tookMs: number,
472 ): void {
473
474 const prefix = meta.q.slice(0, LOG_QUERY_PREFIX_CHARS);
475 const hash = createHash("sha256").update(meta.q, "utf8").digest("hex").slice(0, 16);
476 logger.info(meta.suggest ? "search:suggest" : "search:query", {
477 userId,
478 types: meta.types ?? null,
479 workspaceId: meta.workspaceId ?? null,
480 tookMs,
481 total,
482 queryPrefix: prefix,
483 queryHash: hash,
484 });
485 }
486
487
488
489
490
491
492
493
494 async trackRecent(userId: string, q: string): Promise<void> {
495 try {
496 const key = RECENT_KEY(userId);
497 const redis = this.redis.getRedis();
498 await redis.lrem(key, 1, q);
499 await redis.lpush(key, q);
500 await redis.ltrim(key, 0, RECENT_MAX - 1);
501 } catch (err) {
502 logger.warn(`recent-search tracking failed: ${(err as Error).message}`);
503 }
504 }
505
506 async recent(userId: string): Promise<{ data: string[] }> {
507 try {
508 const items = await this.redis.getRedis().lrange(RECENT_KEY(userId), 0, RECENT_MAX - 1);
509 return { data: items };
510 } catch {
511 return { data: [] };
512 }
513 }
514
515
524 async addRecent(userId: string, q: string): Promise<{ data: string[] }> {
525 const raw = q.trim();
526 if (raw.length === 0)
527 throw ApiError.validation("q is required.", [{ field: "q", issue: "required" }]);
528 await this.enforceRecentWriteLimit(userId);
529 await this.trackRecent(userId, raw);
530 return this.recent(userId);
531 }
532
533 private async enforceRecentWriteLimit(userId: string): Promise<void> {
534 try {
535 const key = `search:recent:rl:${userId}`;
536 const now = Date.now();
537 const pipe = this.redis.getRedis().pipeline();
538 pipe.zremrangebyscore(key, 0, now - RECENT_WRITE_WINDOW_MS);
539 pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`);
540 pipe.zcard(key);
541 pipe.pexpire(key, RECENT_WRITE_WINDOW_MS);
542 const results = await pipe.exec();
543 const card = results?.[2]?.[1];
544 const count = typeof card === "number" ? card : 1;
545 if (count > RECENT_WRITE_LIMIT) {
546 throw new ApiError("RATE_LIMITED", 429, "Too many recent-search writes.", [
547 { retryAfterSeconds: Math.ceil(RECENT_WRITE_WINDOW_MS / 1000) },
548 ]);
549 }
550 } catch (err) {
551
552
553 if (err instanceof ApiError) throw err;
554 logger.warn(
555 `recent-write rate limit degraded (redis unavailable): ${(err as Error).message}`,
556 );
557 }
558 }
559
560 async clearRecent(userId: string): Promise<{ cleared: boolean }> {
561 try {
562 await this.redis.getRedis().del(RECENT_KEY(userId));
563 return { cleared: true };
564 } catch {
565 return { cleared: false };
566 }
567 }
568}
569
570
571function clampLimit(limit: number | undefined): number {
572 if (limit === undefined) return 20;
573 if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
574 throw ApiError.validation("limit must be an integer between 1 and 50.", [
575 { field: "limit", issue: "1..50", received: String(limit) },
576 ]);
577 }
578 return limit;
579}
580
581function clampSuggest(limit: number | undefined): number {
582 if (limit === undefined) return SUGGEST_DEFAULT_SIZE;
583 if (!Number.isInteger(limit) || limit < 1) {
584 throw ApiError.validation("limit must be an integer between 1 and 20.", [
585 { field: "limit", issue: "1..20", received: String(limit) },
586 ]);
587 }
588 return Math.min(limit, SUGGEST_MAX_SIZE);
589}
590