diff --git a/apps/api/src/router.py b/apps/api/src/router.py index 2725a263..54851a55 100644 --- a/apps/api/src/router.py +++ b/apps/api/src/router.py @@ -2,7 +2,7 @@ import os from fastapi import APIRouter, Depends from src.routers import health from src.routers import usergroups -from src.routers import dev, trail, users, auth, orgs, roles +from src.routers import dev, trail, users, auth, orgs, roles, search from src.routers.ai import ai from src.routers.courses import chapters, collections, courses, assignments from src.routers.courses.activities import activities, blocks @@ -23,6 +23,7 @@ v1_router.include_router(orgs.router, prefix="/orgs", tags=["orgs"]) v1_router.include_router(roles.router, prefix="/roles", tags=["roles"]) v1_router.include_router(blocks.router, prefix="/blocks", tags=["blocks"]) v1_router.include_router(courses.router, prefix="/courses", tags=["courses"]) +v1_router.include_router(search.router, prefix="/search", tags=["search"]) v1_router.include_router( assignments.router, prefix="/assignments", tags=["assignments"] ) diff --git a/apps/api/src/routers/search.py b/apps/api/src/routers/search.py new file mode 100644 index 00000000..8bce4417 --- /dev/null +++ b/apps/api/src/routers/search.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session +from src.core.events.database import get_db_session +from src.db.users import PublicUser +from src.security.auth import get_current_user +from src.services.search.search import search_across_org, SearchResult + +router = APIRouter() + +@router.get("/org_slug/{org_slug}", response_model=SearchResult) +async def api_search_across_org( + request: Request, + org_slug: str, + query: str, + page: int = 1, + limit: int = 10, + db_session: Session = Depends(get_db_session), + current_user: PublicUser = Depends(get_current_user), +) -> SearchResult: + """ + Search across courses, collections and users within an organization + """ + return await search_across_org( + request=request, + current_user=current_user, + org_slug=org_slug, + search_query=query, + db_session=db_session, + page=page, + limit=limit + ) \ No newline at end of file diff --git a/apps/api/src/services/courses/courses.py b/apps/api/src/services/courses/courses.py index 192df13a..53d11fd2 100644 --- a/apps/api/src/services/courses/courses.py +++ b/apps/api/src/services/courses/courses.py @@ -288,10 +288,22 @@ async def get_courses_orgslug( # Create CourseRead objects with authors course_reads = [] for course in courses: - course_read = CourseRead( - **course.model_dump(), - authors=course_authors.get(course.course_uuid, []) - ) + course_read = CourseRead.model_validate({ + "id": course.id or 0, # Ensure id is never None + "org_id": course.org_id, + "name": course.name, + "description": course.description or "", + "about": course.about or "", + "learnings": course.learnings or "", + "tags": course.tags or "", + "thumbnail_image": course.thumbnail_image or "", + "public": course.public, + "open_to_contributors": course.open_to_contributors, + "course_uuid": course.course_uuid, + "creation_date": course.creation_date, + "update_date": course.update_date, + "authors": course_authors.get(course.course_uuid, []) + }) course_reads.append(course_read) return course_reads @@ -380,8 +392,22 @@ async def search_courses( for resource_author, user in author_results ] - course_read = CourseRead.model_validate(course) - course_read.authors = authors + course_read = CourseRead.model_validate({ + "id": course.id or 0, # Ensure id is never None + "org_id": course.org_id, + "name": course.name, + "description": course.description or "", + "about": course.about or "", + "learnings": course.learnings or "", + "tags": course.tags or "", + "thumbnail_image": course.thumbnail_image or "", + "public": course.public, + "open_to_contributors": course.open_to_contributors, + "course_uuid": course.course_uuid, + "creation_date": course.creation_date, + "update_date": course.update_date, + "authors": authors + }) course_reads.append(course_read) return course_reads @@ -700,22 +726,22 @@ async def get_user_courses( ) # Create CourseRead object - course_read = CourseRead( - id=course.id, - org_id=course.org_id, - name=course.name, - description=course.description, - about=course.about, - learnings=course.learnings, - tags=course.tags, - thumbnail_image=course.thumbnail_image, - public=course.public, - open_to_contributors=course.open_to_contributors, - course_uuid=course.course_uuid, - creation_date=course.creation_date, - update_date=course.update_date, - authors=authors_with_role, - ) + course_read = CourseRead.model_validate({ + "id": course.id or 0, # Ensure id is never None + "org_id": course.org_id, + "name": course.name, + "description": course.description or "", + "about": course.about or "", + "learnings": course.learnings or "", + "tags": course.tags or "", + "thumbnail_image": course.thumbnail_image or "", + "public": course.public, + "open_to_contributors": course.open_to_contributors, + "course_uuid": course.course_uuid, + "creation_date": course.creation_date, + "update_date": course.update_date, + "authors": authors_with_role + }) result.append(course_read) diff --git a/apps/api/src/services/search/search.py b/apps/api/src/services/search/search.py new file mode 100644 index 00000000..e9077549 --- /dev/null +++ b/apps/api/src/services/search/search.py @@ -0,0 +1,118 @@ +from typing import List, TypeVar +from fastapi import Request +from sqlmodel import Session, select, or_, text, and_ +from sqlalchemy import true as sa_true +from pydantic import BaseModel +from src.db.users import PublicUser, AnonymousUser, UserRead, User +from src.db.courses.courses import Course, CourseRead +from src.db.collections import Collection, CollectionRead +from src.db.collections_courses import CollectionCourse +from src.db.organizations import Organization +from src.services.courses.courses import search_courses + +T = TypeVar('T') + +class SearchResult(BaseModel): + courses: List[CourseRead] + collections: List[CollectionRead] + users: List[UserRead] + + class Config: + arbitrary_types_allowed = True + +async def search_across_org( + request: Request, + current_user: PublicUser | AnonymousUser, + org_slug: str, + search_query: str, + db_session: Session, + page: int = 1, + limit: int = 10, +) -> SearchResult: + """ + Search across courses, collections and users within an organization + """ + offset = (page - 1) * limit + + # Get organization + org_statement = select(Organization).where(Organization.slug == org_slug) + org = db_session.exec(org_statement).first() + + if not org: + return SearchResult(courses=[], collections=[], users=[]) + + # Search courses using existing search_courses function + courses = await search_courses(request, current_user, org_slug, search_query, db_session, page, limit) + + # Search collections + collections_query = ( + select(Collection) + .where(Collection.org_id == org.id) + .where( + or_( + text('LOWER("collection".name) LIKE LOWER(:pattern)'), + text('LOWER("collection".description) LIKE LOWER(:pattern)') + ) + ) + .params(pattern=f"%{search_query}%") + ) + + # Search users + users_query = ( + select(User) + .where( + or_( + text('LOWER("user".username) LIKE LOWER(:pattern) OR ' + + 'LOWER("user".first_name) LIKE LOWER(:pattern) OR ' + + 'LOWER("user".last_name) LIKE LOWER(:pattern) OR ' + + 'LOWER("user".bio) LIKE LOWER(:pattern)') + ) + ) + .params(pattern=f"%{search_query}%") + ) + + if isinstance(current_user, AnonymousUser): + # For anonymous users, only show public collections + collections_query = collections_query.where(Collection.public == sa_true()) + else: + # For authenticated users, show public collections and those in their org + collections_query = ( + collections_query + .where( + or_( + Collection.public == sa_true(), + Collection.org_id == org.id + ) + ) + ) + + # Apply pagination to queries + collections = db_session.exec(collections_query.offset(offset).limit(limit)).all() + users = db_session.exec(users_query.offset(offset).limit(limit)).all() + + # Convert collections to CollectionRead objects with courses + collection_reads = [] + for collection in collections: + # Get courses in collection + statement = ( + select(Course) + .select_from(Course) + .join(CollectionCourse, and_( + CollectionCourse.course_id == Course.id, + CollectionCourse.collection_id == collection.id, + CollectionCourse.org_id == collection.org_id + )) + .distinct() + ) + collection_courses = list(db_session.exec(statement).all()) + collection_read = CollectionRead(**collection.model_dump(), courses=collection_courses) + collection_reads.append(collection_read) + + # Convert users to UserRead objects + user_reads = [UserRead.model_validate(user) for user in users] + + return SearchResult( + courses=courses, + collections=collection_reads, + users=user_reads + ) \ No newline at end of file diff --git a/apps/web/components/Objects/Search/SearchBar.tsx b/apps/web/components/Objects/Search/SearchBar.tsx index 85178f6c..e84c8cad 100644 --- a/apps/web/components/Objects/Search/SearchBar.tsx +++ b/apps/web/components/Objects/Search/SearchBar.tsx @@ -1,31 +1,75 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch } from 'lucide-react'; -import { searchOrgCourses } from '@services/courses/courses'; +import { Search, ArrowRight, Sparkles, Book, GraduationCap, ArrowUpRight, TextSearch, ScanSearch, Users } from 'lucide-react'; +import { searchOrgContent } from '@services/search/search'; import { useLHSession } from '@components/Contexts/LHSessionContext'; import Link from 'next/link'; -import { getCourseThumbnailMediaDirectory } from '@services/media/media'; +import { getCourseThumbnailMediaDirectory, getUserAvatarMediaDirectory } from '@services/media/media'; import { useDebounce } from '@/hooks/useDebounce'; import { useOrg } from '@components/Contexts/OrgContext'; import { getUriWithOrg } from '@services/config/config'; import { removeCoursePrefix } from '../Thumbnails/CourseThumbnail'; +import UserAvatar from '../UserAvatar'; + +interface User { + username: string; + first_name: string; + last_name: string; + email: string; + avatar_image: string; + bio: string; + details: Record; + profile: Record; + id: number; + user_uuid: string; +} + +interface Author { + user: User; + authorship: string; + authorship_status: string; + creation_date: string; + update_date: string; +} interface Course { name: string; description: string; + about: string; + learnings: string; + tags: string; thumbnail_image: string; + public: boolean; + open_to_contributors: boolean; + id: number; + org_id: number; + authors: Author[]; course_uuid: string; - tags?: string[]; - authors: Array<{ - first_name: string; - last_name: string; - avatar_image: string; - }>; + creation_date: string; + update_date: string; +} + +interface Collection { + name: string; + public: boolean; + description: string; + id: number; + courses: string[]; + collection_uuid: string; + creation_date: string; + update_date: string; +} + +interface SearchResults { + courses: Course[]; + collections: Collection[]; + users: User[]; } interface SearchBarProps { orgslug: string; className?: string; isMobile?: boolean; + showSearchSuggestions?: boolean; } const CourseResultsSkeleton = () => ( @@ -50,10 +94,15 @@ export const SearchBar: React.FC = ({ orgslug, className = '', isMobile = false, + showSearchSuggestions = false, }) => { const org = useOrg() as any; const [searchQuery, setSearchQuery] = useState(''); - const [courses, setCourses] = useState([]); + const [searchResults, setSearchResults] = useState({ + courses: [], + collections: [], + users: [] + }); const [isLoading, setIsLoading] = useState(false); const [showResults, setShowResults] = useState(false); const searchRef = useRef(null); @@ -75,16 +124,16 @@ export const SearchBar: React.FC = ({ }, []); useEffect(() => { - const fetchCourses = async () => { + const fetchResults = async () => { if (debouncedSearch.trim().length === 0) { - setCourses([]); + setSearchResults({ courses: [], collections: [], users: [] }); setIsLoading(false); return; } setIsLoading(true); try { - const results = await searchOrgCourses( + const response = await searchOrgContent( orgslug, debouncedSearch, 1, @@ -92,16 +141,31 @@ export const SearchBar: React.FC = ({ null, session?.data?.tokens?.access_token ); - setCourses(results); + + console.log('Search API Response:', response); // Debug log + + // Type assertion and safe access + const typedResponse = response.data as any; + + // Ensure we have the correct structure and handle potential undefined values + const processedResults: SearchResults = { + courses: Array.isArray(typedResponse?.courses) ? typedResponse.courses : [], + collections: Array.isArray(typedResponse?.collections) ? typedResponse.collections : [], + users: Array.isArray(typedResponse?.users) ? typedResponse.users : [] + }; + + console.log('Processed Results:', processedResults); // Debug log + + setSearchResults(processedResults); } catch (error) { - console.error('Error searching courses:', error); - setCourses([]); + console.error('Error searching content:', error); + setSearchResults({ courses: [], collections: [], users: [] }); } setIsLoading(false); setIsInitialLoad(false); }; - fetchCourses(); + fetchResults(); }, [debouncedSearch, orgslug, session?.data?.tokens?.access_token]); const MemoizedEmptyState = useMemo(() => { @@ -160,8 +224,12 @@ export const SearchBar: React.FC = ({ return null; }, [searchQuery, searchTerms, orgslug]); - const MemoizedCourseResults = useMemo(() => { - if (!courses.length) return null; + const MemoizedQuickResults = useMemo(() => { + const hasResults = searchResults.courses.length > 0 || + searchResults.collections.length > 0 || + searchResults.users.length > 0; + + if (!hasResults) return null; return (
@@ -169,40 +237,114 @@ export const SearchBar: React.FC = ({ Quick Results
- {courses.map((course) => ( - -
- {course.thumbnail_image ? ( - {course.name} 0 && ( +
+
+ + Users +
+ {searchResults.users.map((user) => ( + + - ) : ( +
+
+

+ {user.first_name} {user.last_name} +

+ User +
+

@{user.username}

+
+ + ))} +
+ )} + + {/* Courses Section */} + {searchResults.courses.length > 0 && ( +
+
+ + Courses +
+ {searchResults.courses.map((course) => ( + +
+ {course.thumbnail_image ? ( + {course.name} + ) : ( +
+ +
+ )} +
+ +
+
+
+
+

{course.name}

+ Course +
+

{course.description}

+
+ + ))} +
+ )} + + {/* Collections Section */} + {searchResults.collections.length > 0 && ( +
+
+ + Collections +
+ {searchResults.collections.map((collection) => ( +
- )} -
- -
-
-
-
-

{course.name}

- Course -
-

{course.description}

-
- - ))} +
+
+

{collection.name}

+ Collection +
+

{collection.description}

+
+ + ))} +
+ )} ); - }, [courses, orgslug, org?.org_uuid]); + }, [searchResults, orgslug, org?.org_uuid]); const handleSearchChange = useCallback((e: React.ChangeEvent) => { setSearchQuery(e.target.value); @@ -238,13 +380,16 @@ export const SearchBar: React.FC = ({ MemoizedEmptyState ) : ( <> - {MemoizedSearchSuggestions} + {showSearchSuggestions && MemoizedSearchSuggestions} {isLoading ? ( ) : ( <> - {MemoizedCourseResults} - {(courses.length > 0 || searchQuery.trim()) && ( + {MemoizedQuickResults} + {((searchResults.courses.length > 0 || + searchResults.collections.length > 0 || + searchResults.users.length > 0) || + searchQuery.trim()) && (