diff --git a/client/src/components/commons/AdvancedSearchDrawer.tsx b/client/src/components/commons/AdvancedSearchDrawer.tsx index 2da3dd1b..a7b238d9 100644 --- a/client/src/components/commons/AdvancedSearchDrawer.tsx +++ b/client/src/components/commons/AdvancedSearchDrawer.tsx @@ -16,10 +16,10 @@ import CatalogBookFilters from "./CommonsCatalog/CatalogBookFilters"; interface AdvancedSearchDrawerProps extends React.HTMLAttributes { + activeIndex: number; + setActiveIndex: (index: number) => void; searchString: string; setSearchString: (searchString: string) => void; - resourceType: "books" | "assets" | "projects"; - setResourceType: (resourceType: "books" | "assets" | "projects") => void; submitSearch: () => void; assetFilters: AssetFilters; setAssetFilters: (filters: AssetFilters) => void; @@ -30,10 +30,10 @@ interface AdvancedSearchDrawerProps } const AdvancedSearchDrawer: React.FC = ({ + activeIndex, + setActiveIndex, searchString, setSearchString, - resourceType, - setResourceType, submitSearch, assetFilters, setAssetFilters, @@ -54,8 +54,8 @@ const AdvancedSearchDrawer: React.FC = ({ setStrictMode(false); } - function handleChangeResourceType(type: "books" | "assets" | "projects") { - setResourceType(type); + function handleChangeResourceType(idx: number) { + setActiveIndex(idx); setBookFilters({}); setAssetFilters({}); setStrictMode(false); @@ -63,11 +63,8 @@ const AdvancedSearchDrawer: React.FC = ({ return (
-
+
e.preventDefault()}> -

- Advanced Search Options -

@@ -76,8 +73,8 @@ const AdvancedSearchDrawer: React.FC = ({ name="resource-type" id="books" className="!ml-4" - checked={resourceType === "books"} - onChange={() => handleChangeResourceType("books")} + checked={activeIndex === 0} + onChange={() => handleChangeResourceType(0)} />
-
diff --git a/client/src/components/commons/CommonsCatalog.tsx b/client/src/components/commons/CommonsCatalog.tsx index 96c130ef..611974cc 100644 --- a/client/src/components/commons/CommonsCatalog.tsx +++ b/client/src/components/commons/CommonsCatalog.tsx @@ -29,9 +29,6 @@ const CommonsCatalog = () => { const [searchString, setSearchString] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); - const [searchResourceType, setSearchResourceType] = useState< - "books" | "assets" | "projects" - >("books"); const [activeIndex, setActiveIndex] = useState(0); const [activePage, setActivePage] = useState(1); @@ -492,8 +489,8 @@ const CommonsCatalog = () => { { useEffect(() => { getClosedTickets(); - }, [activePage, itemsPerPage, activeSort]) + }, [activePage, itemsPerPage, activeSort]); async function getClosedTickets() { try { @@ -58,8 +58,8 @@ const SupportDashboard = () => { } setTotalItems(res.data.total); - setTotalPages(Math.ceil(res.data.total / totalItems)); - return (res.data.tickets as SupportTicket[]) ?? []; + setTotalPages(Math.ceil(res.data.total / itemsPerPage)); + setClosedTickets(res.data.tickets); } catch (err) { handleGlobalError(err); return []; diff --git a/server/api/books.ts b/server/api/books.ts index 58cef379..f3683cf6 100644 --- a/server/api/books.ts +++ b/server/api/books.ts @@ -785,14 +785,15 @@ async function getCommonsCatalog( libreCoverID: 1, }; - const projResults = await Project.aggregate([ - { - $match: projectWithAssociatedBookQuery, - }, - { - $project: projectWithAssociatedBookProjection, - }, - ]) ?? []; + const projResults = + (await Project.aggregate([ + { + $match: projectWithAssociatedBookQuery, + }, + { + $project: projectWithAssociatedBookProjection, + }, + ])) ?? []; const projBookIDs = projResults.map( (proj) => `${proj.libreLibrary}-${proj.libreCoverID}` @@ -879,19 +880,22 @@ async function getCommonsCatalog( }, []); // Ensure no duplicates - const resultBookIDs = [...new Set(aggResults.map((book) => book.bookID))]; - const resultBooks = [ - ...aggResults.filter((book) => resultBookIDs.includes(book.bookID)), - ] - + const resultBookIDs = new Set(); + const resultBooks = aggResults.filter((book) => { + if (!resultBookIDs.has(book.bookID)) { + resultBookIDs.add(book.bookID); + return true; + } + return false; + }); + const totalNumBooks = resultBooks.length; - const offset = getRandomOffset(totalNumBooks) + const offset = getRandomOffset(totalNumBooks); - const randomized = resultBooks.slice(offset, offset + limit) + const randomized = resultBooks.slice(offset, offset + limit); return res.send({ err: false, - numFound: resultBooks.length, numTotal: totalNumBooks, books: randomized, }); diff --git a/server/api/projects.js b/server/api/projects.js index df15844c..e6e0f75c 100644 --- a/server/api/projects.js +++ b/server/api/projects.js @@ -1347,14 +1347,17 @@ async function getPublicProjects(req, res) { visibility: "public", }).select({ notes: 0, leads: 0 , liaisons: 0, members: 0, auditors: 0, a11yReview: 0, flag: 0, flagDescrip: 0 - }).lean().exec(); + }).skip(offset).limit(limit).lean().exec(); + + const totalCount = await Project.estimatedDocumentCount({ + orgID: process.env.ORG_ID, + visibility: "public", + }); - const totalCount = projects.length; - const paginatedProjects = projects.slice(offset, offset + limit); return res.send({ err: false, - projects: paginatedProjects, - totalCount, + projects: projects || [], + totalCount: totalCount || 0, }); } catch (e) { debugError(e); diff --git a/server/api/search.ts b/server/api/search.ts index 0b437bfd..92e1a7bc 100644 --- a/server/api/search.ts +++ b/server/api/search.ts @@ -23,6 +23,7 @@ import { userSearchSchema, } from "./validators/search.js"; import ProjectFile from "../models/projectfile.js"; +import authAPI from "./auth.js"; /** * Performs a global search across multiple Conductor resource types (e.g. Projects, Books, etc.) @@ -48,12 +49,27 @@ async function projectsSearch( const projectsLimit = parseInt(req.query.limit?.toString()) || 25; const projectsOffset = getPaginationOffset(projectsPage, req.query.limit); + let isSuperAdmin = false; + + if (req.user?.decoded?.uuid) { + const user = await User.findOne({ uuid: req.user?.decoded?.uuid }); + if (user) { + isSuperAdmin = authAPI.checkHasRole( + user, + "libretexts", + "superadmin", + true + ); + } + } + const projectMatchObj = _generateProjectMatchObj({ - projLocation: req.query.location || undefined, - projStatus: req.query.status || undefined, - projVisibility: req.query.visibility || undefined, + projLocation: req.query.location, + projStatus: req.query.status, + projClassification: req.query.classification, queryRegex, - userUUID: req.user?.decoded.uuid || undefined, + userUUID: req.user?.decoded.uuid, + isSuperAdmin: isSuperAdmin, }); const results = await Project.aggregate([ @@ -161,17 +177,17 @@ async function booksSearch( const booksPage = parseInt(req.query.page?.toString()) || 1; const booksLimit = parseInt(req.query.limit?.toString()) || 25; - const booksOffset = getPaginationOffset(booksPage, req.query.limit); + const booksOffset = getPaginationOffset(booksPage, booksLimit); const matchObj = _generateBookMatchObj({ - library: req.query.library || undefined, - subject: req.query.subject || undefined, - location: req.query.location || undefined, - license: req.query.license || undefined, - author: req.query.author || undefined, - course: req.query.course || undefined, - publisher: req.query.publisher || undefined, - affiliation: req.query.affiliation || undefined, + library: req.query.library, + subject: req.query.subject, + location: req.query.location, + license: req.query.license, + author: req.query.author, + course: req.query.course, + publisher: req.query.publisher, + affiliation: req.query.affiliation, queryRegex: queryRegex, }); @@ -185,38 +201,16 @@ async function booksSearch( __v: 0, }, }, + { + $sort: { + [req.query.sort]: 1, + }, + }, ]); const totalCount = results.length; const paginated = results.slice(booksOffset, booksOffset + booksLimit); - // Does this need to happen before paginating? - paginated.sort((a, b) => { - let aData = null; - let bData = null; - if (req.query.sort === "title") { - aData = _transformToCompare(a.title); - bData = _transformToCompare(b.title); - } else if (req.query.sort === "author") { - aData = _transformToCompare(a.author); - bData = _transformToCompare(b.author); - } else if (req.query.sort === "library") { - aData = _transformToCompare(a.library); - bData = _transformToCompare(b.library); - } else if (req.query.sort === "subject") { - aData = _transformToCompare(a.subject); - bData = _transformToCompare(b.subject); - } else if (req.query.sort === "affiliation") { - aData = _transformToCompare(a.affiliation); - bData = _transformToCompare(b.affiliation); - } - if (aData !== null && bData !== null) { - if (aData < bData) return -1; - if (aData > bData) return 1; - } - return 0; - }); - return res.send({ err: false, numResults: totalCount, @@ -253,7 +247,7 @@ function _generateBookMatchObj({ let bookFiltersOptions = {}; if (library) { - bookFilters.push({ libreLibrary: library }); + bookFilters.push({ library }); } if (subject) { @@ -316,24 +310,31 @@ function _generateBookMatchObj({ function _generateProjectMatchObj({ projLocation, projStatus, - projVisibility, + projClassification, queryRegex, userUUID, + isSuperAdmin, }: { projLocation?: string; projStatus?: string; - projVisibility?: "public" | "private"; + projClassification?: string; queryRegex?: object; userUUID?: string; + isSuperAdmin?: boolean; }) { const projectFilters = []; let projectFiltersOptions = {}; - // If project location is not 'any', add it to the filters + // If project location is not 'global', add it to the filters if (projLocation === "local") { projectFilters.push({ orgID: process.env.ORG_ID }); } + // If project classification is not 'any', add it to the filters + if (projClassification !== "any") { + projectFilters.push({ classification: projClassification }); + } + // If project status is not 'any', add it to the filters if (projStatus && projectAPI.projectStatusOptions.includes(projStatus)) { projectFilters.push({ status: projStatus }); @@ -341,22 +342,24 @@ function _generateProjectMatchObj({ // Generate visibility query let visibilityQuery = {}; - // if (origin === "conductor" && userUUID && projVisibility === "private") { - // const teamMemberQuery = - // projectAPI.constructProjectTeamMemberQuery(userUUID); - - // const privateProjectQuery = { - // $and: [{ visibility: "private" }, { $or: teamMemberQuery }], - // }; - - // visibilityQuery = { - // ...privateProjectQuery, - // }; - // } else { - // visibilityQuery = { visibility: "public" }; - // } - // projectFilters.push(visibilityQuery); - projectFilters.push({ visibility: "public" }); // TODO: handle showing private projects when logged in + if (!isSuperAdmin && userUUID) { + const teamMemberQuery = + projectAPI.constructProjectTeamMemberQuery(userUUID); + + const privateProjectQuery = { + $and: [{ visibility: "private" }, { $or: teamMemberQuery }], + }; + + visibilityQuery = { + ...privateProjectQuery, + }; + } else { + visibilityQuery = { visibility: "public" }; + } + + if (Object.keys(visibilityQuery).length > 0) { + projectFilters.push(visibilityQuery); + } // If multiple filters, use $and, otherwise just use the filter if (projectFilters.length > 1) { @@ -394,36 +397,38 @@ export async function assetsSearch( try { //req.query = getSchemaWithDefaults(req.query, conductorSearchQuerySchema); - const mongoSearchQueryTerm = req.query.searchQuery; + const mongoSearchQueryTerm = req.query.searchQuery ?? ""; const assetsPage = parseInt(req.query.page?.toString()) || 1; const assetsLimit = parseInt(req.query.limit?.toString()) || 25; - const assetsOffset = getPaginationOffset(assetsPage, req.query.limit); + const assetsOffset = getPaginationOffset(assetsPage, assetsLimit); const searchQueryObj = _buildAssetsSearchQuery({ query: mongoSearchQueryTerm, - fileTypeFilter: req.query.fileType || undefined, - licenseFilter: req.query.license || undefined, - licenseVersionFilter: req.query.licenseVersion || undefined, - strictMode: req.query.strictMode || false, + fileTypeFilter: req.query.fileType, + licenseFilter: req.query.license, + licenseVersionFilter: req.query.licenseVersion, + strictMode: req.query.strictMode, }); const matchObj = _buildFilesFilter({ - query: mongoSearchQueryTerm || "", - fileTypeFilter: req.query.fileType || undefined, - licenseFilter: req.query.license || undefined, - licenseVersionFilter: req.query.licenseVersion || undefined, - strictMode: req.query.strictMode || false, + query: mongoSearchQueryTerm, + fileTypeFilter: req.query.fileType, + licenseFilter: req.query.license, + licenseVersionFilter: req.query.licenseVersion, + strictMode: req.query.strictMode, }); const fromProjectFilesPromise = ProjectFile.aggregate([ - { - $search: searchQueryObj, - }, - { - $addFields: { - score: { $meta: "searchScore" }, - }, - }, + ...(Object.keys(searchQueryObj).length > 0 + ? [ + { $search: searchQueryObj }, + { + $addFields: { + score: { $meta: "searchScore" }, + }, + }, + ] + : []), { $match: { $and: [ @@ -448,7 +453,6 @@ export async function assetsSearch( localField: "framework", foreignField: "_id", pipeline: [ - // Go through each template in framework and lookup key { $unwind: { path: "$templates", @@ -545,6 +549,8 @@ export async function assetsSearch( $expr: { $eq: ["$projectID", "$$searchID"], }, + visibility: "public", + orgID: process.env.ORG_ID, }, }, { @@ -564,11 +570,20 @@ export async function assetsSearch( }, }, }, + { + $match: { + // Filter where project was not public or does not exist, so projectInfo wasn't set + projectInfo: { + $exists: true, + $ne: [null, {}], + }, + }, + }, { $sort: { _id: 1, - } - } + }, + }, ]); const fromAssetTagsPromise = AssetTag.aggregate([ @@ -622,41 +637,40 @@ export async function assetsSearch( }, }, { - $project: - { - projectInfo: { - _id: 0, - projectID: 0, - orgID: 0, - status: 0, - visibility: 0, - currentProgress: 0, - peerProgress: 0, - a11yProgress: 0, - leads: 0, - liaisons: 0, - members: 0, - auditors: 0, - tags: 0, - allowAnonPR: 0, - cidDescriptors: 0, - associatedOrgs: 0, - a11yReview: 0, - createdAt: 0, - updatedAt: 0, - __v: 0, - adaptCourseID: 0, - adaptURL: 0, - classification: 0, - defaultFileLicense: 0, - libreCampus: 0, - libreLibrary: 0, - libreShelf: 0, - projectURL: 0, - didCreateWorkbench: 0, - didMigrateWorkbench: 0, - }, + $project: { + projectInfo: { + _id: 0, + projectID: 0, + orgID: 0, + status: 0, + visibility: 0, + currentProgress: 0, + peerProgress: 0, + a11yProgress: 0, + leads: 0, + liaisons: 0, + members: 0, + auditors: 0, + tags: 0, + allowAnonPR: 0, + cidDescriptors: 0, + associatedOrgs: 0, + a11yReview: 0, + createdAt: 0, + updatedAt: 0, + __v: 0, + adaptCourseID: 0, + adaptURL: 0, + classification: 0, + defaultFileLicense: 0, + libreCampus: 0, + libreLibrary: 0, + libreShelf: 0, + projectURL: 0, + didCreateWorkbench: 0, + didMigrateWorkbench: 0, }, + }, }, { $lookup: { @@ -761,8 +775,8 @@ export async function assetsSearch( { $sort: { _id: 1, - } - } + }, + }, ]); const aggregations = [fromProjectFilesPromise]; @@ -789,7 +803,6 @@ export async function assetsSearch( const totalCount = withoutDuplicates.length; - //'Paginate' results since we can't use skip/limit since there are two aggregations const paginated = withoutDuplicates.slice( assetsOffset, assetsOffset + assetsLimit @@ -879,23 +892,30 @@ function _buildAssetsSearchQuery({ strictMode?: boolean; }) { const baseQuery: Record = { - text: { - query: query || "", - path: { - wildcard: "*", - }, - fuzzy: { - maxEdits: 2, - maxExpansions: 50, + ...(query && { + text: { + query, + path: { + wildcard: "*", + }, + fuzzy: { + maxEdits: 2, + maxExpansions: 50, + }, }, - }, + }), }; - if (!fileTypeFilter && !licenseFilter && !licenseVersionFilter) { + if (!fileTypeFilter && !licenseFilter) { return baseQuery; } const compoundQueries = []; + + if (Object.keys(baseQuery).length > 0) { + compoundQueries.push(baseQuery); + } + if (fileTypeFilter) { const isWildCard = fileTypeFilter.includes("*"); const parsedFileFilter = isWildCard @@ -903,7 +923,7 @@ function _buildAssetsSearchQuery({ : fileTypeFilter; // if mime type is wildcard, only use the first part of the mime type compoundQueries.push({ text: { - path: "files.mimeType", + path: "mimeType", query: parsedFileFilter, }, }); @@ -912,20 +932,21 @@ function _buildAssetsSearchQuery({ if (licenseFilter) { compoundQueries.push({ text: { - path: "files.license.name", + path: "license.name", query: licenseFilter, }, }); } - const compoundQueryObj = strictMode === true ? { must: [...compoundQueries] } : { should: [...compoundQueries] }; - + const compoundQueryObj = + strictMode === true + ? { must: [...compoundQueries] } + : { should: [...compoundQueries] }; + // Build compound query - const filteredQuery = { + return { compound: compoundQueryObj, }; - - return filteredQuery; } async function homeworkSearch( diff --git a/server/api/support.ts b/server/api/support.ts index 9e2c3193..528de9fe 100644 --- a/server/api/support.ts +++ b/server/api/support.ts @@ -615,23 +615,34 @@ async function updateTicket( const user = await User.findOne({ uuid: userUUID }).orFail(); - const ticket = await SupportTicket.findOneAndUpdate( + const ticket = await SupportTicket.findOne({ uuid }).orFail(); + + // If status/priority is the same, just return the ticket + if (ticket.status === status && ticket.priority === priority) { + return res.send({ + err: false, + ticket, + }); + } + + let updatedFeed = ticket.feed; + if (status === "closed") { + const feedEntry = _createFeedEntry_Closed( + `${user.firstName} ${user.lastName}` + ); + updatedFeed.push(feedEntry); + } + + await SupportTicket.updateOne( { uuid }, { priority, status, + feed: updatedFeed, timeClosed: status === "closed" ? new Date().toISOString() : undefined, // if status is closed, set timeClosed to now } ).orFail(); - if (status === "closed") { - const feedEntry = _createFeedEntry_Closed( - `${user.firstName} ${user.lastName}` - ); - ticket.feed.push(feedEntry); - await ticket.save(); - } - return res.send({ err: false, ticket, diff --git a/server/api/validators/search.ts b/server/api/validators/search.ts index ccf5edaf..b1e38666 100644 --- a/server/api/validators/search.ts +++ b/server/api/validators/search.ts @@ -63,7 +63,7 @@ export const projectSearchSchema = z.object({ .object({ location: z.enum(["local", "global"]).default("global"), status: z.string().default("any"), - visibility: z.enum(["public", "private"]).default("public"), + classification: z.string().default("any"), sort: z .enum([ "title", diff --git a/server/models/project.ts b/server/models/project.ts index 288470d3..c2e83e91 100644 --- a/server/models/project.ts +++ b/server/models/project.ts @@ -95,6 +95,7 @@ const ProjectSchema = new Schema( type: String, default: "private", enum: ["public", "private"], + index: true, }, /** * Estimated Project progress (%).