// src/services/search.ts // Copyright (C) 2025 DTP Technologies, LLC // All Rights Reserved import { google } from "googleapis"; import { DtpService } from "../lib/service.js"; import User, { IGoogleCseConnection } from "../models/user.js"; export interface SearchResult { title: string; link: string; snippet: string; image?: string; position?: number; displayLink?: string; } export interface SearchOptions { num?: number; siteSearch?: string; dateRestrict?: string; fileType?: string; safe?: "active" | "off"; sort?: "relevance" | "date"; start?: number; } export class SearchServiceError extends Error { constructor( public code: string, message: string, public details?: any, ) { super(message); this.name = "SearchServiceError"; } } class SearchService extends DtpService { get name(): string { return "SearchService"; } get slug(): string { return "search"; } constructor() { super(); } async start(): Promise { this.log.info("service started"); } async stop(): Promise { this.log.info("service stopped"); } /** * Check if a user has Google CSE credentials configured */ async userHasCseConfigured(userId: string): Promise { try { const credentials = await this.getUserCseCredentials(userId); return credentials !== null; } catch (error) { this.log.error("Error checking CSE configuration", { userId, error }); return false; } } /** * Get user's CSE credentials from database * Note: The schema has select: false on apiKey and engineId, * so we need to explicitly request them */ private async getUserCseCredentials( userId: string, ): Promise { const user = await User.findById(userId) .select("+connections.google.cse.apiKey +connections.google.cse.engineId") .lean(); if (!user) { throw new SearchServiceError( "USER_NOT_FOUND", `User ${userId} not found`, ); } const cseConfig = user.connections?.google?.cse; if (!cseConfig || !cseConfig.apiKey || !cseConfig.engineId) { return null; } return { apiKey: cseConfig.apiKey, engineId: cseConfig.engineId, }; } /** * Test CSE credentials by performing a simple search */ async testCredentials( apiKey: string, engineId: string, ): Promise<{ success: boolean; error?: string }> { try { const customSearch = google.customsearch({ version: "v1", auth: apiKey, }); // Perform a simple test search await customSearch.cse.list({ q: "test", cx: engineId, num: 1, }); // If we get here without error, credentials are valid return { success: true }; } catch (error: any) { const errorInfo = this.parseCseError(error); return { success: false, error: errorInfo.message, }; } } /** * Search for a user using their CSE credentials */ async searchForUser( userId: string, query: string, options: SearchOptions = {}, ): Promise { const credentials = await this.getUserCseCredentials(userId); if (!credentials) { throw new SearchServiceError( "CSE_NOT_CONFIGURED", "Google Custom Search is not configured for this user. " + "Please configure your Google CSE credentials in Account Settings.", { recoveryHint: "Press Ctrl+, to open Account Settings", }, ); } return this.executeSearch(credentials, query, options); } /** * Execute search with provided credentials */ private async executeSearch( credentials: IGoogleCseConnection, query: string, options: SearchOptions = {}, ): Promise { const customSearch = google.customsearch({ version: "v1", auth: credentials.apiKey, }); const params: any = { q: query, cx: credentials.engineId, num: options.num || 10, }; if (options.siteSearch) { params.siteSearch = options.siteSearch; } if (options.dateRestrict) { params.dateRestrict = options.dateRestrict; } if (options.fileType) { params.fileType = options.fileType; } if (options.safe) { params.safe = options.safe; } if (options.sort) { params.sort = options.sort; } if (options.start) { params.start = options.start; } try { const response = await customSearch.cse.list(params); const results: SearchResult[] = []; if (response.data.items) { response.data.items.forEach((item: any, index: number) => { const result: SearchResult = { title: item.title || "", link: item.link || "", snippet: item.snippet || "", position: item.position || index + 1, displayLink: item.displayLink || "", }; // Extract thumbnail if available if (item.pagemap?.cse_thumbnail?.[0]?.src) { result.image = item.pagemap.cse_thumbnail[0].src; } results.push(result); }); } return results; } catch (error: any) { this.handleCseError(error); } } /** * Legacy search method - will be removed after migration * For now, throws error to force migration to per-user search */ async search( _query: string, _options: SearchOptions = {}, ): Promise { throw new SearchServiceError( "DEPRECATED_METHOD", "The search() method is deprecated. Use searchForUser(userId, query, options) instead.", ); } /** * Legacy searchWithPagination - will be removed after migration */ async searchWithPagination( _query: string, _page: number = 1, _resultsPerPage: number = 10, ): Promise { throw new SearchServiceError( "DEPRECATED_METHOD", "The searchWithPagination() method is deprecated. Use searchForUser(userId, query, { start: ... }) instead.", ); } /** * Parse Google CSE API errors and provide meaningful messages */ private parseCseError(error: any): { code: string; message: string; details?: any; } { // Handle Google API error structure if (error.response?.data?.error) { const apiError = error.response.data.error; const statusCode = error.response.status; switch (statusCode) { case 401: return { code: "UNAUTHORIZED", message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`, details: { statusCode, apiError }, }; case 403: return { code: "FORBIDDEN", message: `403 Forbidden - ${apiError.message || "Access denied"}. Check your Engine ID and API key permissions.`, details: { statusCode, apiError }, }; case 429: const retryAfter = error.response.headers?.["retry-after"]; return { code: "RATE_LIMIT_EXCEEDED", message: `429 Too Many Requests - Rate limit exceeded. ${retryAfter ? `Retry after: ${retryAfter} seconds.` : ""} ${apiError.message || ""}`, details: { statusCode, apiError, retryAfter }, }; default: return { code: `HTTP_${statusCode}`, message: `HTTP ${statusCode} - ${apiError.message || "Unknown error"}`, details: { statusCode, apiError }, }; } } // Handle network errors if (error.code === "ENOTFOUND") { return { code: "NETWORK_ERROR", message: "Network error - Unable to reach Google API", details: { code: error.code }, }; } // Generic error return { code: error.code || "UNKNOWN_ERROR", message: error.message || "An unknown error occurred", details: error, }; } /** * Handle CSE errors and throw appropriate SearchServiceError */ private handleCseError(error: any): never { const errorInfo = this.parseCseError(error); throw new SearchServiceError( errorInfo.code, errorInfo.message, errorInfo.details, ); } } export default new SearchService();