diff --git a/libs/react/containers/src/layouts/layout-module/layout-module.tsx b/libs/react/containers/src/layouts/layout-module/layout-module.tsx index 4526e0b48..374354ace 100644 --- a/libs/react/containers/src/layouts/layout-module/layout-module.tsx +++ b/libs/react/containers/src/layouts/layout-module/layout-module.tsx @@ -164,7 +164,6 @@ export const LayoutModule = container( width: Sidebar.css.remainingWidth, transition: Sidebar.css.widthTransition, right: 0, - boxShadow: (t) => t.shadow.md, }} > diff --git a/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.stories.tsx b/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.stories.tsx index 027d4c614..145105ed0 100644 --- a/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.stories.tsx +++ b/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.stories.tsx @@ -1,5 +1,6 @@ import { mockLearningObjectiveSearchItems, + mockSubjects, trpcMsw, } from "@chair-flight/trpc/mock"; import { LearningObjectivesSearch } from "./learning-objectives-search"; @@ -22,10 +23,10 @@ const meta: Meta = { }, msw: { handlers: [ - trpcMsw.questionBank.getNumberOfLearningObjectives.query(() => ({ - count: 123, + trpcMsw.questionBank.getAllSubjects.query(() => ({ + subjects: mockSubjects, })), - trpcMsw.questionBank.searchLearningObjectives.query(() => { + trpcMsw.questionBankLoSearch.searchLearningObjectives.query(() => { const items = mockLearningObjectiveSearchItems; return { diff --git a/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.tsx b/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.tsx index e415f598c..5977fbdec 100644 --- a/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.tsx +++ b/libs/react/containers/src/learning-objectives/learning-objectives-search/learning-objectives-search.tsx @@ -1,32 +1,49 @@ import { Fragment, useState } from "react"; import { NoSsr } from "@mui/base"; import { default as CheckIcon } from "@mui/icons-material/Check"; +import { default as FilterIcon } from "@mui/icons-material/FilterAltOutlined"; import { + Badge, Box, + IconButton, Link, List, ListDivider, ListItem, ListItemContent, + Select, + Option, Sheet, Stack, Table, Typography, - styled, + selectClasses, useTheme, + CircularProgress, + Modal, + ModalDialog, + ModalClose, + Divider, + Button, } from "@mui/joy"; import { CourseNames } from "@chair-flight/core/app"; import { CtaSearch, - MarkdownClient, + MarkdownClientCompressed, + Ups, + useDisclose, useMediaQuery, } from "@chair-flight/react/components"; import { trpc } from "@chair-flight/trpc/client"; import { container, getRequiredParam } from "../../wraper/container"; -import type { CourseName, QuestionBankName } from "@chair-flight/base/types"; +import type { + CourseName, + QuestionBankName, + QuestionBankSubject, +} from "@chair-flight/base/types"; const useSearchLos = - trpc.questionBank.searchLearningObjectives.useInfiniteQuery; + trpc.questionBankLoSearch.searchLearningObjectives.useInfiniteQuery; type Props = { questionBank: QuestionBankName; @@ -37,32 +54,35 @@ type Params = { }; type Data = { - numberOfLearningObjectives: number; + subjects: QuestionBankSubject[]; }; -const TdWithMarkdown = styled("td")` - margin: ${({ theme }) => theme.spacing(0.5, 0)}; - - & p { - margin: 0; - } - - & ul { - margin: 0; - } -`; +type SearchField = "text" | "id"; export const LearningObjectivesSearch = container( ({ component = "section", questionBank, sx }) => { const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const isMobile = useMediaQuery(theme.breakpoints.down("lg")); const [search, setSearch] = useState(""); + const [searchField, setSearchField] = useState(null); + const [courseName, setCourseName] = useState(null); + const [subject, setSubject] = useState(null); + const { subjects } = LearningObjectivesSearch.useData({ questionBank }); - const { data, isLoading, fetchNextPage } = useSearchLos( + const { + isOpen: isFilterModalOpen, + open: openFilterModal, + close: closeFilterModal, + } = useDisclose(); + + const { data, isLoading, isError, fetchNextPage } = useSearchLos( { q: search, limit: 20, questionBank: questionBank, + subject, + searchField, + course: courseName, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, @@ -70,9 +90,12 @@ export const LearningObjectivesSearch = container( }, ); - const results = (data?.pages ?? []) - .flatMap((p) => p.items) - .map((d) => d.result); + const numberOfFilters = Number(!!searchField) + Number(!!subject); + const hasError = isError; + const hasQueryResults = !!data?.pages[0].totalResults; + const hasResults = !isLoading && !hasError && hasQueryResults; + const hasNoResults = !isLoading && !hasError && !hasResults; + const results = (data?.pages ?? []).flatMap((p) => p.items); const onScroll = (e: React.UIEvent) => { const target = e.target as HTMLDivElement; @@ -81,40 +104,97 @@ export const LearningObjectivesSearch = container( if (distance < 200 && !isLoading) fetchNextPage(); }; + const filters = ( + <> + + + + + + + ); + return ( - setSearch(value)} - sx={{ my: 1, mx: "auto" }} - placeholder="search Learning Objectives..." - /> + + setSearch(value)} + sx={{ flex: 1 }} + placeholder="search Learning Objectives..." + /> + + {filters} + + + + + + + + - {isMobile ? ( - - {results.map((result) => ( - - - - - {result.id} - - {result.text} - - source: {result.text} - - - - - - ))} - - ) : ( + {isLoading && ( + + )} + {hasNoResults && } + {hasResults && !isMobile && ( @@ -126,7 +206,7 @@ export const LearningObjectivesSearch = container( children={courseName} style={{ fontSize: 10, - width: 14 + courseName.length * 6, + width: 14 + courseName.length * 6.5, }} /> ))} @@ -143,12 +223,16 @@ export const LearningObjectivesSearch = container( {result.contentId} - - {result.text} - - {result.source} - - + {Object.keys(CourseNames).map((courseName) => (
+ + {result.text} + + + + {result.source} + + + {result.courses.includes( @@ -169,9 +253,61 @@ export const LearningObjectivesSearch = container(
)} + {hasResults && isMobile && ( + + {results.map((result) => ( + + + + + {result.id} + + + {result.courses + .map((c) => CourseNames[c]) + .join(", ")} + + + + {result.text} + + + + + + + ))} + + )}
+ + + + + Filters + + + + {filters} + + + +
); }, @@ -182,20 +318,23 @@ LearningObjectivesSearch.displayName = "LearningObjectivesSearch"; LearningObjectivesSearch.getData = async ({ helper, params }) => { const questionBank = getRequiredParam(params, "questionBank"); - const data = await helper.questionBank.getNumberOfLearningObjectives.fetch({ - questionBank, - }); + const [data] = await Promise.all([ + helper.questionBank.getAllSubjects.fetch({ questionBank }), + ]); - return { numberOfLearningObjectives: data.count }; + return { + subjects: data.subjects, + }; }; LearningObjectivesSearch.useData = (params) => { const questionBank = getRequiredParam(params, "questionBank"); - const [data] = - trpc.questionBank.getNumberOfLearningObjectives.useSuspenseQuery({ - questionBank, - }); + const [data] = trpc.questionBank.getAllSubjects.useSuspenseQuery({ + questionBank, + }); - return { numberOfLearningObjectives: data.count }; + return { + subjects: data.subjects, + }; }; diff --git a/libs/react/containers/src/questions/question-editor/components/input-autocomplete-learning-objectives.tsx b/libs/react/containers/src/questions/question-editor/components/input-autocomplete-learning-objectives.tsx index 7a880fe3b..7c9d2ad2e 100644 --- a/libs/react/containers/src/questions/question-editor/components/input-autocomplete-learning-objectives.tsx +++ b/libs/react/containers/src/questions/question-editor/components/input-autocomplete-learning-objectives.tsx @@ -17,17 +17,20 @@ export const InputAutocompleteLearningObjectives = forwardRef< const [search, setSearch] = useState(""); const { data, isLoading } = - trpc.questionBank.searchLearningObjectives.useQuery({ + trpc.questionBankLoSearch.searchLearningObjectives.useQuery({ q: search, limit: 10, cursor: 0, questionBank: "atpl", + subject: null, + course: null, + searchField: null, }); const optionsMap = (data?.items ?? []).reduce< Record >((acc, result) => { - acc[result.result.id] = result.result; + acc[result.id] = result; return acc; }, {}); @@ -44,7 +47,7 @@ export const InputAutocompleteLearningObjectives = forwardRef< onInputChange={(_, newInputValue) => setSearch(newInputValue)} filterOptions={(options) => options} placeholder="Learning Objectives" - options={data?.items.map((result) => result.result.id) ?? []} + options={data?.items.map((result) => result.id) ?? []} sx={{ "& input": { minWidth: "100%", diff --git a/libs/react/containers/src/questions/question-editor/question-editor.stories.tsx b/libs/react/containers/src/questions/question-editor/question-editor.stories.tsx index ccb29922f..dd6cc277d 100644 --- a/libs/react/containers/src/questions/question-editor/question-editor.stories.tsx +++ b/libs/react/containers/src/questions/question-editor/question-editor.stories.tsx @@ -76,7 +76,7 @@ const meta: Meta = { trpcMsw.questionBank.getQuestionFromGithub.query(() => ({ questionTemplate: mockQuestion, })), - trpcMsw.questionBank.searchLearningObjectives.query(() => ({ + trpcMsw.questionBankLoSearch.searchLearningObjectives.query(() => ({ items: [], totalResults: 0, nextCursor: -1, diff --git a/libs/react/containers/src/questions/question-search/question-search.tsx b/libs/react/containers/src/questions/question-search/question-search.tsx index ce7980ed1..acd269472 100644 --- a/libs/react/containers/src/questions/question-search/question-search.tsx +++ b/libs/react/containers/src/questions/question-search/question-search.tsx @@ -1,4 +1,5 @@ import { Fragment, useState } from "react"; +import { NoSsr } from "@mui/base"; import { default as FilterIcon } from "@mui/icons-material/FilterAltOutlined"; import { Box, @@ -59,23 +60,18 @@ export const QuestionSearch = container( ({ sx, component = "section", questionBank }) => { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); - const params = { questionBank }; const [search, setSearch] = useState(""); const [searchField, setSearchField] = useState(null); const [subject, setSubject] = useState(null); - const { subjects } = QuestionSearch.useData(params); + const { subjects } = QuestionSearch.useData({ questionBank }); + const { isOpen: isFilterModalOpen, open: openFilterModal, close: closeFilterModal, } = useDisclose(); - const { - data: searchQuestionsData, - isLoading: searchQuestionsLoading, - isError: searchQuestionsError, - fetchNextPage, - } = useSearchQuestions( + const { data, isLoading, isError, fetchNextPage } = useSearchQuestions( { q: search, searchField, @@ -90,24 +86,17 @@ export const QuestionSearch = container( ); const numberOfFilters = Number(!!searchField) + Number(!!subject); - - const hasResults = - !searchQuestionsLoading && - !searchQuestionsError && - searchQuestionsData?.pages[0].totalResults > 0; - - const hasNoResults = - !searchQuestionsLoading && !searchQuestionsError && !hasResults; - - const hasError = searchQuestionsError; - - const results = (searchQuestionsData?.pages ?? []).flatMap((p) => p.items); + const hasError = isError; + const hasQueryResults = !!data?.pages[0].totalResults; + const hasResults = !isLoading && !hasError && hasQueryResults; + const hasNoResults = !isLoading && !hasError && !hasResults; + const results = (data?.pages ?? []).flatMap((p) => p.items); const onScroll = (e: React.UIEvent) => { const target = e.target as HTMLUListElement; const { scrollHeight, scrollTop, clientHeight } = target; const distance = scrollHeight - scrollTop - clientHeight; - if (distance < 500 && !searchQuestionsLoading) fetchNextPage(); + if (distance < 500 && !isLoading) fetchNextPage(); }; const filters = ( @@ -152,7 +141,7 @@ export const QuestionSearch = container( setSearch(value)} sx={{ flex: 1 }} placeholder="search Questions..." @@ -173,119 +162,123 @@ export const QuestionSearch = container( - - - {searchQuestionsLoading && ( - - )} - {hasNoResults && } - {hasError && ( - - )} - {hasResults && !isMobile && ( - - - - - - - - - - - {results.map((result) => ( - - - - - + + + + {isLoading && ( + + )} + {hasNoResults && } + {hasError && ( + + )} + {hasResults && !isMobile && ( +
IDQuestionLearning ObjectivesExternal IDs
- - {result.questionId} -
- - {result.variantId} - - -
- - {result.text} - - - {result.learningObjectives.map(({ name, href }) => ( - - ))} - - {result.externalIds.map((id) => ( - - {id} - - ))} -
+ + + + + + - ))} - -
IDQuestionLearning ObjectivesExternal IDs
- )} - {hasResults && isMobile && ( - - {results.map((result) => ( - - - - - {result.questionId} - - - {result.variantId} - - - {searchField === "externalIds" ? ( - - {result.externalIds.join(", ")} - - ) : ( - - {result.learningObjectives.join(", ")} - - )} - + + + {results.map((result) => ( + + + + {result.questionId} +
+ + {result.variantId} + + + + {result.text} -
-
-
- -
- ))} -
- )} + + + {result.learningObjectives.map(({ name, href }) => ( + + ))} + + + {result.externalIds.map((id) => ( + + {id} + + ))} + + + ))} + + + )} + {hasResults && isMobile && ( + + {results.map((result) => ( + + + + + {result.questionId} + + + {result.variantId} + + + {searchField === "externalIds" ? ( + + {result.externalIds.join(", ")} + + ) : ( + + {result.learningObjectives + .map((lo) => lo.name) + .join(", ")} + + )} + + + {result.text} + + + + + + + ))} + + )} +
diff --git a/libs/trpc/mock/src/__mocks__/learning-objectives-search.mock.ts b/libs/trpc/mock/src/__mocks__/learning-objectives-search.mock.ts index b6a6f3bb7..e3e7a84d7 100644 --- a/libs/trpc/mock/src/__mocks__/learning-objectives-search.mock.ts +++ b/libs/trpc/mock/src/__mocks__/learning-objectives-search.mock.ts @@ -1,491 +1,492 @@ -import type { QuestionBankLearningObjective } from "@chair-flight/base/types"; -import type { MatchInfo } from "minisearch"; +import type { CourseName, QuestionBankName } from "@chair-flight/base/types"; export const mockLearningObjectiveSearchItems = [ { - result: { - id: "062.01.01.01.02", - courses: ["ATPL_A", "CPL_A", "ATPL_H_IR", "ATPL_H_VFR", "CPL_H", "IR"], - questions: [], - text: "Define a ‘cycle’:\n- a complete series of values of a periodical process.", - contentId: "062.01.01.01.02", - source: "", - href: "", - }, - score: 3.6775614433072037, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + "IR", + "CBIR_A", + ] satisfies CourseName[], + questions: [], + text: "Air Law", + contentId: "010", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "010.09.04.04.07", - courses: [ - "ATPL_A", - "CPL_A", - "ATPL_H_IR", - "ATPL_H_VFR", - "CPL_H", - "IR", - "CBIR_A", - ], - questions: ["QCW0WVU25H", "QOH5UTFAK3"], - text: "Describe the location of:\n- a Rwy designation sign at a Twy/Rwy intersection;\n- a ‘No Entry’ sign;\n- a Rwy holding position sign.", - contentId: "010.09.04.04.07", - source: "ICAO Annex 14, Volume 1, Chapter 5.4 Signs", - href: "", - }, - score: 3.646131788322905, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q000YBA1ON", + "Q4C6S9NWGH", + "Q9O4GSAUCE", + "Q9R9VLD9NK", + "QOFKKXR650", + "QQI73SKGJD", + "QXT8DQPDMR", + "QYMZ7WHWKN", + "QZEYYWZCFK", + "QL2NSAOJTT", + "Q5ODFU6MV3", + "QYW1HC5B97", + "QYGJ9YUC5Y", + "QJMEXQUX22", + "QZQB4V3OS7", + "Q4MIEV4WCR", + "QNBD73UCGU", + "QYICVYV10Y", + "QQOO7V8D8Y", + "QII8OY7M7Z", + "QQEAGWEACP", + "QQOFYZT9ZA", + ], + text: "International Law:\n- Conventions, Agreements And Organisations", + contentId: "010.01", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "022.02.05.01.05", - courses: [ - "ATPL_A", - "CPL_A", - "ATPL_H_IR", - "ATPL_H_VFR", - "CPL_H", - "IR", - "CBIR_A", - ], - questions: [ - "Q6AQWIY7OA", - "QA0IVAMA3M", - "QAGQEWXLIT", - "QDWJX9B8I6", - "QYZ5F5IUW6", - "QZ2IX4OT4X", - ], - text: "Describe the effects on a Vsi of a blockage or a leakage on the static pressure line.", - contentId: "022.02.05.01.05", - source: "", - href: "", - }, - score: 3.500355599163358, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "The Convention on International Civil Aviation (Chicago) - Icao Doc 7300/9 - Convention on the High Seas (Geneva, 29 April 1958)", + contentId: "010.01.01", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "022.15.01.01.01", - courses: ["ATPL_A", "ATPL_H_IR", "ATPL_H_VFR", "IR", "CBIR_A"], - questions: [ - "QXSLQZBC54", - "Q9U343WAQS", - "Q07K5MEJIQ", - "Q3GNWCWGEK", - "Q4OS1Y9GSC", - "Q5BC1VCMPQ", - "Q5C52MYUSU", - "Q6JAGHTGT7", - "Q88SNZTF7K", - "Q997KGHSD7", - "QBPNRKOJ7H", - "QIG6OGENA0", - "QIKVMBWE2G", - "QKEVDLW0J0", - "QLIOLI2XQK", - "QMDRRUXUJ3", - "QOXF072O1X", - "QPLNK7DVP6", - "QQEFA9WDTT", - "QST9WUG26B", - "QU8MOGH309", - "QXNGJMKCH9", - "QXR85CNR5E", - "QYBWBHTQGP", - "QYOARVDU7Y", - "QYVK7NW3EN", - "QZXT2J2DEU", - ], - text: "Define a ‘computer’ as a machine for manipulating data according to a list of instructions.", - contentId: "022.15.01.01.01", - source: "", - href: "", - }, - score: 3.500355599163358, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "The establishment of the Convention on International Civil Aviation, Chicago, 7 December 1944", + contentId: "010.01.01.01", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "032.04.01.07.01", - courses: ["ATPL_A", "CPL_A"], - questions: [ - "Q4ZWNLYXIW", - "Q5BWR5QSOI", - "Q6JCRABSCZ", - "QARPK95AT0", - "QGXOOPG1ZW", - "QERUMJD2HX", - "QH3X17N4VF", - "QI8B3LJ3L6", - "QITHAS34A5", - "QKNM6MKYRP", - "QOFCBQUP8S", - "QQ7B3GPVPS", - "QR3JXA40ST", - "QSUTM0HI3G", - "QZ5G35O35J", - "QZC4B4H9NV", - ], - text: "Define a ‘contaminated runway’, ‘wet runway’, and a ‘dry runway’. ", - contentId: "032.04.01.07.01", - source: "", - href: "", - }, - score: 3.4734259931454115, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.01.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q2CGFE1OCO", + "Q3XNN22GER", + "Q8SHFKRBVI", + "Q9LPY4W3HK", + "QH9GKUTS9C", + "QI5Y2GJ9KH", + "QL4Y6YL9KJ", + "QMCMBNZX2X", + "QRMLWB4E3T", + "QSQQ54NWQJ", + "QUQ75C28Y2", + "QV5DQ7BOWN", + "QX2WS2CFIU", + ], + text: "Explain the circumstances that led to the establishment of the Convention on International Civil Aviation, Chicago, 7 December 1944.", + contentId: "010.01.01.01.01", + source: "ICAO Doc 7300/9 Preamble", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "062.07.05.09.02", - courses: ["ATPL_H_IR", "IR"], - questions: [], - text: "State that a PinS approach procedure includes either a ‘proceed Vfr’ or a ‘proceed visually’ instruction from the missed approach point (MAPt) to a landing location.", - contentId: "062.07.05.09.02", - source: "", - href: "", - }, - score: 3.452569850674864, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.02", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "Part I - Air navigation", + contentId: "010.01.01.02", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "022.12.09.02.01", - courses: ["ATPL_A", "ATPL_H_IR", "ATPL_H_VFR"], - questions: ["QGWYN0B1TW", "QMY9J6RNMA", "QW4PDMPA9A", "QZ3OOM2PO7"], - text: "Explain the purpose of a Taws for aeroplanes and of a Htaws for helicopters, and explain the difference from a Gpws. ", - contentId: "022.12.09.02.01", - source: "", - href: "", - }, - score: 3.4198606722033777, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.02.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q4DIWPPO56", + "Q6UYEVTVQ4", + "Q9C7KOEYUT", + "QA1PW4XVFW", + "QAYKP3GYXK", + "QCG7QUI6F1", + "QDQDTVG95V", + "QFXQA3PSF7", + "QGT6I5U7FI", + "QJOKGPWTG1", + "QMY2RU9687", + "QQ9XCEDVJI", + "QQKM8L85GJ", + "QR0SDREX6Z", + "QXQXX8U31M", + "QYXL6F2K8L", + "QAJPPPUK85", + "QO35LKEKPO", + "QR5QCYBZ8M", + ], + text: "Recall the general contents of relevant parts of the following chapters:\n- general principles and application of the Convention;\n- flight over territory of Contracting States;\n- nationality of aircraft;\n- international standards and recommended practices (SARPs), especially notification of differences and validity of endorsed certificates and licences.", + contentId: "010.01.01.02.01", + source: + "ICAO Doc 7300/9 Part 1, Articles 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 16, 17, 18, 19, 20, 37, 38, 39, 40", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "050.09.04.02.04", - courses: [ - "ATPL_A", - "CPL_A", - "ATPL_H_IR", - "ATPL_H_VFR", - "CPL_H", - "IR", - "CBIR_A", - ], - questions: ["Q65HHP3MII"], - text: "Indicate on a sketch the most dangerous zones in and around a single-cell and a multi-cell thunderstorm.", - contentId: "050.09.04.02.04", - source: "", - href: "", - }, - score: 3.4198606722033777, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.02.02", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: ["QJGECH5KQ9", "QMDIQ6XSOF"], + text: "General principles - Describe the application of the following terms in civil aviation:\n- sovereignty;\n- territory and high seas according to the Un Convention on the High Seas. ", + contentId: "010.01.01.02.02", + source: + "Convention on the High Seas (Geneva, 29 April 1958) Articles 1, 2;\r\nICAO Doc 7300/9 Part 1, Articles 1, 2", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "082.07.02.05.01", - courses: ["ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: [], - text: "Know that a large static roll stability together with a small directional stability may lead to a Dutch roll.", - contentId: "082.07.02.05.01", - source: "", - href: "", - }, - score: 3.4198606722033777, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.02.03", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q5BMMU05JE", + "Q74ICEQ3V4", + "Q7M840K5LX", + "Q8535X5P7G", + "Q98FYI0TAB", + "Q9XBLSDHOP", + "QC41LVC1UI", + "QCQL8QGRG2", + "QCVMHNYAKK", + "QIOLAEP4JV", + "QN94VJ9UPM", + "QPFC1XV2UO", + "QVSAXISQAW", + "QXJVWYSV5E", + ], + text: "Explain the following terms and how they apply to international air traffic:\n- right of non-scheduled flight (including the two technical freedoms of the air);\n- scheduled air services;\n- cabotage;\n- landing at customs airports;\n- Rules of the Air;\n- search of aircraft.", + contentId: "010.01.01.02.03", + source: "ICAO Doc 7300/9, Articles 5, 6, 7, 10, 12, 16", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "021.08.01.02.03", - courses: ["ATPL_A", "CPL_A", "ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: [], - text: "Describe a gravity fuel feed system and a pressure feed fuel system.", - contentId: "021.08.01.02.03", - source: "", - href: "", - }, - score: 3.413812146832177, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.02.04", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q1CSODOI5C", + "Q3ZP858O4I", + "Q64Z2CM3AC", + "Q7ALBZ3YSP", + "Q7RK9NJZJG", + "Q84JNPGDA6", + "Q90MXOWJ2D", + "QB8PVRTXN6", + "QBZ6A7LF7B", + "QCTH9GY034", + "QD8YOPCPX8", + "QF1UCWTUXJ", + "QI6SF2ZISD", + "QIUVAK2VXE", + "QL7K3X26R0", + "QLIR83O062", + "QSPEPQMFZM", + "QSRUHIDBUN", + "QUG32YP4QB", + "QY84JPN3K1", + "Q6TJX129E9", + ], + text: "Explain the duties of Contracting States in relation to:\n- documents carried on board the aircraft:\n- certificate of registration;\n- certificates of airworthiness;\n- licences of personnel;\n- recognition of certificates and licences;\n- cargo restrictions;\n- photographic apparatus.", + contentId: "010.01.01.02.04", + source: "ICAO Doc 7300/9, Articles 29, 31, 32, 33, 35, 36", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "022.02.07.01.05", - courses: ["ATPL_A"], - questions: [ - "Q56WC6QTAH", - "Q2X3T664I8", - "Q6Q4LT9MDN", - "Q7LNLHWKHE", - "QKYDJV18M2", - "QL5EBJTVKY", - "QZK3IKL3MF", - ], - text: "Describe the effects on a Machmeter of a blockage or a leakage in the static or total pressure line(s).", - contentId: "022.02.07.01.05", - source: "", - href: "", - }, - score: 3.381427054208791, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.03", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "Part Ii - The International Civil Aviation Organization (Icao)", + contentId: "010.01.01.03", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "082.04.03.01.04", - courses: ["ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: [], - text: "Show that through cyclic feathering this imbalance could be eliminated by a low alpha (accomplished by a low pitch angle) on the advancing blade, and a high alpha (accomplished by a high pitch angle) on the retreating blade. ", - contentId: "082.04.03.01.04", - source: "", - href: "", - }, - score: 3.3655407481269837, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.03.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q1XSGFH9NV", + "Q26L3WKZ61", + "Q3XNYTOH0S", + "Q3YFO0WJ1I", + "Q5TZDYZHXG", + "Q7OJG2WRFW", + "QM56M8M1HF", + "QMWBYWDPJ5", + "QO7MWZ4WRG", + "QRHORC2JXN", + "QT3IOPLU6O", + "QUA16SNSGW", + "QVELORJRVZ", + "QWEMOTIF8O", + "QXFCDYVAIF", + "QZ03GEKKS1", + "QZ66LA1N4J", + "Q2KR0V61G1", + "QNOFSGZXSL", + "QM8N7KUQK1", + "QV6F3KVWM1", + "QV1OOIFH77", + ], + text: "Describe the objectives of Icao.", + contentId: "010.01.01.03.01", + source: "ICAO Doc 7300/9, Article 44", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "061.01.07.01.02", - courses: ["ATPL_A", "CPL_A", "ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: ["QEW6N0A3ML"], - text: "Evaluate the difference between a Dr and a fix position.", - contentId: "061.01.07.01.02", - source: "", - href: "", - }, - score: 3.3568587439451916, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.03.02", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q0JGOZVR7S", + "QAX38WR259", + "QBBU2EXUQ0", + "QHDZQ19ABV", + "QJ1OT6UFOL", + "QPEA49Y9QY", + "QUNDNWZRPF", + ], + text: "Recognise the organisation and duties of the Icao Assembly, Council and Air Navigation Commission (Anc).", + contentId: "010.01.01.03.02", + source: "ICAO Doc 7300/9, Articles 48, 49, 50, 54, 56, 57", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "081.01.01.02.02", - courses: ["ATPL_A", "CPL_A"], - questions: [], - text: "Explain the concept of a streamline and a stream tube.", - contentId: "081.01.01.02.02", - source: "", - href: "", - }, - score: 3.3568587439451916, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.01.03.03", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q7JS9FCTF3", + "Q7STLP9XQK", + "QJ4MQVYQJH", + "QJZ93PIMRC", + "QLHXI54XLI", + ], + text: "Describe the annexes to the Convention.", + contentId: "010.01.01.03.03", + source: "ICAO Doc 7300/9, Articles 54, 90, 94, 95", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "062.02.03.01.04", - courses: [ - "ATPL_A", - "CPL_A", - "ATPL_H_IR", - "ATPL_H_VFR", - "CPL_H", - "IR", - "CBIR_A", - ], - questions: [ - "QGNI9BXK6G", - "Q7MN6FP8PX", - "Q862RAQT6I", - "Q8U57M2U7C", - "QAS0T3UNXF", - "QE4ZP03429", - "QEBQ7ZPCB9", - "QOP37OMLM1", - ], - text: "State that the following types of Vor are in operation:\n- conventional Vor (Cvor):\n- a first-generation Vor station emitting signals by means of a rotating antenna;\n- Doppler Vor (Dvor):\n- a second-generation Vor station emitting signals by means of a combination of fixed antennas utilising the Doppler principle;\n- en-route Vor for use by Ifr traffic;\n- terminal Vor (Tvor):\n- a station with a shorter range used as part of the approach and departure structure at major aerodromes;\n- test Vor (Vot):\n- a Vor station emitting a signal to test Vor indicators in an aircraft.", - contentId: "062.02.03.01.04", - source: "", - href: "", - }, - score: 3.3148725729390076, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.02", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "Other conventions and agreements", + contentId: "010.01.02", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "022.08.02.01.03", - courses: ["ATPL_A", "CPL_A"], - questions: ["Q8EOOHTKIZ"], - text: "Explain the operation of a yaw-damper system and state the difference between a yaw-damper system and a 3-axis autopilot operation on the rudder channel.", - contentId: "022.08.02.01.03", - source: "", - href: "", - }, - score: 3.3079229732660607, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.02.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "The International Air Services Transit Agreement (Icao Doc 7500)", + contentId: "010.01.02.01", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "050.10.02.01.02", - courses: [ - "ATPL_A", - "CPL_A", - "ATPL_H_IR", - "ATPL_H_VFR", - "CPL_H", - "IR", - "CBIR_A", - ], - questions: [ - "Q97P50O2CQ", - "Q3SV2OPOYM", - "Q44WSC3DJL", - "Q7Y1MZGBQ9", - "QCAVUF9TDB", - "QFAD5NI4TA", - "QIVGJCMXS0", - "QRULTLE9L9", - "QRVTX9MNQR", - "QWIR78AY3P", - "QZSQ57B4V6", - "Q9TQJQEIQR", - "QNYZHDE3L4", - "QVT6CLE3GF", - "Q3Z01AQJ95", - "Q49SFUYRLR", - "Q5TRSZ3HS5", - "Q6NV2KMTLB", - "Q6Q3I5L3WP", - "Q6W9GHO63Z", - "Q859HPTQRF", - "QGZBCMC28W", - "Q9ESRPIFS6", - "Q9QW985E2T", - "QAMKP6KVRJ", - "QBJO9Z8PVM", - "QD4FIGRCMI", - "QE5NQ9PSTV", - "QHITZ1E5B2", - "QINQSDQU9L", - "QJV6L7UQGG", - "QKL27S7WPP", - "QLY70XWU7K", - "QMTYRKNWBK", - "QPBSPVS90C", - "QPDT9YONGB", - "QPJ08DC4NS", - "QQA9KJUQ70", - "QT5MSA694B", - "QVHCJRUO70", - "QX3IOK3V1U", - "QYHIK3GGVQ", - ], - text: "Describe from a significant weather chart the flight conditions at designated locations or along a defined flight route at a given Fl.", - contentId: "050.10.02.01.02", - source: "", - href: "", - }, - score: 3.3079229732660607, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.02.01.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "Q8X757B2TY", + "Q9YBKOO57T", + "QCGGYAISS9", + "QG253GJTYR", + "QJATVQL1B0", + ], + text: "Explain the two technical freedoms of the air.", + contentId: "010.01.02.01.01", + source: "ICAO Doc 7500", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "021.04.01.02.03", - courses: ["ATPL_A", "CPL_A", "ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: [ - "QC1EBESUSB", - "QVXTG3CIN9", - "QOAU7OX9RE", - "QOMSLVVGCO", - "QP3T43G8BY", - "QP93PL10LG", - "QTWPQ86TYY", - "QJ241DIIM9", - ], - text: "Name the different components of a landing gear, using a diagram.", - contentId: "021.04.01.02.03", - source: "", - href: "", - }, - score: 3.3023915765725094, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.02.02", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "The International Air Transport Agreement (Icao Doc 9626)", + contentId: "010.01.02.02", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "021.09.01.07.01", - courses: ["ATPL_A", "CPL_A", "ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: ["QRIYIMOEJA", "QSXN4HLUT8", "QAM5JK09FR"], - text: "Explain the working principle of a fuse and a circuit breaker.", - contentId: "021.09.01.07.01", - source: "", - href: "", - }, - score: 3.3023915765725094, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.02.02.01", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [ + "QA4HIXUKAZ", + "QD6TE6EYLO", + "QDJ15RXJXO", + "QF92R417N3", + "QPG9OMOLDU", + "QRS992FQKN", + ], + text: "Explain the three commercial freedoms of the air.", + contentId: "010.01.02.02.01", + source: "ICAO Doc 9626", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, { - result: { - id: "021.09.04.02.01", - courses: ["ATPL_A", "CPL_A", "ATPL_H_IR", "ATPL_H_VFR", "CPL_H"], - questions: ["Q0QOLF9JNR", "Q1F8D023X2"], - text: "Describe a simple Dc electrical system of a single-engine aircraft.", - contentId: "021.09.04.02.01", - source: "", - href: "", - }, - score: 3.3023915765725094, - match: { - a: ["text"], - }, - terms: ["a"], + id: "010.01.02.03", + courses: [ + "ATPL_A", + "CPL_A", + "ATPL_H_IR", + "ATPL_H_VFR", + "CPL_H", + ] satisfies CourseName[], + questions: [], + text: "Suppression of Unlawful Acts Against the Safety of Civil Aviation - The Tokyo Convention of 1963", + contentId: "010.01.02.03", + source: "", + href: "", + questionBank: "atpl" as const satisfies QuestionBankName, + subject: "010", }, -] as Array<{ - result: QuestionBankLearningObjective; - score: number; - match: MatchInfo; - terms: string[]; -}>; +]; diff --git a/libs/trpc/server/src/common/count.ts b/libs/trpc/server/src/common/count.ts deleted file mode 100644 index 422173f30..000000000 --- a/libs/trpc/server/src/common/count.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { publicProcedure } from "../config/trpc"; - -export const makeCountHandler = ({ - getData, -}: { - getData: () => Promise; -}) => - publicProcedure.query(async () => { - const data = await getData(); - return { count: data.length }; - }); diff --git a/libs/trpc/server/src/common/search.ts b/libs/trpc/server/src/common/search.ts deleted file mode 100644 index 8eb06c775..000000000 --- a/libs/trpc/server/src/common/search.ts +++ /dev/null @@ -1,73 +0,0 @@ -import MiniSearch from "minisearch"; -import { z } from "zod"; -import { publicProcedure } from "../config/trpc"; -import type { MatchInfo, SearchResult } from "minisearch"; - -export type SearchResponseItem = { - result: T; - score: number; - match: MatchInfo; - terms: string[]; -}; - -export const makeSearchHandler = < - BaseType, - SearchType extends Record, - ResultType, ->({ - searchFields, - getData, - processData, - preprocessResults = async () => undefined, - processResults, -}: { - searchFields: (keyof SearchType)[]; - getData: () => Promise; - processData: (data: BaseType[]) => SearchType[]; - preprocessResults?: ( - data: BaseType[], - q: string | undefined, - ) => Promise[] | undefined>; - processResults: ( - results: SearchResult[], - ) => Promise[]>; -}) => { - let initializationWork: Promise | undefined; - let data: BaseType[]; - - const index = new MiniSearch({ - fields: searchFields as string[], - storeFields: searchFields as string[], - }); - - const searchValidation = z.object({ - q: z.string().optional(), - limit: z.number().min(1).max(50), - cursor: z.number().default(0), - }); - - return publicProcedure.input(searchValidation).query(async ({ input }) => { - if (initializationWork) await initializationWork; - - if (index.documentCount === 0) { - data = await getData(); - const processedData = processData(data); - await index.addAllAsync(processedData); - await initializationWork; - } - - const { q, limit, cursor = 0 } = input; - - const processedResults = - (await preprocessResults(data, q)) ?? - (await processResults(q ? index.search(q, { fuzzy: 0.2 }) : [])); - - const items = processedResults.slice(cursor, cursor + limit); - - return { - items, - totalResults: processedResults.length, - nextCursor: cursor + items.length, - }; - }); -}; diff --git a/libs/trpc/server/src/config/app-router.ts b/libs/trpc/server/src/config/app-router.ts index 11ffddf29..5ea36d7ac 100644 --- a/libs/trpc/server/src/config/app-router.ts +++ b/libs/trpc/server/src/config/app-router.ts @@ -1,5 +1,6 @@ import { analyticsRouter } from "../routers/analytics"; import { questionBankRouter } from "../routers/question-bank"; +import { questionBankLoSearchRouter } from "../routers/question-bank-lo-search"; import { questionBankQuestionSearchRouter } from "../routers/question-bank-question-search"; import { statusRouter } from "../routers/status"; import { router } from "./trpc"; @@ -9,6 +10,7 @@ export const appRouter = router({ status: statusRouter, questionBank: questionBankRouter, questionBankQuestionSearch: questionBankQuestionSearchRouter, + questionBankLoSearch: questionBankLoSearchRouter, }); export type AppRouter = typeof appRouter; diff --git a/libs/trpc/server/src/routers/question-bank-lo-search.ts b/libs/trpc/server/src/routers/question-bank-lo-search.ts new file mode 100644 index 000000000..9929371b2 --- /dev/null +++ b/libs/trpc/server/src/routers/question-bank-lo-search.ts @@ -0,0 +1,108 @@ +import { default as MiniSearch } from "minisearch"; +import { z } from "zod"; +import { questionBanks } from "@chair-flight/core/question-bank"; +import { questionBankNameSchema as questionBank } from "@chair-flight/core/schemas"; +import { publicProcedure, router } from "../config/trpc"; +import type { + QuestionBankLearningObjective, + QuestionBankName, +} from "@chair-flight/base/types"; + +type SearchField = "id" | "text"; + +type SearchDocument = Record; + +type SearchResult = QuestionBankLearningObjective & { + subject: string; + questionBank: QuestionBankName; +}; + +let initializationWork: Promise | undefined; + +const RESULTS = new Map(); + +const SEARCH_INDEX_FIELDS = ["id", "text"] as const satisfies SearchField[]; + +const SEARCH_STORE_FIELDS = ["id"] as const satisfies SearchField[]; + +const SEARCHABLE_FIELDS = ["id", "text"] as const satisfies SearchField[]; + +const SEARCH_INDEX = new MiniSearch({ + fields: SEARCH_INDEX_FIELDS, + storeFields: SEARCH_STORE_FIELDS, +}); + +const populateSearchIndex = async (bank: QuestionBankName): Promise => { + const qb = questionBanks[bank]; + if (initializationWork) await initializationWork; + + initializationWork = (async () => { + const los = await qb.getAll("learningObjectives"); + const hasLos = await qb.has("learningObjectives"); + const firstId = los.at(0)?.id; + + if (!hasLos) return; + if (SEARCH_INDEX.has(firstId)) return; + + const searchItems: SearchDocument[] = los.map((lo) => ({ + id: lo.id, + text: lo.text, + })); + + const resultItems: SearchResult[] = los.flatMap((lo) => ({ + ...lo, + questionBank: bank, + subject: lo.id.split(".")[0], + })); + + await SEARCH_INDEX.addAllAsync(searchItems); + resultItems.forEach((r) => RESULTS.set(r.id, r)); + })(); + + await initializationWork; +}; + +export const questionBankLoSearchRouter = router({ + searchLearningObjectives: publicProcedure + .input( + z.object({ + questionBank, + q: z.string(), + subject: z.string().nullable(), + course: z.string().nullable(), + searchField: z.enum(SEARCHABLE_FIELDS).nullable(), + limit: z.number().min(1).max(50), + cursor: z.number().default(0), + }), + ) + .query(async ({ input }) => { + const { q, subject, searchField, questionBank, limit, cursor, course } = + input; + + await populateSearchIndex(questionBank); + + const fields = searchField ? [searchField] : undefined; + const opts = { fuzzy: 0.2, fields }; + + const results = q + ? SEARCH_INDEX.search(q, opts).map(({ id }) => RESULTS.get(id)) + : Array.from(RESULTS.values()); + + const processedResults = results.filter((r): r is SearchResult => { + if (!r) return false; + if (r.questionBank !== questionBank) return false; + if (subject && !r.subject.includes(subject)) return false; + if (searchField && !`${r[searchField]}`.includes(q)) return false; + if (course && !r.courses.includes(course)) return false; + return true; + }); + + const finalItems = processedResults.slice(cursor, cursor + limit); + + return { + items: finalItems, + totalResults: processedResults.length, + nextCursor: cursor + finalItems.length, + }; + }), +}); diff --git a/libs/trpc/server/src/routers/question-bank.ts b/libs/trpc/server/src/routers/question-bank.ts index 915f3d691..d17784989 100644 --- a/libs/trpc/server/src/routers/question-bank.ts +++ b/libs/trpc/server/src/routers/question-bank.ts @@ -1,6 +1,4 @@ -import { default as MiniSearch } from "minisearch"; import { z } from "zod"; -import { UnimplementedError } from "@chair-flight/base/errors"; import { createTest, newTestConfigurationSchema } from "@chair-flight/core/app"; import { createNewQuestionPr, @@ -12,38 +10,6 @@ import { questionEditSchema, } from "@chair-flight/core/schemas"; import { publicProcedure, router } from "../config/trpc"; -import type { QuestionBankLearningObjective } from "@chair-flight/base/types"; -import type { MatchInfo } from "minisearch"; - -const learningObjectiveSearchFields = ["id", "text"]; - -const learningObjectiveSearchIndexes: Record<"atpl", MiniSearch> = { - atpl: new MiniSearch({ - fields: learningObjectiveSearchFields, - storeFields: learningObjectiveSearchFields, - }), -}; - -// Marks that a request is already indexing a Minisearch to avoid saturating -// the server. -let initializationWork: Promise | undefined; - -export type SearchResponseItem = { - result: T; - score: number; - match: MatchInfo; - terms: string[]; -}; - -export type QuestionPreview = { - questionId: string; - variantId: string; - text: string; - numberOfVariants: number; - learningObjectives: string[]; - externalIds: string[]; - href: string; -}; export const questionBankRouter = router({ getConfig: publicProcedure @@ -142,76 +108,6 @@ export const questionBankRouter = router({ const count = allFlashcards.reduce((s, e) => s + e.flashcards.length, 0); return { count }; }), - searchLearningObjectives: publicProcedure - .input( - z.object({ - questionBank, - q: z.string().optional(), - limit: z.number().min(1).max(50), - cursor: z.number().default(0), - }), - ) - .query(async ({ input }) => { - if (input.questionBank !== "atpl") throw new UnimplementedError(""); - const qb = questionBanks[input.questionBank]; - const searchIndex = learningObjectiveSearchIndexes[input.questionBank]; - const los = await qb.getAll("learningObjectives"); - - const { q, limit, cursor = 0 } = input; - - if (searchIndex.documentCount === 0) { - initializationWork = (async () => { - const processedData = los.map(({ questions, ...lo }) => ({ - ...lo, - courses: lo.courses.join(", "), - })); - await searchIndex.addAllAsync(processedData); - })(); - await initializationWork; - } - - const MATCH_LO_ID = /^[0-9]{3}(.[0-9]{2}){0,3}$/; - - let results: SearchResponseItem[] = []; - if (!q) { - results = los.map((result) => ({ - result, - score: 1, - match: {}, - terms: [], - })); - } else if (MATCH_LO_ID.test(q)) { - results = los - .filter((doc) => doc.id.startsWith(q)) - .map((result) => ({ - result, - score: 1, - match: {}, - terms: [], - })); - } else { - const search = searchIndex.search(q, { fuzzy: 0.2 }); - const los = await qb.getSome( - "learningObjectives", - search.map((d) => d.id), - ); - - results = search.map((result, key) => ({ - result: los[key], - score: result.score, - match: result.match, - terms: result.terms, - })); - } - - const items = results.slice(cursor, cursor + limit); - - return { - items, - totalResults: results.length, - nextCursor: cursor + items.length, - }; - }), createTest: publicProcedure .input(z.object({ questionBank, config: newTestConfigurationSchema })) .mutation(async ({ input }) => {