diff --git a/package-lock.json b/package-lock.json index f5edb998e4e..3c4f6e7758e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,6 @@ "i18next": "^23.16.4", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", - "lodash-es": "^4.17.21", "postcss-loader": "^8.1.1", "qrcode.react": "^4.1.0", "raviger": "^4.1.2", @@ -68,7 +67,6 @@ "@types/events": "^3.0.3", "@types/google.maps": "^3.58.1", "@types/jsdom": "^21.1.7", - "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-copy-to-clipboard": "^5.0.7", @@ -5176,23 +5174,6 @@ "optional": true, "peer": true }, - "node_modules/@types/lodash": { - "version": "4.17.11", - "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.11.tgz", - "integrity": "sha512-jzqWo/uQP/iqeGGTjhgFp2yaCrCYTauASQcpdzESNCkHjSprBJVcZP9KG9aQ0q+xcsXiKd/iuw/4dLjS3Odc7Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/lodash-es": { - "version": "4.17.12", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", - "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/lodash": "*" - } - }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -11938,12 +11919,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, - "node_modules/lodash-es": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", - "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", - "license": "MIT" - }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", diff --git a/package.json b/package.json index 60250c2209e..bea0a664f1d 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "i18next": "^23.16.4", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.6.2", - "lodash-es": "^4.17.21", "postcss-loader": "^8.1.1", "qrcode.react": "^4.1.0", "raviger": "^4.1.2", @@ -107,7 +106,6 @@ "@types/events": "^3.0.3", "@types/google.maps": "^3.58.1", "@types/jsdom": "^21.1.7", - "@types/lodash-es": "^4.17.12", "@types/node": "^22.9.0", "@types/react": "^18.3.12", "@types/react-copy-to-clipboard": "^5.0.7", diff --git a/src/Utils/.Notifications.js.swp b/src/Utils/.Notifications.js.swp new file mode 100644 index 00000000000..322de92b8df Binary files /dev/null and b/src/Utils/.Notifications.js.swp differ diff --git a/src/Utils/Notifications.js b/src/Utils/Notifications.js index 5b3ecdf143c..ca47c02803e 100644 --- a/src/Utils/Notifications.js +++ b/src/Utils/Notifications.js @@ -1,6 +1,7 @@ import { Stack, alert, defaultModules } from "@pnotify/core"; import * as PNotifyMobile from "@pnotify/mobile"; -import { camelCase, startCase } from "lodash-es"; + +import { camelCase, startCase } from "./utils"; defaultModules.set(PNotifyMobile, {}); diff --git a/src/Utils/utils.ts b/src/Utils/utils.ts index 3888406ad29..baff0d12e94 100644 --- a/src/Utils/utils.ts +++ b/src/Utils/utils.ts @@ -1,3 +1,5 @@ +import { useEffect, useRef } from "react"; + import { PatientModel } from "@/components/Patient/models"; import { AREACODES, IN_LANDLINE_AREA_CODES } from "@/common/constants"; @@ -544,3 +546,46 @@ export const fahrenheitToCelsius = (fahrenheit: number) => { export const keysOf = (obj: T) => { return Object.keys(obj) as (keyof T)[]; }; + +// Capitalize the first letter of each word in a string, handling edge cases +export const startCase = (str: string): string => { + if (!str) return ""; + + return str + .toLowerCase() + .replace(/\s+/g, " ") + .trim() + .split(" ") + .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : "")) + .join(" "); +}; + +// Converts a string to camelCase format, first word - lowercase and each subsequent word - uppercase letter, with no spaces. +export const camelCase = (str: string) => { + if (!str) return ""; + return str + .trim() + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : "")) + .replace(/^[A-Z]/, (c) => c.toLowerCase()); +}; + +export const useDebounce = ( + callback: (...args: string[]) => void, + delay: number, +) => { + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + const timeoutRef = useRef | null>(null); + const debouncedCallback = (...args: string[]) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(...args); + }, delay); + }; + return debouncedCallback; +}; diff --git a/src/components/Common/ExcelFIleDragAndDrop.tsx b/src/components/Common/ExcelFIleDragAndDrop.tsx index 67c64f0f433..d868e03f2a4 100644 --- a/src/components/Common/ExcelFIleDragAndDrop.tsx +++ b/src/components/Common/ExcelFIleDragAndDrop.tsx @@ -1,4 +1,3 @@ -import { forIn } from "lodash-es"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import * as XLSX from "xlsx"; @@ -68,9 +67,9 @@ export default function ExcelFileDragAndDrop({ const data = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); //converts the date to string data.forEach((row: any) => { - forIn(row, (value: any, key: string) => { - if (value instanceof Date) { - row[key] = value.toISOString().split("T")[0]; + Object.keys(row).forEach((key) => { + if (row[key] instanceof Date) { + row[key] = row[key].toISOString().split("T")[0]; } }); }); diff --git a/src/components/Facility/Investigations/Reports/index.tsx b/src/components/Facility/Investigations/Reports/index.tsx index e711372da47..32f0abcb7bc 100644 --- a/src/components/Facility/Investigations/Reports/index.tsx +++ b/src/components/Facility/Investigations/Reports/index.tsx @@ -1,4 +1,3 @@ -import _ from "lodash"; import { useCallback, useReducer, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -179,16 +178,16 @@ const InvestigationReports = ({ id }: any) => { ), ); - const investigationList = _.chain(data) - .flatMap((i) => i?.data?.results) - .compact() - .flatten() - .map((i) => ({ - ...i, - name: `${i.name} ${i.groups[0].name && " | " + i.groups[0].name} `, - })) - .unionBy("external_id") - .value(); + const investigationList = Array.from( + data + .flatMap((i) => i?.data?.results || []) + .map((i) => ({ + ...i, + name: `${i.name} ${i.groups[0].name ? " | " + i.groups[0].name : ""}`, + })) + .reduce((map, item) => map.set(item.external_id, item), new Map()) + .values(), + ); dispatch({ type: "set_investigations", payload: investigationList }); dispatch({ diff --git a/src/components/Facility/Investigations/Reports/utils.tsx b/src/components/Facility/Investigations/Reports/utils.tsx index 073be541600..349b067bc62 100644 --- a/src/components/Facility/Investigations/Reports/utils.tsx +++ b/src/components/Facility/Investigations/Reports/utils.tsx @@ -1,31 +1,67 @@ -import _ from "lodash"; -import { findIndex, memoize } from "lodash-es"; - import { InvestigationResponse } from "@/components/Facility/Investigations/Reports/types"; +const memoize = any>(fn: T): T => { + const cache = new Map>(); + const MAX_CACHE_SIZE = 1000; + return ((...args: Parameters): ReturnType => { + const key = args + .map((arg) => + typeof arg === "object" + ? arg instanceof Date + ? arg.getTime().toString() + : JSON.stringify(Object.entries(arg).sort()) + : String(arg), + ) + .join("|"); + if (!cache.has(key)) { + if (cache.size >= MAX_CACHE_SIZE) { + const firstKey: any = cache.keys().next().value; + cache.delete(firstKey); + } + cache.set(key, fn(...args)); + } + return cache.get(key)!; + }) as T; +}; + export const transformData = memoize((data: InvestigationResponse) => { - const sessions = _.chain(data) - .map((value: any) => { - return { - ...value.session_object, - facility_name: value.consultation_object?.facility_name, - facility_id: value.consultation_object?.facility, - }; - }) - .uniqBy("session_external_id") - .orderBy("session_created_date", "desc") - .value(); - const groupByInvestigation = _.chain(data) - .groupBy("investigation_object.external_id") - .values() - .value(); + const sessions = Array.from( + new Map( + data.map((value: any) => [ + value.session_object.session_external_id, + { + ...value.session_object, + facility_name: value.consultation_object?.facility_name, + facility_id: value.consultation_object?.facility, + }, + ]), + ).values(), + ).sort( + (a, b) => + new Date(b.session_created_date).getTime() - + new Date(a.session_created_date).getTime(), + ); + + const groupByInvestigation = Object.values( + data.reduce( + (acc, value: any) => { + const key = value.investigation_object.external_id; + if (!acc[key]) acc[key] = []; + acc[key].push(value); + return acc; + }, + {} as { [key: string]: any[] }, + ), + ); + + const sessionMap = new Map( + sessions.map((session, index) => [session.session_external_id, index]), + ); const reqData = groupByInvestigation.map((value: any) => { const sessionValues = Array.from({ length: sessions.length }); value.forEach((val: any) => { - const sessionIndex = findIndex(sessions, [ - "session_external_id", - val.session_object.session_external_id, - ]); + const sessionIndex = + sessionMap.get(val.session_object.session_external_id) ?? -1; if (sessionIndex > -1) { sessionValues[sessionIndex] = { min: val.investigation_object.min_value, @@ -58,6 +94,7 @@ export const transformData = memoize((data: InvestigationResponse) => { sessionValues, }; }); + return { sessions, data: reqData }; }); diff --git a/src/components/Facility/Investigations/ShowInvestigation.tsx b/src/components/Facility/Investigations/ShowInvestigation.tsx index 11e9b5cc0ad..0e298377002 100644 --- a/src/components/Facility/Investigations/ShowInvestigation.tsx +++ b/src/components/Facility/Investigations/ShowInvestigation.tsx @@ -1,5 +1,3 @@ -import _ from "lodash"; -import { set } from "lodash-es"; import { useCallback, useReducer } from "react"; import { useTranslation } from "react-i18next"; @@ -93,7 +91,32 @@ export default function ShowInvestigation(props: ShowInvestigationProps) { const handleValueChange = (value: any, name: string) => { const changedFields = { ...state.changedFields }; - set(changedFields, name, value); + const keys = name.split("."); + let current = changedFields; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + + // Protect against prototype pollution by skipping unsafe keys - crai + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + + // Use Object.create(null) to prevent accidental inheritance from Object prototype - coderabbit + current[key] = current[key] || Object.create(null); + current = current[key]; + } + + const lastKey = keys[keys.length - 1]; + + // Final key assignment, ensuring no prototype pollution vulnerability - coderabbit + if ( + lastKey !== "__proto__" && + lastKey !== "constructor" && + lastKey !== "prototype" + ) { + current[lastKey] = value; + } + dispatch({ type: "set_changed_fields", changedFields }); }; @@ -151,15 +174,19 @@ export default function ShowInvestigation(props: ShowInvestigationProps) { }; const handleUpdateCancel = useCallback(() => { - const changedValues = _.chain(state.initialValues) - .map((val: any, _key: string) => ({ - id: val?.id, - initialValue: val?.notes || val?.value || null, - value: val?.value || null, - notes: val?.notes || null, - })) - .reduce((acc: any, cur: any) => ({ ...acc, [cur.id]: cur }), {}) - .value(); + const changedValues = Object.keys(state.initialValues).reduce( + (acc: any, key: any) => { + const val = state.initialValues[key]; + acc[key] = { + id: val?.id, + initialValue: val?.notes || val?.value || null, + value: val?.value || null, + notes: val?.notes || null, + }; + return acc; + }, + {}, + ); dispatch({ type: "set_changed_fields", changedFields: changedValues }); }, [state.initialValues]); diff --git a/src/components/Facility/Investigations/Table.tsx b/src/components/Facility/Investigations/Table.tsx index 3a267279eb7..0d2b5f73971 100644 --- a/src/components/Facility/Investigations/Table.tsx +++ b/src/components/Facility/Investigations/Table.tsx @@ -1,4 +1,3 @@ -import { set } from "lodash-es"; import { useState } from "react"; import { SelectFormField } from "@/components/Form/FormFields/SelectFormField"; @@ -60,7 +59,25 @@ export const TestTable = ({ title, data, state, dispatch }: any) => { const handleValueChange = (value: any, name: string) => { const form = { ...state }; - set(form, name, value); + const keys = name.split("."); + + // Validate keys to prevent prototype pollution - coderabbit suggested + if ( + keys.some((key) => + ["__proto__", "constructor", "prototype"].includes(key), + ) + ) { + console.error("Invalid object key detected"); + return; + } + + let current = form; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) current[key] = {}; + current = current[key]; + } + current[keys[keys.length - 1]] = value; dispatch({ type: "set_form", form }); }; diff --git a/src/components/Form/AutoCompleteAsync.tsx b/src/components/Form/AutoCompleteAsync.tsx index b0cb9208d73..903a3b8090d 100644 --- a/src/components/Form/AutoCompleteAsync.tsx +++ b/src/components/Form/AutoCompleteAsync.tsx @@ -5,7 +5,6 @@ import { ComboboxOption, ComboboxOptions, } from "@headlessui/react"; -import { debounce } from "lodash-es"; import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -69,10 +68,14 @@ const AutoCompleteAsync = (props: Props) => { const hasSelection = (!multiple && selected) || (multiple && selected?.length > 0); - const fetchDataAsync = useMemo( - () => - debounce(async (query: string) => { - setLoading(true); + const fetchDataAsync = useMemo(() => { + let timeoutId: ReturnType; + + return async (query: string) => { + clearTimeout(timeoutId); + setLoading(true); + + timeoutId = setTimeout(async () => { const data = ((await fetchData(query)) || [])?.filter((d: any) => filter ? filter(d) : true, ); @@ -82,10 +85,11 @@ const AutoCompleteAsync = (props: Props) => { } else { setData(data); } + setLoading(false); - }, debounceTime), - [fetchData, showNOptions, debounceTime], - ); + }, debounceTime); + }; + }, [fetchData, showNOptions, debounceTime]); useEffect(() => { fetchDataAsync(query); diff --git a/src/components/Form/Form.tsx b/src/components/Form/Form.tsx index e31467b6c2a..4f06a441f68 100644 --- a/src/components/Form/Form.tsx +++ b/src/components/Form/Form.tsx @@ -1,4 +1,3 @@ -import { isEmpty, omitBy } from "lodash-es"; import { useEffect, useMemo, useState } from "react"; import { Cancel, Submit } from "@/components/Common/ButtonV2"; @@ -58,7 +57,13 @@ const Form = ({ event.stopPropagation(); if (validate) { - const errors = omitBy(validate(state.form), isEmpty) as FormErrors; + const errors = Object.fromEntries( + Object.entries(validate(state.form)).filter( + ([_key, value]) => + value !== "" && value !== null && value !== undefined, + ), + ) as FormErrors; + if (Object.keys(errors).length) { dispatch({ type: "set_errors", errors }); diff --git a/src/components/Patient/DiagnosesFilter.tsx b/src/components/Patient/DiagnosesFilter.tsx index c8fc2bc1a44..31cbce47d7e 100644 --- a/src/components/Patient/DiagnosesFilter.tsx +++ b/src/components/Patient/DiagnosesFilter.tsx @@ -1,4 +1,3 @@ -import { debounce } from "lodash-es"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -9,7 +8,7 @@ import AutocompleteMultiSelectFormField from "@/components/Form/FormFields/Autoc import { Error } from "@/Utils/Notifications"; import routes from "@/Utils/request/api"; import useQuery from "@/Utils/request/useQuery"; -import { mergeQueryOptions } from "@/Utils/utils"; +import { mergeQueryOptions, useDebounce } from "@/Utils/utils"; export const FILTER_BY_DIAGNOSES_KEYS = [ "diagnoses", @@ -34,6 +33,7 @@ interface Props { value?: string; onChange: (event: { name: DiagnosesFilterKey; value: string }) => void; } + export default function DiagnosesFilter(props: Props) { const { t } = useTranslation(); const [diagnoses, setDiagnoses] = useState([]); @@ -68,6 +68,10 @@ export default function DiagnosesFilter(props: Props) { }); }, [props.value]); + const debouncedQuery = useDebounce((query: string) => { + refetch({ query: { query } }); + }, 0); + return ( obj.id)} optionLabel={(option) => option.label} optionValue={(option) => option} - onQuery={debounce((query: string) => refetch({ query: { query } }), 300)} + onQuery={debouncedQuery} isLoading={loading} /> ); diff --git a/src/components/Patient/PatientRegister.tsx b/src/components/Patient/PatientRegister.tsx index 5cc958d44de..2597477943b 100644 --- a/src/components/Patient/PatientRegister.tsx +++ b/src/components/Patient/PatientRegister.tsx @@ -1,6 +1,4 @@ import careConfig from "@careConfig"; -import { startCase, toLower } from "lodash-es"; -import { debounce } from "lodash-es"; import { navigate } from "raviger"; import { useCallback, useReducer, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -76,6 +74,7 @@ import { usePubSub } from "@/Utils/pubsubContext"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; import useQuery from "@/Utils/request/useQuery"; +import { startCase } from "@/Utils/utils"; import { compareBy, dateQueryString, @@ -83,6 +82,7 @@ import { includesIgnoreCase, parsePhoneNumber, scrollTo, + useDebounce, } from "@/Utils/utils"; export type PatientForm = PatientModel & @@ -627,7 +627,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { ? formData.last_vaccinated_date : null : null, - name: startCase(toLower(formData.name)), + name: startCase(formData.name.toLowerCase()), pincode: formData.pincode ? formData.pincode : undefined, gender: Number(formData.gender), nationality: formData.nationality, @@ -747,7 +747,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { }); }; - const duplicateCheck = debounce(async (phoneNo: string) => { + const duplicateCheck = useDebounce(async (phoneNo: string) => { if ( phoneNo && PhoneNumberValidator()(parsePhoneNumber(phoneNo) ?? "") === undefined @@ -772,7 +772,7 @@ export const PatientRegister = (props: PatientRegisterProps) => { } } } - }, 300); + }, 0); const handleDialogClose = (action: string) => { if (action === "transfer") { diff --git a/src/components/Patient/SampleDetails.tsx b/src/components/Patient/SampleDetails.tsx index c16ef1c0e86..acc216e336b 100644 --- a/src/components/Patient/SampleDetails.tsx +++ b/src/components/Patient/SampleDetails.tsx @@ -1,4 +1,3 @@ -import { camelCase, capitalize, startCase } from "lodash-es"; import { navigate } from "raviger"; import { useTranslation } from "react-i18next"; @@ -15,6 +14,7 @@ import { GENDER_TYPES, TEST_TYPE_CHOICES } from "@/common/constants"; import { DetailRoute } from "@/Routers/types"; import routes from "@/Utils/request/api"; import useQuery from "@/Utils/request/useQuery"; +import { camelCase } from "@/Utils/utils"; import { formatDateTime, formatPatientAge } from "@/Utils/utils"; export const SampleDetails = ({ id }: DetailRoute) => { @@ -270,11 +270,11 @@ export const SampleDetails = ({ id }: DetailRoute) => { {t("status")}:{" "} {" "} - {startCase(camelCase(flow.status))} + {camelCase(flow.status || "")}
{t("label")}:{" "} - {capitalize(flow.notes)} + {flow.notes}
@@ -370,7 +370,9 @@ export const SampleDetails = ({ id }: DetailRoute) => { {t("doctors_name")}:{" "} - {startCase(camelCase(sampleDetails.doctor_name))} + + {camelCase(sampleDetails.doctor_name)} +
)} {sampleDetails?.diagnosis && ( @@ -451,7 +453,9 @@ export const SampleDetails = ({ id }: DetailRoute) => { {t("sample_type")}:{" "} - {startCase(camelCase(sampleDetails.sample_type))} + + {camelCase(sampleDetails.sample_type)} + )} {sampleDetails?.sample_type === "OTHER TYPE" && ( diff --git a/src/components/Patient/SampleTestCard.tsx b/src/components/Patient/SampleTestCard.tsx index fef2c9e4d08..bdb2df7a5a2 100644 --- a/src/components/Patient/SampleTestCard.tsx +++ b/src/components/Patient/SampleTestCard.tsx @@ -1,4 +1,3 @@ -import { camelCase, startCase } from "lodash-es"; import { navigate } from "raviger"; import { useState } from "react"; @@ -13,7 +12,7 @@ import { NonReadOnlyUsers } from "@/Utils/AuthorizeFor"; import * as Notification from "@/Utils/Notifications"; import routes from "@/Utils/request/api"; import request from "@/Utils/request/request"; -import { formatDateTime } from "@/Utils/utils"; +import { camelCase, formatDateTime } from "@/Utils/utils"; interface SampleDetailsProps { facilityId: number; @@ -101,7 +100,9 @@ export const SampleTestCard = (props: SampleDetailsProps) => { Status{" "}
- {startCase(camelCase(itemData.status))} + + {camelCase(itemData.status || "")} +
@@ -133,7 +134,9 @@ export const SampleTestCard = (props: SampleDetailsProps) => { Result{" "}
- {startCase(camelCase(itemData.result))} + + {camelCase(itemData.result || "")} +