Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ FIREBASE_MESSAGING_SENDER_ID=...
FIREBASE_APP_ID=...
FIREBASE_MEASUREMENT_ID=...

# Database Configuration Variables
DB_HOST=...
DB_PORT=...
DB_USER=...
DB_PASSWORD=...
DB_DATABASE=...

# OAuth
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
Expand Down
6 changes: 4 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
"@types/express": "^4.17.22",
"@types/formidable": "^3.4.5",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.7",
"@types/ms": "^2.1.0",
"@types/pg": "^8.15.4",
"eslint": "^9.13.0",
"globals": "^15.11.0",
"jest": "^29.7.0",
Expand All @@ -38,7 +40,6 @@
"@aws-sdk/s3-request-presigner": "^3.816.0",
"@google/generative-ai": "^0.24.1",
"@langchain/core": "^0.3.57",
"@types/jsonwebtoken": "^9.0.7",
"algoliasearch": "^4.24.0",
"axios": "^1.9.0",
"compression": "^1.8.0",
Expand All @@ -52,7 +53,8 @@
"formidable": "^3.5.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"stripe": "^18.1.1",
"pg": "^8.16.0",
"stripe": "^18.2.1",
"winston": "^3.17.0",
"zod": "^3.24.4"
}
Expand Down
53 changes: 15 additions & 38 deletions backend/src/modules/search/controllers/search.controller.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,40 @@
import { Request, Response } from "express";
import SearchService from "../services/search.service";
import { logger } from "../../../shared/utils/logger";
import { searchType } from "../types";


