feat: implement multi search on search bar

This commit is contained in:
swve 2025-04-06 13:25:30 +02:00
parent ed1ae628cb
commit 0167fecbe8
6 changed files with 413 additions and 73 deletions

View file

@ -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"]
)

View file

@ -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
)

View file

@ -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)

View file

@ -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
)

View file

@ -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<string, any>;
profile: Record<string, any>;
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<SearchBarProps> = ({
orgslug,
className = '',
isMobile = false,
showSearchSuggestions = false,
}) => {
const org = useOrg() as any;
const [searchQuery, setSearchQuery] = useState('');
const [courses, setCourses] = useState<Course[]>([]);
const [searchResults, setSearchResults] = useState<SearchResults>({
courses: [],
collections: [],
users: []
});
const [isLoading, setIsLoading] = useState(false);
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
@ -75,16 +124,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
}, []);
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<SearchBarProps> = ({
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<SearchBarProps> = ({
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 (
<div className="p-2">
@ -169,40 +237,114 @@ export const SearchBar: React.FC<SearchBarProps> = ({
<TextSearch size={16} />
<span className="font-medium">Quick Results</span>
</div>
{courses.map((course) => (
<Link
key={course.course_uuid}
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<div className="relative">
{course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
alt={course.name}
className="w-10 h-10 object-cover rounded-lg"
{/* Users Section */}
{searchResults.users.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
<Users size={12} />
<span>Users</span>
</div>
{searchResults.users.map((user) => (
<Link
key={user.user_uuid}
href={getUriWithOrg(orgslug, `/user/${user.username}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<UserAvatar
width={40}
avatar_url={user.avatar_image ? getUserAvatarMediaDirectory(user.user_uuid, user.avatar_image) : ''}
predefined_avatar={user.avatar_image ? undefined : 'empty'}
userId={user.id.toString()}
showProfilePopup
rounded="rounded-full"
backgroundColor="bg-gray-100"
/>
) : (
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-black/80 truncate">
{user.first_name} {user.last_name}
</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">User</span>
</div>
<p className="text-xs text-black/50 truncate">@{user.username}</p>
</div>
</Link>
))}
</div>
)}
{/* Courses Section */}
{searchResults.courses.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
<GraduationCap size={12} />
<span>Courses</span>
</div>
{searchResults.courses.map((course) => (
<Link
key={course.course_uuid}
href={getUriWithOrg(orgslug, `/course/${removeCoursePrefix(course.course_uuid)}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<div className="relative">
{course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(org?.org_uuid, course.course_uuid, course.thumbnail_image)}
alt={course.name}
className="w-10 h-10 object-cover rounded-lg"
/>
) : (
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
<Book size={20} className="text-black/40" />
</div>
)}
<div className="absolute -bottom-1 -right-1 bg-white shadow-sm p-1 rounded-full">
<GraduationCap size={11} className="text-black/60" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-black/80 truncate">{course.name}</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Course</span>
</div>
<p className="text-xs text-black/50 truncate">{course.description}</p>
</div>
</Link>
))}
</div>
)}
{/* Collections Section */}
{searchResults.collections.length > 0 && (
<div className="mb-2">
<div className="flex items-center gap-2 px-2 py-1 text-xs text-black/40">
<Book size={12} />
<span>Collections</span>
</div>
{searchResults.collections.map((collection) => (
<Link
key={collection.collection_uuid}
href={getUriWithOrg(orgslug, `/collection/${collection.collection_uuid}`)}
className="flex items-center gap-3 p-2 hover:bg-black/[0.02] rounded-lg transition-colors"
>
<div className="w-10 h-10 bg-black/5 rounded-lg flex items-center justify-center">
<Book size={20} className="text-black/40" />
</div>
)}
<div className="absolute -bottom-1 -right-1 bg-white shadow-sm p-1 rounded-full">
<GraduationCap size={11} className="text-black/60" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-black/80 truncate">{course.name}</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Course</span>
</div>
<p className="text-xs text-black/50 truncate">{course.description}</p>
</div>
</Link>
))}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-black/80 truncate">{collection.name}</h3>
<span className="text-[10px] font-medium text-black/40 uppercase tracking-wide whitespace-nowrap">Collection</span>
</div>
<p className="text-xs text-black/50 truncate">{collection.description}</p>
</div>
</Link>
))}
</div>
)}
</div>
);
}, [courses, orgslug, org?.org_uuid]);
}, [searchResults, orgslug, org?.org_uuid]);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
@ -238,13 +380,16 @@ export const SearchBar: React.FC<SearchBarProps> = ({
MemoizedEmptyState
) : (
<>
{MemoizedSearchSuggestions}
{showSearchSuggestions && MemoizedSearchSuggestions}
{isLoading ? (
<CourseResultsSkeleton />
) : (
<>
{MemoizedCourseResults}
{(courses.length > 0 || searchQuery.trim()) && (
{MemoizedQuickResults}
{((searchResults.courses.length > 0 ||
searchResults.collections.length > 0 ||
searchResults.users.length > 0) ||
searchQuery.trim()) && (
<Link
href={getUriWithOrg(orgslug, `/search?q=${encodeURIComponent(searchQuery)}`)}
className="flex items-center justify-between px-4 py-2.5 text-xs text-black/50 hover:text-black/70 hover:bg-black/[0.02] transition-colors"

View file

@ -0,0 +1,19 @@
import { RequestBodyWithAuthHeader } from "@services/utils/ts/requests"
import { getAPIUrl } from "@services/config/config"
import { errorHandling, getResponseMetadata } from "@services/utils/ts/requests"
export async function searchOrgContent(
org_slug: string,
query: string,
page: number = 1,
limit: number = 10,
next: any,
access_token?: any
) {
const result: any = await fetch(
`${getAPIUrl()}search/org_slug/${org_slug}?query=${encodeURIComponent(query)}&page=${page}&limit=${limit}`,
RequestBodyWithAuthHeader('GET', null, next, access_token)
)
const res = await getResponseMetadata(result)
return res
}