diff --git a/client/src/Conductor.jsx b/client/src/Conductor.jsx index 80d1a5264..0a32a3f1e 100644 --- a/client/src/Conductor.jsx +++ b/client/src/Conductor.jsx @@ -44,7 +44,7 @@ import OrganizationsManager from './components/controlpanel/OrganizationsManager import PeerReviewPage from './components/peerreview/PeerReviewPage'; import PeerReviewRubricManage from './components/controlpanel/PeerReviewRubricManage'; import PeerReviewRubrics from './components/controlpanel/PeerReviewRubrics'; -const PeopleManager = lazy(() => import('./screens/conductor/controlpanel/PeopleManager')); +const AuthorsManager = lazy(() => import('./screens/conductor/controlpanel/AuthorsManager')); import ProjectAccessibility from './components/projects/ProjectAccessibility'; import ProjectPeerReview from './components/projects/ProjectPeerReview'; const MyProjects = lazy(() => import('./screens/conductor/Projects')); @@ -143,7 +143,7 @@ const Conductor = () => { - + diff --git a/client/src/api.ts b/client/src/api.ts index 98d102abd..8d985f707 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -52,7 +52,6 @@ import { CentralIdentityAppLicense, StoreDigitalDeliveryOption, StoreOrder, - GetStoreOrdersResponse, StoreOrderWithStripeSession, OrderCharge, OrderSession, @@ -79,7 +78,7 @@ import { CustomFilter, MiniRepoSearchParams, } from "./types/Search"; -import { CloudflareCaptionData, SortDirection } from "./types/Misc"; +import { CloudflareCaptionData, ConductorInfiniteScrollResponse, SortDirection } from "./types/Misc"; import { TrafficAnalyticsAggregatedMetricsByPageDataPoint, TrafficAnalyticsBaseRequestParams, @@ -284,11 +283,10 @@ class API { return res; } - public cloudflareStreamUploadURL: string = `${ - import.meta.env.MODE === "development" + public cloudflareStreamUploadURL: string = `${import.meta.env.MODE === "development" ? import.meta.env.VITE_DEV_BASE_URL : "" - }/api/v1/cloudflare/stream-url`; + }/api/v1/cloudflare/stream-url`; async getPermanentLink(projectID: string, fileID: string) { const res = await axios.get< @@ -303,25 +301,20 @@ class API { async getAuthors({ page, limit, - sort, query, + sort, }: { page?: number; limit?: number; - sort?: string; query?: string; + sort?: string; }) { - const res = await axios.get< - { - authors: Author[]; - totalCount: number; - } & ConductorBaseResponse - >("/authors", { + const res = await axios.get>("/authors", { params: { page, limit, - sort, query, + sort, }, }); return res; @@ -360,15 +353,6 @@ class API { return res; } - async bulkCreateAuthors(authors: Omit[]) { - const res = await axios.post< - { - authors: Author[]; - } & ConductorBaseResponse - >("/authors/bulk", { authors }); - return res; - } - async updateAuthor(id: string, data: Author) { const res = await axios.patch< { @@ -648,7 +632,7 @@ class API { lulu_status?: string; query?: string; }) { - const res = await axios.get( + const res = await axios.get>( "/store/admin/orders", { params, @@ -1418,18 +1402,18 @@ class API { async updateUserPinnedProjects( data: | { - action: "add-project" | "move-project"; - folder: string; - projectID: string; - } + action: "add-project" | "move-project"; + folder: string; + projectID: string; + } | { - action: "remove-project"; - projectID: string; - } + action: "remove-project"; + projectID: string; + } | { - action: "add-folder" | "remove-folder"; - folder: string; - } + action: "add-folder" | "remove-folder"; + folder: string; + } ) { const res = await axios.patch( "/user/projects/pinned", diff --git a/client/src/components/FilesManager/AuthorsForm.tsx b/client/src/components/FilesManager/AuthorsForm.tsx index 64d51819c..bff6a2f75 100644 --- a/client/src/components/FilesManager/AuthorsForm.tsx +++ b/client/src/components/FilesManager/AuthorsForm.tsx @@ -5,14 +5,11 @@ import { useMemo, useState, } from "react"; -import { Button, Dropdown, Form, Icon } from "semantic-ui-react"; -import { Author } from "../../types"; +import { Dropdown, Form } from "semantic-ui-react"; import useGlobalError from "../error/ErrorHooks"; import useDebounce from "../../hooks/useDebounce"; import api from "../../api"; import { ProjectFileAuthor } from "../../types/Project"; -import ManualEntryModal from "../util/ManualEntryModal"; -import { useModals } from "../../context/ModalContext"; interface AuthorsFormProps { mode: "project-default" | "file"; @@ -40,7 +37,6 @@ const AuthorsForm = forwardRef( const { handleGlobalError } = useGlobalError(); const { debounce } = useDebounce(); - const { openModal, closeAllModals } = useModals(); const [authorOptions, setAuthorOptions] = useState([]); const [secondaryAuthorOptions, setSecondaryAuthorOptions] = useState< @@ -111,12 +107,12 @@ const AuthorsForm = forwardRef( if (res.data.err) { throw new Error(res.data.errMsg); } - if (!res.data.authors || !Array.isArray(res.data.authors)) { + if (!res.data.items || !Array.isArray(res.data.items)) { throw new Error("Failed to load author options"); } const opts = [ - ...res.data.authors, + ...res.data.items, ...(selectedPrimary ? [selectedPrimary] : []), ...(selectedCorresponding ? [selectedCorresponding] : []), ...(selectedSecondary ?? []), @@ -143,11 +139,11 @@ const AuthorsForm = forwardRef( if (res.data.err) { throw new Error(res.data.errMsg); } - if (!res.data.authors || !Array.isArray(res.data.authors)) { + if (!res.data.items || !Array.isArray(res.data.items)) { throw new Error("Failed to load author options"); } - const opts = [...res.data.authors, ...(selectedSecondary ?? [])]; + const opts = [...res.data.items, ...(selectedSecondary ?? [])]; const unique = opts.filter( (a, i, self) => self.findIndex((b) => b._id === a._id) === i @@ -168,12 +164,12 @@ const AuthorsForm = forwardRef( if (res.data.err) { throw new Error(res.data.errMsg); } - if (!res.data.authors || !Array.isArray(res.data.authors)) { + if (!res.data.items || !Array.isArray(res.data.items)) { throw new Error("Failed to load author options"); } const opts = [ - ...res.data.authors, + ...res.data.items, ...(selectedCorresponding ? [selectedCorresponding] : []), ]; @@ -200,74 +196,13 @@ const AuthorsForm = forwardRef( 200 ); - const handleAddAuthor = (newAuthor: Author, ctx: string) => { - if (!["primary", "secondary", "corresponding"].includes(ctx)) return; - - const withID = { - ...newAuthor, - _id: crypto.randomUUID(), - } - - // Set the manually added author as the primary or secondary author - // based on where the manual entry was triggered - if (ctx === "primary") { - setSelectedPrimary(withID); - setAuthorOptions([...authorOptions, withID]); - } - - if (ctx === "corresponding") { - setSelectedCorresponding(withID); - setCorrespondingAuthorOptions([...correspondingAuthorOptions, withID]); - } - - if (ctx === "secondary") { - setSecondaryAuthorOptions([...secondaryAuthorOptions, withID]); - if (currentAuthors) { - setSelectedSecondary([...currentAuthors, withID]); - } else { - setSelectedSecondary([withID]); - } - } - }; - - const ManualEntryButton = ({ - from, - }: { - from: "primary" | "secondary" | "corresponding"; - }) => ( -
- -
- ); - const primaryAuthorOpts = useMemo(() => { const opts = authorOptions .filter((a) => !selectedSecondary?.find((ca) => ca._id === a._id)) .map((a) => ({ key: crypto.randomUUID(), value: a._id ?? "", - text: `${a.firstName} ${a.lastName}`, + text: a.name ?? "Unknown", })); opts.unshift({ @@ -285,7 +220,7 @@ const AuthorsForm = forwardRef( .map((a) => ({ key: crypto.randomUUID(), value: a._id ?? "", - text: `${a.firstName} ${a.lastName}`, + text: a.name ?? "Unknown", })); opts.unshift({ @@ -301,7 +236,7 @@ const AuthorsForm = forwardRef( const opts = correspondingAuthorOptions.map((a) => ({ key: crypto.randomUUID(), value: a._id ?? "", - text: `${a.firstName} ${a.lastName}`, + text: a.name ?? "Unknown", })); opts.unshift({ @@ -343,7 +278,6 @@ const AuthorsForm = forwardRef( loading={loadingAuthors} /> -
@@ -417,7 +350,6 @@ const AuthorsForm = forwardRef( loading={loadingSecondaryAuthors} /> -
); diff --git a/client/src/components/FilesManager/FilesUploader.tsx b/client/src/components/FilesManager/FilesUploader.tsx index 336195440..7f21fe801 100644 --- a/client/src/components/FilesManager/FilesUploader.tsx +++ b/client/src/components/FilesManager/FilesUploader.tsx @@ -130,24 +130,24 @@ const FilesUploader: React.FC = ({ formData.append("parentID", uploadPath); // If uploader exists in authors collection, add them as an author to the file - if (mode === "add" && user) { - const authorsRes = await api.getAuthors({ query: user.email }); - if (authorsRes.data.err) { - console.error(authorsRes.data.errMsg); - } - if (!authorsRes.data.authors) { - console.error("An error occurred while getting authors"); - } - - if (authorsRes.data.authors) { - const foundAuthor = authorsRes.data.authors.find( - (author) => author.email === user.email - ); - if (foundAuthor && foundAuthor._id) { - formData.append("authors", [foundAuthor._id].toString()); - } - } - } + // if (mode === "add" && user) { + // const authorsRes = await api.getAuthors({ query: user.email }); + // if (authorsRes.data.err) { + // console.error(authorsRes.data.errMsg); + // } + // if (!authorsRes.data.authors) { + // console.error("An error occurred while getting authors"); + // } + + // if (authorsRes.data.authors) { + // const foundAuthor = authorsRes.data.authors.find( + // (author) => author.email === user.email + // ); + // if (foundAuthor && foundAuthor._id) { + // formData.append("authors", [foundAuthor._id].toString()); + // } + // } + // } if (mode === "replace") { formData.append("overwriteName", overwriteName.toString()); // Only used for replace mode diff --git a/client/src/components/commons/CommonsCatalog/AuthorsTable.tsx b/client/src/components/commons/CommonsCatalog/AuthorsTable.tsx index fae3d9751..349527445 100644 --- a/client/src/components/commons/CommonsCatalog/AuthorsTable.tsx +++ b/client/src/components/commons/CommonsCatalog/AuthorsTable.tsx @@ -20,7 +20,7 @@ const AuthorsTable: React.FC = ({
Name
-
Primary Institution
+
Institution/Program
URL
@@ -35,21 +35,21 @@ const AuthorsTable: React.FC = ({ {truncateString( - `${item.firstName ?? ""} ${item.lastName ?? ""}`, + `${item.name}`, 50 )}

- {item.primaryInstitution && - truncateString(item.primaryInstitution, 50)} + { + truncateString(item.companyName || item.programName || "", 50)}

- {item.url && ( - - {item.url} + {item.nameURL && ( + + {truncateString(item.nameURL, 50)} )} diff --git a/client/src/components/commons/CommonsCatalog/CatalogAssetFilters.tsx b/client/src/components/commons/CommonsCatalog/CatalogAssetFilters.tsx index 53ec9fd7e..027eaf845 100644 --- a/client/src/components/commons/CommonsCatalog/CatalogAssetFilters.tsx +++ b/client/src/components/commons/CommonsCatalog/CatalogAssetFilters.tsx @@ -88,8 +88,8 @@ const CatalogAssetFilters: React.FC = ({ allFilters.peopleOptions = res.data.people.map((p) => { return { key: crypto.randomUUID(), - text: `${p.firstName} ${p.lastName}`, - value: `${p.firstName} ${p.lastName}`, + text: p.name, + value: p.name, }; }); } diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/AuthorCardContent.tsx b/client/src/components/commons/CommonsCatalog/CatalogCard/AuthorCardContent.tsx index f462cd424..40c608324 100644 --- a/client/src/components/commons/CommonsCatalog/CatalogCard/AuthorCardContent.tsx +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/AuthorCardContent.tsx @@ -12,11 +12,6 @@ const AuthorCardContent: React.FC = ({ author, ...rest }) => { - const authorNameTruncated = useMemo(() => { - const fullName = `${author.firstName ?? ""} ${author.lastName ?? ""}`; - return truncateString(fullName, 50); - }, [author.firstName, author.lastName]); - return ( = ({ className="commons-content-card-header !mt-1 !mb-1 text-left hover:underline cursor-pointer !hover:text-blue-500" href={`/author/${author._id}`} > - {authorNameTruncated} + {truncateString(author.name, 100)} - {author.primaryInstitution && ( + {(author.companyName || author.programName) && ( -
{author.primaryInstitution ?? ""}
+
{truncateString(author.companyName || author.programName || "", 50)}
)} {author.projects?.length > 0 && ( @@ -54,16 +49,16 @@ const AuthorCardContent: React.FC = ({ )} - {author.url && ( + {author.nameURL && ( diff --git a/client/src/components/commons/CommonsCatalog/CatalogCard/FileCardContent.tsx b/client/src/components/commons/CommonsCatalog/CatalogCard/FileCardContent.tsx index 1c041a68e..f8ce9260f 100644 --- a/client/src/components/commons/CommonsCatalog/CatalogCard/FileCardContent.tsx +++ b/client/src/components/commons/CommonsCatalog/CatalogCard/FileCardContent.tsx @@ -27,8 +27,7 @@ const FileCardContent: React.FC = ({ const allAuthors = [file.primaryAuthor, ...(file.authors ?? [])] - .filter((a) => a && !!a.firstName && !!a.lastName) - .map((a) => `${a?.firstName} ${a?.lastName}`) + .map((a) => a?.name) .join(", ") || "Unknown"; async function handleFileDownload(file: ConductorSearchResponseFile) { diff --git a/client/src/components/commons/CommonsCatalog/DetailModal/AssetDetailModal.tsx b/client/src/components/commons/CommonsCatalog/DetailModal/AssetDetailModal.tsx index e7f7345d8..222ad087b 100644 --- a/client/src/components/commons/CommonsCatalog/DetailModal/AssetDetailModal.tsx +++ b/client/src/components/commons/CommonsCatalog/DetailModal/AssetDetailModal.tsx @@ -17,17 +17,16 @@ const AssetDetailModal: React.FC = ({ file }) => { const { handleGlobalError } = useGlobalError(); const [downloadLoading, setDownloadLoading] = useState(false); - const getAuthorsElement = () => { + const getAuthorsText = () => { const corresponding = file.correspondingAuthor - ? `${file.correspondingAuthor?.firstName} ${file.correspondingAuthor?.lastName}* (${file.correspondingAuthor?.email})` + ? `${file.correspondingAuthor?.name}* (corresponding)` : ""; const allOthersMapped = file.authors - ?.filter((a) => a && !!a.firstName && !!a.lastName) - .map((a) => `${a?.firstName} ${a?.lastName}`); + ?.map((a) => a?.name); const allTogether = [ file.primaryAuthor - ? `${file.primaryAuthor?.firstName} ${file.primaryAuthor?.lastName}` + ? file.primaryAuthor?.name : "", corresponding, ...(allOthersMapped ?? []), @@ -35,11 +34,7 @@ const AssetDetailModal: React.FC = ({ file }) => { .filter((a) => a) .join(", "); - return ( - - ); + return allTogether || "Unknown"; }; async function handleFileDownload(file: ConductorSearchResponseFile) { @@ -108,7 +103,7 @@ const AssetDetailModal: React.FC = ({ file }) => {
-

{getAuthorsElement()}

+

{getAuthorsText()}

{file.storageType === "file" && ( = ({ file }) => { )}
@@ -168,8 +162,8 @@ const AssetDetailModal: React.FC = ({ file }) => { {file.isURL ? "Open External Link" : file.isVideo - ? "Watch Video" - : "Download File"} + ? "Watch Video" + : "Download File"}
diff --git a/client/src/components/controlpanel/AuthorsManager/GenerateTemplateJSONModal.tsx b/client/src/components/controlpanel/AuthorsManager/GenerateTemplateJSONModal.tsx new file mode 100644 index 000000000..9f3a3bb34 --- /dev/null +++ b/client/src/components/controlpanel/AuthorsManager/GenerateTemplateJSONModal.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +import { Modal } from "semantic-ui-react"; +import { Author } from "../../../types"; +import api from "../../../api"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import Button from "../../NextGenComponents/Button"; + +type GenerateTemplateJSONModalProps = { + onClose: () => void; +} + +/** + * Only these keys from the Author data should be included in the template. + * If the key isn't present in the Author's data, simply omit it from the output JSON. + * The 'nameKey' serves as the key for each author object in the JSON, and it is required. + * The rest of the keys are optional and can be included if the data is available for that author. + */ +const KEYS_TO_INCLUDE: (keyof Author)[] = [ + "nameKey", + "name", + "nameTitle", + "nameURL", + "note", + "noteURL", + "companyName", + "companyURL", + "pictureCircle", + "pictureURL", + "programName", + "programURL", + "attributionPrefix", +] + +const FETCH_LIMIT = 500; + +function toLowercaseKeys(obj: Record): Record { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => [ + key.toLowerCase(), + value !== null && typeof value === "object" && !Array.isArray(value) + ? toLowercaseKeys(value as Record) + : value, + ]) + ); +} + +/** + * Remove invalid escape sequences from string values before they enter the JSON output. + * Collapses one or more backslashes immediately before an apostrophe into just the apostrophe, + * mirroring the fix applied during the author import migration. + * All other escaping is handled correctly by JSON.stringify. + */ +function sanitizeValue(value: unknown): unknown { + if (typeof value !== "string") return value; + return value.replace(/\\+'(?!')/g, "'"); +} + +const GenerateTemplateJSONModal = ({ onClose }: GenerateTemplateJSONModalProps) => { + const [shouldFetch, setShouldFetch] = useState(false); + const [copied, setCopied] = useState(false); + + const { data, isFetching, fetchNextPage, hasNextPage } = useInfiniteQuery({ + queryKey: ["authors-template", FETCH_LIMIT], + queryFn: async ({ pageParam = 1 }) => { + const response = await api.getAuthors({ limit: FETCH_LIMIT, page: pageParam as number }); + if (response.data.err) throw new Error(response.data.errMsg || "Failed to fetch authors."); + return response.data; + }, + getNextPageParam: (lastPage) => { + if (!lastPage?.meta?.has_more || !lastPage?.meta?.next_page) return null; + const parsed = parseInt(lastPage.meta.next_page as string, 10); + return isNaN(parsed) ? undefined : parsed; + }, + enabled: shouldFetch, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + }); + + // Automatically fetch all pages once generation starts + useEffect(() => { + if (shouldFetch && hasNextPage && !isFetching) { + fetchNextPage(); + } + }, [shouldFetch, hasNextPage, isFetching, fetchNextPage]); + + const allAuthors = data?.pages.flatMap((page) => page.items) ?? []; + const isLoading = shouldFetch && isFetching; + const isComplete = shouldFetch && !isFetching && data !== undefined; + + const jsonOutput = isComplete ? (() => { + const seen = new Set(); + const result: Record = {}; + + for (const author of allAuthors) { + if (!author.nameKey || seen.has(author.nameKey)) continue; + seen.add(author.nameKey); + + const filtered = Object.fromEntries( + KEYS_TO_INCLUDE + .filter((key) => { + if (key === "nameKey") return false; // nameKey is used as the root key and should not be nested within the author object + if (!(key in author) || author[key] === undefined || author[key] === "") return false; // exlude keys that are not present or have empty values + if (key === "pictureCircle") return author[key] === "no"; // only include pictureCircle if it's "no" to indicate non-circular pictures, since "yes" is the default assumption in the template + return true; + }) + .map((key) => [key, sanitizeValue(author[key])]) + ); + + result[author.nameKey.toLowerCase()] = toLowercaseKeys(filtered as Record); + } + + return JSON.stringify(result, null, 2); + })() : null; + + async function handleCopy() { + if (!jsonOutput) return; + await navigator.clipboard.writeText(jsonOutput); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( + + Copy Template JSON + +

+ Click Generate to load all author data and preview a template JSON object. Each author's nameKey is used as the root key. + Use the generated JSON to replace the current JSON in Page Content Header and Page Content Footer templates and propagate the changes. Only authors with a nameKey will be included in the output. +

+
+ + {jsonOutput && ( + + )} +
+ {jsonOutput && ( +
+                        {jsonOutput}
+                    
+ )} +
+ + + +
+ ); +}; + +export default GenerateTemplateJSONModal; diff --git a/client/src/components/controlpanel/AuthorsManager/ManageAuthorModal.tsx b/client/src/components/controlpanel/AuthorsManager/ManageAuthorModal.tsx new file mode 100644 index 000000000..002ffb009 --- /dev/null +++ b/client/src/components/controlpanel/AuthorsManager/ManageAuthorModal.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { Form, Modal } from "semantic-ui-react"; +import { Author } from "../../../types"; +import useGlobalError from "../../error/ErrorHooks"; +import CtlTextInput from "../../ControlledInputs/CtlTextInput"; +import { required } from "../../../utils/formRules"; +import api from "../../../api"; +import Button from "../../NextGenComponents/Button"; +import { useQueryClient } from "@tanstack/react-query"; + +interface ManageAuthorModalProps { + show: boolean; + onClose: () => void; + authorID?: string; +} + +const ManageAuthorModal = ({ show, onClose, authorID }: ManageAuthorModalProps) => { + const queryClient = useQueryClient(); + const { handleGlobalError } = useGlobalError(); + const { control, getValues, trigger, reset } = useForm({ + defaultValues: { + nameKey: "", + name: "", + nameTitle: "", + nameURL: "", + note: "", + noteURL: "", + companyName: "", + companyURL: "", + pictureCircle: "yes", + pictureURL: "", + programName: "", + programURL: "", + attributionPrefix: "", + }, + }); + + const mode = authorID ? "edit" : "create"; + const [loading, setLoading] = useState(false); + const [showConfirmDelete, setShowConfirmDelete] = useState(false); + + useEffect(() => { + if (show) { + setShowConfirmDelete(false); + if (authorID) { + loadAuthor(); + } else { + reset(); + } + } + }, [show, authorID]); + + async function loadAuthor() { + try { + if (!authorID) return; + setLoading(true); + const res = await api.getAuthor(authorID); + if (res.data.err) throw new Error(res.data.errMsg); + if (!res.data.author) throw new Error("Invalid response from server."); + reset(res.data.author); + } catch (err) { + handleGlobalError(err); + } finally { + setLoading(false); + } + } + + async function handleSubmit() { + const valid = await trigger(); + if (!valid) return; + + try { + setLoading(true); + const values = getValues(); + + if (mode === "edit" && authorID) { + const res = await api.updateAuthor(authorID, values); + if (res.data.err) throw new Error(res.data.errMsg); + } else { + const res = await api.createAuthor(values); + if (res.data.err) throw new Error(res.data.errMsg); + } + + queryClient.invalidateQueries({ queryKey: ["authors"] }); + + onClose(); + } catch (err) { + handleGlobalError(err); + } finally { + setLoading(false); + } + } + + async function handleDelete() { + try { + if (!authorID) return; + setLoading(true); + const res = await api.deleteAuthor(authorID); + if (res.data.err) throw new Error(res.data.errMsg); + + queryClient.invalidateQueries({ queryKey: ["authors"] }); + + onClose(); + } catch (err) { + handleGlobalError(err); + } finally { + setLoading(false); + setShowConfirmDelete(false); + } + } + + return ( + + {mode === "create" ? "Add" : "Edit"} Author + +
e.preventDefault()} loading={loading}> +
+ + + + + + + + + + + + + + ( + field.onChange(checked ? "yes" : "no")} + /> + )} + /> + +
+
+ {showConfirmDelete && ( +
+

+ Are you sure you want to delete this author? This action cannot be undone. +

+
+ + +
+
+ )} +
+ +
+
+ {mode === "edit" && authorID && !showConfirmDelete && ( + + )} +
+
+ + +
+
+
+
+ ); +}; + +export default ManageAuthorModal; diff --git a/client/src/components/controlpanel/ControlPanel.tsx b/client/src/components/controlpanel/ControlPanel.tsx index bc82190bd..95c7c294d 100644 --- a/client/src/components/controlpanel/ControlPanel.tsx +++ b/client/src/components/controlpanel/ControlPanel.tsx @@ -60,6 +60,13 @@ const ControlPanel = () => { "View requests to access LibreTexts textbook analytics feeds", roles: ["superAdmin"], }, + { + url: "/controlpanel/authorsmanager", + icon: "address book outline", + title: "Authors Manager", + description: + "Manage the master list of Authors that can be associated with Conductor projects and LibreTexts textbooks", + }, { url: "/controlpanel/eventsmanager", icon: "calendar alternate outline", @@ -136,13 +143,6 @@ const ControlPanel = () => { description: "Manage Peer Review rubrics available for use in Conductor projects", }, - { - url: "/controlpanel/peoplemanager", - icon: "address book outline", - title: "People Manager", - description: - "Manage known individuals and their contact information for use in Conductor projects", - }, { url: "/controlpanel/campussettings", icon: "university", diff --git a/client/src/components/controlpanel/PeopleManager/BulkAddPeopleModal.tsx b/client/src/components/controlpanel/PeopleManager/BulkAddPeopleModal.tsx deleted file mode 100644 index 26d85dfaa..000000000 --- a/client/src/components/controlpanel/PeopleManager/BulkAddPeopleModal.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { useState, useRef } from "react"; -import { Control, Controller, useFieldArray } from "react-hook-form"; -import { - Button, - Icon, - Input, - Modal, - ModalProps, - Popup, - Table, -} from "semantic-ui-react"; -import { AssetTagFramework, Author } from "../../../types"; -import "../../../styles/global.css"; -import useGlobalError from "../../error/ErrorHooks"; -import { ParseResult, parse } from "papaparse"; -import LoadingSpinner from "../../LoadingSpinner"; -import api from "../../../api"; - -interface BulkAddPeopleModalProps extends ModalProps { - open: boolean; - onClose: () => void; -} - -const BulkAddPeopleModal: React.FC = ({ - open, - onClose, -}) => { - // Global State & Hooks - const { handleGlobalError } = useGlobalError(); - const [loading, setLoading] = useState(false); - const [peopleToAdd, setPeopleToAdd] = useState([]); - const [didParse, setDidParse] = useState(false); - const fileInputRef = useRef(null); - - const handleFileChange = (event: React.ChangeEvent) => { - // Handle file selection here - const selectedFile = event.target.files?.[0]; - if (selectedFile) { - parseFile(selectedFile); - } - }; - - async function parseFile(file: File) { - try { - setLoading(true); - setDidParse(false); - - function parsePromise(file: File) { - return new Promise>((resolve, reject) => { - // @ts-ignore - parse(file, { - header: true, - skipEmptyLines: true, - trimHeaders: true, - preview: 1500, // Only parse first 1500 rows - complete: (results) => { - resolve(results); // Resolve the Promise with parsed data - }, - error: (error) => { - reject(error); // Reject the Promise with the error - }, - }); - }); - } - - const results = await parsePromise(file); - const dataObjs = results.data.filter( - (r) => typeof r === "object" && r !== null - ) as object[]; - if (dataObjs.length > 1500) { - throw new Error( - "Only 1500 records can be added at a time. Please reduce the number of records and try again." - ); - } - - const peopleToAdd = dataObjs.filter( - (obj) => - obj.hasOwnProperty("firstName") && obj.hasOwnProperty("lastName") - ); - - if (peopleToAdd.length === 0) { - throw new Error("No people to add"); - } - - setPeopleToAdd(peopleToAdd as Author[]); - setDidParse(true); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - async function handleBulkUpload() { - try { - setLoading(true); - - if (!peopleToAdd || peopleToAdd.length === 0) { - throw new Error("No people to add"); - } - - const res = await api.bulkCreateAuthors(peopleToAdd); - - if ( - res.data.err || - !res.data.authors || - !Array.isArray(res.data.authors) - ) { - throw new Error("Error adding people. Please try again."); - } - - setPeopleToAdd([]); - setDidParse(false); - onClose(); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - return ( - onClose()} size="large"> - Bulk Add People - -
- {loading ? ( - - ) : ( - <> - - {didParse && ( -

- - File Parsed Successfully -

- )} - - )} -
-
- -
- - How do I use this? - - } - /> -
- - -
-
-
-
- ); -}; - -export default BulkAddPeopleModal; diff --git a/client/src/components/controlpanel/PeopleManager/ConfirmDeletePersonModal.tsx b/client/src/components/controlpanel/PeopleManager/ConfirmDeletePersonModal.tsx deleted file mode 100644 index 38137be6f..000000000 --- a/client/src/components/controlpanel/PeopleManager/ConfirmDeletePersonModal.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { useState } from "react"; -import { Button, Icon, Modal, ModalProps } from "semantic-ui-react"; -import LoadingSpinner from "../../LoadingSpinner"; -import useGlobalError from "../../error/ErrorHooks"; -import api from "../../../api"; - -interface ConfirmDeletePersonModalProps extends ModalProps { - show: boolean; - personID: string; - onCancel: () => void; - onDeleted: () => void; -} - -const ConfirmDeletePersonModal: React.FC = ({ - show, - personID, - onCancel, - onDeleted, - ...rest -}) => { - // Global state & hooks - const { handleGlobalError } = useGlobalError(); - - // Data & UI - const [loading, setLoading] = useState(false); - - // Methods - async function handleDelete() { - try { - if (!personID) { - throw new Error("No author ID provided"); - } - - setLoading(true); - - const res = await api.deleteAuthor(personID); - - if (res.data.err) { - throw new Error(res.data.errMsg); - } - - if (!res.data.deleted) { - throw new Error("Failed to delete author."); - } - - onDeleted(); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - return ( - - Delete Person - - {loading && ( -
- -
- )} - {!loading && ( -

Are you sure you want to remove this person?

- )} -
- - - - -
- ); -}; - -export default ConfirmDeletePersonModal; diff --git a/client/src/components/controlpanel/PeopleManager/ManagePersonModal.tsx b/client/src/components/controlpanel/PeopleManager/ManagePersonModal.tsx deleted file mode 100644 index 97cbba4ca..000000000 --- a/client/src/components/controlpanel/PeopleManager/ManagePersonModal.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { - Button, - Dropdown, - Form, - Icon, - Modal, - ModalProps, -} from "semantic-ui-react"; -import useGlobalError from "../../error/ErrorHooks"; -import { Controller, useForm } from "react-hook-form"; -import { Author, GenericKeyTextValueObj } from "../../../types"; -import CtlTextInput from "../../ControlledInputs/CtlTextInput"; -import { required } from "../../../utils/formRules"; -import { useEffect, useState } from "react"; -import api from "../../../api"; -import useDebounce from "../../../hooks/useDebounce"; -import ConfirmDeletePersonModal from "./ConfirmDeletePersonModal"; -import { useTypedSelector } from "../../../state/hooks"; - -interface ManagePersonModalProps extends ModalProps { - show: boolean; - onClose: () => void; - personID?: string; -} - -const ManagePersonModal: React.FC = ({ - show, - onClose, - personID, - ...rest -}) => { - const org = useTypedSelector((state) => state.org); - const { debounce } = useDebounce(); - const { handleGlobalError } = useGlobalError(); - const { control, getValues, trigger, reset } = useForm({ - defaultValues: { - firstName: "", - lastName: "", - email: "", - url: "", - primaryInstitution: "", - }, - }); - - const [mode, setMode] = useState<"create" | "edit">("create"); - const [orgOptions, setOrgOptions] = useState< - GenericKeyTextValueObj[] - >([]); - const [loadedOrgs, setLoadedOrgs] = useState(false); - const [loading, setLoading] = useState(false); - const [showConfirmDelete, setShowConfirmDelete] = useState(false); - - const resetForm = () => { - reset({ - firstName: "", - lastName: "", - email: "", - primaryInstitution: "", - }); - }; - - useEffect(() => { - if (show) { - getOrgs(); - if (personID) { - setMode("edit"); - loadAuthor(); - } else { - setMode("create"); - resetForm(); - } - } else { - resetForm(); - } - }, [show, personID]); - - async function loadAuthor() { - try { - if (!personID) { - throw new Error("Invalid author ID."); - } - - setLoading(true); - const res = await api.getAuthor(personID); - if (res.data.err) { - throw new Error(res.data.errMsg); - } - if (!res.data.author) { - throw new Error("Invalid response from server."); - } - - reset(res.data.author); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - async function getOrgs(searchQuery?: string) { - try { - setLoadedOrgs(false); - - const clearOption: GenericKeyTextValueObj = { - key: crypto.randomUUID(), - text: "Clear selection", - value: "", - }; - - // If org has custom org list, use that instead of fetching from server - if(org.customOrgList && org.customOrgList.length > 0){ - const orgs = org.customOrgList.map((org) => { - return { - value: org, - key: crypto.randomUUID(), - text: org, - }; - }); - - setOrgOptions([clearOption, ...orgs]); - return; - } - - const res = await api.getCentralIdentityADAPTOrgs({ - query: searchQuery ?? undefined, - }); - if (res.data.err) { - throw new Error(res.data.errMsg); - } - if (!res.data.orgs || !Array.isArray(res.data.orgs)) { - throw new Error("Invalid response from server."); - } - - const orgs = res.data.orgs.map((org) => { - return { - value: org, - key: crypto.randomUUID(), - text: org, - }; - }); - - setOrgOptions([clearOption, ...orgs]); - } catch (err) { - handleGlobalError(err); - } finally { - setLoadedOrgs(true); - } - } - - const getOrgsDebounced = debounce( - (inputVal: string) => getOrgs(inputVal), - 200 - ); - - async function handeSubmit() { - if (mode === "edit" && personID) { - handleEdit(); - } else { - handleCreate(); - } - } - - async function handleCreate() { - try { - const valid = await trigger(); - if (!valid) { - return; - } - - setLoading(true); - const values = getValues(); - const createRes = await api.createAuthor(values); - - if (createRes.data.err) { - throw new Error(createRes.data.errMsg); - } - - onClose(); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - async function handleEdit() { - try { - const valid = await trigger(); - if (!valid || !personID) { - return; - } - - setLoading(true); - const values = getValues(); - const editRes = await api.updateAuthor(personID, values); - - if (editRes.data.err) { - throw new Error(editRes.data.errMsg); - } - - onClose(); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - async function handleDeleted() { - setShowConfirmDelete(false); - onClose(); - } - - return ( - - - {mode === "create" ? "Create" : "Edit"} Person - - -
e.preventDefault()} loading={loading}> - - - - - - - ( - { - field.onChange(value as string); - }} - fluid - selection - search - onSearchChange={(e, { searchQuery }) => { - getOrgsDebounced(searchQuery); - }} - additionLabel="Add organization: " - allowAdditions - deburr - loading={!loadedOrgs} - onAddItem={(e, { value }) => { - if (value) { - orgOptions.push({ - text: value.toString(), - value: value.toString(), - key: value.toString(), - }); - field.onChange(value.toString()); - } - }} - /> - )} - name="primaryInstitution" - control={control} - /> - - -
- -
- {mode === "edit" && personID && ( - - )} -
- - -
-
-
- {personID && ( - setShowConfirmDelete(false)} - onDeleted={handleDeleted} - personID={personID} - /> - )} -
- ); -}; - -export default ManagePersonModal; diff --git a/client/src/components/projects/ProjectPropertiesModal.tsx b/client/src/components/projects/ProjectPropertiesModal.tsx index 2602c1cf2..c3b8a5f76 100644 --- a/client/src/components/projects/ProjectPropertiesModal.tsx +++ b/client/src/components/projects/ProjectPropertiesModal.tsx @@ -433,12 +433,12 @@ const ProjectPropertiesModal: React.FC = ({ if (res.data.err) { throw new Error(res.data.errMsg); } - if (!res.data.authors || !Array.isArray(res.data.authors)) { + if (!res.data.items || !Array.isArray(res.data.items)) { throw new Error("Failed to load PI options"); } const existing = getValues("principalInvestigators") ?? []; - const opts = [...res.data.authors, ...existing]; + const opts = [...res.data.items, ...existing]; setPIOptions(opts); @@ -467,12 +467,12 @@ const ProjectPropertiesModal: React.FC = ({ if (res.data.err) { throw new Error(res.data.errMsg); } - if (!res.data.authors || !Array.isArray(res.data.authors)) { + if (!res.data.items || !Array.isArray(res.data.items)) { throw new Error("Failed to load co-PI options"); } const opts = [ - ...res.data.authors, + ...res.data.items, ...(watch("coPrincipalInvestigators") ?? []), ]; @@ -647,7 +647,7 @@ const ProjectPropertiesModal: React.FC = ({ return { key: crypto.randomUUID(), value: pi._id ?? "", - text: `${pi.firstName} ${pi.lastName}`, + text: pi.name, }; }); }, [piOptions]); @@ -657,7 +657,7 @@ const ProjectPropertiesModal: React.FC = ({ return { key: crypto.randomUUID(), value: pi?._id ?? "", - text: `${pi.firstName} ${pi.lastName}`, + text: pi?.name ?? "", }; }); }, [coPIOptions]); diff --git a/client/src/components/support/SupportCenterTable.tsx b/client/src/components/support/SupportCenterTable.tsx index 78c36fb5a..ce7d944e3 100644 --- a/client/src/components/support/SupportCenterTable.tsx +++ b/client/src/components/support/SupportCenterTable.tsx @@ -117,7 +117,7 @@ function SupportCenterTable({ key={idx} onClick={() => onRowClick?.(record)} className={ - onRowClick ? "cursor-pointer hover:bg-gray-100" : "" + onRowClick ? "cursor-pointer hover:!bg-gray-200" : "" } > {columns.map((column, index) => ( @@ -212,7 +212,7 @@ function SupportCenterTable({ className={classNames( "absolute top-0 left-0 w-full flex border-b border-gray-200", onRowClick && - "cursor-pointer hover:bg-gray-100 transition-colors", + "cursor-pointer hover:bg-gray-200 transition-colors", isEven ? "bg-white" : "bg-gray-50" )} style={{ diff --git a/client/src/components/util/AuthHelper.ts b/client/src/components/util/AuthHelper.ts index 8c67027fb..22cb73d8f 100644 --- a/client/src/components/util/AuthHelper.ts +++ b/client/src/components/util/AuthHelper.ts @@ -1,6 +1,13 @@ import axios from "axios"; import Cookies from "js-cookie"; +const APP_ENV = window.__APP_ENV__ ?? "production"; +const COOKIE_PREFIX = APP_ENV === "production" ? "conductor" : `conductor_${APP_ENV}`; +export const COOKIE_NAMES = { + ACCESS: `${COOKIE_PREFIX}_access_v2`, + SIGNED: `${COOKIE_PREFIX}_signed_v2`, +}; + /** * Global-use object with helper functions for authentication and session management. */ @@ -11,12 +18,12 @@ const AuthHelper = { * @returns {boolean} True if the token is found, false otherwise */ isAuthenticated: () => { - return Cookies.get("conductor_access_v2") !== undefined; + return Cookies.get(COOKIE_NAMES.ACCESS) !== undefined; }, getAuthToken: () => { - const access = Cookies.get("conductor_access_v2"); - const signed = Cookies.get("conductor_signed_v2"); + const access = Cookies.get(COOKIE_NAMES.ACCESS); + const signed = Cookies.get(COOKIE_NAMES.SIGNED); if (!access || !signed) return null; return `${access}.${signed}`; }, diff --git a/client/src/components/util/ManualEntryModal.tsx b/client/src/components/util/ManualEntryModal.tsx deleted file mode 100644 index c1fdf717a..000000000 --- a/client/src/components/util/ManualEntryModal.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { Modal, Form, Button, Dropdown, Icon } from "semantic-ui-react"; -import useDebounce from "../../hooks/useDebounce"; -import useGlobalError from "../error/ErrorHooks"; -import { useState } from "react"; -import { Author, GenericKeyTextValueObj } from "../../types"; -import api from "../../api"; - -interface ManualEntryModalProps { - show: boolean; - onClose: () => void; - onSaved: (author: Author, ctx: string) => void; - ctx: string; -} - -const ManualEntryModal: React.FC = ({ - show, - onClose, - onSaved, - ctx -}) => { - const { handleGlobalError } = useGlobalError(); - const { debounce } = useDebounce(); - - const [orgOptions, setOrgOptions] = useState< - GenericKeyTextValueObj[] - >([]); - const [loadingOrgs, setLoadingOrgs] = useState(false); - const [newAuthor, setNewAuthor] = useState({ - firstName: "", - lastName: "", - email: "", - url: "", - primaryInstitution: "", - }); - - const clearNewAuthor = () => { - setNewAuthor({ - firstName: "", - lastName: "", - email: "", - url: "", - primaryInstitution: "", - }); - }; - - async function getOrgs(searchQuery?: string) { - try { - setLoadingOrgs(true); - const res = await api.getCentralIdentityADAPTOrgs({ - query: searchQuery ?? undefined, - }); - if (res.data.err) { - throw new Error(res.data.errMsg); - } - if (!res.data.orgs || !Array.isArray(res.data.orgs)) { - throw new Error("Invalid response from server."); - } - - const orgs = res.data.orgs.map((org) => { - return { - value: org, - key: crypto.randomUUID(), - text: org, - }; - }); - - const clearOption: GenericKeyTextValueObj = { - key: crypto.randomUUID(), - text: "Clear selection", - value: "", - }; - - setOrgOptions([clearOption, ...orgs]); - } catch (err) { - handleGlobalError(err); - } finally { - setLoadingOrgs(false); - } - } - - const getOrgsDebounced = debounce( - (inputVal: string) => getOrgs(inputVal), - 200 - ); - - const handleClose = () => { - clearNewAuthor(); - onClose(); - }; - - return ( - - -

Add New Author

-
- - - - setNewAuthor({ - ...newAuthor, - firstName: e.target.value, - }) - } - required - fluid - key={"newAuthorFirstName"} - /> - - - - - setNewAuthor({ - ...newAuthor, - lastName: e.target.value, - }) - } - required - fluid - key={"newAuthorLastName"} - /> - -
-
- - - - setNewAuthor({ - ...newAuthor, - email: e.target.value, - }) - } - fluid - key={"newAuthorEmail"} - /> - - - - - setNewAuthor({ - ...newAuthor, - url: e.target.value, - }) - } - key={"newAuthorUrl"} - /> - -
- - - { - setNewAuthor({ - ...newAuthor, - primaryInstitution: value?.toString() ?? "", - }); - }} - fluid - selection - search - onSearchChange={(e, { searchQuery }) => { - getOrgsDebounced(searchQuery); - }} - additionLabel="Add organization: " - allowAdditions - deburr - loading={loadingOrgs} - onAddItem={(e, { value }) => { - if (value) { - orgOptions.push({ - text: value.toString(), - value: value.toString(), - key: value.toString(), - }); - setNewAuthor({ - ...newAuthor, - primaryInstitution: value.toString(), - }); - } - }} - key={"newAuthorPrimaryInstitution"} - /> - -
- - - - -
- ); -}; - -export default ManualEntryModal; diff --git a/client/src/screens/commons/Author/index.tsx b/client/src/screens/commons/Author/index.tsx index aff7e0716..8ac93762b 100644 --- a/client/src/screens/commons/Author/index.tsx +++ b/client/src/screens/commons/Author/index.tsx @@ -1,15 +1,14 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Icon, Segment, Header, Breadcrumb, Popup } from "semantic-ui-react"; import useGlobalError from "../../../components/error/ErrorHooks"; -import { - ConductorSearchResponseAuthor, - ConductorSearchResponseFile, -} from "../../../types"; import api from "../../../api"; import VisualMode from "../../../components/commons/CommonsCatalog/VisualMode"; import AssetsTable from "../../../components/commons/CommonsCatalog/AssetsTable"; import { useTypedSelector } from "../../../state/hooks"; +import { useQuery, useInfiniteQuery } from "@tanstack/react-query"; + +const ASSETS_LIMIT = 10; /** * Displays an Author's page in the Commons catalog, showing information about an author and their works. @@ -19,117 +18,75 @@ const CommonsAuthor = () => { const { handleGlobalError } = useGlobalError(); const org = useTypedSelector((state) => state.org); - // Author data - const [loadedData, setLoadedData] = useState(false); - const [author, setAuthor] = useState({ - firstName: "", - lastName: "", - email: "", - primaryInstitution: "", - url: "", - projects: [], - }); + const [itemizedMode, setItemizedMode] = useState(false); + const [jumpToBottomClicked, setJumpToBottomClicked] = useState(false); + const [loadingDisabled, setLoadingDisabled] = useState(false); - // Asset data - const [itemizedMode, setItemizedMode] = useState(false); - const [loadingDisabled, setLoadingDisabled] = useState(false); - const [loadedAssets, setLoadedAssets] = useState(false); - const [assets, setAssets] = useState([]); - const [activePage, setActivePage] = useState(1); - const [totalAssets, setTotalAssets] = useState(0); - const [jumpToBottomClicked, setJumpToBottomClicked] = - useState(false); - - useEffect(() => { - getAuthor(); - }, [authorID]); - - const getAuthor = async () => { - try { - if (!authorID) { - throw new Error("No author ID provided."); - } - - setLoadedData(false); + // Author data + const { + data: author, + isLoading: authorLoading, + error: authorError, + } = useQuery({ + queryKey: ["author", authorID], + queryFn: async () => { + if (!authorID) throw new Error("No author ID provided."); const res = await api.getAuthor(authorID); - if (res.data.err) { - throw new Error(res.data.errMsg); - } - if (!res.data.author) { - throw new Error("Error processing server data."); - } - - setAuthor(res.data.author); - getAuthorAssets(); - } catch (e) { - handleGlobalError(e); - } finally { - setLoadedData(true); - } - }; - - const getAuthorAssets = async (page: number = 1) => { - try { - if (!authorID) { - throw new Error("No author ID provided."); - } - - if (loadingDisabled) return; - setLoadedAssets(false); + if (res.data.err) throw new Error(res.data.errMsg); + if (!res.data.author) throw new Error("Error processing server data."); + return res.data.author; + }, + enabled: !!authorID, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + }); + // Assets data + const { + data: assetsData, + isFetching: assetsFetching, + fetchNextPage, + hasNextPage, + error: assetsError, + } = useInfiniteQuery({ + queryKey: ["author-assets", authorID], + queryFn: async ({ pageParam = 1 }) => { + if (!authorID) throw new Error("No author ID provided."); const res = await api.getAuthorAssets(authorID, { - page, - limit: 10, + page: pageParam as number, + limit: ASSETS_LIMIT, }); - if (res.data.err) { - throw new Error(res.data.errMsg); - } - if (!res.data.assets) { - throw new Error("Error processing server data."); - } + if (res.data.err) throw new Error(res.data.errMsg); + if (!res.data.assets) throw new Error("Error processing server data."); + return res.data; + }, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.flatMap((p) => p.assets).length; + return loaded < lastPage.total ? allPages.length + 1 : undefined; + }, + enabled: !!authorID && !loadingDisabled, + staleTime: 1000 * 60 * 5, + refetchOnWindowFocus: false, + }); - setAssets([...assets, ...res.data.assets]); - setTotalAssets(res.data.total); - } catch (e) { - handleGlobalError(e); - } finally { - setLoadedAssets(true); - } - }; + useEffect(() => { + if (authorError) handleGlobalError(authorError); + }, [authorError]); - const onLoadMoreAssets = () => { - const newPage = activePage + 1; - setActivePage(newPage); - getAuthorAssets(newPage); - }; + useEffect(() => { + if (assetsError) handleGlobalError(assetsError); + }, [assetsError]); - /** - * Update page title when data is available. - */ useEffect(() => { - if (author.firstName && author.lastName) { + if (author?.name) { document.title = - `${ - org.shortName !== "LibreTexts" - ? `${org.shortName} Commons` - : "LibreCommons" - } | ` + - author.firstName + - " " + - author.lastName; + `${org.shortName !== "LibreTexts" ? `${org.shortName} Commons` : "LibreCommons"} | ${author.name}`; } - }, [author]); + }, [author?.name, org.shortName]); - // Inline useInfiniteScroll - it was just a pass-through wrapper - const loadMore = onLoadMoreAssets; - const hasMore = assets.length < totalAssets; - const isLoading = !loadedAssets; - - const authorFullName = useMemo(() => { - if (!author || !author.firstName || !author.lastName) - return "Unknown Author"; - return author.firstName + " " + author.lastName; - }, [author]); + const assets = assetsData?.pages.flatMap((p) => p.assets) ?? []; + const totalAssets = assetsData?.pages[0]?.total ?? 0; + const hasMore = (hasNextPage ?? false) && assets.length < totalAssets; const jumpToBottom = () => { setLoadingDisabled(true); @@ -137,6 +94,13 @@ const CommonsAuthor = () => { window.scrollTo(0, document.body.scrollHeight); }; + const renderNameURL = (url: string) => ( +

+ + {url} +

+ ); + return (
@@ -149,47 +113,72 @@ const CommonsAuthor = () => { - {authorFullName} + {author?.name ?? "Author"} - +
+ {author?.pictureURL && ( + {author.name} + )}
- {authorFullName} + {author?.name ?? ""}
- {author.primaryInstitution && ( + {author?.nameTitle && ( +

{author.nameTitle}

+ )} + {author?.nameURL && renderNameURL(author.nameURL)} + {author?.companyName && (

- {author.primaryInstitution} + {author.companyURL ? ( + + {author.companyName} + + ) : ( + author.companyName + )}

)} - {author.url && ( + {author?.programName && (

- - - {author.url} - + + {author.programURL ? ( + + {author.programName} + + ) : ( + author.programName + )}

)} - {author.email && ( + {author?.note && (

- - - {author.email} - + + {author.noteURL ? ( + + {author.note} + + ) : ( + author.note + )}

)} - {author.projects?.length > 0 && ( + {author?.projects && author.projects.length > 0 && (

- {author.projects.map((p) => ( + {author.projects.map((p, i) => ( - {`${p.title}${author.projects.length > 1 ? ", " : ""}`} + {p.title}{i < author.projects.length - 1 ? ", " : ""} ))}

@@ -230,9 +219,7 @@ const CommonsAuthor = () => { { - setItemizedMode(!itemizedMode); - }} + onClick={() => setItemizedMode(!itemizedMode)} className="bg-slate-100 text-black border border-slate-300 rounded-md !pl-1.5 p-1 shadow-sm hover:shadow-md" aria-label={ itemizedMode @@ -257,33 +244,26 @@ const CommonsAuthor = () => {
{itemizedMode ? ( - + ) : ( )} - - {/* Load More Button */} {hasMore && (
)} - - {/* End of results message */} {!hasMore && assets.length > 0 && (

End of results

diff --git a/client/src/screens/commons/File/index.tsx b/client/src/screens/commons/File/index.tsx index 4618bc64a..13abdff70 100644 --- a/client/src/screens/commons/File/index.tsx +++ b/client/src/screens/commons/File/index.tsx @@ -81,7 +81,7 @@ const CommonsFile: React.FC = () => { {file?.authors && file?.authors.length > 0 ? ( {file.authors - .map((a) => `${a.firstName} ${a.lastName}`) + .map((a) => a.name) .join(", ")} ) : ( diff --git a/client/src/screens/commons/Project/index.tsx b/client/src/screens/commons/Project/index.tsx index 81411d1ec..fb7a0d0bb 100644 --- a/client/src/screens/commons/Project/index.tsx +++ b/client/src/screens/commons/Project/index.tsx @@ -98,7 +98,7 @@ const CommonsProject = () => { {project?.principalInvestigators && project?.principalInvestigators.length > 0 ? ( project?.principalInvestigators - ?.map((p) => `${p.firstName} ${p.lastName}`) + ?.map((p) => p.name) .join(", ") ) : ( No principal investigators @@ -109,7 +109,7 @@ const CommonsProject = () => { {project?.coPrincipalInvestigators && project?.coPrincipalInvestigators.length > 0 ? ( project?.coPrincipalInvestigators - ?.map((p) => `${p.firstName} ${p.lastName}`) + ?.map((p) => p.name) .join(", ") ) : ( diff --git a/client/src/screens/conductor/FallbackAuth/index.tsx b/client/src/screens/conductor/FallbackAuth/index.tsx index 8d7909cd3..94fc7d20e 100644 --- a/client/src/screens/conductor/FallbackAuth/index.tsx +++ b/client/src/screens/conductor/FallbackAuth/index.tsx @@ -15,7 +15,7 @@ import Cookies from "js-cookie"; import { isEmptyString } from "../../../components/util/HelperFunctions"; import useGlobalError from "../../../components/error/ErrorHooks"; import styles from './FallbackAuth.module.css'; -import AuthHelper from "../../../components/util/AuthHelper"; +import AuthHelper, { COOKIE_NAMES } from "../../../components/util/AuthHelper"; const FallbackAuth = () => { const dispatch = useDispatch(); @@ -108,7 +108,7 @@ const FallbackAuth = () => { if (authRes.data?.err) { handleGlobalError(authRes.data.errMsg); } - if (Cookies.get("conductor_access_v2") !== undefined) { + if (Cookies.get(COOKIE_NAMES.ACCESS) !== undefined) { dispatch({ type: "SET_AUTH" }); if (redirectURI !== "") { // redirect to the page the user tried to visit directly diff --git a/client/src/screens/conductor/controlpanel/AuthorsManager/index.tsx b/client/src/screens/conductor/controlpanel/AuthorsManager/index.tsx new file mode 100644 index 000000000..08358a5ff --- /dev/null +++ b/client/src/screens/conductor/controlpanel/AuthorsManager/index.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { + Header, + Segment, + Grid, + Breadcrumb, + Icon, + Dropdown, + Input, +} from "semantic-ui-react"; +import { Author } from "../../../../types"; +import useGlobalError from "../../../../components/error/ErrorHooks"; +import useDebounce from "../../../../hooks/useDebounce"; +import api from "../../../../api"; +import useDocumentTitle from "../../../../hooks/useDocumentTitle"; +import SupportCenterTable from "../../../../components/support/SupportCenterTable"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useModals } from "../../../../context/ModalContext"; +import Button from "../../../../components/NextGenComponents/Button"; +import { truncateString } from "../../../../components/util/HelperFunctions"; +import ManageAuthorModal from "../../../../components/controlpanel/AuthorsManager/ManageAuthorModal" +import GenerateTemplateJSONModal from "../../../../components/controlpanel/AuthorsManager/GenerateTemplateJSONModal" + +const LIMIT = 25; + +const AuthorsManager = () => { + //Global State & Hooks + useDocumentTitle("LibreTexts Conductor | Authors Manager"); + const { handleGlobalError } = useGlobalError(); + const { debounce } = useDebounce(); + const { openModal, closeAllModals } = useModals(); + + //UI + const [sortChoice, setSortChoice] = useState("nameKey"); + const [searchString, setSearchString] = useState(""); + const [activeSearch, setActiveSearch] = useState(""); + + const sortOptions = [ + { key: "nameKey", text: "Sort by Name Key", value: "nameKey" }, + { key: "name", text: "Sort by Name", value: "name" }, + { key: "companyName", text: "Sort by Company Name", value: "companyName" }, + ]; + + //Data + const { data, isFetching, isInitialLoading, fetchNextPage } = + useInfiniteQuery({ + queryKey: ["authors", LIMIT, sortChoice, activeSearch], + queryFn: async ({ pageParam = null }) => { + const response = await api.getAuthors({ + limit: LIMIT, + page: pageParam || 1, + sort: sortChoice, + query: activeSearch || undefined, + }); + + if (response.data.err) { + handleGlobalError( + response.data.errMsg || "Failed to fetch authors." + ); + return { + items: [], + meta: { total_count: 0, has_more: false, next_page: null }, + }; + } + return response.data; + }, + getNextPageParam: (lastPage) => { + const nextPage = lastPage?.meta?.has_more && lastPage.meta.next_page ? lastPage.meta.next_page : undefined; + const parsed = parseInt(nextPage as string, 10); + if (isNaN(parsed)) { + return undefined; + } + return parsed; + }, + staleTime: 1000 * 60 * 5, // 5 minutes + refetchOnWindowFocus: false, + }); + + const allData = data?.pages.flatMap((page) => page.items) || []; + const lastPage = data?.pages[data.pages.length - 1]; + + function handleOpenManageModal(authorID?: string) { + openModal( + closeAllModals()} authorID={authorID} /> + ); + } + + function renderURLField(url?: string) { + if (!url) return null; + + let parsedURL: URL; + try { + parsedURL = new URL(url); + } catch (error) { + return null; + } + + const truncated = truncateString(parsedURL.toString(), 30); + + return parsedURL.hostname ? ( + + {truncated} + + ) : null; + } + + return ( + + + +
+ Authors Manager +
+
+
+ + + + +
+ + + Control Panel + + + Authors Manager + +
+ + +
+
+
+ + + + + { + setSortChoice(value as string); + }} + value={sortChoice} + /> + + + { + setSearchString(e.target.value); + debounce((val: string) => setActiveSearch(val), 300)(e.target.value.trim()); + }} + value={searchString} + fluid + /> + + + + + + + loading={isInitialLoading} + data={allData || []} + onRowClick={(author) => { + handleOpenManageModal(author._id); + }} + columns={[ + { + accessor: "nameKey", + title: "Name Key (Unique)", + copyButton: true, + render(record) { + return {truncateString(record.nameKey, 30)}; + } + }, + { + accessor: "name", + title: "Name", + render(record, index) { + return ( +
+ {record.pictureURL && ( + {record.name} + )} +

{record.name}

+
+ ) + }, + }, + { + accessor: "nameTitle", + title: "Name Title", + }, + { + accessor: "nameURL", + title: "Name URL", + render(record) { + return renderURLField(record.nameURL); + }, + }, + { + accessor: "companyName", + title: "Company Name", + }, + { + accessor: "companyURL", + title: "Company URL", + render(record) { + return renderURLField(record.companyURL); + }, + }, + { + accessor: "programName", + title: "Program Name", + }, + { + accessor: "programURL", + title: "Program URL", + render(record) { + return renderURLField(record.programURL); + }, + }, + ]} + /> + {lastPage?.meta?.has_more && ( +
+ +
+ )} +
+
+
+
+
+ ); +}; + +export default AuthorsManager; diff --git a/client/src/screens/conductor/controlpanel/PeopleManager/index.tsx b/client/src/screens/conductor/controlpanel/PeopleManager/index.tsx deleted file mode 100644 index 68db18f8b..000000000 --- a/client/src/screens/conductor/controlpanel/PeopleManager/index.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import { useState, useEffect, lazy } from "react"; -import { Link } from "react-router-dom"; -import { - Header, - Segment, - Grid, - Breadcrumb, - Table, - Icon, - Button, - Dropdown, - Input, -} from "semantic-ui-react"; -import { Author } from "../../../../types"; -import useGlobalError from "../../../../components/error/ErrorHooks"; -import { PaginationWithItemsSelect } from "../../../../components/util/PaginationWithItemsSelect"; -import useDebounce from "../../../../hooks/useDebounce"; -import api from "../../../../api"; -const ManagePersonModal = lazy( - () => - import( - "../../../../components/controlpanel/PeopleManager/ManagePersonModal" - ) -); -const BulkAddPeopleModal = lazy( - () => - import( - "../../../../components/controlpanel/PeopleManager/BulkAddPeopleModal" - ) -); - -const PeopleManager = () => { - //Global State & Hooks - const { handleGlobalError } = useGlobalError(); - const { debounce } = useDebounce(); - - //UI - const [loading, setLoading] = useState(false); - const [activePage, setActivePage] = useState(1); - const [totalItems, setTotalItems] = useState(0); - const [totalPages, setTotalPages] = useState(1); - const [itemsPerPage, setItemsPerPage] = useState(10); - const [sortChoice, setSortChoice] = useState("firstName"); - const [searchString, setSearchString] = useState(""); - const TABLE_COLS = [ - { key: "firstName", text: "First Name" }, - { key: "lastName", text: "Last Name" }, - { key: "email", text: "Email" }, - { key: "primaryInstitution", text: "Primary Institution" }, - { key: "Actions", text: "Actions" }, - ]; - - const sortOptions = [ - { key: "firstName", text: "Sort by First Name", value: "firstName" }, - { key: "lastName", text: "Sort by Last Name", value: "lastName" }, - { key: "email", text: "Sort by Email", value: "email" }, - ]; - - //Data - const [people, setPeople] = useState([]); - const [selectedPersonId, setSelectedPersonId] = useState( - undefined - ); - const [showManageModal, setShowManageModal] = useState(false); - const [showBulkAddModal, setShowBulkAddModal] = useState(false); - - //Effects - useEffect(() => { - getPeople(searchString); - }, [activePage, itemsPerPage, sortChoice]); - - // Handlers & Methods - async function getPeople(searchString?: string) { - try { - setLoading(true); - - const res = await api.getAuthors({ - page: activePage, - limit: itemsPerPage, - query: searchString, - sort: sortChoice ? sortChoice : undefined, - }); - - if ( - res.data.err || - !res.data.authors || - !Array.isArray(res.data.authors) || - res.data.totalCount === undefined - ) { - throw new Error("Error retrieving authors"); - } - - setPeople(res.data.authors); - setTotalItems(res.data.totalCount); - setTotalPages(Math.ceil(res.data.totalCount / itemsPerPage)); - } catch (err) { - handleGlobalError(err); - } finally { - setLoading(false); - } - } - - const getPeopleDebounced = debounce((searchVal: string) => { - setActivePage(1); // reset to first page when query changes - getPeople(searchVal); - }, 500); - - function handleSelectPerson(id?: string) { - if (!id) return; - setSelectedPersonId(id); - setShowManageModal(true); - } - - function handleCloseManageModal() { - setSelectedPersonId(undefined); - getPeople(searchString); - setShowManageModal(false); - } - - function handleCloseBulkAddModal() { - setSelectedPersonId(undefined); - setShowBulkAddModal(false); - getPeople(searchString); - } - - return ( - - - -
- People Manager -
-
-
- - - - -
- - - Control Panel - - - People Manager - -
- - -
-
-
- - - - - { - setSortChoice(value as string); - }} - value={sortChoice} - /> - - - { - setSearchString(e.target.value); - getPeopleDebounced(e.target.value.trim()); - }} - value={searchString} - fluid - /> - - - - - - - - - - - - {TABLE_COLS.map((item) => ( - - {item.text} - - ))} - - - - {people.length > 0 && - people.map((p) => { - return ( - - - {p.firstName} - - - {p.lastName} - - - {p.email} - - - {p.primaryInstitution} - - - - - - ); - })} - {people.length === 0 && ( - - -

- No results found. -

-
-
- )} -
-
-
- - - -
- - -
-
-
- ); -}; - -export default PeopleManager; diff --git a/client/src/screens/conductor/controlpanel/StoreManager/index.tsx b/client/src/screens/conductor/controlpanel/StoreManager/index.tsx index c84384457..1ae463af2 100644 --- a/client/src/screens/conductor/controlpanel/StoreManager/index.tsx +++ b/client/src/screens/conductor/controlpanel/StoreManager/index.tsx @@ -1,7 +1,6 @@ import { Link } from "react-router-dom"; import { Header, Segment, Grid, Breadcrumb } from "semantic-ui-react"; import { - GetStoreOrdersResponse, StoreOrderWithStripeSession, } from "../../../../types"; import useGlobalError from "../../../../components/error/ErrorHooks"; @@ -30,7 +29,7 @@ const StoreManager = () => { const [luluStatusFilter, setLuluStatusFilter] = useState("all"); // const [sortBy, setSortBy] = useState("created"); const { data, isFetching, isInitialLoading, fetchNextPage } = - useInfiniteQuery({ + useInfiniteQuery({ queryKey: ["store-orders", limit, statusFilter, luluStatusFilter], queryFn: async ({ pageParam = null }) => { const response = await api.adminGetStoreOrders({ @@ -155,13 +154,13 @@ const StoreManager = () => { {record.createdAt ? new Date(record.createdAt).toLocaleDateString( - "en-US", - { - year: "numeric", - month: "2-digit", - day: "2-digit", - } - ) + "en-US", + { + year: "numeric", + month: "2-digit", + day: "2-digit", + } + ) : ""} ); @@ -183,9 +182,9 @@ const StoreManager = () => { {record.stripe_session?.amount_total ? formatPrice( - record.stripe_session.amount_total, - true - ) + record.stripe_session.amount_total, + true + ) : "$0.00"} ); @@ -225,14 +224,13 @@ const StoreManager = () => { render(record) { return ( {record.luluJobStatus || "--"} @@ -245,11 +243,10 @@ const StoreManager = () => { render(record) { return ( {record.status} diff --git a/client/src/state/userReducer.ts b/client/src/state/userReducer.ts index 582100628..e1474b858 100644 --- a/client/src/state/userReducer.ts +++ b/client/src/state/userReducer.ts @@ -5,6 +5,7 @@ /* Utils */ import Cookies from "js-cookie"; +import { COOKIE_NAMES } from "../components/util/AuthHelper"; import { User } from "../types"; import { AnyAction } from "redux"; /* User */ @@ -43,7 +44,7 @@ export default function userReducer( isAuthenticated: true, }; case "CHECK_AUTH": - if (Cookies.get("conductor_access_v2") !== undefined) { + if (Cookies.get(COOKIE_NAMES.ACCESS) !== undefined) { return { ...state, isAuthenticated: true, diff --git a/client/src/types/Author.ts b/client/src/types/Author.ts index 26947107b..0a18251f9 100644 --- a/client/src/types/Author.ts +++ b/client/src/types/Author.ts @@ -1,10 +1,19 @@ export type Author = { _id?: string; - firstName: string; - lastName: string; - email?: string; - url?: string; - primaryInstitution?: string; + orgID: string; + nameKey: string; // Unique identifier for the author within an org + name: string; + nameTitle?: string; + nameURL?: string; + note?: string; + noteURL?: string; + companyName?: string; + companyURL?: string; + pictureCircle?: string; // i.e. "yes" or "no" + pictureURL?: string; + programName?: string; + programURL?: string; + attributionPrefix?: string; userUUID?: string; -} \ No newline at end of file +} diff --git a/client/src/types/Misc.ts b/client/src/types/Misc.ts index 7250f4fcc..33a637398 100644 --- a/client/src/types/Misc.ts +++ b/client/src/types/Misc.ts @@ -24,6 +24,19 @@ export type ConductorBaseResponse = | { err: false } | { err: true; errMsg: string }; +export type ConductorInfiniteScrollResponse = { + err: false; + items: T[]; + meta: { + total_count: number; + has_more: boolean; + next_page: string | number | null; + } +} | { + err: true; + errMsg: string; +}; + export type _MoveFile = Pick< ProjectFile, "fileID" | "name" | "storageType" | "description" diff --git a/client/src/types/Store.ts b/client/src/types/Store.ts index 95b63629c..5cf951c11 100644 --- a/client/src/types/Store.ts +++ b/client/src/types/Store.ts @@ -99,12 +99,3 @@ export type StoreOrderWithStripeSession = StoreOrder & { stripe_session: Stripe.Checkout.Session; stripe_charge?: Stripe.Charge | null; } - -export type GetStoreOrdersResponse = { - items: StoreOrderWithStripeSession[]; - meta: { - total_count: number; - has_more: boolean; - next_page: string | null; - }; -} \ No newline at end of file diff --git a/client/src/utils/assetHelpers.ts b/client/src/utils/assetHelpers.ts index ba6e1265c..443ef0598 100644 --- a/client/src/utils/assetHelpers.ts +++ b/client/src/utils/assetHelpers.ts @@ -113,14 +113,14 @@ export function getPrettyAuthorsList( const authorList = authors - .filter((a) => !!a.firstName && !!a.lastName) - .map((a) => `${a.firstName} ${a.lastName}`) + .filter((a) => a?.name) + .map((a) => a.name) .join(", ") || "Unknown"; return primaryAuthor - ? `${primaryAuthor.firstName} ${primaryAuthor.lastName} et al.` + ? `${primaryAuthor.name} et al.` : correspondingAuthor - ? `${correspondingAuthor.firstName} ${correspondingAuthor.lastName}, ${authorList}` + ? `${correspondingAuthor.name}, ${authorList}` : authorList; } diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 11f02fe2a..2d8d36ceb 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1 +1,5 @@ /// + +interface Window { + __APP_ENV__: string; // This will be injected at runtime by the server +} diff --git a/server/api.js b/server/api.js index 40a4d5bdb..850bcea9b 100644 --- a/server/api.js +++ b/server/api.js @@ -742,27 +742,17 @@ router router .route("/authors") .get( - middleware.validateZod(AuthorsValidators.GetAllAuthorsValidator), + middleware.validateZod(AuthorsValidators.GetAuthorsValidator), authorsAPI.getAuthors ) .post( authAPI.verifyRequest, authAPI.getUserAttributes, - authAPI.checkHasRoleMiddleware(process.env.ORG_ID, "campusadmin"), + authAPI.checkHasRoleMiddleware("libretexts", "superadmin"), middleware.validateZod(AuthorsValidators.CreateAuthorValidator), authorsAPI.createAuthor ); -router - .route("/authors/bulk") - .post( - authAPI.verifyRequest, - authAPI.getUserAttributes, - authAPI.checkHasRoleMiddleware(process.env.ORG_ID, "campusadmin"), - middleware.validateZod(AuthorsValidators.BulkCreateAuthorsValidator), - authorsAPI.bulkCreateAuthors - ); - router .route("/authors/:id") .get( @@ -772,14 +762,14 @@ router .patch( authAPI.verifyRequest, authAPI.getUserAttributes, - authAPI.checkHasRoleMiddleware(process.env.ORG_ID, "campusadmin"), + authAPI.checkHasRoleMiddleware("libretexts", "superadmin"), middleware.validateZod(AuthorsValidators.UpdateAuthorValidator), authorsAPI.updateAuthor ) .delete( authAPI.verifyRequest, authAPI.getUserAttributes, - authAPI.checkHasRoleMiddleware(process.env.ORG_ID, "campusadmin"), + authAPI.checkHasRoleMiddleware("libretexts", "superadmin"), middleware.validateZod(AuthorsValidators.DeleteAuthorValidator), authorsAPI.deleteAuthor ); diff --git a/server/api/auth.ts b/server/api/auth.ts index 1003ceada..038771efd 100644 --- a/server/api/auth.ts +++ b/server/api/auth.ts @@ -21,6 +21,16 @@ const SALT_ROUNDS = 10; const JWT_SECRET = new TextEncoder().encode(process.env.SECRETKEY); const JWT_COOKIE_DOMAIN = (process.env.PRODUCTIONURLS || "").split(",")[0]; const SESSION_DEFAULT_EXPIRY_MINUTES = 60 * 24 * 7; // 7 days + +const APP_ENV = process.env.APP_ENV ?? "production"; +const COOKIE_PREFIX = APP_ENV === "production" ? "conductor" : `conductor_${APP_ENV}`; +export const COOKIE_NAMES = { + ACCESS: `${COOKIE_PREFIX}_access_v2`, + SIGNED: `${COOKIE_PREFIX}_signed_v2`, + OIDC_STATE: `${COOKIE_PREFIX}_oidc_state`, + OIDC_NONCE: `${COOKIE_PREFIX}_oidc_nonce`, + AUTH_REDIRECT: `${COOKIE_PREFIX}_auth_redirect`, +}; const SESSION_DEFAULT_EXPIRY_MILLISECONDS = SESSION_DEFAULT_EXPIRY_MINUTES * 60 * 1000; @@ -106,11 +116,11 @@ async function createAndAttachLocalSession( domain: JWT_COOKIE_DOMAIN, maxAge: SESSION_DEFAULT_EXPIRY_MILLISECONDS, }; - res.cookie("conductor_access_v2", access, { + res.cookie(COOKIE_NAMES.ACCESS, access, { path: "/", ...(process.env.NODE_ENV === "production" && prodCookieConfig), }); - res.cookie("conductor_signed_v2", signed, { + res.cookie(COOKIE_NAMES.SIGNED, signed, { path: "/", httpOnly: true, ...(process.env.NODE_ENV === "production" && prodCookieConfig), @@ -169,19 +179,19 @@ async function initLogin(req: Request, res: Response) { process.env.CONDUCTOR_DOMAIN !== "commons.libretexts.org" ) { const authRedirectURL = `${oidcCallbackProto}://${process.env.CONDUCTOR_DOMAIN}`; - res.cookie("conductor_auth_redirect", authRedirectURL, { + res.cookie(COOKIE_NAMES.AUTH_REDIRECT, authRedirectURL, { encode: String, httpOnly: true, ...(process.env.NODE_ENV === "production" && prodCookieConfig), }); } - res.cookie("oidc_state", base64State, { + res.cookie(COOKIE_NAMES.OIDC_STATE, base64State, { encode: String, httpOnly: true, ...(process.env.NODE_ENV === "production" && prodCookieConfig), }); - res.cookie("oidc_nonce", nonce, { + res.cookie(COOKIE_NAMES.OIDC_NONCE, nonce, { encode: String, httpOnly: true, ...(process.env.NODE_ENV === "production" && prodCookieConfig), @@ -208,7 +218,7 @@ async function completeLogin(req: Request, res: Response) { encodeURIComponent(value).replace(/%20/g, "+"); // Compare state nonce - const { oidc_state } = req.cookies; + const oidc_state = req.cookies[COOKIE_NAMES.OIDC_STATE]; const { state: stateQuery } = req.query; const safeParseState = (stateStr: string) => { @@ -262,7 +272,7 @@ async function completeLogin(req: Request, res: Response) { }); // Compare nonce hash - const { oidc_nonce } = req.cookies; + const oidc_nonce = req.cookies[COOKIE_NAMES.OIDC_NONCE]; const { nonce } = payload; const nonceString = nonce?.toString(); if (!nonce || !oidc_nonce || !nonceString) { @@ -370,8 +380,8 @@ async function completeLogin(req: Request, res: Response) { // Determine base of redirect URL let finalRedirectURL = `${oidcCallbackProto}://${oidcCallbackHost}`; // Default to callback host - if(req.cookies.conductor_auth_redirect){ - finalRedirectURL = req.cookies.conductor_auth_redirect; // Use auth redirect cookie if available + if(req.cookies[COOKIE_NAMES.AUTH_REDIRECT]){ + finalRedirectURL = req.cookies[COOKIE_NAMES.AUTH_REDIRECT]; // Use auth redirect cookie if available } // Check if redirectURI is a full URL and decode if needed @@ -414,8 +424,8 @@ async function completeLogin(req: Request, res: Response) { async function logout(_req: Request, res: Response) { try { // Attempt to invalidate the user's session - const accessCookie = _req.cookies.conductor_access_v2; - const signedCookie = _req.cookies.conductor_signed_v2; + const accessCookie = _req.cookies[COOKIE_NAMES.ACCESS]; + const signedCookie = _req.cookies[COOKIE_NAMES.SIGNED]; const sessionJWT = `${accessCookie}.${signedCookie}`; if (accessCookie && signedCookie && sessionJWT) { try { @@ -445,11 +455,11 @@ async function logout(_req: Request, res: Response) { secure: true, domain: JWT_COOKIE_DOMAIN, }; - res.clearCookie("conductor_access_v2", { + res.clearCookie(COOKIE_NAMES.ACCESS, { path: "/", ...(process.env.NODE_ENV === "production" && prodCookieConfig), }); - res.clearCookie("conductor_signed_v2", { + res.clearCookie(COOKIE_NAMES.SIGNED, { path: "/", httpOnly: true, ...(process.env.NODE_ENV === "production" && prodCookieConfig), diff --git a/server/api/authors.ts b/server/api/authors.ts index abdcf133b..20891d023 100644 --- a/server/api/authors.ts +++ b/server/api/authors.ts @@ -2,63 +2,30 @@ import { z } from "zod"; import { debugError } from "../debug.js"; import Author from "../models/author.js"; import { - BulkCreateAuthorsValidator, CreateAuthorValidator, DeleteAuthorValidator, - GetAllAuthorsValidator, + GetAuthorsValidator, GetAuthorAssetsValidator, GetAuthorValidator, UpdateAuthorValidator, } from "./validators/authors.js"; import { Response } from "express"; -import { conductor500Err } from "../util/errorutils.js"; +import { conductor404Err, conductor500Err } from "../util/errorutils.js"; import { getPaginationOffset } from "../util/helpers.js"; import ProjectFile from "../models/projectfile.js"; -import { Types } from "mongoose"; +import AuthorService from "./services/author-service.js"; async function getAuthors( - req: z.infer, + req: z.infer, res: Response ) { try { - const limit = req.query.limit || 10; - const offset = getPaginationOffset(req.query.page, limit); - - const queryObj: Record = { - $and: [{ orgID: process.env.ORG_ID }], - }; - - if (req.query.query) { - queryObj.$and.push({ - $or: [ - { firstName: { $regex: req.query.query, $options: "i" } }, - { lastName: { $regex: req.query.query, $options: "i" } }, - { email: { $regex: req.query.query, $options: "i" } }, - ], - }); - } - - const authorsPromise = Author.find(queryObj) - .skip(offset) - .limit(limit) - .sort(req.query.sort || "lastName") - .lean(); - - const totalPromise = Author.countDocuments(queryObj); - - const [authors, total] = await Promise.allSettled([ - authorsPromise, - totalPromise, - ]); - - if (authors.status === "rejected" || total.status === "rejected") { - return conductor500Err(res); - } + const authorService = new AuthorService(); + const data = await authorService.getAuthors(req.query); res.send({ err: false, - authors: authors.value, - totalCount: total.value, + ...data, }); } catch (error) { debugError(error); @@ -71,18 +38,8 @@ async function getAuthor( res: Response ) { try { - const convertedID = new Types.ObjectId(req.params.id); - const aggRes = await Author.aggregate([ - { - $match: { - _id: convertedID, - orgID: process.env.ORG_ID, - }, - }, - LOOKUP_AUTHOR_PROJECTS, - ]); - - const author = aggRes[0]; + const authorService = new AuthorService(); + const author = await authorService.getAuthorByID(req.params.id); if (!author) { return res.status(404).send({ @@ -96,12 +53,6 @@ async function getAuthor( author, }); } catch (err: any) { - if (err.name === "DocumentNotFoundError") { - return res.status(404).send({ - err: true, - message: "Author not found", - }); - } debugError(err); return conductor500Err(res); } @@ -267,10 +218,7 @@ async function getAuthorAssets( }); } catch (err: any) { if (err.name === "DocumentNotFoundError") { - return res.status(404).send({ - err: true, - message: "Author not found", - }); + return conductor404Err(res); } debugError(err); return conductor500Err(res); @@ -282,52 +230,8 @@ async function createAuthor( res: Response ) { try { - const { - firstName, - lastName, - email, - primaryInstitution, - url, - isAdminEntry, - } = req.body; - - // If an author was already created with the same email (and was not admin entry), we should "merge" the two, preferring the admin's data - if (email) { - const existingAuthor = await Author.findOne({ - email, - isAdminEntry: false, - orgID: process.env.ORG_ID, - }); - - if (existingAuthor) { - await Author.updateOne( - { _id: existingAuthor._id }, - { - $set: { - isAdminEntry: true, - firstName, - lastName, - primaryInstitution, - ...(url && { url }), - }, - } - ); - return res.send({ - err: false, - author: existingAuthor, - }); - } - } - - const author = await Author.create({ - firstName, - lastName, - primaryInstitution, - ...(email && { email }), - ...(url && { url }), - isAdminEntry: true, // only Campus Admins use this endpoint so set to true - orgID: process.env.ORG_ID, - }); + const authorService = new AuthorService(); + const author = await authorService.createAuthor(req.body); res.send({ err: false, @@ -337,45 +241,7 @@ async function createAuthor( if (err.name === "MongoServerError" && err.code === 11000) { return res.status(409).send({ err: true, - message: "Author with that email already exists", - }); - } - debugError(err); - return conductor500Err(res); - } -} - -async function bulkCreateAuthors( - req: z.infer, - res: Response -) { - try { - const existingAuthorEmails = (await Author.find().lean()).map( - (author) => author.email - ); - - // Ignore duplicates (email) - const noDuplicates = req.body.authors.filter( - (author) => !existingAuthorEmails.includes(author.email) - ); - - const withAdminFlag = noDuplicates.map((author) => ({ - ...author, - isAdminEntry: true, - orgID: process.env.ORG_ID, - })); - - const insertRes = await Author.insertMany(withAdminFlag); - - return res.send({ - err: false, - authors: insertRes, - }); - } catch (err: any) { - if (err.name === "MongoServerError" && err.code === 11000) { - return res.status(409).send({ - err: true, - message: "Author with that email already exists", + errMsg: "An author with that nameKey already exists.", }); } debugError(err); @@ -388,37 +254,22 @@ async function updateAuthor( res: Response ) { try { - const { firstName, lastName, email, primaryInstitution, url } = req.body; - await Author.updateOne( - { _id: req.params.id, orgID: process.env.ORG_ID }, - { - firstName, - lastName, - primaryInstitution, - ...(email && { email }), - ...(url && { url }), - orgID: process.env.ORG_ID, - } - ).orFail(); - - const updated = await Author.findById(req.params.id).orFail().lean(); + const authorService = new AuthorService(); + const author = await authorService.updateAuthor(req.params.id, req.body); return res.send({ err: false, - author: updated, + author, }); } catch (err: any) { if (err.name === "MongoServerError" && err.code === 11000) { return res.status(409).send({ err: true, - message: "Author with that email already exists", + errMsg: "An author with that nameKey already exists.", }); } if (err.name === "DocumentNotFoundError") { - return res.status(404).send({ - err: true, - message: "Author not found", - }); + return conductor404Err(res); } debugError(err); return conductor500Err(res); @@ -430,10 +281,8 @@ async function deleteAuthor( res: Response ) { try { - await Author.deleteOne({ - _id: req.params.id, - orgID: process.env.ORG_ID, - }).orFail(); + const authorService = new AuthorService(); + await authorService.deleteAuthor(req.params.id); return res.send({ err: false, @@ -441,104 +290,20 @@ async function deleteAuthor( }); } catch (err: any) { if (err.name === "DocumentNotFoundError") { - return res.status(404).send({ - err: true, - message: "Author not found", - }); + return conductor404Err(res); } debugError(err); return conductor500Err(res); } } -const LOOKUP_AUTHOR_PROJECTS = { - $lookup: { - from: "projects", - let: { - authorID: "$_id", - }, - pipeline: [ - { - $match: { - orgID: process.env.ORG_ID, - visibility: "public", - }, - }, - { - $match: { - $expr: { - $or: [ - { - $eq: ["$defaultPrimaryAuthorID", "$$authorID"], - }, - { - $eq: ["$defaultCorrespondingAuthorID", "$$authorID"], - }, - { - $in: [ - "$$authorID", - { - $cond: { - if: { - $isArray: "$defaultSecondaryAuthorIDs", - }, - then: "$defaultSecondaryAuthorIDs", - else: [], - }, - }, - ], - }, - { - $in: [ - "$$authorID", - { - $cond: { - if: { - $isArray: "$principalInvestigatorIDs", - }, - then: "$principalInvestigatorIDs", - else: [], - }, - }, - ], - }, - { - $in: [ - "$$authorID", - { - $cond: { - if: { - $isArray: "$coPrincipalInvestigatorIDs", - }, - then: "$coPrincipalInvestigatorIDs", - else: [], - }, - }, - ], - }, - ], - }, - }, - }, - { - $project: { - _id: 0, - projectID: 1, - title: 1, - }, - }, - ], - as: "projects", - }, -}; + export default { getAuthors, getAuthor, getAuthorAssets, createAuthor, - bulkCreateAuthors, updateAuthor, deleteAuthor, - LOOKUP_AUTHOR_PROJECTS, }; diff --git a/server/api/search.ts b/server/api/search.ts index 1da017d8a..bc565ec7e 100644 --- a/server/api/search.ts +++ b/server/api/search.ts @@ -37,6 +37,7 @@ import { _getBookPublicOrInstructorAssetsCount, buildOrganizationNamesList } fro import CustomCatalog, { CustomCatalogInterface } from "../models/customcatalog.js"; import { normalizedSort } from "../util/searchutils.js"; import SearchService from "./services/search-service.js"; +import AuthorService from "./services/author-service.js"; const searchQueryCache: SearchQueryInterface_Raw[] = []; // in-memory cache for search queries @@ -1890,7 +1891,7 @@ async function authorsSearch( orgID: process.env.ORG_ID, }, }, - authorsAPI.LOOKUP_AUTHOR_PROJECTS, + AuthorService.LOOKUP_AUTHOR_PROJECTS_STAGE, { $project: { _id: 1, diff --git a/server/api/services/author-service.ts b/server/api/services/author-service.ts new file mode 100644 index 000000000..b3856421c --- /dev/null +++ b/server/api/services/author-service.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; +import { GetAuthorsValidator, CreateAuthorValidator, UpdateAuthorValidator } from "../validators/authors.js"; +import { BaseConductorInfiniteScrollResponse } from "../../types"; +import Author, { AuthorInterface } from "../../models/author.js"; +import { escapeRegEx, getPaginationOffset } from "../../util/helpers.js"; +import { Types } from "mongoose"; + +export default class AuthorService { + public async getAuthors(params: z.infer['query']): Promise> { + let filter: any = { orgID: process.env.ORG_ID }; + + if (params.query) { + const queryRegex = new RegExp(escapeRegEx(params.query), "i"); + filter.$or = [{ name: queryRegex }, { nameKey: queryRegex }, { companyName: queryRegex }, { programName: queryRegex }]; + } + + const offset = getPaginationOffset(params.page, params.limit); + + const authors = await Author.find(filter).sort({ [params.sort]: 1 }).skip(offset).limit(params.limit).lean(); + const total = await Author.countDocuments(filter); + + const has_more = offset + params.limit < total; + const next_page = has_more ? params.page + 1 : null; + + return { + items: authors as unknown as AuthorInterface[], + meta: { + has_more, + next_page, + total_count: total, + } + }; + } + + public async getAuthorByID(id: string): Promise { + const convertedID = new Types.ObjectId(id); + + const aggRes = await Author.aggregate([ + { + $match: { + _id: convertedID, + orgID: process.env.ORG_ID, + }, + }, + AuthorService.LOOKUP_AUTHOR_PROJECTS_STAGE, + ]); + + return aggRes.length > 0 ? aggRes[0] : null; + } + + public async createAuthor(data: z.infer['body']): Promise { + const author = await Author.create({ + ...this.sanitizeAuthorData(data), + orgID: process.env.ORG_ID, + }); + return author; + } + + public async updateAuthor(id: string, data: z.infer['body']): Promise { + const toSet: Record = {}; + const toUnset: Record = {}; + + for (const [key, value] of Object.entries(this.sanitizeAuthorData(data))) { + if (value !== undefined) { + toSet[key] = value; + } + } + + // Unset fields that were explicitly cleared (empty string sent by client) + for (const [key, value] of Object.entries(data)) { + if (key !== 'pictureCircle' && value === "") { + toUnset[key] = 1; + } + } + + await Author.updateOne( + { _id: id, orgID: process.env.ORG_ID }, + { + ...(Object.keys(toSet).length > 0 && { $set: toSet }), + ...(Object.keys(toUnset).length > 0 && { $unset: toUnset }), + } + ).orFail(); + + return await Author.findById(id).orFail().lean() as unknown as AuthorInterface; + } + + public async deleteAuthor(id: string): Promise { + await Author.deleteOne({ _id: id, orgID: process.env.ORG_ID }).orFail(); + } + + private sanitizeAuthorData(data: Partial['body']>): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (value !== "" && value !== undefined) { + result[key] = value; + } + } + + return result; + } + + static readonly LOOKUP_AUTHOR_PROJECTS_STAGE = { + $lookup: { + from: "projects", + let: { + authorID: "$_id", + }, + pipeline: [ + { + $match: { + orgID: process.env.ORG_ID, + visibility: "public", + }, + }, + { + $match: { + $expr: { + $or: [ + { + $eq: ["$defaultPrimaryAuthorID", "$$authorID"], + }, + { + $eq: ["$defaultCorrespondingAuthorID", "$$authorID"], + }, + { + $in: [ + "$$authorID", + { + $cond: { + if: { + $isArray: "$defaultSecondaryAuthorIDs", + }, + then: "$defaultSecondaryAuthorIDs", + else: [], + }, + }, + ], + }, + { + $in: [ + "$$authorID", + { + $cond: { + if: { + $isArray: "$principalInvestigatorIDs", + }, + then: "$principalInvestigatorIDs", + else: [], + }, + }, + ], + }, + { + $in: [ + "$$authorID", + { + $cond: { + if: { + $isArray: "$coPrincipalInvestigatorIDs", + }, + then: "$coPrincipalInvestigatorIDs", + else: [], + }, + }, + ], + }, + ], + }, + }, + }, + { + $project: { + _id: 0, + projectID: 1, + title: 1, + }, + }, + ], + as: "projects", + }, + }; +} diff --git a/server/api/validators/authors.ts b/server/api/validators/authors.ts index 883d5051d..b3ad02eb0 100644 --- a/server/api/validators/authors.ts +++ b/server/api/validators/authors.ts @@ -2,12 +2,20 @@ import { z } from "zod"; import { PaginationSchema, isMongoIDValidator } from "./misc.js"; const _AuthorValidator = z.object({ - firstName: z.string().trim().min(1).max(100), - lastName: z.string().trim().min(1).max(100), - email: z.string().trim().email().optional().or(z.literal("")), - url: z.string().url().optional().or(z.literal("")), - primaryInstitution: z.string().trim().optional().or(z.literal("")), - userUUID: z.string().uuid().optional(), + nameKey: z.string().trim().min(1).max(100), + name: z.string().trim().min(1).max(200), + nameTitle: z.string().trim().max(50).optional().or(z.literal("")), + nameURL: z.url().optional().or(z.literal("")), + note: z.string().trim().max(1000).optional().or(z.literal("")), + noteURL: z.url().optional().or(z.literal("")), + companyName: z.string().trim().max(200).optional().or(z.literal("")), + companyURL: z.url().optional().or(z.literal("")), + pictureCircle: z.enum(["yes", "no"]).optional(), + pictureURL: z.url().optional().or(z.literal("")), + programName: z.string().trim().max(200).optional().or(z.literal("")), + programURL: z.url().optional().or(z.literal("")), + attributionPrefix: z.string().trim().max(100).optional().or(z.literal("")), + userUUID: z.uuid().optional(), }); const AuthorIDParams = z.object({ @@ -16,13 +24,14 @@ const AuthorIDParams = z.object({ }), }); -export const GetAllAuthorsValidator = z.object({ +export const GetAuthorsValidator = z.object({ query: z .object({ - query: z.string().optional(), - sort: z.enum(["firstName", "lastName", "email"]).optional(), - }) - .merge(PaginationSchema), + page: z.coerce.number().min(1).optional().default(1), + limit: z.coerce.number().int().min(1).optional().default(25), + query: z.string().optional().or(z.literal("")), + sort: z.enum(["nameKey", "name", "companyName"]).optional().default("nameKey"), + }).optional().default({ page: 1, limit: 25, sort: "nameKey" }), }); export const GetAuthorValidator = AuthorIDParams; @@ -34,20 +43,12 @@ export const GetAuthorAssetsValidator = z.object({ }); export const CreateAuthorValidator = z.object({ - body: _AuthorValidator.merge( - z.object({ - isAdminEntry: z.boolean().optional().default(false), - }) - ), + body: _AuthorValidator, }); -export const UpdateAuthorValidator = - CreateAuthorValidator.merge(AuthorIDParams); +export const UpdateAuthorValidator = z.object({ + body: _AuthorValidator.partial(), + params: AuthorIDParams.shape.params, +}); export const DeleteAuthorValidator = AuthorIDParams; - -export const BulkCreateAuthorsValidator = z.object({ - body: z.object({ - authors: z.array(_AuthorValidator).min(1).max(1500), - }), -}); diff --git a/server/middleware.ts b/server/middleware.ts index a544cde60..b8c661b2f 100644 --- a/server/middleware.ts +++ b/server/middleware.ts @@ -9,7 +9,7 @@ import { validationResult } from "express-validator"; import conductorErrors from "./conductor-errors.js"; import { ZodObject } from "zod"; import { debugError } from "./debug.js"; -import authAPI from "./api/auth.js"; +import authAPI, { COOKIE_NAMES } from "./api/auth.js"; import { TypedReqBodyWithUser, TypedReqParamsAndBodyWithUser, @@ -118,10 +118,10 @@ function authSanitizer(req: Request, _res: Response, next: NextFunction) { const { cookies } = req; if ( !req.header("authorization") && - cookies.conductor_access_v2 && - cookies.conductor_signed_v2 + cookies[COOKIE_NAMES.ACCESS] && + cookies[COOKIE_NAMES.SIGNED] ) { - req.headers.authorization = `${cookies.conductor_access_v2}.${cookies.conductor_signed_v2}`; + req.headers.authorization = `${cookies[COOKIE_NAMES.ACCESS]}.${cookies[COOKIE_NAMES.SIGNED]}`; } } return next(); diff --git a/server/migrations/ImportAuthorsFromJSON.js b/server/migrations/ImportAuthorsFromJSON.js new file mode 100644 index 000000000..def683aca --- /dev/null +++ b/server/migrations/ImportAuthorsFromJSON.js @@ -0,0 +1,275 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import mongoose from "mongoose"; +import Author from "../models/author.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Imports author records from prod-authors-header.json and prod-authors-footer.json + * into the Author collection. + * + * Merge rules: + * - Within each file, the FIRST occurrence of a duplicate key wins. + * - Between files, prod-authors-header.json takes precedence over prod-authors-footer.json. + * - Records already present in the DB (matched by nameKey + orgID) are skipped (idempotent). + * + * Both files contain invalid JSON escape sequences (\') which are sanitized before parsing. + * + * Field mapping (source keys are all lowercase in the JSON): + * root key → nameKey + * name → name + * nametitle → nameTitle + * nameurl → nameURL + * note → note + * noteurl → noteURL + * companyname → companyName + * companyurl → companyURL + * picturecircle → pictureCircle ("yes" / "no" string) + * pictureurl → pictureURL + * programname → programName + * programurl → programURL + * attributionprefix → attributionPrefix + */ + +const FIELD_MAP = { + name: "name", + nametitle: "nameTitle", + nameurl: "nameURL", + note: "note", + noteurl: "noteURL", + companyname: "companyName", + companyurl: "companyURL", + picturecircle: "pictureCircle", + pictureurl: "pictureURL", + programname: "programName", + programurl: "programURL", + attributionprefix: "attributionPrefix", +}; + +/** + * Replace invalid JSON escape sequences involving apostrophes with a plain apostrophe. + * Both \' and \\' appear in the source files (single and double-escaped apostrophes), + * so we collapse any run of one or more backslashes immediately before a ' into + * just the apostrophe itself. + * Standard JSON only allows \", \\, \/, \b, \f, \n, \r, \t, \uXXXX. + */ +function fixEscapes(raw) { + return raw.replace(/\\+'(?!')/g, "'"); +} + +/** + * Parse a JSON file, preserving first-occurrence order for duplicate top-level keys. + * + * JSON.parse gives last-wins for duplicate keys (undefined behavior per spec, but + * consistent in V8). Since both source files contain many within-file duplicates, we + * scan the raw text for top-level key positions and extract each object value via + * brace-matching, skipping any key we have already seen. + */ +function parseFileFirstWins(filePath) { + const raw = fs.readFileSync(filePath, "utf8"); + const fixed = fixEscapes(raw); + + const result = {}; + const seenKeys = new Set(); + + // Match top-level keys: a line that starts with optional whitespace, + // a quoted string, a colon, and an opening brace. + const keyPattern = /\n[ \t]*"([^"]+)"[ \t]*:[ \t]*\{/g; + let match; + + while ((match = keyPattern.exec(fixed)) !== null) { + const key = match[1]; + if (seenKeys.has(key)) continue; + seenKeys.add(key); + + // Walk forward from the opening brace of this value using brace-matching. + const openBrace = match.index + match[0].length - 1; // index of '{' + let depth = 0; + let i = openBrace; + let inString = false; + let prevBackslash = false; + + while (i < fixed.length) { + const ch = fixed[i]; + + if (prevBackslash) { + prevBackslash = false; + i++; + continue; + } + + if (ch === "\\" && inString) { + prevBackslash = true; + i++; + continue; + } + + if (ch === '"') { + inString = !inString; + i++; + continue; + } + + if (!inString) { + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + const valueStr = fixed.substring(openBrace, i + 1); + try { + result[key] = JSON.parse(valueStr); + } catch (e) { + console.warn( + ` Warning: could not parse value for key "${key}" in ${path.basename(filePath)}: ${e.message}. Skipping.` + ); + } + break; + } + } + } + + i++; + } + } + + return result; +} + +/** + * Merge footer and header data. + * Footer is processed first; header entries always overwrite footer entries + * (header takes precedence for cross-file duplicates). + */ +function mergeData(header, footer) { + const result = {}; + + for (const [key, value] of Object.entries(footer)) { + result[key] = value; + } + + for (const [key, value] of Object.entries(header)) { + result[key] = value; + } + + return result; +} + +/** + * Transform a raw JSON record into an Author document. + * Trims whitespace from all string values and skips empty strings. + */ +function transformRecord(nameKey, record, orgID) { + const doc = { nameKey, orgID }; + + for (const [srcField, value] of Object.entries(record)) { + const destField = FIELD_MAP[srcField.toLowerCase()]; + if (!destField) continue; + + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed) doc[destField] = trimmed; + } else if (value !== undefined && value !== null) { + doc[destField] = value; + } + } + + return doc; +} + +export async function runMigration() { + const migrationTitle = "Import Authors from JSON Files"; + try { + console.log(`Running migration "${migrationTitle}"...`); + + if (!process.env.MONGOOSEURI) { + throw new Error("MONGOOSEURI environment variable is not set."); + } + if (!process.env.ORG_ID) { + throw new Error("ORG_ID environment variable is not set."); + } + + await mongoose.connect(process.env.MONGOOSEURI, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + console.log("Connected to MongoDB."); + + const headerPath = path.resolve(__dirname, "../../prod-authors-header.json"); + const footerPath = path.resolve(__dirname, "../../prod-authors-footer.json"); + + console.log("Parsing prod-authors-header.json (first-wins)..."); + const headerData = parseFileFirstWins(headerPath); + console.log(` ${Object.keys(headerData).length} unique records`); + + console.log("Parsing prod-authors-footer.json (first-wins)..."); + const footerData = parseFileFirstWins(footerPath); + console.log(` ${Object.keys(footerData).length} unique records`); + + const merged = mergeData(headerData, footerData); + console.log(`Merged total: ${Object.keys(merged).length} unique records`); + + // Load existing nameKeys to make this migration idempotent + const existing = await Author.find({ orgID: process.env.ORG_ID }) + .select("nameKey") + .lean(); + const existingKeys = new Set(existing.map((a) => a.nameKey)); + console.log(`Existing authors in DB: ${existingKeys.size}`); + + const toInsert = []; + const skippedExisting = []; + const skippedInvalid = []; + + for (const [nameKey, record] of Object.entries(merged)) { + if (existingKeys.has(nameKey)) { + skippedExisting.push(nameKey); + continue; + } + + const doc = transformRecord(nameKey, record, process.env.ORG_ID); + + if (!doc.name) { + console.warn(` Skipping "${nameKey}": missing required "name" field.`); + skippedInvalid.push(nameKey); + continue; + } + + toInsert.push(doc); + } + + console.log(`Records to insert: ${toInsert.length}`); + console.log(`Records skipped (already in DB): ${skippedExisting.length}`); + console.log(`Records skipped (invalid data): ${skippedInvalid.length}`); + + if (toInsert.length === 0) { + console.log("Nothing to insert. Migration already complete."); + return; + } + + // ordered: false — continue inserting even if individual docs fail + const insertResult = await Author.insertMany(toInsert, { ordered: false }); + console.log(`Inserted ${insertResult.length} authors successfully.`); + console.log(`Completed migration "${migrationTitle}".`); + } catch (e) { + // MongoBulkWriteError is thrown by insertMany when ordered:false and some docs fail + if (e.name === "MongoBulkWriteError") { + console.log( + `Inserted ${e.result?.nInserted ?? 0} authors (${e.writeErrors?.length ?? 0} skipped due to write errors).` + ); + return; + } + console.error(`Fatal error during migration "${migrationTitle}": ${e.toString()}`); + throw e; + } finally { + await mongoose.disconnect(); + } +} + +// Uncomment to run standalone: +// runMigration() +// .then(() => process.exit(0)) +// .catch((e) => { +// console.error(`Migration failed: ${e.toString()}`); +// process.exit(1); +// }); diff --git a/server/migrations/TransformAuthorFields.js b/server/migrations/TransformAuthorFields.js new file mode 100644 index 000000000..a715d5237 --- /dev/null +++ b/server/migrations/TransformAuthorFields.js @@ -0,0 +1,128 @@ +import Author from "../models/author.js"; +import mongoose from "mongoose"; +// import dotenv from 'dotenv'; +// dotenv.config(); + +/** + * Transforms the Author collection to match the new schema: + * - Combines firstName + lastName into name + * - Renames url to nameURL + * - Keeps orgID and userUUID unchanged + * - Removes email, primaryInstitution, and isAdminEntry fields + * + * Idempotent - can be run multiple times safely. + */ +export async function runMigration() { + const migrationTitle = "Transform Author Fields"; + try { + console.log(`Running migration "${migrationTitle}"...`); + + console.log("Connecting to MongoDB..."); + if (!process.env.MONGOOSEURI) { + throw new Error("MONGOOSEURI environment variable is not set."); + } + + await mongoose.connect(process.env.MONGOOSEURI, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + console.log("Connected to MongoDB."); + + // Find all authors that haven't been migrated yet + // (they still have firstName field, which indicates they need migration) + const authorsToMigrate = await Author.find({ + firstName: { $exists: true }, + }).lean(); + + console.log(`Found ${authorsToMigrate.length} authors to migrate.`); + + if (authorsToMigrate.length === 0) { + console.log( + "No authors need migration. Migration already complete or no authors exist.", + ); + return; + } + + // Get the underlying MongoDB collection to bypass Mongoose schema validation + const collection = Author.collection; + + const operations = authorsToMigrate.map((author) => { + const updateObj = { + $set: {}, + $unset: {}, + }; + + // Generate nameKey from firstName and lastName + if (author.firstName || author.lastName) { + const firstName = author.firstName || ""; + const lastName = author.lastName || ""; + updateObj.$set.nameKey = generateNameKey(firstName, lastName); + } + + if (!updateObj.$set.nameKey) { + console.warn( + `Author ${author._id} is missing both firstName and lastName. Skipping migration for this author.`, + ); + return collection.updateOne({ _id: author._id }, { $set: {} }); // No-op update to skip this author + } + + // Combine firstName + lastName into name + // Only set if both firstName and lastName exist + if (author.firstName || author.lastName) { + const firstName = author.firstName || ""; + const lastName = author.lastName || ""; + updateObj.$set.name = `${firstName} ${lastName}`.trim(); + } + + // Rename url to nameURL if it exists + if (author.url) { + updateObj.$set.nameURL = author.url; + } + + // Remove old fields + updateObj.$unset.firstName = ""; + updateObj.$unset.lastName = ""; + updateObj.$unset.url = ""; + updateObj.$unset.email = ""; + updateObj.$unset.primaryInstitution = ""; + updateObj.$unset.isAdminEntry = ""; + + console.log(`Updating author ${author._id}:`, updateObj); + + // Use native MongoDB collection.updateOne() to bypass Mongoose schema validation + return collection.updateOne({ _id: author._id }, updateObj); + }); + + const results = await Promise.all(operations); + + const updatedCount = results.filter((r) => r.modifiedCount > 0).length; + + console.log(`Updated ${updatedCount} authors successfully.`); + console.log(`Completed migration "${migrationTitle}".`); + } catch (e) { + console.error( + `Fatal error during migration "${migrationTitle}": ${e.toString()}`, + ); + throw e; + } finally { + await mongoose.disconnect(); + } +} + +function generateNameKey(firstName, lastName) { + const first = firstName ? firstName.trim() : ""; + const last = lastName ? lastName.trim() : ""; + + return `${first.toLowerCase()}-${last.toLowerCase()}`.trim(); +} + +// runMigration() +// .then(() => { +// console.log('Migration completed successfully.'); +// process.exit(0); +// }) +// .catch((e) => { +// console.error(`Migration failed: ${e.toString()}`); +// process.exit(1); +// }); diff --git a/server/models/author.ts b/server/models/author.ts index b58df1f2a..e16eb56b2 100644 --- a/server/models/author.ts +++ b/server/models/author.ts @@ -1,14 +1,21 @@ -import { Schema, model } from "mongoose"; +import { Schema, model, Document } from "mongoose"; export interface AuthorInterface extends Document { orgID: string; - firstName: string; - lastName: string; - email?: string; - url?: string; - primaryInstitution?: string; - userUUID?: string; - isAdminEntry?: boolean; + nameKey: string; // A normalized version of the name for indexing and searching. Must be unique within an orgID. + name: string; + nameTitle?: string; + nameURL?: string; + note?: string; + noteURL?: string; + companyName?: string; + companyURL?: string; + pictureCircle?: string; // i.e. "yes" or "no" + pictureURL?: string; + programName?: string; + programURL?: string; + attributionPrefix?: string; + userUUID?: string; // Optional field if the user uuid of the matching author is known } const AuthorSchema = new Schema({ @@ -16,41 +23,65 @@ const AuthorSchema = new Schema({ type: String, required: true, }, - firstName: { + nameKey: { type: String, required: true, }, - lastName: { + name: { type: String, required: true, }, - email: { + nameTitle: { type: String, required: false, }, - url: { + nameURL: { type: String, required: false, }, - primaryInstitution: { + note: { type: String, required: false, }, - userUUID: { + noteURL: { + type: String, + required: false, + }, + companyName: { + type: String, + required: false, + }, + companyURL: { + type: String, + required: false, + }, + pictureCircle: { + type: String, + }, + pictureURL: { type: String, required: false, }, - isAdminEntry: { - type: Boolean, + programName: { + type: String, + required: false, + }, + programURL: { + type: String, + required: false, + }, + attributionPrefix: { + type: String, + required: false, + }, + userUUID: { + type: String, required: false, }, }); -// Email is unique, but not required -AuthorSchema.index( - { email: 1, orgID: 1}, - { unique: true, partialFilterExpression: { email: { $exists: true } } } -); +AuthorSchema.index({ orgID: 1, nameKey: 1 }, { unique: true }); // Ensure nameKey is unique within an orgID + const Author = model("Author", AuthorSchema); diff --git a/server/package-lock.json b/server/package-lock.json index 3e11c7418..e8588fb27 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -46,7 +46,7 @@ "form-data": "^4.0.4", "fs-extra": "^10.0.0", "fuse.js": "^7.0.0", - "gpt-tokenizer": "^2.8.1", + "gpt-tokenizer": "^3.4.0", "helmet": "^4.1.1", "isomorphic-dompurify": "^1.9.0", "jose": "^4.15.5", @@ -1027,17 +1027,6 @@ } } }, - "node_modules/@aws-sdk/util-utf8-browser": { - "version": "3.259.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-utf8-browser/-/util-utf8-browser-3.259.0.tgz", - "integrity": "sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.3.1" - } - }, "node_modules/@aws-sdk/xml-builder": { "version": "3.972.10", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz", @@ -1442,14 +1431,439 @@ "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", "license": "MIT", - "peer": true + "peer": true + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT", + "peer": true + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "peer": true + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } }, "node_modules/@esbuild/win32-x64": { "version": "0.27.3", @@ -2020,81 +2434,403 @@ "resolved": "https://registry.npmjs.org/@node-oauth/oauth2-server/-/oauth2-server-4.3.3.tgz", "integrity": "sha512-0LiQ6sGwgd+WPQCkayORgv3ju6JNcZsU9IpiXSGCQ/LNlvhQCgwriVaVageXl1J9GwY/rq4wMg6DPwakGZLC2g==", "license": "MIT", - "dependencies": { - "@node-oauth/formats": "1.0.0", - "basic-auth": "2.0.1", - "bluebird": "3.7.2", - "promisify-any": "2.0.1", - "type-is": "1.6.18" - }, - "engines": { - "node": ">=14.0.0" - } + "dependencies": { + "@node-oauth/formats": "1.0.0", + "basic-auth": "2.0.1", + "bluebird": "3.7.2", + "promisify-any": "2.0.1", + "type-is": "1.6.18" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@qdrant/js-client-rest": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.17.0.tgz", + "integrity": "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A==", + "license": "Apache-2.0", + "dependencies": { + "@qdrant/openapi-typescript-fetch": "1.2.6", + "undici": "^6.23.0" + }, + "engines": { + "node": ">=18.17.0", + "pnpm": ">=8" + }, + "peerDependencies": { + "typescript": ">=4.7" + } + }, + "node_modules/@qdrant/openapi-typescript-fetch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@qdrant/openapi-typescript-fetch/-/openapi-typescript-fetch-1.2.6.tgz", + "integrity": "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">= 8" - } + "optional": true, + "os": [ + "openbsd" + ] }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } + "optional": true, + "os": [ + "openharmony" + ] }, - "node_modules/@qdrant/js-client-rest": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/@qdrant/js-client-rest/-/js-client-rest-1.17.0.tgz", - "integrity": "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A==", - "license": "Apache-2.0", - "dependencies": { - "@qdrant/openapi-typescript-fetch": "1.2.6", - "undici": "^6.23.0" - }, - "engines": { - "node": ">=18.17.0", - "pnpm": ">=8" - }, - "peerDependencies": { - "typescript": ">=4.7" - } + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@qdrant/openapi-typescript-fetch": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@qdrant/openapi-typescript-fetch/-/openapi-typescript-fetch-1.2.6.tgz", - "integrity": "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", - "engines": { - "node": ">=18.0.0", - "pnpm": ">=8" - } + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.59.0", @@ -4208,9 +4944,9 @@ } }, "node_modules/cheerio/node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.2.tgz", + "integrity": "sha512-P9J1HWYV/ajFr8uCqk5QixwiRKmB1wOamgS0e+o2Z4A44Ej2+thFVRLG/eA7qprx88XXhnV5Bl8LHXTURpzB3Q==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -6225,6 +6961,21 @@ "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/fstream": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", @@ -6500,9 +7251,9 @@ } }, "node_modules/gpt-tokenizer": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-2.9.0.tgz", - "integrity": "sha512-YSpexBL/k4bfliAzMrRqn3M6+it02LutVyhVpDeMKrC/O9+pCe/5s8U2hYKa2vFLD5/vHhsKc8sOn/qGqII8Kg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-3.4.0.tgz", + "integrity": "sha512-wxFLnhIXTDjYebd9A9pGl3e31ZpSypbpIJSOswbgop5jLte/AsZVDvjlbEuVFlsqZixVKqbcoNmRlFDf6pz/UQ==", "license": "MIT" }, "node_modules/graceful-fs": { @@ -7831,48 +8582,6 @@ } } }, - "node_modules/langchain/node_modules/@aws-crypto/crc32": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-3.0.0.tgz", - "integrity": "sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@aws-crypto/util": "^3.0.0", - "@aws-sdk/types": "^3.222.0", - "tslib": "^1.11.1" - } - }, - "node_modules/langchain/node_modules/@aws-crypto/crc32/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD", - "optional": true, - "peer": true - }, - "node_modules/langchain/node_modules/@aws-crypto/util": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-3.0.0.tgz", - "integrity": "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@aws-sdk/types": "^3.222.0", - "@aws-sdk/util-utf8-browser": "^3.0.0", - "tslib": "^1.11.1" - } - }, - "node_modules/langchain/node_modules/@aws-crypto/util/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "license": "0BSD", - "optional": true, - "peer": true - }, "node_modules/langchain/node_modules/@langchain/community": { "version": "0.0.57", "resolved": "https://registry.npmjs.org/@langchain/community/-/community-0.0.57.tgz", @@ -8307,156 +9016,6 @@ "node": ">=18" } }, - "node_modules/langchain/node_modules/@smithy/eventstream-codec": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-2.2.0.tgz", - "integrity": "sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@aws-crypto/crc32": "3.0.0", - "@smithy/types": "^2.12.0", - "@smithy/util-hex-encoding": "^2.2.0", - "tslib": "^2.6.2" - } - }, - "node_modules/langchain/node_modules/@smithy/is-array-buffer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", - "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/protocol-http": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-3.3.0.tgz", - "integrity": "sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@smithy/types": "^2.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/signature-v4": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-2.3.0.tgz", - "integrity": "sha512-ui/NlpILU+6HAQBfJX8BBsDXuKSNrjTSuOYArRblcrErwKFutjrCNb/OExfVRyj9+26F9J+ZmfWT+fKWuDrH3Q==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "@smithy/types": "^2.12.0", - "@smithy/util-hex-encoding": "^2.2.0", - "@smithy/util-middleware": "^2.2.0", - "@smithy/util-uri-escape": "^2.2.0", - "@smithy/util-utf8": "^2.3.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/types": { - "version": "2.12.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-2.12.0.tgz", - "integrity": "sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/util-buffer-from": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", - "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@smithy/is-array-buffer": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/util-hex-encoding": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-2.2.0.tgz", - "integrity": "sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/util-middleware": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-2.2.0.tgz", - "integrity": "sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@smithy/types": "^2.12.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/util-uri-escape": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-2.2.0.tgz", - "integrity": "sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/langchain/node_modules/@smithy/util-utf8": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", - "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "@smithy/util-buffer-from": "^2.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/langchain/node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", @@ -12068,9 +12627,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" diff --git a/server/package.json b/server/package.json index 7a790a261..1dc49d431 100644 --- a/server/package.json +++ b/server/package.json @@ -48,7 +48,7 @@ "form-data": "^4.0.4", "fs-extra": "^10.0.0", "fuse.js": "^7.0.0", - "gpt-tokenizer": "^2.8.1", + "gpt-tokenizer": "^3.4.0", "helmet": "^4.1.1", "isomorphic-dompurify": "^1.9.0", "jose": "^4.15.5", diff --git a/server/server.ts b/server/server.ts index 274fe6e4b..863e63c36 100644 --- a/server/server.ts +++ b/server/server.ts @@ -7,6 +7,7 @@ import "dotenv/config"; import path from "path"; import { exit } from "process"; import { fileURLToPath } from "url"; +import fs from "fs"; import express from "express"; import mongoose from "mongoose"; import cookieParser from "cookie-parser"; @@ -125,9 +126,14 @@ app.use("/health", (_req, res) => { // Serve frontend assets. Use directories relative to server/dist app.use(express.static(path.join(__dirname, "../../client/dist"))); +// Inject APP_ENV into index.html for frontend use (Vite env variables are not accessible in server code, so we inject at runtime) +const appEnv = process.env.APP_ENV ?? "production"; +const envScript = ``; +const indexHtmlPath = path.resolve(__dirname, "../../client/dist/index.html"); +const indexHtml = fs.readFileSync(indexHtmlPath, "utf-8").replace("", `${envScript}`); let cliRouter = express.Router(); cliRouter.route("*").get((_req, res) => { - res.sendFile(path.resolve(__dirname, "../../client/dist/index.html")); + res.setHeader("Content-Type", "text/html").send(indexHtml); }); app.use("/", cliRouter); diff --git a/server/types/Misc.ts b/server/types/Misc.ts index faf773d11..0ca4b46e2 100644 --- a/server/types/Misc.ts +++ b/server/types/Misc.ts @@ -17,6 +17,28 @@ export type License = { additionalTerms?: string; }; +export type ConductorInfiniteScrollResponse = ({ + err: false; +} & BaseConductorInfiniteScrollResponse) | { + err: true; + errMsg: string; +}; + +/** + * The base data structure for responses from the Conductor server when fetching items with infinite scroll pagination. + * The controller method should return data in the `ConductorInfiniteScrollResponse` format which wraps this base structure and adds an `err` field to indicate success or failure of the request. + * This type is used to ensure consistent response formats across different API endpoints that implement infinite scroll pagination. + */ +export type BaseConductorInfiniteScrollResponse = { + items: T[]; + meta: { + total_count: number; + has_more: boolean; + next_page: string | number | null; + } +} + + /** * A TypeScript type alias called `Prettify`. * It takes a type as its argument and returns a new type that has the same properties as the original type, diff --git a/server/util/helpers.js b/server/util/helpers.js index bad6cdbdc..8918d175e 100644 --- a/server/util/helpers.js +++ b/server/util/helpers.js @@ -388,6 +388,16 @@ export function sanitizeControlCharacters(str) { return str.replace(/\p{C}/gu, ''); } +/** + * Escapes special characters in a string for use in a regular expression. + * @param {*} input - The string to escape. + * @returns {*} The escaped string, or the original value if not a string. + */ +export function escapeRegEx(input) { + if (typeof input !== 'string') return input; + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + /** * Decodes a percent-encoded string if applicable, otherwise returns the original string. * @param {*} str - The string to decode.