diff --git a/app/src/app/packages/category/[categoryName]/page.tsx b/app/src/app/packages/category/[categoryName]/page.tsx new file mode 100644 index 0000000..e43aaef --- /dev/null +++ b/app/src/app/packages/category/[categoryName]/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +import CategoryPackages from "../../../../pages/CategoryPackages"; + +export default function CategoryPage() { + const params = useParams(); + const categoryName = decodeURIComponent(params.categoryName as string); + + return ; +} \ No newline at end of file diff --git a/app/src/features/detail/components/PackageSidebar.tsx b/app/src/features/detail/components/PackageSidebar.tsx index ec0d421..130bcb0 100644 --- a/app/src/features/detail/components/PackageSidebar.tsx +++ b/app/src/features/detail/components/PackageSidebar.tsx @@ -11,6 +11,7 @@ import { ListItem, Link, Typography, + Chip, } from "@mui/material"; import { FullPackage } from "../hooks/usePackageDetail"; import "./PackageSidebar.css"; @@ -20,6 +21,8 @@ import GitHubIcon from "@mui/icons-material/GitHub"; import DescriptionIcon from "@mui/icons-material/Description"; import HomeIcon from "@mui/icons-material/Home"; import LinkIcon from "@mui/icons-material/Link"; +import LocalOfferIcon from "@mui/icons-material/LocalOffer"; +import LabelIcon from "@mui/icons-material/Label"; interface PackageSidebarProps { data: FullPackage | null; @@ -79,6 +82,77 @@ const PackageSidebar = ({ data, loading, error }: PackageSidebarProps) => { + {/* Categories Section */} + {data.categories && data.categories.length > 0 && ( + + + + + Categories + + + + {data.categories.map((category) => ( + { + window.location.href = `/packages/category/${encodeURIComponent(category)}`; + }} + /> + ))} + + + )} + + {/* Keywords Section */} + {data.keywords && data.keywords.length > 0 && ( + + + + + Keywords + + + + {data.keywords.map((keyword) => ( + { + const newParams = new URLSearchParams(); + newParams.set("query", keyword); + newParams.set("page", "1"); + window.location.href = `/?${newParams.toString()}`; + }} + /> + ))} + + + )} + {data.repository && (
diff --git a/app/src/features/detail/hooks/usePackageDetail.ts b/app/src/features/detail/hooks/usePackageDetail.ts index 00358ec..5b411f7 100644 --- a/app/src/features/detail/hooks/usePackageDetail.ts +++ b/app/src/features/detail/hooks/usePackageDetail.ts @@ -20,6 +20,8 @@ export interface FullPackage { urls: string[]; readme: string | null; license: string | null; + categories: string[]; + keywords: string[]; } const usePackageDetail = (packageName: string, version?: string) => { diff --git a/app/src/pages/CategoryPackages.tsx b/app/src/pages/CategoryPackages.tsx new file mode 100644 index 0000000..df3c569 --- /dev/null +++ b/app/src/pages/CategoryPackages.tsx @@ -0,0 +1,322 @@ +"use client"; + +import React, { Suspense, useEffect, useState, useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { + Box, + Card, + CardContent, + Typography, + CircularProgress, + Pagination, + Chip, +} from "@mui/material"; +import { PackagePreview } from "../utils/http"; +import { formatDate } from "../utils/date"; +import { SERVER_URI } from "../constants"; +import NextLink from "next/link"; + +const PER_PAGE = 10; + +interface CategoryPackagesProps { + categoryName: string; +} + +function CategoryPackages({ categoryName }: CategoryPackagesProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const abortControllerRef = useRef(); + + const currentPage = parseInt(searchParams.get("page") || "1", 10); + + useEffect(() => { + if (!categoryName) { + setResults([]); + setTotalPages(1); + setTotalCount(0); + setError(null); + setLoading(false); + abortControllerRef.current?.abort(); + return; + } + + // Cancel previous request + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + + setLoading(true); + setError(null); + + fetch(`${SERVER_URI}/packages/category/${encodeURIComponent(categoryName)}?page=${currentPage}&per_page=${PER_PAGE}`, { + signal: abortControllerRef.current?.signal, + credentials: 'include', + }) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to fetch packages"); + } + return response.json(); + }) + .then((data) => { + setResults(data.data); + setTotalPages(data.totalPages); + setTotalCount(data.totalCount); + }) + .catch((err) => { + if (err.name !== "AbortError") { + setError("Failed to fetch packages for this category"); + console.error("Category packages error:", err); + } + }) + .finally(() => { + setLoading(false); + }); + + return () => { + abortControllerRef.current?.abort(); + }; + }, [categoryName, currentPage]); + + const handlePageChange = (_: React.ChangeEvent, page: number) => { + const newParams = new URLSearchParams(searchParams); + newParams.set("page", page.toString()); + router.replace(`/packages/category/${encodeURIComponent(categoryName)}?${newParams.toString()}`); + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + const containerStyles = { + maxWidth: "1200px", + mx: "auto", + px: { xs: 1, sm: 2, md: 3 }, + width: "100%", + }; + + if (loading) { + return ( + + + + + Loading packages for category "{categoryName}"... + + + + ); + } + + if (error) { + return ( + + + Error Loading Packages + + {error} + + Please try again or contact support if the problem persists. + + + ); + } + + if (results.length === 0) { + return ( + + + No packages found + + + No packages found for category "{categoryName}". + + + ); + } + + return ( + + + + Packages in Category: {categoryName} + + + Found {totalCount} package{totalCount === 1 ? "" : "s"} in category "{categoryName}" + + + + + {results.map((result) => ( + + + + + + + {result.name} + + + v{result.version} + + + + + + {result.description || "No description available"} + + + {/* Categories and Keywords Tags */} + {(result.categories?.length > 0 || result.keywords?.length > 0) && ( + + {result.categories?.map((category) => ( + { + e.preventDefault(); + e.stopPropagation(); + // Navigate to category filter + window.location.href = `/packages/category/${encodeURIComponent(category)}`; + }} + /> + ))} + {result.keywords?.map((keyword) => ( + { + e.preventDefault(); + e.stopPropagation(); + // Navigate to keyword search + const newParams = new URLSearchParams(); + newParams.set("query", keyword); + newParams.set("page", "1"); + window.location.href = `/?${newParams.toString()}`; + }} + /> + ))} + + )} + + + + Updated {formatDate(result.updatedAt)} + + + + + + ))} + + + {totalPages > 1 && ( + + + + )} + + ); +} + +export default function CategoryPackagesWrapper({ categoryName }: CategoryPackagesProps) { + return ( + Loading category packages...
}> + + + ); +} \ No newline at end of file diff --git a/app/src/pages/SearchResults.tsx b/app/src/pages/SearchResults.tsx index 07fcb1c..4536294 100644 --- a/app/src/pages/SearchResults.tsx +++ b/app/src/pages/SearchResults.tsx @@ -9,6 +9,7 @@ import { Typography, CircularProgress, Pagination, + Chip, } from "@mui/material"; import HTTP, { PackagePreview } from "../utils/http"; import { formatDate } from "../utils/date"; @@ -214,12 +215,74 @@ function SearchResults() { - - {result.description || "No description available"} - + + + {result.description || "No description available"} + + + {/* Categories and Keywords Tags */} + {(result.categories?.length > 0 || result.keywords?.length > 0) && ( + + {result.categories?.map((category) => ( + { + e.preventDefault(); + e.stopPropagation(); + // Navigate to category filter + const newParams = new URLSearchParams(); + newParams.set("category", category); + newParams.set("page", "1"); + window.location.href = `/packages/category/${encodeURIComponent(category)}`; + }} + /> + ))} + {result.keywords?.map((keyword) => ( + { + e.preventDefault(); + e.stopPropagation(); + // Navigate to keyword search + const newParams = new URLSearchParams(); + newParams.set("query", keyword); + newParams.set("page", "1"); + window.location.href = `/?${newParams.toString()}`; + }} + /> + ))} + + )} + , pub readme: Option, pub license: Option, + pub categories: Vec, + pub keywords: Vec, } -impl From for FullPackage { - fn from(full_package: crate::models::FullPackage) -> Self { +impl From for FullPackage { + fn from(full_package_with_categories: crate::models::FullPackageWithCategories) -> Self { + let full_package = full_package_with_categories.package; fn string_to_url(s: String) -> Option { Url::parse(&s).ok() } @@ -66,6 +69,8 @@ impl From for FullPackage { .collect(), license: full_package.license, readme: full_package.readme, + categories: full_package_with_categories.categories, + keywords: full_package_with_categories.keywords, } } } diff --git a/src/db/package_category_keyword.rs b/src/db/package_category_keyword.rs index 830d144..7dc7a1b 100644 --- a/src/db/package_category_keyword.rs +++ b/src/db/package_category_keyword.rs @@ -1,6 +1,6 @@ use super::error::DatabaseError; use super::{schema, DbConn}; -use crate::models::{NewPackageCategory, NewPackageKeyword}; +use crate::models::{NewPackageCategory, NewPackageKeyword, PackageCategory, PackageKeyword}; use diesel::prelude::*; use uuid::Uuid; @@ -42,4 +42,56 @@ impl DbConn<'_> { .execute(self.inner()) .map_err(DatabaseError::InsertPackageKeywordsFailed) } + + /// Retrieve all categories for a package by package ID. + pub fn get_categories_for_package( + &mut self, + package_id: Uuid, + ) -> Result, DatabaseError> { + schema::package_categories::table + .filter(schema::package_categories::package_id.eq(package_id)) + .order_by(schema::package_categories::category.asc()) + .select(PackageCategory::as_select()) + .load(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get categories for package".to_string(), err)) + } + + /// Retrieve all keywords for a package by package ID. + pub fn get_keywords_for_package( + &mut self, + package_id: Uuid, + ) -> Result, DatabaseError> { + schema::package_keywords::table + .filter(schema::package_keywords::package_id.eq(package_id)) + .order_by(schema::package_keywords::keyword.asc()) + .select(PackageKeyword::as_select()) + .load(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get keywords for package".to_string(), err)) + } + + /// Retrieve categories for multiple packages by package IDs. + pub fn get_categories_for_packages( + &mut self, + package_ids: &[Uuid], + ) -> Result, DatabaseError> { + schema::package_categories::table + .filter(schema::package_categories::package_id.eq_any(package_ids)) + .order_by((schema::package_categories::package_id.asc(), schema::package_categories::category.asc())) + .select(PackageCategory::as_select()) + .load(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get categories for packages".to_string(), err)) + } + + /// Retrieve keywords for multiple packages by package IDs. + pub fn get_keywords_for_packages( + &mut self, + package_ids: &[Uuid], + ) -> Result, DatabaseError> { + schema::package_keywords::table + .filter(schema::package_keywords::package_id.eq_any(package_ids)) + .order_by((schema::package_keywords::package_id.asc(), schema::package_keywords::keyword.asc())) + .select(PackageKeyword::as_select()) + .load(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get keywords for packages".to_string(), err)) + } } diff --git a/src/db/package_version.rs b/src/db/package_version.rs index 4eaa6a6..ca7c244 100644 --- a/src/db/package_version.rs +++ b/src/db/package_version.rs @@ -3,7 +3,7 @@ use super::{models, schema, DbConn}; use crate::api::pagination::{PaginatedResponse, Pagination}; use crate::handlers::publish::PublishInfo; use crate::models::{ - ApiToken, AuthorInfo, CountResult, FullPackage, PackagePreview, PackageVersionInfo, + ApiToken, AuthorInfo, CountResult, FullPackage, FullPackageWithCategories, PackagePreview, PackagePreviewWithCategories, PackageVersionInfo, }; use chrono::{DateTime, Utc}; use diesel::prelude::*; @@ -368,7 +368,7 @@ impl DbConn<'_> { .collect()) } - /// Search for packages by name or description using fuzzy search. + /// Search for packages by name, description, categories, or keywords using fuzzy search. pub fn search_packages( &mut self, query: String, @@ -386,21 +386,44 @@ impl DbConn<'_> { p.created_at AS created_at, pv.created_at AS updated_at, ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank, - -- Combined relevance scoring + -- Combined relevance scoring including categories and keywords GREATEST( similarity($1, LOWER(p.package_name)), CASE WHEN LOWER(p.package_name) ILIKE '%' || $1 || '%' THEN 0.7 ELSE 0.0 END, - similarity($1, LOWER(COALESCE(pv.package_description, ''))) * 0.3 + similarity($1, LOWER(COALESCE(pv.package_description, ''))) * 0.3, + -- Category matches get high relevance + COALESCE(MAX( + CASE + WHEN LOWER(pc.category) ILIKE '%' || $1 || '%' THEN 0.8 + WHEN similarity($1, LOWER(pc.category)) > 0.3 THEN 0.6 + ELSE 0.0 + END + ), 0.0), + -- Keyword matches get medium relevance + COALESCE(MAX( + CASE + WHEN LOWER(pk.keyword) ILIKE '%' || $1 || '%' THEN 0.7 + WHEN similarity($1, LOWER(pk.keyword)) > 0.3 THEN 0.5 + ELSE 0.0 + END + ), 0.0) ) AS relevance_score FROM package_versions pv JOIN packages p ON pv.package_id = p.id + LEFT JOIN package_categories pc ON p.id = pc.package_id + LEFT JOIN package_keywords pk ON p.id = pk.package_id WHERE LOWER(p.package_name) ILIKE '%' || $1 || '%' OR similarity($1, LOWER(p.package_name)) > 0.2 OR - similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 + similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 OR + LOWER(pc.category) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(pc.category)) > 0.3 OR + LOWER(pk.keyword) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(pk.keyword)) > 0.3 + GROUP BY p.id, p.package_name, pv.id, pv.num, pv.package_description, p.created_at, pv.created_at ) SELECT name, @@ -423,15 +446,21 @@ impl DbConn<'_> { DatabaseError::QueryFailed("search packages".to_string(), err) })?; - // Count total matches + // Count total matches including categories and keywords let total = diesel::sql_query( r#"SELECT COUNT(DISTINCT p.id) AS count FROM packages p JOIN package_versions pv ON pv.package_id = p.id + LEFT JOIN package_categories pc ON p.id = pc.package_id + LEFT JOIN package_keywords pk ON p.id = pk.package_id WHERE LOWER(p.package_name) ILIKE '%' || $1 || '%' OR similarity($1, LOWER(p.package_name)) > 0.2 OR - similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 + similarity($1, LOWER(COALESCE(pv.package_description, ''))) > 0.1 OR + LOWER(pc.category) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(pc.category)) > 0.3 OR + LOWER(pk.keyword) ILIKE '%' || $1 || '%' OR + similarity($1, LOWER(pk.keyword)) > 0.3 "#, ) .bind::(query_lower) @@ -447,4 +476,218 @@ impl DbConn<'_> { per_page: pagination.limit(), }) } + + /// Search for packages with categories and keywords included in the response. + pub fn search_packages_with_categories( + &mut self, + query: String, + pagination: Pagination, + ) -> Result, DatabaseError> { + // First get the basic search results + let basic_results = self.search_packages(query, pagination)?; + + // Extract package names to get package IDs for categories/keywords lookup + let package_names: Vec = basic_results.data.iter().map(|p| p.name.clone()).collect(); + + // Get package IDs for these packages + let package_ids: Vec = schema::packages::table + .filter(schema::packages::package_name.eq_any(&package_names)) + .select(schema::packages::id) + .load::(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get package ids".to_string(), err))?; + + // Get categories and keywords for these packages + let categories = self.get_categories_for_packages(&package_ids)?; + let keywords = self.get_keywords_for_packages(&package_ids)?; + + // Create a map of package_id -> package_name for quick lookup + let package_id_to_name: std::collections::HashMap = schema::packages::table + .filter(schema::packages::package_name.eq_any(&package_names)) + .select((schema::packages::id, schema::packages::package_name)) + .load::<(Uuid, String)>(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get package id to name mapping".to_string(), err))? + .into_iter() + .collect(); + + // Create maps for quick lookup + let mut package_categories: std::collections::HashMap> = std::collections::HashMap::new(); + let mut package_keywords: std::collections::HashMap> = std::collections::HashMap::new(); + + for category in categories { + if let Some(package_name) = package_id_to_name.get(&category.package_id) { + package_categories.entry(package_name.clone()).or_default().push(category.category); + } + } + + for keyword in keywords { + if let Some(package_name) = package_id_to_name.get(&keyword.package_id) { + package_keywords.entry(package_name.clone()).or_default().push(keyword.keyword); + } + } + + // Combine results + let enhanced_results: Vec = basic_results + .data + .into_iter() + .map(|package| PackagePreviewWithCategories { + categories: package_categories.get(&package.name).cloned().unwrap_or_default(), + keywords: package_keywords.get(&package.name).cloned().unwrap_or_default(), + package, + }) + .collect(); + + Ok(PaginatedResponse { + data: enhanced_results, + total_count: basic_results.total_count, + total_pages: basic_results.total_pages, + current_page: basic_results.current_page, + per_page: basic_results.per_page, + }) + } + + /// Filter packages by category. + pub fn filter_packages_by_category( + &mut self, + category: String, + pagination: Pagination, + ) -> Result, DatabaseError> { + let category_lower = category.to_lowercase(); + + let packages = diesel::sql_query( + r#"WITH ranked_versions AS ( + SELECT + p.id AS package_id, + p.package_name AS name, + pv.num AS version, + pv.package_description AS description, + p.created_at AS created_at, + pv.created_at AS updated_at, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pv.created_at DESC) AS rank + FROM package_versions pv + JOIN packages p ON pv.package_id = p.id + JOIN package_categories pc ON p.id = pc.package_id + WHERE + LOWER(pc.category) = $1 OR + LOWER(pc.category) ILIKE '%' || $1 || '%' + ) + SELECT + name, + version, + description, + created_at, + updated_at + FROM ranked_versions + WHERE rank = 1 + ORDER BY created_at DESC + OFFSET $2 + LIMIT $3; + "#, + ) + .bind::(category_lower.clone()) + .bind::(pagination.offset()) + .bind::(pagination.limit()) + .load::(self.inner()) + .map_err(|err: diesel::result::Error| { + DatabaseError::QueryFailed("filter packages by category".to_string(), err) + })?; + + // Count total matches + let total = diesel::sql_query( + r#"SELECT COUNT(DISTINCT p.id) AS count + FROM packages p + JOIN package_categories pc ON p.id = pc.package_id + WHERE + LOWER(pc.category) = $1 OR + LOWER(pc.category) ILIKE '%' || $1 || '%' + "#, + ) + .bind::(category_lower) + .get_result::(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("count category filter".to_string(), err))? + .count; + + // Extract package names to get package IDs for categories/keywords lookup + let package_names: Vec = packages.iter().map(|p| p.name.clone()).collect(); + + // Get package IDs for these packages + let package_ids: Vec = schema::packages::table + .filter(schema::packages::package_name.eq_any(&package_names)) + .select(schema::packages::id) + .load::(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get package ids for category filter".to_string(), err))?; + + // Get categories and keywords for these packages + let categories = self.get_categories_for_packages(&package_ids)?; + let keywords = self.get_keywords_for_packages(&package_ids)?; + + // Create a map of package_id -> package_name for quick lookup + let package_id_to_name: std::collections::HashMap = schema::packages::table + .filter(schema::packages::package_name.eq_any(&package_names)) + .select((schema::packages::id, schema::packages::package_name)) + .load::<(Uuid, String)>(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get package id to name mapping for category filter".to_string(), err))? + .into_iter() + .collect(); + + // Create maps for quick lookup + let mut package_categories: std::collections::HashMap> = std::collections::HashMap::new(); + let mut package_keywords: std::collections::HashMap> = std::collections::HashMap::new(); + + for category in categories { + if let Some(package_name) = package_id_to_name.get(&category.package_id) { + package_categories.entry(package_name.clone()).or_default().push(category.category); + } + } + + for keyword in keywords { + if let Some(package_name) = package_id_to_name.get(&keyword.package_id) { + package_keywords.entry(package_name.clone()).or_default().push(keyword.keyword); + } + } + + // Combine results + let enhanced_results: Vec = packages + .into_iter() + .map(|package| PackagePreviewWithCategories { + categories: package_categories.get(&package.name).cloned().unwrap_or_default(), + keywords: package_keywords.get(&package.name).cloned().unwrap_or_default(), + package, + }) + .collect(); + + Ok(PaginatedResponse { + data: enhanced_results, + total_count: total, + total_pages: ((total as f64) / (pagination.limit() as f64)).ceil() as i64, + current_page: pagination.page(), + per_page: pagination.limit(), + }) + } + + /// Get a full package with categories and keywords by name and version. + pub fn get_full_package_with_categories( + &mut self, + pkg_name: String, + version: String, + ) -> Result { + // First get the basic package info + let package = self.get_full_package_version(pkg_name, version)?; + + // Get package ID for this package + let package_id: Uuid = schema::packages::table + .filter(schema::packages::package_name.eq(&package.name)) + .select(schema::packages::id) + .first(self.inner()) + .map_err(|err| DatabaseError::QueryFailed("get package id for full package".to_string(), err))?; + + // Get categories and keywords for this package + let categories = self.get_categories_for_package(package_id)?; + let keywords = self.get_keywords_for_package(package_id)?; + + Ok(FullPackageWithCategories { + package, + categories: categories.into_iter().map(|c| c.category).collect(), + keywords: keywords.into_iter().map(|k| k.keyword).collect(), + }) + } } diff --git a/src/main.rs b/src/main.rs index 09f2af5..99455db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,7 +26,7 @@ use forc_pub::handlers::upload::{handle_project_upload, install_forc_at_path, Up use forc_pub::middleware::cors::Cors; use forc_pub::middleware::session_auth::{SessionAuth, SESSION_COOKIE_NAME}; use forc_pub::middleware::token_auth::TokenAuth; -use forc_pub::models::{PackagePreview, PackageVersionInfo}; +use forc_pub::models::{FullPackageWithCategories, PackagePreviewWithCategories, PackageVersionInfo}; use forc_pub::util::{load_env, validate_or_format_semver}; use rocket::http::Status; use rocket::tokio::task; @@ -277,7 +277,17 @@ fn packages( let updated_after = updated_after.and_then(|date_str| DateTime::::from_str(date_str).ok()); let db_data = db.transaction(|conn| conn.get_full_packages(updated_after, pagination.clone()))?; - let data = db_data.data.into_iter().map(FullPackage::from).collect(); + + // For now, convert to FullPackageWithCategories with empty categories/keywords + // This endpoint could be enhanced later to include categories if needed + let data = db_data.data.into_iter().map(|pkg| { + let full_package_with_categories = FullPackageWithCategories { + package: pkg, + categories: vec![], // Empty for now + keywords: vec![], // Empty for now + }; + FullPackage::from(full_package_with_categories) + }).collect(); Ok(Json(PaginatedResponse { data, @@ -291,7 +301,7 @@ fn packages( #[get("/package?&")] fn package(db: &State, name: String, version: Option) -> ApiResult { let db_data = - db.transaction(|conn| conn.get_full_package_version(name, version.unwrap_or_default()))?; + db.transaction(|conn| conn.get_full_package_with_categories(name, version.unwrap_or_default()))?; Ok(Json(FullPackage::from(db_data))) } @@ -345,7 +355,7 @@ fn search( db: &State, query: String, pagination: Pagination, -) -> ApiResult> { +) -> ApiResult> { if query.trim().is_empty() || query.len() > 100 { return Err(ApiError::Generic( "Invalid query parameter".into(), @@ -353,7 +363,24 @@ fn search( )); } - let result = db.transaction(|conn| conn.search_packages(query, pagination))?; + let result = db.transaction(|conn| conn.search_packages_with_categories(query, pagination))?; + Ok(Json(result)) +} + +#[get("/packages/category/?")] +fn packages_by_category( + db: &State, + category: String, + pagination: Pagination, +) -> ApiResult> { + if category.trim().is_empty() || category.len() > 50 { + return Err(ApiError::Generic( + "Invalid category parameter".into(), + Status::BadRequest, + )); + } + + let result = db.transaction(|conn| conn.filter_packages_by_category(category, pagination))?; Ok(Json(result)) } @@ -394,6 +421,7 @@ async fn rocket() -> _ { package_versions, recent_packages, search, + packages_by_category, all_options, health ], diff --git a/src/models.rs b/src/models.rs index ccbee20..23a98a0 100644 --- a/src/models.rs +++ b/src/models.rs @@ -201,7 +201,7 @@ pub struct NewPackageKeyword { pub keyword: String, } -#[derive(QueryableByName, Serialize, Debug)] +#[derive(QueryableByName, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct PackagePreview { #[diesel(sql_type = Text)] @@ -216,6 +216,15 @@ pub struct PackagePreview { pub updated_at: DateTime, } +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PackagePreviewWithCategories { + #[serde(flatten)] + pub package: PackagePreview, + pub categories: Vec, + pub keywords: Vec, +} + #[derive(QueryableByName, Serialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct FullPackage { @@ -258,6 +267,15 @@ pub struct FullPackage { pub license: Option, } +#[derive(Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FullPackageWithCategories { + #[serde(flatten)] + pub package: FullPackage, + pub categories: Vec, + pub keywords: Vec, +} + #[derive(QueryableByName)] pub struct CountResult { #[diesel(sql_type = BigInt)] diff --git a/tests/db_integration.rs b/tests/db_integration.rs index 6c978e5..e0f60b6 100644 --- a/tests/db_integration.rs +++ b/tests/db_integration.rs @@ -45,6 +45,8 @@ fn setup_db() -> Database { fn clear_tables(db: &mut Database) { db.transaction(|conn| { + diesel::delete(forc_pub::schema::package_categories::table).execute(conn.inner())?; + diesel::delete(forc_pub::schema::package_keywords::table).execute(conn.inner())?; diesel::delete(forc_pub::schema::package_versions::table).execute(conn.inner())?; diesel::delete(forc_pub::schema::packages::table).execute(conn.inner())?; diesel::delete(forc_pub::schema::api_tokens::table).execute(conn.inner())?; @@ -421,3 +423,520 @@ fn test_package_categories_keywords() { Ok::<(), diesel::result::Error>(()) }); } + +#[test] +#[serial] +fn test_search_with_categories() { + let db = &mut setup_db(); + let _ = db.transaction(|conn| { + // Set up session, user, token, and upload. + let session = conn + .new_user_session(&mock_user_1(), 1000) + .expect("session is ok"); + let user = conn.get_user_for_session(session.id).expect("user is ok"); + let (token, _) = conn + .new_token(user.id, "test token".to_string()) + .expect("token is ok"); + let upload = conn + .new_upload(&NewUpload { + id: uuid::Uuid::new_v4(), + forc_version: TEST_VERSION_1.into(), + source_code_ipfs_hash: "test-ipfs-hash".into(), + abi_ipfs_hash: None, + bytecode_identifier: None, + readme: None, + forc_manifest: TEST_MANIFEST.into(), + }) + .expect("upload is ok"); + + // Create a package with categories and keywords + let request = PublishInfo { + package_name: "web3-utils".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("Web3 utilities for blockchain development".into()), + repository: None, + documentation: None, + homepage: None, + urls: vec![], + readme: None, + license: None, + }; + + let version_result = conn + .new_package_version(&token, &request) + .expect("version result is ok"); + + // Insert categories + let categories = vec!["blockchain".to_string(), "web3".to_string()]; + conn.insert_categories(version_result.package_id, &categories) + .expect("insert categories is ok"); + + // Insert keywords + let keywords = vec!["ethereum".to_string(), "smart-contracts".to_string()]; + conn.insert_keywords(version_result.package_id, &keywords) + .expect("insert keywords is ok"); + + // Test search by category + let search_result = conn + .search_packages_with_categories( + "blockchain".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("search by category is ok"); + + assert_eq!(search_result.data.len(), 1); + assert_eq!(search_result.data[0].package.name, "web3-utils"); + assert_eq!(search_result.data[0].categories.len(), 2); + assert!(search_result.data[0].categories.contains(&"blockchain".to_string())); + assert!(search_result.data[0].categories.contains(&"web3".to_string())); + + // Test search by keyword + let search_result = conn + .search_packages_with_categories( + "ethereum".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("search by keyword is ok"); + + assert_eq!(search_result.data.len(), 1); + assert_eq!(search_result.data[0].package.name, "web3-utils"); + assert_eq!(search_result.data[0].keywords.len(), 2); + assert!(search_result.data[0].keywords.contains(&"ethereum".to_string())); + assert!(search_result.data[0].keywords.contains(&"smart-contracts".to_string())); + + // Test search by package name (should still work) + let search_result = conn + .search_packages_with_categories( + "web3".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("search by name is ok"); + + assert_eq!(search_result.data.len(), 1); + assert_eq!(search_result.data[0].package.name, "web3-utils"); + + Ok::<(), diesel::result::Error>(()) + }); +} + +#[test] +#[serial] +fn test_filter_by_category() { + let db = &mut setup_db(); + let _ = db.transaction(|conn| { + // Set up session, user, token, and upload. + let session = conn + .new_user_session(&mock_user_1(), 1000) + .expect("session is ok"); + let user = conn.get_user_for_session(session.id).expect("user is ok"); + let (token, _) = conn + .new_token(user.id, "test token".to_string()) + .expect("token is ok"); + let upload = conn + .new_upload(&NewUpload { + id: uuid::Uuid::new_v4(), + forc_version: TEST_VERSION_1.into(), + source_code_ipfs_hash: "test-ipfs-hash".into(), + abi_ipfs_hash: None, + bytecode_identifier: None, + readme: None, + forc_manifest: TEST_MANIFEST.into(), + }) + .expect("upload is ok"); + + // Create first package with "defi" category + let request1 = PublishInfo { + package_name: "defi-lib".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("DeFi utilities".into()), + repository: None, + documentation: None, + homepage: None, + urls: vec![], + readme: None, + license: None, + }; + + let version_result1 = conn + .new_package_version(&token, &request1) + .expect("version result is ok"); + + conn.insert_categories(version_result1.package_id, &vec!["defi".to_string()]) + .expect("insert categories is ok"); + + // Create second package with "gaming" category + let request2 = PublishInfo { + package_name: "game-engine".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("Gaming utilities".into()), + repository: None, + documentation: None, + homepage: None, + urls: vec![], + readme: None, + license: None, + }; + + let version_result2 = conn + .new_package_version(&token, &request2) + .expect("version result is ok"); + + conn.insert_categories(version_result2.package_id, &vec!["gaming".to_string()]) + .expect("insert categories is ok"); + + // Test filter by "defi" category + let filter_result = conn + .filter_packages_by_category( + "defi".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("filter by defi category is ok"); + + assert_eq!(filter_result.data.len(), 1); + assert_eq!(filter_result.data[0].package.name, "defi-lib"); + assert!(filter_result.data[0].categories.contains(&"defi".to_string())); + + // Test filter by "gaming" category + let filter_result = conn + .filter_packages_by_category( + "gaming".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("filter by gaming category is ok"); + + assert_eq!(filter_result.data.len(), 1); + assert_eq!(filter_result.data[0].package.name, "game-engine"); + assert!(filter_result.data[0].categories.contains(&"gaming".to_string())); + + // Test filter by non-existent category + let filter_result = conn + .filter_packages_by_category( + "nonexistent".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("filter by nonexistent category is ok"); + + assert_eq!(filter_result.data.len(), 0); + + Ok::<(), diesel::result::Error>(()) + }); +} + +#[test] +#[serial] +fn test_get_full_package_with_categories() { + let db = &mut setup_db(); + let _ = db.transaction(|conn| { + // Set up session, user, token, and upload. + let session = conn + .new_user_session(&mock_user_1(), 1000) + .expect("session is ok"); + let user = conn.get_user_for_session(session.id).expect("user is ok"); + let (token, _) = conn + .new_token(user.id, "test token".to_string()) + .expect("token is ok"); + let upload = conn + .new_upload(&NewUpload { + id: uuid::Uuid::new_v4(), + forc_version: TEST_VERSION_1.into(), + source_code_ipfs_hash: "test-ipfs-hash".into(), + abi_ipfs_hash: None, + bytecode_identifier: None, + readme: Some("Test README".into()), + forc_manifest: TEST_MANIFEST.into(), + }) + .expect("upload is ok"); + + let request = PublishInfo { + package_name: "full-test-package".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("Full test package description".into()), + repository: Url::parse(TEST_URL_REPO).ok(), + documentation: Url::parse(TEST_URL_DOC).ok(), + homepage: Url::parse(TEST_URL_HOME).ok(), + urls: vec![Url::parse(TEST_URL_OTHER).expect("other url")], + readme: Some("Test README".into()), + license: Some("MIT".into()), + }; + + let version_result = conn + .new_package_version(&token, &request) + .expect("version result is ok"); + + // Insert categories and keywords + let categories = vec!["utilities".to_string(), "testing".to_string()]; + let keywords = vec!["mock".to_string(), "test".to_string(), "unit".to_string()]; + + conn.insert_categories(version_result.package_id, &categories) + .expect("insert categories is ok"); + conn.insert_keywords(version_result.package_id, &keywords) + .expect("insert keywords is ok"); + + // Test get_full_package_with_categories + let full_package = conn + .get_full_package_with_categories("full-test-package".to_string(), TEST_VERSION_1.to_string()) + .expect("get full package with categories is ok"); + + assert_eq!(full_package.package.name, "full-test-package"); + assert_eq!(full_package.package.version, TEST_VERSION_1); + assert_eq!(full_package.package.description, Some("Full test package description".into())); + assert_eq!(full_package.package.license, Some("MIT".into())); + + // Check categories + assert_eq!(full_package.categories.len(), 2); + assert!(full_package.categories.contains(&"utilities".to_string())); + assert!(full_package.categories.contains(&"testing".to_string())); + + // Check keywords + assert_eq!(full_package.keywords.len(), 3); + assert!(full_package.keywords.contains(&"mock".to_string())); + assert!(full_package.keywords.contains(&"test".to_string())); + assert!(full_package.keywords.contains(&"unit".to_string())); + + Ok::<(), diesel::result::Error>(()) + }); +} + +#[test] +#[serial] +fn test_get_categories_and_keywords_for_packages() { + let db = &mut setup_db(); + let _ = db.transaction(|conn| { + // Set up session, user, token, and upload. + let session = conn + .new_user_session(&mock_user_1(), 1000) + .expect("session is ok"); + let user = conn.get_user_for_session(session.id).expect("user is ok"); + let (token, _) = conn + .new_token(user.id, "test token".to_string()) + .expect("token is ok"); + let upload = conn + .new_upload(&NewUpload { + id: uuid::Uuid::new_v4(), + forc_version: TEST_VERSION_1.into(), + source_code_ipfs_hash: "test-ipfs-hash".into(), + abi_ipfs_hash: None, + bytecode_identifier: None, + readme: None, + forc_manifest: TEST_MANIFEST.into(), + }) + .expect("upload is ok"); + + // Create two packages + let request1 = PublishInfo { + package_name: "package-one".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("First package".into()), + repository: None, + documentation: None, + homepage: None, + urls: vec![], + readme: None, + license: None, + }; + + let version_result1 = conn + .new_package_version(&token, &request1) + .expect("version result is ok"); + + let request2 = PublishInfo { + package_name: "package-two".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("Second package".into()), + repository: None, + documentation: None, + homepage: None, + urls: vec![], + readme: None, + license: None, + }; + + let version_result2 = conn + .new_package_version(&token, &request2) + .expect("version result is ok"); + + // Insert categories and keywords for both packages + conn.insert_categories(version_result1.package_id, &vec!["cat1".to_string(), "common".to_string()]) + .expect("insert categories is ok"); + conn.insert_keywords(version_result1.package_id, &vec!["key1".to_string(), "shared".to_string()]) + .expect("insert keywords is ok"); + + conn.insert_categories(version_result2.package_id, &vec!["cat2".to_string(), "common".to_string()]) + .expect("insert categories is ok"); + conn.insert_keywords(version_result2.package_id, &vec!["key2".to_string(), "shared".to_string()]) + .expect("insert keywords is ok"); + + // Test get_categories_for_packages + let categories = conn + .get_categories_for_packages(&vec![version_result1.package_id, version_result2.package_id]) + .expect("get categories for packages is ok"); + + assert_eq!(categories.len(), 4); // 2 categories per package + let category_names: Vec = categories.iter().map(|c| c.category.clone()).collect(); + assert!(category_names.contains(&"cat1".to_string())); + assert!(category_names.contains(&"cat2".to_string())); + assert!(category_names.contains(&"common".to_string())); + + // Test get_keywords_for_packages + let keywords = conn + .get_keywords_for_packages(&vec![version_result1.package_id, version_result2.package_id]) + .expect("get keywords for packages is ok"); + + assert_eq!(keywords.len(), 4); // 2 keywords per package + let keyword_names: Vec = keywords.iter().map(|k| k.keyword.clone()).collect(); + assert!(keyword_names.contains(&"key1".to_string())); + assert!(keyword_names.contains(&"key2".to_string())); + assert!(keyword_names.contains(&"shared".to_string())); + + Ok::<(), diesel::result::Error>(()) + }); +} + +#[test] +#[serial] +fn test_keywords_specific_functionality() { + let db = &mut setup_db(); + let _ = db.transaction(|conn| { + // Set up session, user, token, and upload. + let session = conn + .new_user_session(&mock_user_1(), 1000) + .expect("session is ok"); + let user = conn.get_user_for_session(session.id).expect("user is ok"); + let (token, _) = conn + .new_token(user.id, "test token".to_string()) + .expect("token is ok"); + let upload = conn + .new_upload(&NewUpload { + id: uuid::Uuid::new_v4(), + forc_version: TEST_VERSION_1.into(), + source_code_ipfs_hash: "test-ipfs-hash".into(), + abi_ipfs_hash: None, + bytecode_identifier: None, + readme: None, + forc_manifest: TEST_MANIFEST.into(), + }) + .expect("upload is ok"); + + // Create package with ONLY keywords (no categories) + let request = PublishInfo { + package_name: "keyword-only-package".into(), + upload_id: upload.id, + num: Version::parse(TEST_VERSION_1).unwrap(), + package_description: Some("A package with only keywords for testing".into()), + repository: None, + documentation: None, + homepage: None, + urls: vec![], + readme: None, + license: None, + }; + + let version_result = conn + .new_package_version(&token, &request) + .expect("version result is ok"); + + // Insert ONLY keywords (no categories) + let keywords = vec![ + "rust".to_string(), + "blockchain".to_string(), + "smart-contracts".to_string(), + "defi".to_string(), + "testing".to_string(), + ]; + conn.insert_keywords(version_result.package_id, &keywords) + .expect("insert keywords is ok"); + + // Test 1: Search by specific keyword + let search_result = conn + .search_packages_with_categories( + "rust".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("search by rust keyword is ok"); + + assert_eq!(search_result.data.len(), 1); + assert_eq!(search_result.data[0].package.name, "keyword-only-package"); + assert_eq!(search_result.data[0].categories.len(), 0); // No categories + assert_eq!(search_result.data[0].keywords.len(), 5); // All keywords + assert!(search_result.data[0].keywords.contains(&"rust".to_string())); + assert!(search_result.data[0].keywords.contains(&"blockchain".to_string())); + assert!(search_result.data[0].keywords.contains(&"smart-contracts".to_string())); + + // Test 2: Search by compound keyword + let search_result = conn + .search_packages_with_categories( + "smart-contracts".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("search by smart-contracts keyword is ok"); + + assert_eq!(search_result.data.len(), 1); + assert_eq!(search_result.data[0].package.name, "keyword-only-package"); + assert!(search_result.data[0].keywords.contains(&"smart-contracts".to_string())); + + // Test 3: Search by partial keyword match + let search_result = conn + .search_packages_with_categories( + "contract".to_string(), + Pagination { + page: Some(1), + per_page: Some(10), + }, + ) + .expect("search by partial keyword is ok"); + + assert_eq!(search_result.data.len(), 1); + assert_eq!(search_result.data[0].package.name, "keyword-only-package"); + + // Test 4: Test get_keywords_for_package + let package_keywords = conn + .get_keywords_for_package(version_result.package_id) + .expect("get keywords for package is ok"); + + assert_eq!(package_keywords.len(), 5); + let keyword_names: Vec = package_keywords.iter().map(|k| k.keyword.clone()).collect(); + assert!(keyword_names.contains(&"rust".to_string())); + assert!(keyword_names.contains(&"blockchain".to_string())); + assert!(keyword_names.contains(&"smart-contracts".to_string())); + assert!(keyword_names.contains(&"defi".to_string())); + assert!(keyword_names.contains(&"testing".to_string())); + + // Test 5: Verify keywords are sorted alphabetically + let sorted_keywords: Vec = package_keywords.iter().map(|k| k.keyword.clone()).collect(); + let mut expected_sorted = keywords.clone(); + expected_sorted.sort(); + assert_eq!(sorted_keywords, expected_sorted); + + Ok::<(), diesel::result::Error>(()) + }); +}