326 lines
8.1 KiB
TypeScript
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();
|