export class SearchController {
/**
* @description
* Fetches 5 users at a time where their username matches or starts with the text
* provided to the search query. If a starting point is provided, the search query
* starts from the provided starting point.
* Searches for users and/or content that matches the provided search text.
*
* @param req - Express request object.
* @param res - Express response object.
*/
static async searchUsers(req: Request, res: Response) {
static async search(req: Request, res: Response) {
const searchText = req.query.searchText as string;
const userStartingPoint = req.query.userStartingPoint as string;
const searchType = req.query.searchType as searchType;
const limit = parseInt(req.query.limit as string);
const offset = parseInt(req.query.offset as string);
Comment on lines +17 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add validation for numeric parameters.

The limit and offset parameters should be validated to ensure they are valid numbers and within reasonable bounds.

  const searchText = req.query.searchText as string;
  const searchType = req.query.searchType as searchType;
-  const limit = parseInt(req.query.limit as string);
-  const offset = parseInt(req.query.offset as string);
+  const limit = parseInt(req.query.limit as string) || 5;
+  const offset = parseInt(req.query.offset as string) || 0;
+  
+  // Validate parameters
+  if (limit <= 0 || limit > 100) {
+    return res.status(400).json({ error: "Limit must be between 1 and 100" });
+  }
+  if (offset < 0) {
+    return res.status(400).json({ error: "Offset must be non-negative" });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const searchType = req.query.searchType as searchType;
const limit = parseInt(req.query.limit as string);
const offset = parseInt(req.query.offset as string);
const searchText = req.query.searchText as string;
const searchType = req.query.searchType as searchType;
const limit = parseInt(req.query.limit as string) || 5;
const offset = parseInt(req.query.offset as string) || 0;
// Validate parameters
if (limit <= 0 || limit > 100) {
return res.status(400).json({ error: "Limit must be between 1 and 100" });
}
if (offset < 0) {
return res.status(400).json({ error: "Offset must be non-negative" });
}
🤖 Prompt for AI Agents
In backend/src/modules/search/controllers/search.controller.ts around lines 17
to 19, the limit and offset parameters are parsed from the query without
validation. Add checks to ensure that limit and offset are valid numbers and
fall within acceptable ranges (e.g., limit should be a positive integer within a
maximum allowed value, offset should be a non-negative integer). If validation
fails, handle the error appropriately, such as returning a 400 Bad Request
response with a clear error message.


logger.info(`Searching for users that match the following: ${searchText}`);
logger.info(`Searching for users and content that match the following: ${searchText}`);
try {
const response = await SearchService.searchUsers(
const response = await SearchService.search(
searchText,
userStartingPoint
searchType,
limit,
offset,
);
res.status(200).json(response);
} catch (error) {
if (error instanceof Error) {
logger.error(`Error searching users: ${error.message}`);
logger.error(`Error searching for ${searchType}: ${error.message}`);
} else {
logger.error(`Error searching users: ${String(error)}`);
logger.error(`Error searching for ${searchType}: ${error}`);
}
res.status(500).json({ error: "Failed to search users" });
}
}

/**
* @description
* Fetches 5 content items at a time where their title matches or starts with the
* text provided to the search query.
*
* @param req - Express request object.
* @param res - Express response object.
*/
static async searchContents(req: Request, res: Response) {
const searchText = req.query.searchText as string;
logger.info(
`Searching for content that matches the following: ${searchText}`
);

try {
const response = await SearchService.searchContents(searchText);
res.status(200).json(response);
} catch (error) {
if (error instanceof Error) {
logger.error(`Error searching content: ${error.message}`);
} else {
logger.error(`Error searching content: ${String(error)}`);
}
res.status(500).json({ error: "Failed to search content" });
res.status(500).json({error: "Failed to search users"});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Update error message to be generic.

The error message is still specific to users but should reflect the unified search functionality.

-      res.status(500).json({error: "Failed to search users"});
+      res.status(500).json({error: "Failed to perform search"});
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
res.status(500).json({error: "Failed to search users"});
res.status(500).json({error: "Failed to perform search"});
🤖 Prompt for AI Agents
In backend/src/modules/search/controllers/search.controller.ts at line 36, the
error message "Failed to search users" is too specific and should be updated to
a generic message reflecting the unified search functionality. Change the error
message to something like "Failed to perform search" to cover all search types
uniformly.

}
}
}

65 changes: 65 additions & 0 deletions backend/src/modules/search/repository/search.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { logger } from "../../../shared/utils/logger";
import { query } from '../../../shared/database/postgres.connector';
import { User, Content } from '../types';

/**
* Searches the 'users' table for a username.
*/
export async function findUsers(searchText: string, limit: number, offset: number): Promise<User[]> {
const sql = `
SELECT
user_id,
username,
first_name,
last_name,
profile_image
FROM users
WHERE
username ILIKE $1 OR
first_name ILIKE $1 OR
last_name ILIKE $1
ORDER BY first_name, last_name
LIMIT $2 OFFSET $3;
`;
const params = [`%${searchText}%`, limit, offset];

try {
const result = await query(sql, params);
return result.rows;
} catch (err) {
logger.error('Error executing user search query:', err);
throw new Error('Database query for users failed.');
}
}

/**
* Searches the 'content' table.
* It uses the 'summary' column for the preview and searches title and content body.
*/
export async function findContent(searchText: string, limit: number, offset: number): Promise<Content[]> {
const sql = `
SELECT
c.content_id,
u.username,
u.first_name,
u.last_name,
u.profile_image,
c.title,
COALESCE(c.summary, LEFT(c.content, 150)) as summary,
c.date_created
FROM content c
JOIN users u ON c.creator_id = u.user_id
WHERE c.title ILIKE $1 OR c.content ILIKE $1
ORDER BY c.date_created DESC
LIMIT $2 OFFSET $3;
`;
const params = [`%${searchText}%`, limit, offset];

try {
const result = await query(sql, params);
return result.rows;
} catch (err) {
logger.error('Error executing content search query:', err);
throw new Error('Database query for content failed.');
}
}
3 changes: 1 addition & 2 deletions backend/src/modules/search/routes/search.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Router } from "express";
import { SearchController } from "../controllers/search.controller";
const searchRoutes = Router();

searchRoutes.get("/users", SearchController.searchUsers);
searchRoutes.get("/contents", SearchController.searchContents);
searchRoutes.get("/", SearchController.search);

export default searchRoutes;
157 changes: 36 additions & 121 deletions backend/src/modules/search/services/search.service.ts
Original file line number Diff line number Diff line change
@@ -1,141 +1,56 @@
import algoliasearch from "algoliasearch";
import { db } from "../../../shared/config/firebase.config";
import {
collection,
query,
getDocs,
where,
orderBy,
limit,
startAfter,
doc,
getDoc,
} from "firebase/firestore";
import { logger } from "../../../shared/utils/logger";
import { User } from "../../user/models/user.model";
import { SearchResponse, searchType, User, Content } from "../types";
import * as searchRepository from '../repository/search.repository';

export class SearchService {
private static algoliaClient: ReturnType<typeof algoliasearch> | null = null;
private static readonly ALGOLIA_INDEX_NAME = process.env
.ALGOLIA_INDEX_NAME as string;

private static getAlgoliaClient() {
if (!SearchService.algoliaClient) {
const ALGOLIA_APP_ID = process.env.ALGOLIA_APP_ID as string;
const ALGOLIA_ADMIN_KEY = process.env.ALGOLIA_API_KEY as string;
SearchService.algoliaClient = algoliasearch(
ALGOLIA_APP_ID,
ALGOLIA_ADMIN_KEY
);
}
return SearchService.algoliaClient;
}
export class SearchService {

/**
* searchUsers(searchText: string, startingPoint:string)
* search(searchText: string, searchType: searchType, limit: number, offset: number)
*
* @description
* Fetches 5 users at a time where their username matches or starts with the text provided to the search query.
* If a starting point is provided, the search query starts from the provided starting point.
* Searches for users and/or content by calling the data repository.
* It orchestrates calls for different search types.
*
* @param searchText - The text to search for in the username.
* @param startingPoint - The starting point for the search query.
* @returns An array of users matching the search query.
* @param searchText - The text to search for.
* @param searchType - The type of search to perform (users, content, or all).
* @param limit - The maximum number of results to return per type.
* @param offset - The starting point of the search (used for pagination).
*/
static async searchUsers(
static async search(
searchText: string,
startingPoint: string | null = null
) {
logger.info(`Searching for users that match the following: ${searchText}`);
const userRef = collection(db, "users");
const limitNumber: number = 5;

// Build the base query
const baseQuery = query(
userRef,
where("usernameLower", ">=", searchText.toLowerCase()),
where("usernameLower", "<=", searchText.toLowerCase() + "\uf8ff"),
orderBy("usernameLower"),
limit(limitNumber)
);

// If a starting point is provided, add it to the query
const finalQuery = startingPoint
? query(baseQuery, startAfter(startingPoint))
: baseQuery;

// Execute the query
const results = await getDocs(finalQuery);

const users = results.docs.map((doc) => doc.data() as User);

// Determine the next starting point
const nextStartingPoint =
users.length >= limitNumber
? results.docs[results.docs.length - 1]?.data().usernameLower
: null;

logger.info(`Next starting point: ${nextStartingPoint}`);
return { users, nextStartingPoint };
}

/**
* searchContent(searchText: string, startingPoint:string)
*
* @description
* Fetches 5 items at a time where their titles match or start with the text provided to the search query.
* If a starting point is provided, the search query starts from the provided starting point.
*
* @param searchText - Text to search for
* @returns - Object containing the documents and next starting point
* @throws - Error if search fails, i.e if the search query fails
*/
static async searchContents(searchText: string) {
if (!searchText) {
return { documents: [], nextStartingPoint: null };
}

const client = SearchService.getAlgoliaClient();
const index = client.initIndex(this.ALGOLIA_INDEX_NAME);

searchType: searchType = "all",
limit: number = 5,
offset: number = 0,
): Promise<SearchResponse> {
try {
const { hits } = await index.search(searchText);
const promises = [];

logger.info(
`Algolia search results length: ${hits.length}, hits: ${JSON.stringify(
hits
)}`
);
// Add promises to the array based on the searchType,
if (searchType === 'users' || searchType === 'all') {
promises.push(searchRepository.findUsers(searchText, limit, offset));
} else {
promises.push(Promise.resolve([])); // Add empty promise to maintain structure
}

// Fetch corresponding Firebase content documents
const firebaseContents = await Promise.all(
hits.map(async (hit) => {
const docRef = doc(db, "contents", hit.objectID);
const docSnap = await getDoc(docRef);
if (searchType === 'content' || searchType === 'all') {
promises.push(searchRepository.findContent(searchText, limit, offset));
} else {
promises.push(Promise.resolve([])); // Add empty promise to maintain structure
}

if (docSnap.exists()) {
return {
id: docSnap.id,
...docSnap.data(),
searchRanking: hit._rankingInfo?.nbTypos ?? 0,
};
}
return null;
})
);

// Filter out any null values (in case some documents weren't found in Firebase)
const contents = firebaseContents.filter(
(content): content is NonNullable<typeof content> => content !== null
);
// Execute all database queries in parallel for efficiency
const [users, content] = await Promise.all(promises) as [User[], Content[]];

return {
contents,
nextStartingPoint: null, // Placeholder for future pagination logic
users,
content,
};
} catch (err) {
logger.error(`Failed to search for contents, error: ${err}`);
throw new Error(`Failed to search for contents, error: ${err}`);

} catch (error) {
logger.error(`Error in SearchService for ${searchType}: ${error}`);
// Re-throw the error to be caught by a global error handler or the controller
throw error;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions backend/src/modules/search/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './search.types';
Loading