// src/controllers/api/v1/search.ts // Copyright (C) 2026 Robert Colbert // All Rights Reserved import { Request, Response } from "express"; import { DtpController } from "../../../lib/controller.js"; import VectorStoreService, { ISearchFilters, ISearchResult } from "../../../services/vector-store.js"; import User from "../../../models/user.js"; import Project from "../../../models/project.js"; import ChatSession from "../../../models/chat-session.js"; import ChatTurn from "../../../models/chat-turn.js"; interface IHydratedSearchResult extends ISearchResult { user?: { _id: string; displayName: string; username: string }; project?: { _id: string; name: string; slug: string }; session?: { _id: string; name: string }; turn?: { _id: string; status: string; mode: string; llm: string }; } interface ISearchRequestBody { query: string; userId?: string; projectId?: string; sessionId?: string; turnId?: string; topK?: number; } class SearchController extends DtpController { get name(): string { return "SearchController"; } get slug(): string { return "ctrl:api:v1:search"; } get route(): string { return "/search"; } async start(): Promise { this.router.post("/", this.requireUser(), this.search.bind(this)); } /** * POST /api/v1/search * Semantic search across chat history. * * Enforces userId filter: users can only search their own corpus. */ private async search(req: Request, res: Response): Promise { try { const body = req.body as ISearchRequestBody; if (!body.query?.trim()) { res.status(400).json({ success: false, message: "query is required", }); return; } // Enforce that the user can only search their own corpus const userId = (req.user as any)?._id; if (!userId) { res.status(403).json({ success: false, message: "Authentication required", }); return; } const filters: ISearchFilters = { userId, projectId: body.projectId, sessionId: body.sessionId, turnId: body.turnId, }; const topK = body.topK && body.topK > 0 && body.topK <= 50 ? body.topK : 10; // Check if vector store is initialized if (!VectorStoreService.isReady) { res.status(503).json({ success: false, message: "Vector store is not configured. Set qdrant.providerId in gadget-code.yaml to enable semantic search.", }); return; } const results = await VectorStoreService.search( body.query.trim(), filters, topK, ); // Hydrate results with MongoDB documents const hydratedResults = await this.hydrateResults(results); res.json({ success: true, data: hydratedResults, }); } catch (error) { this.log.error("search failed", { error }); res.status(500).json({ success: false, message: (error as Error).message || "Search failed", }); } } /** * Hydrate search results with user, project, session, and turn data from MongoDB. * Collects unique IDs to batch-load documents efficiently. */ private async hydrateResults( results: ISearchResult[], ): Promise { if (results.length === 0) return []; // Collect unique IDs for batch loading const userIds = [...new Set(results.map((r) => r.userId).filter(Boolean))]; const projectIds = [...new Set(results.map((r) => r.projectId).filter(Boolean))]; const sessionIds = [...new Set(results.map((r) => r.sessionId).filter(Boolean))]; const turnIds = [...new Set(results.map((r) => r.turnId).filter(Boolean))]; // Batch load from MongoDB const [users, projects, sessions, turns] = await Promise.all([ userIds.length > 0 ? User.find({ _id: { $in: userIds } }) .select("_id displayName username") .lean() : [], projectIds.length > 0 ? Project.find({ _id: { $in: projectIds } }) .select("_id name slug") .lean() : [], sessionIds.length > 0 ? ChatSession.find({ _id: { $in: sessionIds } }) .select("_id name") .lean() : [], turnIds.length > 0 ? ChatTurn.find({ _id: { $in: turnIds } }) .select("_id status mode llm") .lean() : [], ]); // Build lookup maps const userMap = new Map(users.map((u) => [u._id, u])); const projectMap = new Map(projects.map((p) => [p._id, p])); const sessionMap = new Map(sessions.map((s) => [s._id, s])); const turnMap = new Map(turns.map((t) => [t._id, t])); return results.map((result) => ({ ...result, user: userMap.get(result.userId) as any || undefined, project: projectMap.get(result.projectId) as any || undefined, session: sessionMap.get(result.sessionId) as any || undefined, turn: turnMap.get(result.turnId) as any || undefined, })); } } export default SearchController;