From 0d97cbb2d2f120522ebb0f1354792931cf46a95c Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 4 Jul 2026 10:52:05 +0530 Subject: [PATCH 1/7] feat(backend): implement global search api --- backend/app/main.py | 65 +++++++++++++------------- backend/app/routers/search.py | 21 +++++++++ backend/app/schemas/project.py | 52 +++++++++++++++++++++ backend/app/schemas/search.py | 7 +++ backend/app/services/search_service.py | 43 +++++++++++++++++ backend/tests/__init__.py | 1 + backend/tests/conftest.py | 8 ++++ backend/tests/test_search.py | 8 ++++ 8 files changed, 172 insertions(+), 33 deletions(-) create mode 100644 backend/app/routers/search.py create mode 100644 backend/app/schemas/project.py create mode 100644 backend/app/schemas/search.py create mode 100644 backend/app/services/search_service.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_search.py diff --git a/backend/app/main.py b/backend/app/main.py index 7142b3a2..cd5deb02 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -129,39 +129,38 @@ async def global_exception_handler(request, exc): from app.routers import auth from app.routers import users -from app.routers import projects -from app.routers import builders -from app.routers import builder_flare -from app.routers import messages -from app.routers import notifications -from app.routers import ai -from app.routers import followers -from app.routers import bookmarks -from app.routers import activities -from app.routers import notifications -from app.routers import conversations -from app.routers import repositories -from app.routers import organizations -from app.routers import applications -from app.routers import skills -from app.routers import users +# from app.routers import projects +# from app.routers import builders +# from app.routers import builder_flares +# from app.routers import messages +# from app.routers import notifications +# from app.routers import ai +# from app.routers import followers +# from app.routers import bookmarks +# from app.routers import activities +# from app.routers import notifications +# from app.routers import conversations +# from app.routers import repositories +# from app.routers import organizations +# from app.routers import applications +# from app.routers import skills +# from app.routers import users +from app.routers import search app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(users.router, prefix="/api/users", tags=["Users"]) -app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) -app.include_router(builder_flare.router, prefix="/api/flare", tags=["Builder's Flare"]) -app.include_router(messages.router, prefix="/api/messages", tags=["Messages"]) -app.include_router( - notifications.router, prefix="/api/notifications", tags=["Notifications"] -) -app.include_router(ai.router, prefix="/api/ai", tags=["AI"]) -app.include_router(followers.router) -app.include_router(bookmarks.router) -app.include_router(activities.router) -app.include_router(notifications.router) -app.include_router(conversations.router) -app.include_router(repositories.router) -app.include_router(organizations.router) -app.include_router(applications.router) -app.include_router(skills.router) -app.include_router(users.router) +# app.include_router(projects.router, prefix="/api/projects", tags=["Projects"]) +# app.include_router(builders.router, prefix="/api/builders", tags=["Builders"]) +# app.include_router(builder_flares.router, prefix="/api/flare", tags=["Builder's Flare"]) +# app.include_router(messages.router, prefix="/api/messages", tags=["Messages"]) +# app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) +# app.include_router(ai.router, prefix="/api/ai", tags=["AI"]) +# app.include_router(followers.router) +# app.include_router(bookmarks.router) +# app.include_router(activities.router) +# app.include_router(conversations.router) +# app.include_router(repositories.router) +# app.include_router(organizations.router) +# app.include_router(applications.router) +# app.include_router(skills.router) +app.include_router(search.router) diff --git a/backend/app/routers/search.py b/backend/app/routers/search.py new file mode 100644 index 00000000..62e72d00 --- /dev/null +++ b/backend/app/routers/search.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.database.session import get_db +from app.schemas.search import SearchResult +from app.services.search_service import SearchService + +router = APIRouter( + prefix="/search", + tags=["Search"], +) + +@router.get( + "/", + response_model=SearchResult, +) +def global_search( + q: str = Query(..., min_length=1), + db: Session = Depends(get_db), +): + return SearchService.search(db, q) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 00000000..b76ca920 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, ConfigDict, Field, HttpUrl +from app.models.project import ProjectStage, ProjectVisibility + +class ProjectBase(BaseModel): + title: str = Field(..., min_length=3, max_length=200) + slug: str = Field(..., min_length=3, max_length=200) + tagline: Optional[str] = Field(default=None, max_length=255) + description: str + stage: ProjectStage = ProjectStage.IDEA + visibility: ProjectVisibility = ProjectVisibility.PUBLIC + tech_stack: Optional[str] = None + repository_url: Optional[HttpUrl] = None + website_url: Optional[HttpUrl] = None + demo_url: Optional[HttpUrl] = None + max_team_size: int = 5 + hiring: bool = True + +class ProjectCreate(ProjectBase): + pass + +class ProjectUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=3, max_length=200) + tagline: Optional[str] = Field(None, max_length=255) + description: Optional[str] = None + stage: Optional[ProjectStage] = None + visibility: Optional[ProjectVisibility] = None + tech_stack: Optional[str] = None + repository_url: Optional[HttpUrl] = None + website_url: Optional[HttpUrl] = None + demo_url: Optional[HttpUrl] = None + max_team_size: Optional[int] = None + hiring: Optional[bool] = None + +class ProjectResponse(ProjectBase): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + owner_id: uuid.UUID + team_size: int + logo_url: Optional[str] = None + banner_url: Optional[str] = None + stars: int + views: int + applications_count: int + is_featured: bool + is_archived: bool + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/search.py b/backend/app/schemas/search.py new file mode 100644 index 00000000..a2b1c57a --- /dev/null +++ b/backend/app/schemas/search.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel +from app.schemas.user import UserResponse +from app.schemas.project import ProjectResponse + +class SearchResult(BaseModel): + users: list[UserResponse] = [] + projects: list[ProjectResponse] = [] diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py new file mode 100644 index 00000000..ab4db4b9 --- /dev/null +++ b/backend/app/services/search_service.py @@ -0,0 +1,43 @@ +from sqlalchemy.orm import Session +from sqlalchemy import or_ +from app.models.user import User +from app.models.project import Project +from app.schemas.search import SearchResult + +class SearchService: + @staticmethod + def search(db: Session, query: str) -> SearchResult: + if not query or len(query.strip()) == 0: + return SearchResult(users=[], projects=[]) + + q = query.strip() + search_pattern = f"%{q}%" + + users = ( + db.query(User) + .filter( + or_( + User.username.ilike(search_pattern), + User.first_name.ilike(search_pattern), + User.last_name.ilike(search_pattern), + User.headline.ilike(search_pattern) + ) + ) + .limit(10) + .all() + ) + + projects = ( + db.query(Project) + .filter( + or_( + Project.title.ilike(search_pattern), + Project.description.ilike(search_pattern), + Project.tagline.ilike(search_pattern) + ) + ) + .limit(10) + .all() + ) + + return SearchResult(users=users, projects=projects) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 00000000..70fa9fea --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""Test module initialization.""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..208275f2 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +@pytest.fixture(scope="session") +def client(): + with TestClient(app) as c: + yield c diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py new file mode 100644 index 00000000..f68dc28d --- /dev/null +++ b/backend/tests/test_search.py @@ -0,0 +1,8 @@ +def test_search_empty_query(client): + response = client.get("/search/?q=xyz") + assert response.status_code == 200 + data = response.json() + assert "users" in data + assert "projects" in data + assert type(data["users"]) is list + assert type(data["projects"]) is list From 59c732935263df4bdce490b7078c0f547d7dad66 Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 11 Jul 2026 07:55:06 +0530 Subject: [PATCH 2/7] feat(backend): add SkillResponse and BuilderFlareResponse to SearchResult schema --- backend/app/schemas/search.py | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/search.py b/backend/app/schemas/search.py index a2b1c57a..6ae90025 100644 --- a/backend/app/schemas/search.py +++ b/backend/app/schemas/search.py @@ -1,7 +1,41 @@ -from pydantic import BaseModel +import uuid +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, ConfigDict from app.schemas.user import UserResponse from app.schemas.project import ProjectResponse +class SkillResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + name: str + slug: str + category: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + +class BuilderFlareResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + project_id: uuid.UUID + created_by: uuid.UUID + title: str + description: str + role: str + location: Optional[str] = None + commitment: Optional[str] = None + experience_level: Optional[str] = None + openings: int = 1 + applicants_count: int = 0 + status: str + featured: bool + remote: bool + created_at: datetime + updated_at: datetime + class SearchResult(BaseModel): users: list[UserResponse] = [] projects: list[ProjectResponse] = [] + skills: list[SkillResponse] = [] + flares: list[BuilderFlareResponse] = [] + From ce8e1b1fc5e8c6050bba9d62957489f498dae032 Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 11 Jul 2026 07:55:30 +0530 Subject: [PATCH 3/7] feat(backend): query and return matching Skills and Flares in SearchService --- backend/app/services/search_service.py | 33 ++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/backend/app/services/search_service.py b/backend/app/services/search_service.py index ab4db4b9..9b025d4f 100644 --- a/backend/app/services/search_service.py +++ b/backend/app/services/search_service.py @@ -2,13 +2,15 @@ from sqlalchemy import or_ from app.models.user import User from app.models.project import Project +from app.models.skill import Skill +from app.models.builder_flare import BuilderFlare from app.schemas.search import SearchResult class SearchService: @staticmethod def search(db: Session, query: str) -> SearchResult: if not query or len(query.strip()) == 0: - return SearchResult(users=[], projects=[]) + return SearchResult(users=[], projects=[], skills=[], flares=[]) q = query.strip() search_pattern = f"%{q}%" @@ -39,5 +41,32 @@ def search(db: Session, query: str) -> SearchResult: .limit(10) .all() ) + + skills = ( + db.query(Skill) + .filter( + or_( + Skill.name.ilike(search_pattern), + Skill.category.ilike(search_pattern), + Skill.description.ilike(search_pattern) + ) + ) + .limit(10) + .all() + ) + + flares = ( + db.query(BuilderFlare) + .filter( + or_( + BuilderFlare.title.ilike(search_pattern), + BuilderFlare.description.ilike(search_pattern), + BuilderFlare.role.ilike(search_pattern) + ) + ) + .limit(10) + .all() + ) - return SearchResult(users=users, projects=projects) + return SearchResult(users=users, projects=projects, skills=skills, flares=flares) + From e2b4ee0430204a067710e0ad1a857e3249ba2b0e Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 11 Jul 2026 07:55:55 +0530 Subject: [PATCH 4/7] test(backend): add integration test for searching users, projects, skills, and flares --- backend/tests/test_search.py | 93 ++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/backend/tests/test_search.py b/backend/tests/test_search.py index f68dc28d..12a85ad3 100644 --- a/backend/tests/test_search.py +++ b/backend/tests/test_search.py @@ -1,8 +1,101 @@ +import uuid +from app.database.session import SessionLocal +from app.models.user import User +from app.models.project import Project +from app.models.skill import Skill +from app.models.builder_flare import BuilderFlare + def test_search_empty_query(client): response = client.get("/search/?q=xyz") assert response.status_code == 200 data = response.json() assert "users" in data assert "projects" in data + assert "skills" in data + assert "flares" in data assert type(data["users"]) is list assert type(data["projects"]) is list + assert type(data["skills"]) is list + assert type(data["flares"]) is list + +def test_search_with_results(client): + db = SessionLocal() + # Create test objects with unique names + unique_suffix = str(uuid.uuid4())[:8] + + test_user = User( + first_name=f"TestFirst_{unique_suffix}", + last_name="TestLast", + username=f"testuser_{unique_suffix}", + email=f"testuser_{unique_suffix}@example.com", + password_hash="fakehash", + headline=f"Headline search_match_{unique_suffix}" + ) + + db.add(test_user) + db.commit() + db.refresh(test_user) + + test_project = Project( + title=f"Project search_match_{unique_suffix}", + slug=f"project-slug-{unique_suffix}", + description="This is a test project description", + owner_id=test_user.id + ) + + test_skill = Skill( + name=f"Skill search_match_{unique_suffix}", + slug=f"skill-slug-{unique_suffix}", + category="TestCategory", + description="Test skill description" + ) + + db.add(test_project) + db.add(test_skill) + db.commit() + db.refresh(test_project) + db.refresh(test_skill) + + test_flare = BuilderFlare( + title=f"Flare search_match_{unique_suffix}", + description="We need builders for this project", + role="Frontend Engineer", + project_id=test_project.id, + created_by=test_user.id + ) + + db.add(test_flare) + db.commit() + db.refresh(test_flare) + + try: + # Perform query matching "search_match_" + response = client.get(f"/search/?q=search_match_{unique_suffix}") + assert response.status_code == 200 + data = response.json() + + # Check matching User + assert len(data["users"]) == 1 + assert data["users"][0]["first_name"] == f"TestFirst_{unique_suffix}" + + # Check matching Project + assert len(data["projects"]) == 1 + assert data["projects"][0]["title"] == f"Project search_match_{unique_suffix}" + + # Check matching Skill + assert len(data["skills"]) == 1 + assert data["skills"][0]["name"] == f"Skill search_match_{unique_suffix}" + + # Check matching Flare + assert len(data["flares"]) == 1 + assert data["flares"][0]["title"] == f"Flare search_match_{unique_suffix}" + + finally: + # Cleanup + db.delete(test_flare) + db.delete(test_skill) + db.delete(test_project) + db.delete(test_user) + db.commit() + db.close() + From 95b05945915a15f99d5f4f96e41e975a4fbc3fff Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 11 Jul 2026 07:56:07 +0530 Subject: [PATCH 5/7] feat(frontend): create searchService to hit live global search backend API --- frontend/src/services/index.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index e8b19bbb..c3f5219c 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -46,4 +46,27 @@ export const userService = { me: () => mock(seed.currentUser), }; +const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export interface SearchResult { + users: any[]; + projects: any[]; + skills: any[]; + flares: any[]; +} + +export const searchService = { + globalSearch: async (query: string): Promise => { + if (!query || query.trim() === "") { + return { users: [], projects: [], skills: [], flares: [] }; + } + const response = await fetch(`${API_URL}/search/?q=${encodeURIComponent(query)}`); + if (!response.ok) { + throw new Error("Search request failed"); + } + return response.json(); + }, +}; + export type { Builder, Project, Activity, Flare, Conversation, Notification, Hackathon, Deadline } from "@/mocks/seed"; + From f09abda47ff1b4675dd876459baf895a34998b09 Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 11 Jul 2026 07:56:22 +0530 Subject: [PATCH 6/7] feat(frontend): connect SearchPage to live backend search API with react-query --- frontend/src/routes/_app.search.tsx | 44 +++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/frontend/src/routes/_app.search.tsx b/frontend/src/routes/_app.search.tsx index 46d7f205..b36644d6 100644 --- a/frontend/src/routes/_app.search.tsx +++ b/frontend/src/routes/_app.search.tsx @@ -3,7 +3,9 @@ import { Card, TagChip, Avatar } from "@/components/shared/primitives"; import { builders, projects, flares } from "@/mocks/seed"; import { useState } from "react"; import { cn } from "@/lib/utils"; -import { Search } from "lucide-react"; +import { Search, Loader2 } from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { searchService } from "@/services"; const tabs = ["Developers", "Projects", "Skills", "Flares"] as const; type Tab = (typeof tabs)[number]; @@ -22,10 +24,42 @@ function SearchPage() { const [q, setQ] = useState(""); const [tab, setTab] = useState("Developers"); - const devs = builders.filter((b) => (b.name + b.skills.join(" ")).toLowerCase().includes(q.toLowerCase())); - const projs = projects.filter((p) => (p.name + p.stack.join(" ")).toLowerCase().includes(q.toLowerCase())); - const skillSet = Array.from(new Set(builders.flatMap((b) => b.skills))).filter((s) => s.toLowerCase().includes(q.toLowerCase())); - const fls = flares.filter((f) => f.content.toLowerCase().includes(q.toLowerCase())); + const { data, isLoading } = useQuery({ + queryKey: ["search", q], + queryFn: () => searchService.globalSearch(q), + enabled: q.trim().length > 0, + }); + + const devs = q.trim().length > 0 + ? (data?.users || []).map((u: any) => ({ + id: u.id, + name: `${u.first_name} ${u.last_name}`, + role: u.role || "Developer", + avatar: u.profile_image, + })) + : builders.filter((b) => (b.name + b.skills.join(" ")).toLowerCase().includes(q.toLowerCase())); + + const projs = q.trim().length > 0 + ? (data?.projects || []).map((p: any) => ({ + id: p.id, + name: p.title, + icon: "🚀", + stack: p.tech_stack ? p.tech_stack.split(",").map((s: string) => s.trim()) : [], + })) + : projects.filter((p) => (p.name + p.stack.join(" ")).toLowerCase().includes(q.toLowerCase())); + + const skillSet = q.trim().length > 0 + ? (data?.skills || []).map((s: any) => s.name) + : Array.from(new Set(builders.flatMap((b) => b.skills))).filter((s) => s.toLowerCase().includes(q.toLowerCase())); + + const fls = q.trim().length > 0 + ? (data?.flares || []).map((f: any) => ({ + id: f.id, + author: { name: f.role || "Hiring" }, + content: `${f.title}: ${f.description}`, + })) + : flares.filter((f) => f.content.toLowerCase().includes(q.toLowerCase())); + return (
From 28004007620604b4d205ce9383f3f4ad191328d4 Mon Sep 17 00:00:00 2001 From: a7hu-15 Date: Sat, 11 Jul 2026 07:56:52 +0530 Subject: [PATCH 7/7] style(frontend): add loading state and empty result placeholders on search page --- frontend/src/routes/_app.search.tsx | 114 ++++++++++++++++++---------- 1 file changed, 73 insertions(+), 41 deletions(-) diff --git a/frontend/src/routes/_app.search.tsx b/frontend/src/routes/_app.search.tsx index b36644d6..1e91c370 100644 --- a/frontend/src/routes/_app.search.tsx +++ b/frontend/src/routes/_app.search.tsx @@ -64,7 +64,11 @@ function SearchPage() { return (
- + {isLoading ? ( + + ) : ( + + )} setQ(e.target.value)} @@ -89,53 +93,81 @@ function SearchPage() {
{tab === "Developers" && ( -
- {devs.map((b) => ( - - - -
-

{b.name}

-

{b.role}

-
-
- - ))} -
+ devs.length > 0 ? ( +
+ {devs.map((b) => ( + + + +
+

{b.name}

+

{b.role}

+
+
+ + ))} +
+ ) : ( + +

No developers found

+

Try searching for a different name, skill, or role.

+
+ ) )} {tab === "Projects" && ( -
- {projs.map((p) => ( - - -
- {p.icon} -
-

{p.name}

-

{p.stack.join(" · ")}

+ projs.length > 0 ? ( +
+ {projs.map((p) => ( + + +
+ {p.icon} +
+

{p.name}

+

{p.stack.join(" · ")}

+
-
- - - ))} -
+ + + ))} +
+ ) : ( + +

No projects found

+

Try searching for other project titles or technologies.

+
+ ) )} {tab === "Skills" && ( - -
- {skillSet.map((s) => {s})} -
-
+ skillSet.length > 0 ? ( + +
+ {skillSet.map((s) => {s})} +
+
+ ) : ( + +

No skills found

+

Try another search keyword.

+
+ ) )} {tab === "Flares" && ( -
- {fls.map((f) => ( - -

{f.author.name}

-

{f.content}

-
- ))} -
+ fls.length > 0 ? ( +
+ {fls.map((f) => ( + +

{f.author.name}

+

{f.content}

+
+ ))} +
+ ) : ( + +

No flares found

+

Try searching for different roles or project requirements.

+
+ ) )}
);