gadget/docs/archive/services/search.ts

326 lines
8.1 KiB
TypeScript

// 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<void> {
this.log.info("service started");
}
async stop(): Promise<void> {
this.log.info("service stopped");
}
/**
* Check if a user has Google CSE credentials configured
*/
async userHasCseConfigured(userId: string): Promise<boolean> {
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<IGoogleCseConnection | null> {
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<SearchResult[]> {
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<SearchResult[]> {
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<SearchResult[]> {
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<SearchResult[]> {
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();