From acb06a5840ec4e384398c764bd272dc0505a9a59 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Wed, 27 Aug 2025 14:39:52 +0530 Subject: [PATCH] fix: search --- .../eid-wallet.xcodeproj/project.pbxproj | 4 +- .../gen/apple/eid-wallet_iOS/Info.plist | 2 +- .../src/controllers/UserController.ts | 91 +++++++++- platforms/pictique-api/src/index.ts | 2 + .../pictique-api/src/services/UserService.ts | 170 ++++++++++++++++-- 5 files changed, 245 insertions(+), 24 deletions(-) diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj index 008de526..87b68c06 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet.xcodeproj/project.pbxproj @@ -388,7 +388,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0.2.1.1; + CURRENT_PROJECT_VERSION = 0.2.1.2; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; @@ -436,7 +436,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = "eid-wallet_iOS/eid-wallet_iOS.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 0.2.1.1; + CURRENT_PROJECT_VERSION = 0.2.1.2; DEVELOPMENT_TEAM = M49C8XS835; ENABLE_BITCODE = NO; "EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64; diff --git a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist index 12652f76..7d7a310a 100644 --- a/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist +++ b/infrastructure/eid-wallet/src-tauri/gen/apple/eid-wallet_iOS/Info.plist @@ -28,7 +28,7 @@ CFBundleVersion - 0.2.1.1 + 0.2.1.2 LSRequiresIPhoneOS NSAppTransportSecurity diff --git a/platforms/pictique-api/src/controllers/UserController.ts b/platforms/pictique-api/src/controllers/UserController.ts index 4c98b8fa..2997c071 100644 --- a/platforms/pictique-api/src/controllers/UserController.ts +++ b/platforms/pictique-api/src/controllers/UserController.ts @@ -34,7 +34,7 @@ export class UserController { search = async (req: Request, res: Response) => { try { - const { q } = req.query; + const { q, page = "1", limit = "10", verified, sort = "relevance" } = req.query; if (!q || typeof q !== "string") { return res @@ -42,14 +42,99 @@ export class UserController { .json({ error: "Search query is required" }); } - const users = await this.userService.searchUsers(q); - res.json(users); + // Validate search query length + if (q.trim().length < 2) { + return res + .status(400) + .json({ error: "Search query must be at least 2 characters long" }); + } + + // Parse and validate pagination parameters + const pageNum = parseInt(page as string) || 1; + const limitNum = Math.min(parseInt(limit as string) || 10, 50); // Cap at 50 results + + if (pageNum < 1 || limitNum < 1) { + return res + .status(400) + .json({ error: "Invalid pagination parameters" }); + } + + // Parse verified filter + const verifiedOnly = verified === "true"; + + // Validate sort parameter + const validSortOptions = ["relevance", "name", "verified", "newest"]; + const sortOption = validSortOptions.includes(sort as string) ? sort as string : "relevance"; + + // Get users and count in parallel + const [users, total] = await Promise.all([ + this.userService.searchUsers(q, pageNum, limitNum, verifiedOnly, sortOption), + this.userService.getSearchUsersCount(q, verifiedOnly) + ]); + + // Calculate pagination metadata + const totalPages = Math.ceil(total / limitNum); + const hasNextPage = pageNum < totalPages; + const hasPrevPage = pageNum > 1; + + res.json({ + users, + pagination: { + page: pageNum, + limit: limitNum, + total, + totalPages, + hasNextPage, + hasPrevPage + }, + searchInfo: { + query: q, + verifiedOnly, + sortBy: sortOption + } + }); } catch (error) { console.error("Error searching users:", error); res.status(500).json({ error: "Internal server error" }); } }; + getSearchSuggestions = async (req: Request, res: Response) => { + try { + const { q, limit = "5" } = req.query; + + if (!q || typeof q !== "string") { + return res + .status(400) + .json({ error: "Search query is required" }); + } + + // Parse limit parameter + const limitNum = Math.min(parseInt(limit as string) || 5, 20); // Cap at 20 suggestions + + const suggestions = await this.userService.getSearchSuggestions(q, limitNum); + res.json({ suggestions }); + } catch (error) { + console.error("Error getting search suggestions:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getPopularSearches = async (req: Request, res: Response) => { + try { + const { limit = "10" } = req.query; + + // Parse limit parameter + const limitNum = Math.min(parseInt(limit as string) || 10, 50); // Cap at 50 results + + const popularSearches = await this.userService.getPopularSearches(limitNum); + res.json({ popularSearches }); + } catch (error) { + console.error("Error getting popular searches:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + follow = async (req: Request, res: Response) => { try { const followerId = req.user?.id; diff --git a/platforms/pictique-api/src/index.ts b/platforms/pictique-api/src/index.ts index c0ab28b3..8d306fae 100644 --- a/platforms/pictique-api/src/index.ts +++ b/platforms/pictique-api/src/index.ts @@ -128,6 +128,8 @@ app.get( // User routes app.get("/api/users", userController.currentUser); app.get("/api/users/search", userController.search); +app.get("/api/users/suggestions", userController.getSearchSuggestions); +app.get("/api/users/popular", userController.getPopularSearches); app.post("/api/users/:id/follow", authGuard, userController.follow); app.get("/api/users/:id", authGuard, userController.getProfileById); app.patch("/api/users", authGuard, userController.updateProfile); diff --git a/platforms/pictique-api/src/services/UserService.ts b/platforms/pictique-api/src/services/UserService.ts index daab306b..8afe6bad 100644 --- a/platforms/pictique-api/src/services/UserService.ts +++ b/platforms/pictique-api/src/services/UserService.ts @@ -2,7 +2,7 @@ import { AppDataSource } from "../database/data-source"; import { User } from "../database/entities/User"; import { Post } from "../database/entities/Post"; import { signToken } from "../utils/jwt"; -import { Like } from "typeorm"; +import { Like, Raw } from "typeorm"; export class UserService { userRepository = AppDataSource.getRepository(User); @@ -38,24 +38,158 @@ export class UserService { return await this.userRepository.findOneBy({ id }); } - searchUsers = async (query: string) => { - const searchQuery = query; + searchUsers = async ( + query: string, + page: number = 1, + limit: number = 10, + verifiedOnly: boolean = false, + sortBy: string = "relevance" + ) => { + // Sanitize and trim the search query + const searchQuery = query.trim(); + + // Return empty array if query is too short or empty + if (searchQuery.length < 2) { + return []; + } - return this.userRepository.find({ - where: [ - { name: Like(`%${searchQuery}%`) }, - { ename: Like(`%${searchQuery}%`) }, - ], - select: { - id: true, - handle: true, - name: true, - description: true, - avatarUrl: true, - isVerified: true, - }, - take: 10, - }); + // Validate pagination parameters + if (page < 1 || limit < 1 || limit > 100) { + return []; + } + + // Use query builder for more complex queries + const queryBuilder = this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.handle", + "user.name", + "user.description", + "user.avatarUrl", + "user.isVerified" + ]) + .where( + "user.name ILIKE :query OR user.ename ILIKE :query OR user.handle ILIKE :query OR user.description ILIKE :query OR user.name ILIKE :fuzzyQuery OR user.ename ILIKE :fuzzyQuery OR user.handle ILIKE :fuzzyQuery", + { + query: `%${searchQuery}%`, + fuzzyQuery: `%${searchQuery.split('').join('%')}%` // Fuzzy search with wildcards between characters + } + ); + + // Add verified filter if requested + if (verifiedOnly) { + queryBuilder.andWhere("user.isVerified = :verified", { verified: true }); + } + + // Add additional filters for better results + queryBuilder.andWhere("user.isArchived = :archived", { archived: false }); + + // Apply sorting based on sortBy parameter + switch (sortBy) { + case "name": + queryBuilder.orderBy("user.name", "ASC"); + break; + case "verified": + queryBuilder.orderBy("user.isVerified", "DESC").addOrderBy("user.name", "ASC"); + break; + case "newest": + queryBuilder.orderBy("user.createdAt", "DESC"); + break; + case "relevance": + default: + // Default relevance sorting: verified first, then by name + queryBuilder.orderBy("user.isVerified", "DESC").addOrderBy("user.name", "ASC"); + break; + } + + return queryBuilder + .skip((page - 1) * limit) + .take(limit) + .getMany(); + }; + + getSearchUsersCount = async ( + query: string, + verifiedOnly: boolean = false + ) => { + // Sanitize and trim the search query + const searchQuery = query.trim(); + + // Return 0 if query is too short or empty + if (searchQuery.length < 2) { + return 0; + } + + // Use query builder for count + const queryBuilder = this.userRepository + .createQueryBuilder("user") + .where( + "user.name ILIKE :query OR user.ename ILIKE :query OR user.handle ILIKE :query OR user.description ILIKE :query OR user.name ILIKE :fuzzyQuery OR user.ename ILIKE :fuzzyQuery OR user.handle ILIKE :fuzzyQuery", + { + query: `%${searchQuery}%`, + fuzzyQuery: `%${searchQuery.split('').join('%')}%` // Fuzzy search with wildcards between characters + } + ); + + // Add verified filter if requested + if (verifiedOnly) { + queryBuilder.andWhere("user.isVerified = :verified", { verified: true }); + } + + // Add additional filters for consistency + queryBuilder.andWhere("user.isArchived = :archived", { archived: false }); + + return queryBuilder.getCount(); + }; + + getSearchSuggestions = async (query: string, limit: number = 5) => { + // Sanitize and trim the search query + const searchQuery = query.trim(); + + // Return empty array if query is too short + if (searchQuery.length < 1) { + return []; + } + + // Use query builder for suggestions + const queryBuilder = this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.handle", + "user.name", + "user.ename" + ]) + .where( + "user.name ILIKE :query OR user.ename ILIKE :query OR user.handle ILIKE :query", + { query: `%${searchQuery}%` } + ) + .andWhere("user.isArchived = :archived", { archived: false }) + .orderBy("user.isVerified", "DESC") + .addOrderBy("user.name", "ASC") + .take(limit); + + return queryBuilder.getMany(); + }; + + getPopularSearches = async (limit: number = 10) => { + // This could be enhanced with actual search analytics in the future + // For now, return some sample popular searches based on verified users + return this.userRepository + .createQueryBuilder("user") + .select([ + "user.id", + "user.handle", + "user.name", + "user.ename" + ]) + .where("user.isVerified = :verified", { verified: true }) + .andWhere("user.isArchived = :archived", { archived: false }) + .andWhere("user.name IS NOT NULL") + .orderBy("user.name", "ASC") + .take(limit) + .getMany(); }; followUser = async (followerId: string, followingId: string) => {