From 8c9c48ef1d5313e751eea1d83d85b0f6c5197cf3 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 13:53:38 -0400 Subject: [PATCH 001/181] feat: #309 - Add admin-only notice to login page --- frontend/src/pages/Login/LoginForm.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d4579ead..3c1bec01 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -1,6 +1,5 @@ import { useFormik } from "formik"; -// import { Link, useNavigate } from "react-router-dom"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { login, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; @@ -59,11 +58,16 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { onSubmit={handleSubmit} className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12" > -
+
{/* {errorMessage &&
{errorMessage}
} */}

Welcome

+ +
+

This login is for Code for Philly administrators. Providers can use all site features without logging in.

+ Return to Medication Suggester +
From a5965999280f94a6f3135ede2760607cb1b62738 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 14:06:13 -0400 Subject: [PATCH 002/181] feat: #309 - add icon to login notice; add line break to return link --- frontend/src/pages/Login/LoginForm.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 3c1bec01..7639b937 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -6,6 +6,7 @@ import { RootState } from "../../services/actions/types"; import { useState, useEffect } from "react"; import ErrorMessage from "../../components/ErrorMessage"; import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; +import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { isAuthenticated: boolean; @@ -64,9 +65,13 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Welcome -
-

This login is for Code for Philly administrators. Providers can use all site features without logging in.

- Return to Medication Suggester +
+
+ +
+
+

This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

+
From f81704fc0858f2da932ee44affd2007321dcaf57 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 14:08:26 -0400 Subject: [PATCH 003/181] feat: #309 - Remove extra padding above notice --- frontend/src/pages/Login/LoginForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 7639b937..3e3fd0f1 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -65,7 +65,7 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Welcome -
+
From 757e712ebd7daca39cf102abef537cb1d5ef03bd Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 14:31:37 -0400 Subject: [PATCH 004/181] feat: #309 - Remove forced login; update UI elements to reflect public vs. admin login --- frontend/src/components/Header/Header.tsx | 34 ++------ .../components/Header/LoginMenuDropDown.tsx | 78 ------------------- frontend/src/pages/Layout/Layout.tsx | 7 -- .../src/pages/Layout/Layout_V2_Header.tsx | 9 --- frontend/src/pages/Login/LoginForm.tsx | 5 -- 5 files changed, 6 insertions(+), 127 deletions(-) delete mode 100644 frontend/src/components/Header/LoginMenuDropDown.tsx diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 32039605..50b14091 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -2,7 +2,6 @@ import { useState, useRef, useEffect, Fragment } from "react"; // import { useState, Fragment } from "react"; import accountLogo from "../../assets/account.svg"; import { Link, useNavigate, useLocation } from "react-router-dom"; -import LoginMenuDropDown from "./LoginMenuDropDown"; import "../../components/Header/header.css"; import Chat from "./Chat"; import { FeatureMenuDropDown } from "./FeatureMenuDropDown"; @@ -24,7 +23,6 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { const dropdownRef = useRef(null); let delayTimeout: number | null = null; const [showChat, setShowChat] = useState(false); - const [showLoginMenu, setShowLoginMenu] = useState(false); const [redirect, setRedirect] = useState(false); const { setShowSummary, setEnterNewPatient, triggerFormReset, setIsEditing } = useGlobalContext(); @@ -36,19 +34,6 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { setRedirect(false); }; - const guestLinks = () => ( - - ); - const authLinks = () => ( ); - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - const handleMouseEnter = () => { if (delayTimeout !== null) { clearTimeout(delayTimeout); @@ -136,7 +117,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Balancer -
diff --git a/frontend/src/components/Header/LoginMenuDropDown.tsx b/frontend/src/components/Header/LoginMenuDropDown.tsx deleted file mode 100644 index 427fdf07..00000000 --- a/frontend/src/components/Header/LoginMenuDropDown.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -import { classNames } from "../../utils/classNames"; - -interface LoginMenuDropDownProps { - showLoginMenu: boolean; - handleLoginMenu: () => void; -} - -const LoginMenuDropDown: React.FC = ({ - showLoginMenu, -}) => { - return ( - <> - - -
- - Balancer - - - -

- Balancer is an interactive and user-friendly research tool for bipolar - medications, powered by Code for Philly volunteers. -

-

- We built Balancer{" "} - - to improve the health and well-being of people with bipolar - disorder. - -

-

- Balancer is currently still being developed, so do not take any - information on the test site as actual medical advice. -

- - {/*

- You can log in or sign up for a Balancer account using your email, - gmail or Facebook account. -

*/} - - - - - {/* - - */} -
- - ); -}; - -const LoginMenu = ({ show }: { show: boolean }) => { - if (!show) return null; - - return
; -}; - -export default LoginMenuDropDown; diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index 3c12358b..afe880b8 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -2,7 +2,6 @@ import {ReactNode, useState, useEffect} from "react"; import Header from "../../components/Header/Header"; import Footer from "../../components/Footer/Footer"; -import LoginMenuDropDown from "../../components/Header/LoginMenuDropDown"; import {connect} from "react-redux"; import {useAuth} from "./authHooks.ts"; import {RootState} from "../../services/actions/types"; @@ -50,12 +49,6 @@ export const Layout = ({
- {!isAuthenticated && showLoginMenu && ( - - )}
{children}
diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index b510c62d..3c5b7318 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import { Link, useLocation } from "react-router-dom"; -import LoginMenuDropDown from "../../components/Header/LoginMenuDropDown.tsx"; import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; @@ -65,14 +64,6 @@ const Header: React.FC = ({ isAuthenticated }) => { )}
- {!isAuthenticated && showLoginMenu && ( -
- -
- )} ); }; diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 3e3fd0f1..97bcdbe5 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -109,11 +109,6 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
- {/* - - */} From c9f3a319dbd514b29c1aa33a7a94e46db624e13c Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 19:33:11 -0400 Subject: [PATCH 005/181] feat: #309 - Add new logout page; restyle admin dropdown; restyle logout button; fix css of mobile nav links; remove login sidenav --- .../components/Header/FeatureMenuDropDown.tsx | 10 ++--- frontend/src/components/Header/Header.tsx | 31 ++++---------- frontend/src/components/Header/MdNavBar.tsx | 26 ++++-------- frontend/src/components/Header/header.css | 4 +- frontend/src/pages/Layout/Layout.tsx | 31 ++------------ .../src/pages/Layout/Layout_V2_Header.tsx | 22 +--------- frontend/src/pages/Login/LoginForm.tsx | 2 +- frontend/src/pages/Logout/Logout.tsx | 42 +++++++++++++++++++ frontend/src/routes/routes.tsx | 5 +++ frontend/tailwind.config.js | 9 +++- 10 files changed, 83 insertions(+), 99 deletions(-) create mode 100644 frontend/src/pages/Logout/Logout.tsx diff --git a/frontend/src/components/Header/FeatureMenuDropDown.tsx b/frontend/src/components/Header/FeatureMenuDropDown.tsx index b1bbf03e..36d72792 100644 --- a/frontend/src/components/Header/FeatureMenuDropDown.tsx +++ b/frontend/src/components/Header/FeatureMenuDropDown.tsx @@ -4,13 +4,13 @@ export const FeatureMenuDropDown = () => { const location = useLocation(); const currentPath = location.pathname; return ( -
-
+
+
    Manage files -
    +
    Manage and chat with files
@@ -19,7 +19,7 @@ export const FeatureMenuDropDown = () => {
    Manage rules -
    +
    Manage list of rules
@@ -28,7 +28,7 @@ export const FeatureMenuDropDown = () => {
    Manage meds -
    +
    Manage list of meds
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 50b14091..f8c40028 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -1,15 +1,12 @@ -import { useState, useRef, useEffect, Fragment } from "react"; -// import { useState, Fragment } from "react"; -import accountLogo from "../../assets/account.svg"; +import { useState, useRef, useEffect } from "react"; import { Link, useNavigate, useLocation } from "react-router-dom"; import "../../components/Header/header.css"; import Chat from "./Chat"; import { FeatureMenuDropDown } from "./FeatureMenuDropDown"; import MdNavBar from "./MdNavBar"; -import { connect, useDispatch } from "react-redux"; +import { connect } from "react-redux"; import { RootState } from "../../services/actions/types"; -import { logout, AppDispatch } from "../../services/actions/auth"; -import { HiChevronDown } from "react-icons/hi"; +import { FaChevronDown, FaSignOutAlt } from "react-icons/fa"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; interface LoginFormProps { @@ -23,24 +20,14 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { const dropdownRef = useRef(null); let delayTimeout: number | null = null; const [showChat, setShowChat] = useState(false); - const [redirect, setRedirect] = useState(false); const { setShowSummary, setEnterNewPatient, triggerFormReset, setIsEditing } = useGlobalContext(); - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - setRedirect(false); - }; - const authLinks = () => ( - + + Sign Out + + ); const handleMouseEnter = () => { @@ -201,14 +188,12 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { : "absolute ml-1.5 " }`} > - + {showFeaturesMenu && }
)} - - {redirect ? navigate("/") : } {isAuthenticated && ( diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 926794cf..1ed2cd43 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -5,8 +5,6 @@ import Chat from "./Chat"; // import logo from "../../assets/balancer.png"; import closeLogo from "../../assets/close.svg"; import hamburgerLogo from "../../assets/hamburger.svg"; -import {useDispatch} from "react-redux"; -import {logout, AppDispatch} from "../../services/actions/auth"; interface LoginFormProps { isAuthenticated: boolean; @@ -22,13 +20,6 @@ const MdNavBar = (props: LoginFormProps) => { setNav(!nav); }; - const dispatch = useDispatch(); - - const logout_user = () => { - dispatch(logout()); - }; - - return (
{
  • Donate
  • {isAuthenticated && -
  • - - Sign Out - -
  • +
  • + Sign Out + +
  • }
    diff --git a/frontend/src/components/Header/header.css b/frontend/src/components/Header/header.css index 4b0f4a2c..c7e807b9 100644 --- a/frontend/src/components/Header/header.css +++ b/frontend/src/components/Header/header.css @@ -23,7 +23,7 @@ } .header-nav-item { - @apply text-black border-transparent border-b-2 hover:border-blue-600 hover:text-blue-600 hover:border-b-2 hover:border-blue-600; + @apply text-black border-transparent border-b-2 hover:cursor-pointer hover:border-blue-600 hover:text-blue-600 hover:border-b-2 hover:border-blue-600; } .header-nav-item.header-nav-item-selected { @@ -31,7 +31,7 @@ } .subheader-nav-item { - @apply cursor-pointer rounded-lg p-3 transition duration-300 hover:bg-gray-100; + @apply cursor-pointer p-3 transition duration-300 hover:bg-gray-200 border-b border-gray-200; } .subheader-nav-item.subheader-nav-item-selected { diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index afe880b8..84f9c215 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -1,11 +1,10 @@ // Layout.tsx -import {ReactNode, useState, useEffect} from "react"; +import {ReactNode} from "react"; import Header from "../../components/Header/Header"; import Footer from "../../components/Footer/Footer"; import {connect} from "react-redux"; import {useAuth} from "./authHooks.ts"; import {RootState} from "../../services/actions/types"; -import {useLocation} from "react-router-dom"; interface LayoutProps { children: ReactNode; @@ -16,32 +15,8 @@ interface LoginFormProps { } export const Layout = ({ - children, - isAuthenticated, - }: LayoutProps & LoginFormProps): JSX.Element => { - const [showLoginMenu, setShowLoginMenu] = useState(false); - const location = useLocation(); - - - useEffect(() => { - if (!isAuthenticated) { - if ( - location.pathname === "/login" || - location.pathname === "/resetpassword" || - location.pathname.includes("password") || - location.pathname.includes("reset") - ) { - setShowLoginMenu(false); - } else { - setShowLoginMenu(true); - } - } - }, [isAuthenticated, location.pathname]); - - const handleLoginMenu = () => { - setShowLoginMenu(!showLoginMenu); - }; - + children +}: LayoutProps & LoginFormProps): JSX.Element => { useAuth(); return (
    diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index 3c5b7318..3371cef5 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -1,4 +1,3 @@ -import { useState, useEffect } from "react"; import { Link, useLocation } from "react-router-dom"; import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; @@ -7,31 +6,12 @@ interface LoginFormProps { isAuthenticated: boolean; } -const Header: React.FC = ({ isAuthenticated }) => { - const [showLoginMenu, setShowLoginMenu] = useState(false); +const Header: React.FC = () => { const location = useLocation(); const { setShowMetaPanel } = useGlobalContext(); const isOnDrugSummaryPage = location.pathname.includes("/drugsummary"); - useEffect(() => { - // only show the login menu on non‑auth pages - if (!isAuthenticated) { - const path = location.pathname; - const isAuthPage = - path === "/login" || - path === "/resetpassword" || - path.includes("password") || - path.includes("reset"); - - setShowLoginMenu(!isAuthPage); - } - }, [isAuthenticated, location.pathname]); - - const handleLoginMenu = () => { - setShowLoginMenu((prev) => !prev); - }; - useAuth(); return ( diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index 97bcdbe5..ce28c62c 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -70,7 +70,7 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) {
    -

    This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

    +

    This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

    diff --git a/frontend/src/pages/Logout/Logout.tsx b/frontend/src/pages/Logout/Logout.tsx new file mode 100644 index 00000000..b09f0ca3 --- /dev/null +++ b/frontend/src/pages/Logout/Logout.tsx @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useDispatch } from "react-redux"; +import { logout, AppDispatch } from "../../services/actions/auth"; + +const LogoutPage = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(logout()); + + const timer = setTimeout(() => { + navigate('/'); + }, 3000); // Redirect after 3 seconds + + // Cleanup the timer on component unmount + return () => clearTimeout(timer); + }, [dispatch, navigate]); + + return ( +
    +
    +

    You’ve been logged out

    +
    +
    +
    +

    + Thank you for using Balancer. You'll be redirected to the homepage shortly. +

    + +
    +
    + ); +}; + +export default LogoutPage; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 2e6273d4..f96f2574 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -1,6 +1,7 @@ import App from "../App"; import RouteError from "../pages/404/404.tsx"; import LoginForm from "../pages/Login/Login.tsx"; +import Logout from "../pages/Logout/Logout.tsx"; import AdminPortal from "../pages/AdminPortal/AdminPortal.tsx"; import ResetPassword from "../pages/Login/ResetPassword.tsx"; import ResetPasswordConfirm from "../pages/Login/ResetPasswordConfirm.tsx"; @@ -50,6 +51,10 @@ const routes = [ path: "login", element: , }, + { + path: "logout", + element: , + }, { path: "resetPassword", element: , diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index bcc1e693..4161a741 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -10,8 +10,15 @@ export default { lora: "'Lora', serif", 'quicksand': ['Quicksand', 'sans-serif'] }, + keyframes: { + 'loading': { + '0%': { left: '-40%' }, + '100%': { left: '100%' }, + }, + }, animation: { - 'pulse-bounce': 'pulse-bounce 2s infinite', // Adjust duration and iteration as needed + 'pulse-bounce': 'pulse-bounce 2s infinite', + 'loading': 'loading 3s infinite', }, plugins: [], }, From 28834711023046af4d938c6a7c9bd477e77b097f Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Sun, 24 Aug 2025 20:48:35 -0400 Subject: [PATCH 006/181] feat: #309 - Separate public and admin api calls --- frontend/src/api/apiClient.ts | 28 +++++++++++-------- frontend/src/pages/Files/ListOfFiles.tsx | 8 +++--- .../src/pages/ListMeds/useMedications.tsx | 4 +-- frontend/src/pages/ManageMeds/ManageMeds.tsx | 8 +++--- .../pages/PatientManager/NewPatientForm.tsx | 4 +-- .../src/pages/RulesManager/RulesManager.tsx | 4 +-- 6 files changed, 30 insertions(+), 26 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 73b74caf..0b48496b 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,7 +3,11 @@ import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; const baseURL = import.meta.env.VITE_API_BASE_URL; -export const api = axios.create({ +export const publicApi = axios.create({ + baseURL +}); + +export const adminApi = axios.create({ baseURL, headers: { Authorization: `JWT ${localStorage.getItem("access")}`, @@ -11,7 +15,7 @@ export const api = axios.create({ }); // Request interceptor to set the Authorization header -api.interceptors.request.use( +adminApi.interceptors.request.use( (configuration) => { const token = localStorage.getItem("access"); if (token) { @@ -29,7 +33,7 @@ const handleSubmitFeedback = async ( message: FormValues["message"], ) => { try { - const response = await api.post(`/v1/api/feedback/`, { + const response = await publicApi.post(`/v1/api/feedback/`, { feedbacktype: feedbackType, name, email, @@ -45,7 +49,7 @@ const handleSubmitFeedback = async ( const handleSendDrugSummary = async (message: FormValues["message"], guid: string) => { try { const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; - const response = await api.post(endpoint, { + const response = await adminApi.post(endpoint, { message, }); console.log("Response data:", JSON.stringify(response.data, null, 2)); @@ -58,7 +62,7 @@ const handleSendDrugSummary = async (message: FormValues["message"], guid: strin const handleRuleExtraction = async (guid: string) => { try { - const response = await api.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -69,7 +73,7 @@ const handleRuleExtraction = async (guid: string) => { const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" = "include") => { try { - const response = await api.post(`/v1/api/riskWithSources`, { + const response = await adminApi.post(`/v1/api/riskWithSources`, { drug: medication, source: source, }); @@ -192,7 +196,7 @@ const handleSendDrugSummaryStreamLegacy = async ( const fetchConversations = async (): Promise => { try { - const response = await api.get(`/chatgpt/conversations/`); + const response = await publicApi.get(`/chatgpt/conversations/`); return response.data; } catch (error) { console.error("Error(s) during getConversations: ", error); @@ -202,7 +206,7 @@ const fetchConversations = async (): Promise => { const fetchConversation = async (id: string): Promise => { try { - const response = await api.get(`/chatgpt/conversations/${id}/`); + const response = await publicApi.get(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -212,7 +216,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await api.post(`/chatgpt/conversations/`, { + const response = await publicApi.post(`/chatgpt/conversations/`, { messages: [], }); return response.data; @@ -228,7 +232,7 @@ const continueConversation = async ( page_context?: string, ): Promise<{ response: string; title: Conversation["title"] }> => { try { - const response = await api.post( + const response = await publicApi.post( `/chatgpt/conversations/${id}/continue_conversation/`, { message, @@ -244,7 +248,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await api.delete(`/chatgpt/conversations/${id}/`); + const response = await publicApi.delete(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -257,7 +261,7 @@ const updateConversationTitle = async ( newTitle: Conversation["title"], ): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { try { - const response = await api.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await publicApi.patch(`/chatgpt/conversations/${id}/update_title/`, { title: newTitle, }); return response.data; diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index b53874bf..2a579bdc 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; import Layout from "../Layout/Layout"; import FileRow from "./FileRow"; import Table from "../../components/Table/Table"; @@ -37,7 +37,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ try { const url = `${baseUrl}/v1/api/uploadFile`; - const { data } = await api.get(url); + const { data } = await adminApi.get(url); if (Array.isArray(data)) { setFiles(data); @@ -63,7 +63,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); @@ -84,7 +84,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await api.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); diff --git a/frontend/src/pages/ListMeds/useMedications.tsx b/frontend/src/pages/ListMeds/useMedications.tsx index e15cc758..022eb07a 100644 --- a/frontend/src/pages/ListMeds/useMedications.tsx +++ b/frontend/src/pages/ListMeds/useMedications.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; export interface MedData { name: string; @@ -18,7 +18,7 @@ export function useMedications() { try { const url = `${baseUrl}/v1/api/get_full_list_med`; - const { data } = await api.get(url); + const { data } = await publicApi.get(url); data.sort((a: MedData, b: MedData) => { const nameA = a.name.toUpperCase(); diff --git a/frontend/src/pages/ManageMeds/ManageMeds.tsx b/frontend/src/pages/ManageMeds/ManageMeds.tsx index 071a2690..23493f7e 100644 --- a/frontend/src/pages/ManageMeds/ManageMeds.tsx +++ b/frontend/src/pages/ManageMeds/ManageMeds.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import Layout from "../Layout/Layout"; import Welcome from "../../components/Welcome/Welcome"; import ErrorMessage from "../../components/ErrorMessage"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; function ManageMedications() { interface MedData { id: string; @@ -23,7 +23,7 @@ function ManageMedications() { const fetchMedications = async () => { try { const url = `${baseUrl}/v1/api/get_full_list_med`; - const { data } = await api.get(url); + const { data } = await adminApi.get(url); data.sort((a: MedData, b: MedData) => a.name.localeCompare(b.name)); setMedications(data); } catch (e: unknown) { @@ -36,7 +36,7 @@ function ManageMedications() { // Handle Delete Medication const handleDelete = async (name: string) => { try { - await api.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); + await adminApi.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); setMedications((prev) => prev.filter((med) => med.name !== name)); setConfirmDelete(null); } catch (e: unknown) { @@ -56,7 +56,7 @@ function ManageMedications() { return; } try { - await api.post(`${baseUrl}/v1/api/add_medication`, { + await adminApi.post(`${baseUrl}/v1/api/add_medication`, { name: newMedName, benefits: newMedBenefits, risks: newMedRisks, diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 774ebcb3..16143fdb 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -4,7 +4,7 @@ import { PatientInfo, Diagnosis } from "./PatientTypes"; import { useMedications } from "../ListMeds/useMedications"; import ChipsInput from "../../components/ChipsInput/ChipsInput"; import Tooltip from "../../components/Tooltip"; -import { api } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; // import ErrorMessage from "../../components/ErrorMessage"; @@ -155,7 +155,7 @@ const NewPatientForm = ({ const baseUrl = import.meta.env.VITE_API_BASE_URL; const url = `${baseUrl}/v1/api/get_med_recommend`; - const { data } = await api.post(url, payload); + const { data } = await publicApi.post(url, payload); const categorizedMedications = { first: data.first ?? [], diff --git a/frontend/src/pages/RulesManager/RulesManager.tsx b/frontend/src/pages/RulesManager/RulesManager.tsx index be4980d4..0268a4c8 100644 --- a/frontend/src/pages/RulesManager/RulesManager.tsx +++ b/frontend/src/pages/RulesManager/RulesManager.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react"; import Layout from "../Layout/Layout"; import Welcome from "../../components/Welcome/Welcome"; import ErrorMessage from "../../components/ErrorMessage"; -import { api } from "../../api/apiClient"; +import { adminApi } from "../../api/apiClient"; import { ChevronDown, ChevronUp } from "lucide-react"; interface Medication { @@ -69,7 +69,7 @@ function RulesManager() { const fetchMedRules = async () => { try { const url = `${baseUrl}/v1/api/medRules`; - const { data } = await api.get(url); + const { data } = await adminApi.get(url); if (!data || !Array.isArray(data.results)) { throw new Error("Invalid response format"); From 8c4036eb3df48d6d30e6e34ac06503d5582a867a Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Mon, 25 Aug 2025 20:34:02 -0400 Subject: [PATCH 007/181] feat: #309 - Disable JWT auth for newly public endpoints --- frontend/src/api/apiClient.ts | 4 +--- server/api/views/conversations/views.py | 4 ++-- server/api/views/feedback/views.py | 4 +++- server/api/views/listMeds/views.py | 13 +++++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 0b48496b..5e4a5eb6 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,9 +3,7 @@ import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; const baseURL = import.meta.env.VITE_API_BASE_URL; -export const publicApi = axios.create({ - baseURL -}); +export const publicApi = axios.create({ baseURL }); export const adminApi = axios.create({ baseURL, diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index d5921eaf..eeb68809 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -1,7 +1,7 @@ from rest_framework.response import Response from rest_framework import viewsets, status from rest_framework.decorators import action -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from rest_framework.exceptions import APIException from django.http import JsonResponse from bs4 import BeautifulSoup @@ -81,7 +81,7 @@ def __init__(self, detail=None, code=None): class ConversationViewSet(viewsets.ModelViewSet): serializer_class = ConversationSerializer - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get_queryset(self): return Conversation.objects.filter(user=self.request.user) diff --git a/server/api/views/feedback/views.py b/server/api/views/feedback/views.py index dcbef992..d0f0e1da 100644 --- a/server/api/views/feedback/views.py +++ b/server/api/views/feedback/views.py @@ -1,4 +1,4 @@ - +from rest_framework.permissions import AllowAny from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -8,6 +8,8 @@ class FeedbackView(APIView): + permission_classes = [AllowAny] + def post(self, request, *args, **kwargs): serializer = FeedbackSerializer(data=request.data) if serializer.is_valid(): diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 796d9b17..d10a385a 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,4 +1,5 @@ from rest_framework import status +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView @@ -21,6 +22,8 @@ class GetMedication(APIView): + permission_classes = [AllowAny] + def post(self, request): data = request.data state_query = data.get('state', '') @@ -67,6 +70,8 @@ def post(self, request): class ListOrDetailMedication(APIView): + permission_classes = [AllowAny] + def get(self, request): name_query = request.query_params.get('name', None) if name_query: @@ -95,7 +100,7 @@ def post(self, request): name = data.get('name', '').strip() benefits = data.get('benefits', '').strip() risks = data.get('risks', '').strip() - + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) @@ -103,7 +108,7 @@ def post(self, request): return Response({'error': 'Medication benefits are required'}, status=status.HTTP_400_BAD_REQUEST) if not risks: return Response({'error': 'Medication risks are required'}, status=status.HTTP_400_BAD_REQUEST) - + # Check if medication already exists if Medication.objects.filter(name=name).exists(): return Response({'error': f'Medication "{name}" already exists'}, status=status.HTTP_400_BAD_REQUEST) @@ -123,11 +128,11 @@ class DeleteMedication(APIView): def delete(self, request): data = request.data name = data.get('name', '').strip() - + # Validate required fields if not name: return Response({'error': 'Medication name is required'}, status=status.HTTP_400_BAD_REQUEST) - + # Check if medication exists and delete try: medication = Medication.objects.get(name=name) From 3be42d380baf6a6de26dea9be9ccb028bdf4d3ec Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Mon, 25 Aug 2025 20:39:54 -0400 Subject: [PATCH 008/181] feat: #309 - Make riskWithSources endpoint public --- frontend/src/api/apiClient.ts | 2 +- server/api/views/risk/views_riskWithSources.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 5e4a5eb6..a1d32318 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -71,7 +71,7 @@ const handleRuleExtraction = async (guid: string) => { const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" = "include") => { try { - const response = await adminApi.post(`/v1/api/riskWithSources`, { + const response = await publicApi.post(`/v1/api/riskWithSources`, { drug: medication, source: source, }); diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index d1c01615..94076c5c 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -1,6 +1,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status +from rest_framework.permissions import AllowAny from api.views.listMeds.models import Medication from api.models.model_medRule import MedRule, MedRuleSource import openai @@ -8,6 +9,8 @@ class RiskWithSourcesView(APIView): + permission_classes = [AllowAny] + def post(self, request): openai.api_key = os.environ.get("OPENAI_API_KEY") From 789442bc2ae3ff25954a44652e30d299cda62244 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 26 Aug 2025 19:44:58 -0400 Subject: [PATCH 009/181] feat: #309 - Temporarily restrict chatbot to admins only --- frontend/src/api/apiClient.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index a1d32318..a594f921 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -214,7 +214,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await publicApi.post(`/chatgpt/conversations/`, { + const response = await adminApi.post(`/chatgpt/conversations/`, { messages: [], }); return response.data; @@ -230,7 +230,7 @@ const continueConversation = async ( page_context?: string, ): Promise<{ response: string; title: Conversation["title"] }> => { try { - const response = await publicApi.post( + const response = await adminApi.post( `/chatgpt/conversations/${id}/continue_conversation/`, { message, @@ -246,7 +246,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await publicApi.delete(`/chatgpt/conversations/${id}/`); + const response = await adminApi.delete(`/chatgpt/conversations/${id}/`); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -259,7 +259,7 @@ const updateConversationTitle = async ( newTitle: Conversation["title"], ): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { try { - const response = await publicApi.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await adminApi.patch(`/chatgpt/conversations/${id}/update_title/`, { title: newTitle, }); return response.data; From 0986a172df7fc3e0c72573d5edf5cd8384829f69 Mon Sep 17 00:00:00 2001 From: Gregg Stubberfield Date: Tue, 26 Aug 2025 20:43:18 -0400 Subject: [PATCH 010/181] feat: #309 - Allow viewing and downloading of uploads to be public; edits remain admin only --- .../src/pages/DocumentManager/UploadFile.tsx | 3 +-- frontend/src/pages/Files/ListOfFiles.tsx | 8 ++++---- .../src/pages/Layout/Layout_V2_Sidebar.tsx | 6 +----- server/api/views/uploadFile/views.py | 19 ++++++++----------- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index 35c4b84f..f3d0f477 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -28,8 +28,7 @@ const UploadFile: React.FC = () => { formData, { headers: { - "Content-Type": "multipart/form-data", - Authorization: `JWT ${localStorage.getItem("access")}`, // Assuming JWT is used for auth + "Content-Type": "multipart/form-data" }, } ); diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index 2a579bdc..efed19e5 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from "react"; -import { adminApi } from "../../api/apiClient"; +import { publicApi } from "../../api/apiClient"; import Layout from "../Layout/Layout"; import FileRow from "./FileRow"; import Table from "../../components/Table/Table"; @@ -37,7 +37,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ try { const url = `${baseUrl}/v1/api/uploadFile`; - const { data } = await adminApi.get(url); + const { data } = await publicApi.get(url); if (Array.isArray(data)) { setFiles(data); @@ -63,7 +63,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); @@ -84,7 +84,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await adminApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index 19163290..bec32d50 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -25,11 +25,7 @@ const Sidebar: React.FC = () => { const fetchFiles = async () => { try { const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile`, { - headers: { - Authorization: `JWT ${localStorage.getItem("access")}`, - }, - }); + const response = await axios.get(`${baseUrl}/v1/api/uploadFile`); if (Array.isArray(response.data)) { setFiles(response.data); } diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 8989dbc3..003e171e 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework import status from rest_framework.generics import UpdateAPIView @@ -18,17 +18,15 @@ @method_decorator(csrf_exempt, name='dispatch') class UploadFileView(APIView): - permission_classes = [IsAuthenticated] + def get_permissions(self): + if self.request.method == 'GET': + return [AllowAny()] # Public access + return [IsAuthenticated()] # Auth required for other methods def get(self, request, format=None): print("UploadFileView, get list") - # Get the authenticated user - user = request.user - - # Filter the files uploaded by the authenticated user - files = UploadFile.objects.filter(uploaded_by=user.id).defer( - 'file').order_by('-date_of_upload') + files = UploadFile.objects.all().defer('file').order_by('-date_of_upload') serializer = UploadFileSerializer(files, many=True) return Response(serializer.data) @@ -160,12 +158,11 @@ def delete(self, request, format=None): @method_decorator(csrf_exempt, name='dispatch') class RetrieveUploadFileView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get(self, request, guid, format=None): try: - file = UploadFile.objects.get( - guid=guid, uploaded_by=request.user.id) + file = UploadFile.objects.get(guid=guid) response = HttpResponse(file.file, content_type='application/pdf') # print(file.file[:100]) response['Content-Disposition'] = f'attachment; filename="{file.file_name}"' From f036d6d4044ab0c5978918b7d66bb0d70482684f Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 7 Oct 2025 14:30:15 -0400 Subject: [PATCH 011/181] Add comment with workaround PostgreSQL crash with pgvector v0.6.1 on ARM64 --- docker-compose.yml | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d8a8ca75..7e30f85a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,24 @@ services: db: - build: - context: ./db - dockerfile: Dockerfile - volumes: - - postgres_data:/var/lib/postgresql/data/ - environment: - - POSTGRES_USER=balancer - - POSTGRES_PASSWORD=balancer - - POSTGRES_DB=balancer_dev - ports: + # Workaround for PostgreSQL crash with pgvector v0.6.1 on ARM64 + # image: pgvector/pgvector:pg15 + # volumes: + # - postgres_data:/var/lib/postgresql/data/ + # - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql + build: + context: ./db + dockerfile: Dockerfile + volumes: + - postgres_data:/var/lib/postgresql/data/ + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + ports: - "5433:5432" - networks: - app_net: - ipv4_address: 192.168.0.2 + networks: + app_net: + ipv4_address: 192.168.0.2 pgadmin: container_name: pgadmin4 image: dpage/pgadmin4 @@ -52,13 +57,13 @@ services: args: - IMAGE_NAME=balancer-frontend ports: - - "3000:3000" + - "3000:3000" environment: - - CHOKIDAR_USEPOLLING=true - # - VITE_API_BASE_URL=https://balancertestsite.com/ + - CHOKIDAR_USEPOLLING=true + # - VITE_API_BASE_URL=https://balancertestsite.com/ volumes: - - "./frontend:/usr/src/app:delegated" - - "/usr/src/app/node_modules/" + - "./frontend:/usr/src/app:delegated" + - "/usr/src/app/node_modules/" depends_on: - backend networks: @@ -72,4 +77,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 \ No newline at end of file + gateway: 192.168.0.1 From c8a605c4fe4afcdc2f0763437b8ca721adb64457 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 30 Oct 2025 17:33:25 -0400 Subject: [PATCH 012/181] WIP --- frontend/src/api/apiClient.ts | 62 ++++++++++++++++++-------- frontend/src/services/actions/auth.tsx | 6 +-- server/api/views/assistant/views.py | 4 +- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 26a6ab8a..857c1520 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -3,7 +3,9 @@ import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; const baseURL = import.meta.env.VITE_API_BASE_URL; -export const api = axios.create({ +export const publicApi = axios.create({ baseURL }); + +export const adminApi = axios.create({ baseURL, headers: { Authorization: `JWT ${localStorage.getItem("access")}`, @@ -11,7 +13,7 @@ export const api = axios.create({ }); // Request interceptor to set the Authorization header -api.interceptors.request.use( +adminApi.interceptors.request.use( (configuration) => { const token = localStorage.getItem("access"); if (token) { @@ -42,9 +44,14 @@ const handleSubmitFeedback = async ( } }; -const handleSendDrugSummary = async (message: FormValues["message"], guid: string) => { +const handleSendDrugSummary = async ( + message: FormValues["message"], + guid: string, +) => { try { - const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; + const endpoint = guid + ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` + : "/v1/api/embeddings/ask_embeddings"; const response = await api.post(endpoint, { message, }); @@ -58,7 +65,9 @@ const handleSendDrugSummary = async (message: FormValues["message"], guid: strin const handleRuleExtraction = async (guid: string) => { try { - const response = await api.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await api.get( + `/v1/api/rule_extraction_openai?guid=${guid}`, + ); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -67,7 +76,10 @@ const handleRuleExtraction = async (guid: string) => { } }; -const fetchRiskDataWithSources = async (medication: string, source: "include" | "diagnosis" | "diagnosis_depressed" = "include") => { +const fetchRiskDataWithSources = async ( + medication: string, + source: "include" | "diagnosis" | "diagnosis_depressed" = "include", +) => { try { const response = await api.post(`/v1/api/riskWithSources`, { drug: medication, @@ -90,7 +102,7 @@ interface StreamCallbacks { const handleSendDrugSummaryStream = async ( message: string, guid: string, - callbacks: StreamCallbacks + callbacks: StreamCallbacks, ): Promise => { const token = localStorage.getItem("access"); const endpoint = `/v1/api/embeddings/ask_embeddings?stream=true${ @@ -165,12 +177,18 @@ const handleSendDrugSummaryStream = async ( } } } catch (parseError) { - console.error("Failed to parse SSE data:", parseError, "Raw line:", line); + console.error( + "Failed to parse SSE data:", + parseError, + "Raw line:", + line, + ); } } } } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; console.error("Error in stream:", errorMessage); callbacks.onError?.(errorMessage); throw error; @@ -186,7 +204,7 @@ const handleSendDrugSummaryStreamLegacy = async ( return handleSendDrugSummaryStream(message, guid, { onContent: onChunk, onError: (error) => console.error("Stream error:", error), - onComplete: () => console.log("Stream completed") + onComplete: () => console.log("Stream completed"), }); }; @@ -255,11 +273,16 @@ const deleteConversation = async (id: string) => { const updateConversationTitle = async ( id: Conversation["id"], newTitle: Conversation["title"], -): Promise<{status: string, title: Conversation["title"]} | {error: string}> => { +): Promise< + { status: string; title: Conversation["title"] } | { error: string } +> => { try { - const response = await api.patch(`/chatgpt/conversations/${id}/update_title/`, { - title: newTitle, - }); + const response = await api.patch( + `/chatgpt/conversations/${id}/update_title/`, + { + title: newTitle, + }, + ); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -268,9 +291,12 @@ const updateConversationTitle = async ( }; // Assistant API functions -const sendAssistantMessage = async (message: string, previousResponseId?: string) => { +const sendAssistantMessage = async ( + message: string, + previousResponseId?: string, +) => { try { - const response = await api.post(`/v1/api/assistant`, { + const response = await publicApi.post(`/v1/api/assistant`, { message, previous_response_id: previousResponseId, }); @@ -294,5 +320,5 @@ export { handleSendDrugSummaryStream, handleSendDrugSummaryStreamLegacy, fetchRiskDataWithSources, - sendAssistantMessage -}; \ No newline at end of file + sendAssistantMessage, +}; diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 2573c223..189ca4a4 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -170,8 +170,8 @@ export const login = export const logout = () => async (dispatch: AppDispatch) => { // Clear chat conversation data on logout for security - sessionStorage.removeItem('currentConversation'); - + sessionStorage.removeItem("currentConversation"); + dispatch({ type: LOGOUT, }); @@ -207,7 +207,7 @@ export const reset_password_confirm = uid: string, token: string, new_password: string, - re_new_password: string + re_new_password: string, ): ThunkType => async (dispatch: AppDispatch) => { const config = { diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 32089c58..67ba8a56 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -7,7 +7,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -111,7 +111,7 @@ def invoke_functions_from_response( @method_decorator(csrf_exempt, name="dispatch") class Assistant(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def post(self, request): try: From df27961063d4772cb460c49f386a1a49c00131db Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 5 Nov 2025 16:22:41 -0500 Subject: [PATCH 013/181] WIP --- server/api/services/embedding_services.py | 31 +++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 6fd34d35..0f65b7b1 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,5 +1,4 @@ -# services/embedding_services.py - +from django.db.models import Q from pgvector.django import L2Distance from .sentencetTransformer_model import TransformerModel @@ -39,17 +38,29 @@ def get_closest_embeddings( - file_id: GUID of the source file """ - # transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - # Start building the query based on the message's embedding - closest_embeddings_query = ( - Embeddings.objects.filter(upload_file__uploaded_by=user) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) + + if user and user.is_authenticated: + # User sees their own files + files uploaded by superusers + closest_embeddings_query = ( + Embeddings.objects.filter( + Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) + ) + .annotate( + distance=L2Distance("embedding_sentence_transformers", embedding_message) + ) + .order_by("distance") + ) + else: + # Unauthenticated users only see superuser-uploaded files + closest_embeddings_query = ( + Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) + .annotate( + distance=L2Distance("embedding_sentence_transformers", embedding_message) + ) + .order_by("distance") ) - .order_by("distance") - ) # Filter by GUID if provided, otherwise filter by document name if provided if guid: From d617fc5759062b7f0bfafdc15fdaa0d44965386c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 13 Nov 2025 14:49:43 -0500 Subject: [PATCH 014/181] Revert formatting changes automatically made by Zed --- frontend/src/services/actions/auth.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 189ca4a4..2573c223 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -170,8 +170,8 @@ export const login = export const logout = () => async (dispatch: AppDispatch) => { // Clear chat conversation data on logout for security - sessionStorage.removeItem("currentConversation"); - + sessionStorage.removeItem('currentConversation'); + dispatch({ type: LOGOUT, }); @@ -207,7 +207,7 @@ export const reset_password_confirm = uid: string, token: string, new_password: string, - re_new_password: string, + re_new_password: string ): ThunkType => async (dispatch: AppDispatch) => { const config = { From ffe1d576e341992277c0db67fced75fd3a4e91e0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 13 Nov 2025 15:19:49 -0500 Subject: [PATCH 015/181] Update embedding_services.py --- server/api/services/embedding_services.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 0f65b7b1..b50dd750 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -41,7 +41,7 @@ def get_closest_embeddings( transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - if user and user.is_authenticated: + if user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( Embeddings.objects.filter( From f4adf0f6e1fbab3bbec3cf1c4e334de10ef3dd9a Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 16 Nov 2025 12:09:03 -0500 Subject: [PATCH 016/181] feat(ci): update containers-publish workflow --- .github/workflows/containers-publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 9cd5fcce..b178b0b2 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -1,8 +1,8 @@ name: "Containers: Publish" on: - push: - tags: ["v*"] + release: + types: [published] permissions: packages: write @@ -12,10 +12,10 @@ jobs: name: Build and Push runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Login to ghcr.io Docker registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} From e4293fe6722af578effe0c91eae38984ba2ed1fc Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Sun, 16 Nov 2025 15:32:06 -0500 Subject: [PATCH 017/181] refactor(ci): clean up docker and kubernetes deployments --- .dockerignore | 3 + .github/workflows/containers-publish.yml | 40 ++++------- .gitignore | 6 +- Dockerfile.prod | 52 ++++++++++++++ config/env/.env.prod.db | 3 - config/env/{env.dev => dev.env.example} | 2 +- config/env/env.prod.db | 3 - config/env/{env.prod => prod.env.example} | 6 +- deploy/{kind-config.yml => kind-config.yaml} | 0 deploy/manifests/balancer/base/configmap.yml | 19 ----- .../base/{deployment.yml => deployment.yaml} | 9 ++- .../base/{ingress.yml => ingress.yaml} | 10 +-- .../{kustomization.yml => kustomization.yaml} | 8 +-- deploy/manifests/balancer/base/namespace.yaml | 4 ++ .../balancer/base/secret.template.yaml | 29 ++++++++ .../base/{service.yml => service.yaml} | 2 +- .../balancer/overlays/dev/kustomization.yaml | 26 +++++++ .../balancer/overlays/dev/kustomization.yml | 11 --- .../{clusterissuer.yml => clusterissuer.yaml} | 0 deployment.sh | 21 ------ docker-compose.prod.yml | 9 +-- docker-compose.yml | 2 +- frontend/.gitignore | 2 - frontend/Dockerfile.prod | 19 ----- frontend/vite.config.ts | 2 +- server/Dockerfile.prod | 30 -------- server/Dockerfile.prodBackup | 71 ------------------- server/entrypoint.prod.sh | 3 +- 28 files changed, 157 insertions(+), 235 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.prod delete mode 100644 config/env/.env.prod.db rename config/env/{env.dev => dev.env.example} (93%) delete mode 100644 config/env/env.prod.db rename config/env/{env.prod => prod.env.example} (78%) rename deploy/{kind-config.yml => kind-config.yaml} (100%) delete mode 100644 deploy/manifests/balancer/base/configmap.yml rename deploy/manifests/balancer/base/{deployment.yml => deployment.yaml} (76%) rename deploy/manifests/balancer/base/{ingress.yml => ingress.yaml} (62%) rename deploy/manifests/balancer/base/{kustomization.yml => kustomization.yaml} (51%) create mode 100644 deploy/manifests/balancer/base/namespace.yaml create mode 100644 deploy/manifests/balancer/base/secret.template.yaml rename deploy/manifests/balancer/base/{service.yml => service.yaml} (90%) create mode 100644 deploy/manifests/balancer/overlays/dev/kustomization.yaml delete mode 100644 deploy/manifests/balancer/overlays/dev/kustomization.yml rename deploy/manifests/cert-manager/{clusterissuer.yml => clusterissuer.yaml} (100%) delete mode 100644 deployment.sh delete mode 100644 frontend/Dockerfile.prod delete mode 100644 server/Dockerfile.prod delete mode 100644 server/Dockerfile.prodBackup diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..338e2ec5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +server/.gitignore +server/Dockerfile +server/entrypoint.sh diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index b178b0b2..64758fe9 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -34,36 +34,20 @@ jobs: # - name: 'Pull previous Docker container image: :latest' # run: docker pull "${DOCKER_REPOSITORY}:latest" || true - - name: "Pull previous Docker container image: frontend-static:latest" - run: docker pull "${DOCKER_REPOSITORY}/frontend-static:latest" || true + - name: "Pull previous Docker container image: app:latest" + run: docker pull "${DOCKER_REPOSITORY}/app:latest" || true - - name: "Build Docker container image: frontend-static:latest" + - name: "Build Docker container image: app:latest" run: | docker build \ - --cache-from "${DOCKER_REPOSITORY}/frontend-static:latest" \ - --file frontend/Dockerfile.demo \ - --build-arg SERVER_NAME=localhost \ - --tag "${DOCKER_REPOSITORY}/frontend-static:latest" \ - --tag "${DOCKER_REPOSITORY}/frontend-static:${DOCKER_TAG}" \ - frontend - - name: "Push Docker container image frontend-static:latest" - run: docker push "${DOCKER_REPOSITORY}/frontend-static:latest" + --cache-from "${DOCKER_REPOSITORY}/app:latest" \ + --file Dockerfile.prod \ + --tag "${DOCKER_REPOSITORY}/app:latest" \ + --tag "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" \ + . - - name: "Push Docker container image frontend-static:v*" - run: docker push "${DOCKER_REPOSITORY}/frontend-static:${DOCKER_TAG}" -# -# -# - name: 'Build Docker container image: backend:latest' -# run: | -# cd backend && \ -# make && \ -# docker image tag "${DOCKER_REPOSITORY}/backend/local:latest" "${DOCKER_REPOSITORY}/backend:latest" -# -# - name: Push Docker container image backend:latest -# run: docker push "${DOCKER_REPOSITORY}/backend:latest" -# -# - name: Push Docker container image backend:v* -# run: docker push "${DOCKER_REPOSITORY}/backend:${DOCKER_TAG}" + - name: "Push Docker container image app:latest" + run: docker push "${DOCKER_REPOSITORY}/app:latest" -# - name: Push Docker container image :v*" -# run: docker push "${DOCKER_REPOSITORY}:${DOCKER_TAG}" + - name: "Push Docker container image app:v*" + run: docker push "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" diff --git a/.gitignore b/.gitignore index bf4f8dfe..d2cdbd62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -config/* -.idea/ -env* \ No newline at end of file +config/env/* +!config/env/*.example +.idea/ \ No newline at end of file diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 00000000..cd1f3604 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,52 @@ +# Multi-stage Dockerfile for Balancer Application +# Produces a single image with Django backend serving the React frontend + +# Stage 1: Build Frontend +FROM node:18 AS frontend-builder + +WORKDIR /frontend + +# Copy frontend package files +COPY frontend/package*.json ./ + +# Install dependencies +RUN npm ci --legacy-peer-deps + +# Copy frontend source +COPY frontend/ ./ + +# Build frontend - outputs to dist/ as configured in vite.config.ts +RUN npm run build + +# Stage 2: Build Backend +FROM python:3.11.4-slim-bullseye + +# Set work directory +WORKDIR /usr/src/app + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies +RUN apt-get update && apt-get install -y netcat && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +RUN pip install --upgrade pip +COPY server/requirements.txt . +RUN pip install -r requirements.txt + +# Copy backend application code +COPY server/ . + +# Copy frontend build from frontend-builder stage to where Django expects it +COPY --from=frontend-builder /frontend/dist ./build + +# Expose port +EXPOSE 8000 + +# Run entrypoint +ENTRYPOINT ["./entrypoint.prod.sh"] + +# Start gunicorn +CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--noreload"] diff --git a/config/env/.env.prod.db b/config/env/.env.prod.db deleted file mode 100644 index 3c73c247..00000000 --- a/config/env/.env.prod.db +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=set_me -POSTGRES_PASSWORD=set_me -POSTGRES_DB=balancer_prod \ No newline at end of file diff --git a/config/env/env.dev b/config/env/dev.env.example similarity index 93% rename from config/env/env.dev rename to config/env/dev.env.example index 22e70e5d..38d8e382 100644 --- a/config/env/env.dev +++ b/config/env/dev.env.example @@ -13,4 +13,4 @@ OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= \ No newline at end of file +EMAIL_HOST_PASSWORD= diff --git a/config/env/env.prod.db b/config/env/env.prod.db deleted file mode 100644 index 3c73c247..00000000 --- a/config/env/env.prod.db +++ /dev/null @@ -1,3 +0,0 @@ -POSTGRES_USER=set_me -POSTGRES_PASSWORD=set_me -POSTGRES_DB=balancer_prod \ No newline at end of file diff --git a/config/env/env.prod b/config/env/prod.env.example similarity index 78% rename from config/env/env.prod rename to config/env/prod.env.example index 12b3491a..e8e59112 100644 --- a/config/env/env.prod +++ b/config/env/prod.env.example @@ -1,7 +1,6 @@ DEBUG=0 SECRET_KEY=change_this DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] SQL_ENGINE=django.db.backends.postgresql SQL_DATABASE=balancer_prod SQL_USER=set_me @@ -10,5 +9,8 @@ SQL_HOST=db SQL_PORT=5432 DATABASE=postgres LOGIN_REDIRECT_URL= +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +PINECONE_API_KEY= EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= \ No newline at end of file +EMAIL_HOST_PASSWORD= diff --git a/deploy/kind-config.yml b/deploy/kind-config.yaml similarity index 100% rename from deploy/kind-config.yml rename to deploy/kind-config.yaml diff --git a/deploy/manifests/balancer/base/configmap.yml b/deploy/manifests/balancer/base/configmap.yml deleted file mode 100644 index c513fd61..00000000 --- a/deploy/manifests/balancer/base/configmap.yml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v1 -data: - DEBUG: "1" - SECRET_KEY: "foo" - DJANGO_ALLOWED_HOSTS: "localhost 127.0.0.1 [::1] balancertestsite.com" - SQL_ENGINE: "django.db.backends.postgresql" - SQL_DATABASE: "balancer_dev" - SQL_USER: "balancer" - SQL_PASSWORD: "" - SQL_HOST: "" - SQL_PORT: "5432" - DATABASE: "postgres" - LOGIN_REDIRECT_URL: "" - OPENAI_API_KEY: "" - PINECONE_API_KEY: "" - REACT_APP_API_BASE_URL: "https://balancertestsite.com/" -kind: ConfigMap -metadata: - name: balancer-config diff --git a/deploy/manifests/balancer/base/deployment.yml b/deploy/manifests/balancer/base/deployment.yaml similarity index 76% rename from deploy/manifests/balancer/base/deployment.yml rename to deploy/manifests/balancer/base/deployment.yaml index 919d10c9..c50012c2 100644 --- a/deploy/manifests/balancer/base/deployment.yml +++ b/deploy/manifests/balancer/base/deployment.yaml @@ -3,7 +3,6 @@ kind: Deployment metadata: labels: app: balancer - service: backend name: balancer spec: replicas: 1 @@ -17,16 +16,16 @@ spec: app: balancer spec: containers: - - image: ghcr.io/codeforphilly/balancer-main/backend - name: balancer + - image: ghcr.io/codeforphilly/balancer-main/app + name: app envFrom: - - configMapRef: + - secretRef: name: balancer-config ports: - containerPort: 8000 readinessProbe: httpGet: - path: / + path: /admin/ port: 8000 initialDelaySeconds: 30 periodSeconds: 10 diff --git a/deploy/manifests/balancer/base/ingress.yml b/deploy/manifests/balancer/base/ingress.yaml similarity index 62% rename from deploy/manifests/balancer/base/ingress.yml rename to deploy/manifests/balancer/base/ingress.yaml index d931fd5f..fc98305b 100644 --- a/deploy/manifests/balancer/base/ingress.yml +++ b/deploy/manifests/balancer/base/ingress.yaml @@ -2,22 +2,22 @@ apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: balancer - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-staging" + annotations: {} spec: ingressClassName: nginx tls: - hosts: - - balancertestsite.com + - HOSTNAME_PLACEHOLDER secretName: balancer-tls rules: - - host: balancertestsite.com + - host: HOSTNAME_PLACEHOLDER http: paths: + # All traffic routes to balancer service (which serves both API and frontend) - path: / pathType: Prefix backend: service: name: balancer port: - number: 80 + number: 8000 diff --git a/deploy/manifests/balancer/base/kustomization.yml b/deploy/manifests/balancer/base/kustomization.yaml similarity index 51% rename from deploy/manifests/balancer/base/kustomization.yml rename to deploy/manifests/balancer/base/kustomization.yaml index d9e5dd29..c7d2dcd1 100644 --- a/deploy/manifests/balancer/base/kustomization.yml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -2,7 +2,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - - deployment.yml - - service.yml - - configmap.yml - - ingress.yml + - namespace.yaml + - deployment.yaml + - service.yaml + - ingress.yaml diff --git a/deploy/manifests/balancer/base/namespace.yaml b/deploy/manifests/balancer/base/namespace.yaml new file mode 100644 index 00000000..11df30ee --- /dev/null +++ b/deploy/manifests/balancer/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: balancer diff --git a/deploy/manifests/balancer/base/secret.template.yaml b/deploy/manifests/balancer/base/secret.template.yaml new file mode 100644 index 00000000..bebc8a71 --- /dev/null +++ b/deploy/manifests/balancer/base/secret.template.yaml @@ -0,0 +1,29 @@ +# Secret Template for Balancer Application +# +# This file documents the required secret keys for the balancer application. +# +# IMPORTANT: This file is a TEMPLATE only. Do NOT create a Secret manifest in this +# repository. Secrets should be created in each target cluster using cluster-specific +# tools (e.g., SealedSecrets in the cfp-sandbox-cluster). +# +apiVersion: v1 +kind: Secret +metadata: + name: balancer-config + namespace: balancer +type: Opaque +stringData: + DATABASE: postgres + DEBUG: '1' + DJANGO_ALLOWED_HOSTS: localhost 127.0.0.1 [::1] balancer.sandbox.k8s.phl.io + LOGIN_REDIRECT_URL: '' + OPENAI_API_KEY: openapi_key_here + PINECONE_API_KEY: pinecone_key_here + REACT_APP_API_BASE_URL: https://balancer.sandbox.k8s.phl.io/ + SECRET_KEY: randomly_generated_key_ere + SQL_ENGINE: django.db.backends.postgresql + SQL_HOST: sql_host_here + SQL_PORT: '5432' + SQL_DATABASE: balancer_dev + SQL_USER: balancer + SQL_PASSWORD: sql_password_here diff --git a/deploy/manifests/balancer/base/service.yml b/deploy/manifests/balancer/base/service.yaml similarity index 90% rename from deploy/manifests/balancer/base/service.yml rename to deploy/manifests/balancer/base/service.yaml index 2c839248..8f294d53 100644 --- a/deploy/manifests/balancer/base/service.yml +++ b/deploy/manifests/balancer/base/service.yaml @@ -7,7 +7,7 @@ metadata: spec: ports: - name: http - port: 80 + port: 8000 targetPort: 8000 selector: app: balancer diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yaml b/deploy/manifests/balancer/overlays/dev/kustomization.yaml new file mode 100644 index 00000000..d3975eb2 --- /dev/null +++ b/deploy/manifests/balancer/overlays/dev/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: latest + +patches: + - target: + kind: Ingress + name: balancer + patch: |- + - op: add + path: /metadata/annotations/cert-manager.io~1cluster-issuer + value: letsencrypt-staging + - op: replace + path: /spec/tls/0/hosts/0 + value: localhost + - op: replace + path: /spec/rules/0/host + value: localhost diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yml b/deploy/manifests/balancer/overlays/dev/kustomization.yml deleted file mode 100644 index 92a6001b..00000000 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - "../../base" - -images: - - name: ghcr.io/codeforphilly/balancer-main/backend - newTag: "1.0.2" - -namespace: balancer diff --git a/deploy/manifests/cert-manager/clusterissuer.yml b/deploy/manifests/cert-manager/clusterissuer.yaml similarity index 100% rename from deploy/manifests/cert-manager/clusterissuer.yml rename to deploy/manifests/cert-manager/clusterissuer.yaml diff --git a/deployment.sh b/deployment.sh deleted file mode 100644 index 491ed457..00000000 --- a/deployment.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/bin/bash - -cd frontend - -## Frontend - -npm run i && npm run build - -cd .. - -docker compose -f docker-compose.prod.yml up --build # This is generating balancer-backend:latest image - -aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com - -echo "$CR_PAT" | docker login ghcr.io -u TineoC --password-stdin - -docker tag balancer-backend:latest ghcr.io/codeforphilly/balancer-main/backend:latest . - -docker tag balancer-backend:latest chrissst/balancer:latest - -docker push chrissst/balancer:latest \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f6772b9c..0bba34b1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,12 +1,13 @@ name: balancer-prod version: "3.8" + services: - backend: - image: balancer-backend + app: + image: balancer-app build: - context: server + context: . dockerfile: Dockerfile.prod ports: - "8000:8000" env_file: - - ./config/env/env.dev + - ./config/env/prod.env diff --git a/docker-compose.yml b/docker-compose.yml index aea1993b..c6238dda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,7 +36,7 @@ services: ports: - "8000:8000" env_file: - - ./config/env/env.dev + - ./config/env/dev.env depends_on: - db volumes: diff --git a/frontend/.gitignore b/frontend/.gitignore index 6e10711f..d67a573c 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,8 +12,6 @@ dist dist-ssr *.local -config/env/env.dev - # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod deleted file mode 100644 index c0648913..00000000 --- a/frontend/Dockerfile.prod +++ /dev/null @@ -1,19 +0,0 @@ -FROM node:18 as builder - -WORKDIR /usr/src/app - -COPY package*.json ./ - -RUN npm ci --legacy-peer-deps - -COPY . . - -RUN npm run build - -FROM nginx:latest - -COPY nginx.conf /etc/nginx/conf.d/default.conf - -COPY --from=builder /usr/src/app/build /usr/share/nginx/html - -EXPOSE 80 \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index cdf82274..1d907506 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,7 +4,7 @@ import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ build: { - outDir: '../server/build', // Custom output directory + outDir: 'dist', // Output to local dist directory assetsDir: 'static', }, plugins: [react()], diff --git a/server/Dockerfile.prod b/server/Dockerfile.prod deleted file mode 100644 index 97b2c142..00000000 --- a/server/Dockerfile.prod +++ /dev/null @@ -1,30 +0,0 @@ -# pull official base image -FROM python:3.11.4-slim-bullseye - - -# set work directory -WORKDIR /usr/src/app - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 - -# install system dependencies -RUN apt-get update && apt-get install -y netcat - -# install dependencies -RUN pip install --upgrade pip -COPY ./requirements.txt . -RUN pip install -r requirements.txt - -# copy project -COPY . /usr/src/app - -# Correct line endings in entrypoint.sh and make it executable -RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh - -# run entrypoint.sh -ENTRYPOINT ["./entrypoint.prod.sh"] - -# Default command to run on container start -CMD ["python", "manage.py", "runserver", "0.0.0.0:8000", "--noreload"] diff --git a/server/Dockerfile.prodBackup b/server/Dockerfile.prodBackup deleted file mode 100644 index 9c5244c6..00000000 --- a/server/Dockerfile.prodBackup +++ /dev/null @@ -1,71 +0,0 @@ -########### -# BUILDER # -########### - -# pull official base image -FROM python:3.11.4-slim-buster as builder - -# set work directory -WORKDIR /usr/src/app - -# set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 - -# install system dependencies -RUN apt-get update && \ - apt-get install -y --no-install-recommends gcc - -# lint -# RUN pip install --upgrade pip -# RUN pip install flake8==6.0.0 -# COPY . /usr/src/app/ -# RUN flake8 --ignore=E501,F401 . - -# install python dependencies -COPY ./requirements.txt . -RUN pip wheel --no-cache-dir --no-deps --wheel-dir /usr/src/app/wheels -r requirements.txt - - -######### -# FINAL # -######### - -# pull official base image -FROM python:3.11.4-slim-buster - -# create directory for the app user -RUN mkdir -p /home/app - -# create the app user -RUN addgroup --system app && adduser --system --group app - -# create the appropriate directories -ENV HOME=/home/app -ENV APP_HOME=/home/app/web -RUN mkdir $APP_HOME -WORKDIR $APP_HOME - -# install dependencies -RUN apt-get update && apt-get install -y --no-install-recommends netcat -COPY --from=builder /usr/src/app/wheels /wheels -COPY --from=builder /usr/src/app/requirements.txt . -RUN pip install --upgrade pip -RUN pip install --no-cache /wheels/* - -# copy entrypoint.prod.sh -COPY ./entrypoint.prod.sh . -RUN sed -i 's/\r$//g' $APP_HOME/entrypoint.prod.sh -RUN chmod +x $APP_HOME/entrypoint.prod.sh - -# copy project -COPY . $APP_HOME - -# chown all the files to the app user -RUN chown -R app:app $APP_HOME - -# change to the app user -USER app - -# run entrypoint.prod.sh -ENTRYPOINT ["/home/app/web/entrypoint.prod.sh"] \ No newline at end of file diff --git a/server/entrypoint.prod.sh b/server/entrypoint.prod.sh index 9506422f..68dfbc88 100755 --- a/server/entrypoint.prod.sh +++ b/server/entrypoint.prod.sh @@ -4,7 +4,7 @@ if [ "$DATABASE" = "postgres" ] then echo "Waiting for postgres..." - while ! nc -z $SQL_HOST $SQL_PORT; do + while ! nc -z "$SQL_HOST" "$SQL_PORT"; do sleep 0.1 done @@ -18,4 +18,5 @@ python manage.py migrate python manage.py createsu # populate the database on start up python manage.py populatedb + exec "$@" From 4161ecc10ff92e3fc407f47c02764e74c3c45518 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Mon, 17 Nov 2025 00:55:37 -0500 Subject: [PATCH 018/181] fix(ci): remove DJANGO_ALLOWED_HOSTS from secret template --- deploy/manifests/balancer/base/secret.template.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/deploy/manifests/balancer/base/secret.template.yaml b/deploy/manifests/balancer/base/secret.template.yaml index bebc8a71..9abb6e3b 100644 --- a/deploy/manifests/balancer/base/secret.template.yaml +++ b/deploy/manifests/balancer/base/secret.template.yaml @@ -15,7 +15,6 @@ type: Opaque stringData: DATABASE: postgres DEBUG: '1' - DJANGO_ALLOWED_HOSTS: localhost 127.0.0.1 [::1] balancer.sandbox.k8s.phl.io LOGIN_REDIRECT_URL: '' OPENAI_API_KEY: openapi_key_here PINECONE_API_KEY: pinecone_key_here From ea32814ef1d4aff63edee0fb73c48b53c25969ee Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 20 Nov 2025 15:04:04 -0500 Subject: [PATCH 019/181] Add performance tracking to embedding search --- server/api/services/embedding_services.py | 94 ++++++++++++++++++++--- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 6fd34d35..2c51d8cb 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,5 +1,7 @@ # services/embedding_services.py +import time +import logging from pgvector.django import L2Distance from .sentencetTransformer_model import TransformerModel @@ -7,9 +9,12 @@ # Adjust import path as needed from ..models.model_embeddings import Embeddings +# Configure logging +logger = logging.getLogger(__name__) + def get_closest_embeddings( - user, message_data, document_name=None, guid=None, num_results=10 + user, message_data, document_name=None, guid=None, num_results=10, return_metrics=False ): """ Find the closest embeddings to a given message for a specific user. @@ -26,22 +31,46 @@ def get_closest_embeddings( Filter results to a specific document GUID (takes precedence over document_name) num_results : int, default 10 Maximum number of results to return + return_metrics : bool, default False + If True, return a tuple of (results, metrics) instead of just results Returns ------- - list[dict] - List of dictionaries containing embedding results with keys: - - name: document name - - text: embedded text content - - page_number: page number in source document - - chunk_number: chunk number within the document - - distance: L2 distance from query embedding - - file_id: GUID of the source file + list[dict] or tuple[list[dict], dict] + If return_metrics is False (default): + List of dictionaries containing embedding results with keys: + - name: document name + - text: embedded text content + - page_number: page number in source document + - chunk_number: chunk number within the document + - distance: L2 distance from query embedding + - file_id: GUID of the source file + + If return_metrics is True: + Tuple of (results, metrics) where metrics is a dictionary containing: + - encoding_time: Time to encode query (seconds) + - db_query_time: Time for database query (seconds) + - total_time: Total execution time (seconds) + - total_embeddings: Number of embeddings searched + - num_results_returned: Number of results returned + - avg_similarity: Average similarity score (0-1) + - min_distance: Minimum L2 distance + - max_distance: Maximum L2 distance + - avg_distance: Average L2 distance """ - # + # Track total execution time + start_time = time.time() + + # Track transformer encoding time + encoding_start = time.time() transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) + encoding_time = time.time() - encoding_start + + # Track database query time + db_query_start = time.time() + # Start building the query based on the message's embedding closest_embeddings_query = ( Embeddings.objects.filter(upload_file__uploaded_by=user) @@ -51,6 +80,9 @@ def get_closest_embeddings( .order_by("distance") ) + # Get total embeddings in search space before filtering + total_embeddings = closest_embeddings_query.count() + # Filter by GUID if provided, otherwise filter by document name if provided if guid: closest_embeddings_query = closest_embeddings_query.filter( @@ -75,4 +107,46 @@ def get_closest_embeddings( for obj in closest_embeddings_query ] + db_query_time = time.time() - db_query_start + total_time = time.time() - start_time + + # Calculate distance/similarity statistics + num_results_returned = len(results) + if num_results_returned > 0: + distances = [r["distance"] for r in results] + min_distance = min(distances) + max_distance = max(distances) + avg_distance = sum(distances) / num_results_returned + # Convert distance to similarity score (1 - distance for L2) + avg_similarity = 1 - avg_distance + else: + min_distance = max_distance = avg_distance = avg_similarity = 0.0 + + # Log performance metrics similar to assistant/views.py pattern + logger.info( + f"Embedding search completed: " + f"Encoding time: {encoding_time:.3f}s, " + f"DB query time: {db_query_time:.3f}s, " + f"Total time: {total_time:.3f}s, " + f"Searched: {total_embeddings} embeddings, " + f"Returned: {num_results_returned} results, " + f"Avg similarity: {avg_similarity:.3f}, " + f"Distance range: [{min_distance:.3f}, {max_distance:.3f}]" + ) + + # Optionally return metrics along with results + if return_metrics: + metrics = { + "encoding_time": encoding_time, + "db_query_time": db_query_time, + "total_time": total_time, + "total_embeddings": total_embeddings, + "num_results_returned": num_results_returned, + "avg_similarity": avg_similarity, + "min_distance": min_distance, + "max_distance": max_distance, + "avg_distance": avg_distance, + } + return results, metrics + return results From 1fc41a76ba12963b707ecc46157645bba56db449 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 24 Nov 2025 17:08:21 -0500 Subject: [PATCH 020/181] Simplify embedding search --- server/api/services/embedding_services.py | 45 +++++++---------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 2c51d8cb..1828b81c 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,18 +1,13 @@ -# services/embedding_services.py - import time import logging + from pgvector.django import L2Distance from .sentencetTransformer_model import TransformerModel - -# Adjust import path as needed from ..models.model_embeddings import Embeddings -# Configure logging logger = logging.getLogger(__name__) - def get_closest_embeddings( user, message_data, document_name=None, guid=None, num_results=10, return_metrics=False ): @@ -51,24 +46,19 @@ def get_closest_embeddings( - encoding_time: Time to encode query (seconds) - db_query_time: Time for database query (seconds) - total_time: Total execution time (seconds) - - total_embeddings: Number of embeddings searched - num_results_returned: Number of results returned - - avg_similarity: Average similarity score (0-1) - min_distance: Minimum L2 distance - max_distance: Maximum L2 distance - avg_distance: Average L2 distance """ - # Track total execution time start_time = time.time() - # Track transformer encoding time encoding_start = time.time() transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) encoding_time = time.time() - encoding_start - # Track database query time db_query_start = time.time() # Start building the query based on the message's embedding @@ -80,10 +70,7 @@ def get_closest_embeddings( .order_by("distance") ) - # Get total embeddings in search space before filtering - total_embeddings = closest_embeddings_query.count() - - # Filter by GUID if provided, otherwise filter by document name if provided + # Filtering results to a document GUID takes precedence over filtering results to document name if guid: closest_embeddings_query = closest_embeddings_query.filter( upload_file__guid=guid @@ -95,6 +82,7 @@ def get_closest_embeddings( closest_embeddings_query = closest_embeddings_query[:num_results] # Format the results to be returned + # TODO: Research improving the query evaluation performance results = [ { "name": obj.name, @@ -112,37 +100,30 @@ def get_closest_embeddings( # Calculate distance/similarity statistics num_results_returned = len(results) - if num_results_returned > 0: - distances = [r["distance"] for r in results] - min_distance = min(distances) - max_distance = max(distances) - avg_distance = sum(distances) / num_results_returned - # Convert distance to similarity score (1 - distance for L2) - avg_similarity = 1 - avg_distance - else: - min_distance = max_distance = avg_distance = avg_similarity = 0.0 - - # Log performance metrics similar to assistant/views.py pattern + + #TODO: Handle user having no uploaded docs or doc filtering returning no matches + + distances = [r["distance"] for r in results] + min_distance = min(distances) + max_distance = max(distances) + avg_distance = sum(distances) / num_results_returned + logger.info( f"Embedding search completed: " f"Encoding time: {encoding_time:.3f}s, " f"DB query time: {db_query_time:.3f}s, " f"Total time: {total_time:.3f}s, " - f"Searched: {total_embeddings} embeddings, " f"Returned: {num_results_returned} results, " - f"Avg similarity: {avg_similarity:.3f}, " - f"Distance range: [{min_distance:.3f}, {max_distance:.3f}]" + f"Distance range: [{min_distance:.3f}, {max_distance:.3f}], " + f"Average distance: {avg_distance:.3f}" ) - # Optionally return metrics along with results if return_metrics: metrics = { "encoding_time": encoding_time, "db_query_time": db_query_time, "total_time": total_time, - "total_embeddings": total_embeddings, "num_results_returned": num_results_returned, - "avg_similarity": avg_similarity, "min_distance": min_distance, "max_distance": max_distance, "avg_distance": avg_distance, From 156644be05058b6afe8519bf2ae266158a9d00f2 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 25 Nov 2025 19:18:40 -0500 Subject: [PATCH 021/181] Add persistent tracking for semantic search performance and usage --- server/api/models/model_search_usage.py | 42 +++++++++ server/api/services/embedding_services.py | 110 ++++++++++------------ 2 files changed, 92 insertions(+), 60 deletions(-) create mode 100644 server/api/models/model_search_usage.py diff --git a/server/api/models/model_search_usage.py b/server/api/models/model_search_usage.py new file mode 100644 index 00000000..cdc3dee6 --- /dev/null +++ b/server/api/models/model_search_usage.py @@ -0,0 +1,42 @@ +import uuid + +from django.db import models +from django.conf import settings + +class SemanticSearchUsage(models.Model): + """ + Tracks performance metrics and usage data for embedding searches. + """ + guid = models.UUIDField(unique=True, default=uuid.uuid4, editable=False) + timestamp = models.DateTimeField(auto_now_add=True) + query_text = models.TextField(blank=True, null=True, help_text="The search query text") + document_name = models.TextField(blank=True, null=True, help_text="Document name filter if used") + document_guid = models.UUIDField(blank=True, null=True, help_text="Document GUID filter if used") + num_results_requested = models.IntegerField(default=10, help_text="Number of results requested") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='semantic_searches', + null=True, + blank=True, + help_text="User who performed the search (null for unauthenticated users)" + ) + encoding_time = models.FloatField(help_text="Time to encode query in seconds") + db_query_time = models.FloatField(help_text="Time for database query in seconds") + num_results_returned = models.IntegerField(help_text="Number of results returned") + min_distance = models.FloatField(null=True, blank=True, help_text="Minimum L2 distance (null if no results)") + max_distance = models.FloatField(null=True, blank=True, help_text="Maximum L2 distance (null if no results)") + median_distance = models.FloatField(null=True, blank=True, help_text="Median L2 distance (null if no results)") + + + class Meta: + ordering = ['-timestamp'] + indexes = [ + models.Index(fields=['-timestamp']), + models.Index(fields=['user', '-timestamp']), + ] + + def __str__(self): + total_time = self.encoding_time + self.db_query_time + user_display = self.user.email if self.user else "Anonymous" + return f"Search by {user_display} at {self.timestamp} ({total_time:.3f}s)" diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 1828b81c..c937f757 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -1,15 +1,17 @@ import time import logging +from statistics import median from pgvector.django import L2Distance from .sentencetTransformer_model import TransformerModel from ..models.model_embeddings import Embeddings +from ..models.model_search_usage import SemanticSearchUsage logger = logging.getLogger(__name__) def get_closest_embeddings( - user, message_data, document_name=None, guid=None, num_results=10, return_metrics=False + user, message_data, document_name=None, guid=None, num_results=10 ): """ Find the closest embeddings to a given message for a specific user. @@ -26,34 +28,19 @@ def get_closest_embeddings( Filter results to a specific document GUID (takes precedence over document_name) num_results : int, default 10 Maximum number of results to return - return_metrics : bool, default False - If True, return a tuple of (results, metrics) instead of just results Returns ------- - list[dict] or tuple[list[dict], dict] - If return_metrics is False (default): - List of dictionaries containing embedding results with keys: - - name: document name - - text: embedded text content - - page_number: page number in source document - - chunk_number: chunk number within the document - - distance: L2 distance from query embedding - - file_id: GUID of the source file - - If return_metrics is True: - Tuple of (results, metrics) where metrics is a dictionary containing: - - encoding_time: Time to encode query (seconds) - - db_query_time: Time for database query (seconds) - - total_time: Total execution time (seconds) - - num_results_returned: Number of results returned - - min_distance: Minimum L2 distance - - max_distance: Maximum L2 distance - - avg_distance: Average L2 distance + list[dict] + List of dictionaries containing embedding results with keys: + - name: document name + - text: embedded text content + - page_number: page number in source document + - chunk_number: chunk number within the document + - distance: L2 distance from query embedding + - file_id: GUID of the source file """ - start_time = time.time() - encoding_start = time.time() transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) @@ -61,7 +48,7 @@ def get_closest_embeddings( db_query_start = time.time() - # Start building the query based on the message's embedding + # Django QuerySets are lazily evaluated closest_embeddings_query = ( Embeddings.objects.filter(upload_file__uploaded_by=user) .annotate( @@ -70,7 +57,7 @@ def get_closest_embeddings( .order_by("distance") ) - # Filtering results to a document GUID takes precedence over filtering results to document name + # Filtering to a document GUID takes precedence over a document name if guid: closest_embeddings_query = closest_embeddings_query.filter( upload_file__guid=guid @@ -78,10 +65,10 @@ def get_closest_embeddings( elif document_name: closest_embeddings_query = closest_embeddings_query.filter(name=document_name) - # Slice the results to limit to num_results + # Slicing is equivalent to SQL's LIMIT clause closest_embeddings_query = closest_embeddings_query[:num_results] - # Format the results to be returned + # Iterating evaluates the QuerySet and hits the database # TODO: Research improving the query evaluation performance results = [ { @@ -96,38 +83,41 @@ def get_closest_embeddings( ] db_query_time = time.time() - db_query_start - total_time = time.time() - start_time - - # Calculate distance/similarity statistics - num_results_returned = len(results) - - #TODO: Handle user having no uploaded docs or doc filtering returning no matches - - distances = [r["distance"] for r in results] - min_distance = min(distances) - max_distance = max(distances) - avg_distance = sum(distances) / num_results_returned - - logger.info( - f"Embedding search completed: " - f"Encoding time: {encoding_time:.3f}s, " - f"DB query time: {db_query_time:.3f}s, " - f"Total time: {total_time:.3f}s, " - f"Returned: {num_results_returned} results, " - f"Distance range: [{min_distance:.3f}, {max_distance:.3f}], " - f"Average distance: {avg_distance:.3f}" - ) - if return_metrics: - metrics = { - "encoding_time": encoding_time, - "db_query_time": db_query_time, - "total_time": total_time, - "num_results_returned": num_results_returned, - "min_distance": min_distance, - "max_distance": max_distance, - "avg_distance": avg_distance, - } - return results, metrics + try: + # Handle user having no uploaded docs or doc filtering returning no matches + if results: + distances = [r["distance"] for r in results] + SemanticSearchUsage.objects.create( + query_text=message_data, + user=user if (user and user.is_authenticated) else None, + document_guid=guid, + document_name=document_name, + num_results_requested=num_results, + encoding_time=encoding_time, + db_query_time=db_query_time, + num_results_returned=len(results), + max_distance=max(distances), + median_distance=median(distances), + min_distance=min(distances) + ) + else: + logger.warning("Semantic search returned no results") + + SemanticSearchUsage.objects.create( + query_text=message_data, + user=user if (user and user.is_authenticated) else None, + document_guid=guid, + document_name=document_name, + num_results_requested=num_results, + encoding_time=encoding_time, + db_query_time=db_query_time, + num_results_returned=0, + max_distance=None, + median_distance=None, + min_distance=None + ) + except Exception as e: + logger.error(f"Failed to create semantic search usage database record: {e}") return results From 6a843596d50076e1c1877b0ebf0a32396880a0e0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 26 Nov 2025 17:20:25 -0500 Subject: [PATCH 022/181] Add semantic search usage migration file --- .../migrations/0015_semanticsearchusage.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 server/api/migrations/0015_semanticsearchusage.py diff --git a/server/api/migrations/0015_semanticsearchusage.py b/server/api/migrations/0015_semanticsearchusage.py new file mode 100644 index 00000000..0475b71f --- /dev/null +++ b/server/api/migrations/0015_semanticsearchusage.py @@ -0,0 +1,39 @@ +# Generated by Django 4.2.3 on 2025-11-26 21:02 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0014_alter_medrule_rule_type'), + ] + + operations = [ + migrations.CreateModel( + name='SemanticSearchUsage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('guid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('query_text', models.TextField(blank=True, help_text='The search query text', null=True)), + ('document_name', models.TextField(blank=True, help_text='Document name filter if used', null=True)), + ('document_guid', models.UUIDField(blank=True, help_text='Document GUID filter if used', null=True)), + ('num_results_requested', models.IntegerField(default=10, help_text='Number of results requested')), + ('encoding_time', models.FloatField(help_text='Time to encode query in seconds')), + ('db_query_time', models.FloatField(help_text='Time for database query in seconds')), + ('num_results_returned', models.IntegerField(help_text='Number of results returned')), + ('min_distance', models.FloatField(blank=True, help_text='Minimum L2 distance (null if no results)', null=True)), + ('max_distance', models.FloatField(blank=True, help_text='Maximum L2 distance (null if no results)', null=True)), + ('median_distance', models.FloatField(blank=True, help_text='Median L2 distance (null if no results)', null=True)), + ('user', models.ForeignKey(blank=True, help_text='User who performed the search (null for unauthenticated users)', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='semantic_searches', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + 'indexes': [models.Index(fields=['-timestamp'], name='api_semanti_timesta_0b5730_idx'), models.Index(fields=['user', '-timestamp'], name='api_semanti_user_id_e11ecb_idx')], + }, + ), + ] From 685c70a610cddb20b4a175358678a918b8c05c4f Mon Sep 17 00:00:00 2001 From: taichan03 Date: Sun, 30 Nov 2025 20:28:17 -0500 Subject: [PATCH 023/181] fix this so it called to prod endpoint --- config/env/prod.env.example | 16 ---------------- docker-compose.yml | 22 +++++++++++----------- frontend/.env.production | 1 + frontend/src/services/actions/auth.tsx | 7 +++++-- 4 files changed, 17 insertions(+), 29 deletions(-) delete mode 100644 config/env/prod.env.example create mode 100644 frontend/.env.production diff --git a/config/env/prod.env.example b/config/env/prod.env.example deleted file mode 100644 index e8e59112..00000000 --- a/config/env/prod.env.example +++ /dev/null @@ -1,16 +0,0 @@ -DEBUG=0 -SECRET_KEY=change_this -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] -SQL_ENGINE=django.db.backends.postgresql -SQL_DATABASE=balancer_prod -SQL_USER=set_me -SQL_PASSWORD=set_me -SQL_HOST=db -SQL_PORT=5432 -DATABASE=postgres -LOGIN_REDIRECT_URL= -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -PINECONE_API_KEY= -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= diff --git a/docker-compose.yml b/docker-compose.yml index 9039d24a..5d2d5884 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,17 +19,17 @@ services: networks: app_net: ipv4_address: 192.168.0.2 - pgadmin: - container_name: pgadmin4 - image: dpage/pgadmin4 - environment: - PGADMIN_DEFAULT_EMAIL: balancer-noreply@codeforphilly.org - PGADMIN_DEFAULT_PASSWORD: balancer - ports: - - "5050:80" - networks: - app_net: - ipv4_address: 192.168.0.4 + # pgadmin: + # container_name: pgadmin4 + # image: dpage/pgadmin4 + # environment: + # PGADMIN_DEFAULT_EMAIL: balancer-noreply@codeforphilly.org + # PGADMIN_DEFAULT_PASSWORD: balancer + # ports: + # - "5050:80" + # networks: + # app_net: + # ipv4_address: 192.168.0.4 backend: image: balancer-backend build: ./server diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 00000000..a05a022d --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://balancer.live.k8s.phl.io/ \ No newline at end of file diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 2573c223..bfbfbe41 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -76,6 +76,7 @@ export const checkAuthenticated = () => async (dispatch: AppDispatch) => { const body = JSON.stringify({ token: localStorage.getItem("access") }); const baseUrl = import.meta.env.VITE_API_BASE_URL; + console.log(baseUrl); const url = `${baseUrl}/auth/jwt/verify/`; try { const res = await axios.post(url, body, config); @@ -113,6 +114,7 @@ export const load_user = (): ThunkType => async (dispatch: AppDispatch) => { }, }; const baseUrl = import.meta.env.VITE_API_BASE_URL; + console.log(baseUrl); const url = `${baseUrl}/auth/users/me/`; try { const res = await axios.get(url, config); @@ -144,6 +146,7 @@ export const login = const body = JSON.stringify({ email, password }); const baseUrl = import.meta.env.VITE_API_BASE_URL; + console.log(baseUrl); const url = `${baseUrl}/auth/jwt/create/`; try { const res = await axios.post(url, body, config); @@ -170,8 +173,8 @@ export const login = export const logout = () => async (dispatch: AppDispatch) => { // Clear chat conversation data on logout for security - sessionStorage.removeItem('currentConversation'); - + sessionStorage.removeItem("currentConversation"); + dispatch({ type: LOGOUT, }); From 456eb4236fd962a699870155734ea40fb38045d5 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 1 Dec 2025 18:22:25 -0500 Subject: [PATCH 024/181] Remove auth requirement for Chat in both nav files --- frontend/src/components/Header/Header.tsx | 6 +----- frontend/src/components/Header/MdNavBar.tsx | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index ac39544e..6a983a08 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -193,11 +193,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { )} - {isAuthenticated && ( - <> - - - )} + {isAuthenticated && authLinks()}
    diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 1ed2cd43..f2cfc67b 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -138,9 +138,7 @@ const MdNavBar = (props: LoginFormProps) => { }
    - {isAuthenticated && ( - - )} + ); }; From cd1ce44af083158566d85c84b423b2fd3a29c345 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 13:58:38 -0500 Subject: [PATCH 025/181] Clear all session storage when auth state changes for chatbot --- frontend/src/services/actions/auth.tsx | 7 +++++-- frontend/src/services/reducers/auth.ts | 2 ++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index bfbfbe41..3b1cac0e 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -151,6 +151,9 @@ export const login = try { const res = await axios.post(url, body, config); + // Clear all session data from previous unauthenticated session + sessionStorage.clear(); + dispatch({ type: LOGIN_SUCCESS, payload: res.data, @@ -172,8 +175,8 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { - // Clear chat conversation data on logout for security - sessionStorage.removeItem("currentConversation"); + // Clear all session data on logout for privacy + sessionStorage.clear(); dispatch({ type: LOGOUT, diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..914ddb4c 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -99,6 +99,7 @@ export default function authReducer(state = initialState, action: ActionType): S user: action.payload } case AUTHENTICATED_FAIL: + sessionStorage.clear(); return { ...state, isAuthenticated: false @@ -113,6 +114,7 @@ export default function authReducer(state = initialState, action: ActionType): S case LOGIN_FAIL: localStorage.removeItem('access'); localStorage.removeItem('refresh'); + sessionStorage.clear(); return { ...state, access: null, From 4dc8b39e3a3ba8ccba3183c6f7c6be4ab4963894 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 14:08:45 -0500 Subject: [PATCH 026/181] Save patient history data to sessionStorage instead of localStorage --- frontend/src/pages/PatientManager/NewPatientForm.tsx | 12 ++++++------ frontend/src/pages/PatientManager/PatientHistory.tsx | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 3acdd4dd..b2ff2e01 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -113,14 +113,14 @@ const NewPatientForm = ({ }; useEffect(() => { - const patientInfoFromLocalStorage = JSON.parse( + const patientInfoFromSessionStorage = JSON.parse( // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - localStorage.getItem("patientInfos") + sessionStorage.getItem("patientInfos") ); - if (patientInfoFromLocalStorage) { - setAllPatientInfo(patientInfoFromLocalStorage); + if (patientInfoFromSessionStorage) { + setAllPatientInfo(patientInfoFromSessionStorage); } }, []); @@ -190,11 +190,11 @@ const NewPatientForm = ({ updatedAllPatientInfo = [updatedPatientInfo, ...allPatientInfo]; } - // Update state and localStorage + // Update state and sessionStorage setPatientInfo(updatedPatientInfo); setAllPatientInfo(updatedAllPatientInfo); setShowSummary(true); - localStorage.setItem( + sessionStorage.setItem( "patientInfos", JSON.stringify(updatedAllPatientInfo) ); diff --git a/frontend/src/pages/PatientManager/PatientHistory.tsx b/frontend/src/pages/PatientManager/PatientHistory.tsx index f8dc14a6..0a03eea4 100644 --- a/frontend/src/pages/PatientManager/PatientHistory.tsx +++ b/frontend/src/pages/PatientManager/PatientHistory.tsx @@ -44,7 +44,7 @@ const PatientHistory = ({ (patient) => patient.ID !== patientIDToDelete ); - localStorage.setItem("patientInfos", JSON.stringify(updatedPatientInfo)); + sessionStorage.setItem("patientInfos", JSON.stringify(updatedPatientInfo)); setAllPatientInfo(updatedPatientInfo); onPatientDeleted(patientIDToDelete); From affe31e903e744b499be8b1c8ab6dc81a211f4c0 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 14:19:17 -0500 Subject: [PATCH 027/181] Fix clearing sessionStorage on every page load for unauth users --- frontend/src/services/reducers/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 914ddb4c..e353b9cd 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -99,7 +99,8 @@ export default function authReducer(state = initialState, action: ActionType): S user: action.payload } case AUTHENTICATED_FAIL: - sessionStorage.clear(); + // Don't clear sessionStorage here - this is triggered on every page load + // for unauthenticated users who are allowed to use the app return { ...state, isAuthenticated: false From 74a0b8fc6b8e449eb531c8d927895b1c97cd96b1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 14:28:00 -0500 Subject: [PATCH 028/181] Clearing session storage on LOGIN_FAIL public access design of app --- frontend/src/services/reducers/auth.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index e353b9cd..bcafccd1 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -113,9 +113,10 @@ export default function authReducer(state = initialState, action: ActionType): S case GOOGLE_AUTH_FAIL: case FACEBOOK_AUTH_FAIL: case LOGIN_FAIL: + // Don't clear sessionStorage on login failure - users may have entered + // patient data while unauthenticated, and a login typo shouldn't delete their work localStorage.removeItem('access'); localStorage.removeItem('refresh'); - sessionStorage.clear(); return { ...state, access: null, From 257b83106b78c02a9c579fe2140edcba05691170 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 16:27:55 -0500 Subject: [PATCH 029/181] Enforce authentication at the route level for adminportal --- .../ProtectedRoute/ProtectedRoute.tsx | 34 +++++++++++++++++++ frontend/src/routes/routes.tsx | 3 +- 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ProtectedRoute/ProtectedRoute.tsx diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx new file mode 100644 index 00000000..4e5a4041 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -0,0 +1,34 @@ +import { ReactElement, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../services/actions/types'; + +interface ProtectedRouteProps { + children: ReactElement; +} + +const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { + const navigate = useNavigate(); + const { isAuthenticated, loading } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + // Only redirect if we're done loading and user is not authenticated + if (!loading && !isAuthenticated) { + navigate('/login', { replace: true }); + } + }, [isAuthenticated, loading, navigate]); + + // Show loading state while checking authentication + if (loading) { + return ( +
    +
    Loading...
    +
    + ); + } + + // Only render children if authenticated + return isAuthenticated ? children : <>; +}; + +export default ProtectedRoute; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index f96f2574..d012ed4d 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -18,6 +18,7 @@ import UploadFile from "../pages/DocumentManager/UploadFile.tsx"; import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; +import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; const routes = [ { @@ -85,7 +86,7 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", From 7759377170f7311976d6b6df5a8dd47ec10401d6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 2 Dec 2025 17:02:38 -0500 Subject: [PATCH 030/181] Enforce authentication at the route level for non public routes --- frontend/src/routes/routes.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index d012ed4d..dc974e85 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -28,21 +28,21 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", - element: , + element: , }, { path: "register", @@ -90,7 +90,7 @@ const routes = [ }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -98,7 +98,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; From f0eeb35beead96f4c81f5b4a59a876fdec1842e8 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 09:45:52 -0500 Subject: [PATCH 031/181] Make the DrugSummary route and its API endpoints unprotected --- frontend/src/api/apiClient.ts | 16 +++++++++++----- frontend/src/routes/routes.tsx | 2 +- server/api/services/embedding_services.py | 2 +- server/api/views/embeddings/embeddingsView.py | 4 ++-- server/api/views/text_extraction/views.py | 4 ++-- 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 915226d6..b4980e3e 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -63,7 +63,7 @@ const handleSendDrugSummary = async ( const handleRuleExtraction = async (guid: string) => { try { - const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await publicApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -105,13 +105,19 @@ const handleSendDrugSummaryStream = async ( guid ? `&guid=${guid}` : "" }`; + const headers: Record = { + "Content-Type": "application/json", + }; + + // Only add Authorization header if token exists + if (token) { + headers.Authorization = `JWT ${token}`; + } + try { const response = await fetch(baseURL + endpoint, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `JWT ${token}`, - }, + headers, body: JSON.stringify({ message }), }); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dc974e85..acab4f1c 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -42,7 +42,7 @@ const routes = [ }, { path: "drugSummary", - element: , + element: , }, { path: "register", diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index b50dd750..0f65b7b1 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -41,7 +41,7 @@ def get_closest_embeddings( transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - if user.is_authenticated: + if user and user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( Embeddings.objects.filter( diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index d0bdd8ca..13b49cc0 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework import status from django.http import StreamingHttpResponse @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class AskEmbeddingsAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def post(self, request, *args, **kwargs): try: diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index e4122851..6e51d994 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -139,7 +139,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [AllowAny] def get(self, request): try: From 7648a2c916642e170923ce7d6f904daeadd22c6b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 10:01:19 -0500 Subject: [PATCH 032/181] Revert "Make the DrugSummary route and its API endpoints unprotected" This reverts commit f0eeb35beead96f4c81f5b4a59a876fdec1842e8. --- frontend/src/api/apiClient.ts | 16 +++++----------- frontend/src/routes/routes.tsx | 2 +- server/api/services/embedding_services.py | 2 +- server/api/views/embeddings/embeddingsView.py | 4 ++-- server/api/views/text_extraction/views.py | 4 ++-- 5 files changed, 11 insertions(+), 17 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index b4980e3e..915226d6 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -63,7 +63,7 @@ const handleSendDrugSummary = async ( const handleRuleExtraction = async (guid: string) => { try { - const response = await publicApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -105,19 +105,13 @@ const handleSendDrugSummaryStream = async ( guid ? `&guid=${guid}` : "" }`; - const headers: Record = { - "Content-Type": "application/json", - }; - - // Only add Authorization header if token exists - if (token) { - headers.Authorization = `JWT ${token}`; - } - try { const response = await fetch(baseURL + endpoint, { method: "POST", - headers, + headers: { + "Content-Type": "application/json", + Authorization: `JWT ${token}`, + }, body: JSON.stringify({ message }), }); diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index acab4f1c..dc974e85 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -42,7 +42,7 @@ const routes = [ }, { path: "drugSummary", - element: , + element: , }, { path: "register", diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 0f65b7b1..b50dd750 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -41,7 +41,7 @@ def get_closest_embeddings( transformerModel = TransformerModel.get_instance().model embedding_message = transformerModel.encode(message_data) - if user and user.is_authenticated: + if user.is_authenticated: # User sees their own files + files uploaded by superusers closest_embeddings_query = ( Embeddings.objects.filter( diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index 13b49cc0..d0bdd8ca 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,5 +1,5 @@ from rest_framework.views import APIView -from rest_framework.permissions import AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from django.http import StreamingHttpResponse @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class AskEmbeddingsAPIView(APIView): - permission_classes = [AllowAny] + permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): try: diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 6e51d994..e4122851 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated, AllowAny +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -139,7 +139,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [AllowAny] + permission_classes = [IsAuthenticated] def get(self, request): try: From c792295f8dc74374e309b9e0030c2dce8be69e06 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 10:54:49 -0500 Subject: [PATCH 033/181] Remove comments from auth.ts --- frontend/src/services/reducers/auth.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index bcafccd1..6a18b9eb 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -99,8 +99,6 @@ export default function authReducer(state = initialState, action: ActionType): S user: action.payload } case AUTHENTICATED_FAIL: - // Don't clear sessionStorage here - this is triggered on every page load - // for unauthenticated users who are allowed to use the app return { ...state, isAuthenticated: false @@ -113,8 +111,6 @@ export default function authReducer(state = initialState, action: ActionType): S case GOOGLE_AUTH_FAIL: case FACEBOOK_AUTH_FAIL: case LOGIN_FAIL: - // Don't clear sessionStorage on login failure - users may have entered - // patient data while unauthenticated, and a login typo shouldn't delete their work localStorage.removeItem('access'); localStorage.removeItem('refresh'); return { @@ -159,4 +155,4 @@ export default function authReducer(state = initialState, action: ActionType): S default: return state } -} \ No newline at end of file +} From e994ef0c215bbef1c808045b31ff71ab2ee0ce3e Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 11:03:34 -0500 Subject: [PATCH 034/181] Remove final newline automatically added by Zed --- frontend/src/services/reducers/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 6a18b9eb..769f3071 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -155,4 +155,4 @@ export default function authReducer(state = initialState, action: ActionType): S default: return state } -} +} \ No newline at end of file From 6b97fa8d0e7e3d3cb89d9cabeda29fcd7c998f31 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 11:38:18 -0500 Subject: [PATCH 035/181] Simplify and make ProtectedRoute more robust --- .../ProtectedRoute/ProtectedRoute.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 4e5a4041..590e4ddd 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -1,24 +1,16 @@ -import { ReactElement, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { RootState } from '../../services/actions/types'; interface ProtectedRouteProps { - children: ReactElement; + children: ReactNode; } -const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { - const navigate = useNavigate(); +const ProtectedRoute = ({ children }: ProtectedRouteProps) => { + const location = useLocation(); const { isAuthenticated, loading } = useSelector((state: RootState) => state.auth); - useEffect(() => { - // Only redirect if we're done loading and user is not authenticated - if (!loading && !isAuthenticated) { - navigate('/login', { replace: true }); - } - }, [isAuthenticated, loading, navigate]); - - // Show loading state while checking authentication if (loading) { return (
    @@ -27,8 +19,12 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps): ReactElement => { ); } - // Only render children if authenticated - return isAuthenticated ? children : <>; + // If not authenticated, redirect to login and include the original location + if (!isAuthenticated) { + return ; + } + + return <>{children}; }; -export default ProtectedRoute; +export default ProtectedRoute; \ No newline at end of file From bd817ffc39b67b0599ce7869e1f58dbd4919c011 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 11:57:59 -0500 Subject: [PATCH 036/181] More targeted clearing of sessionStorage --- frontend/src/services/actions/auth.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 3b1cac0e..3dcfcac5 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -151,8 +151,9 @@ export const login = try { const res = await axios.post(url, body, config); - // Clear all session data from previous unauthenticated session - sessionStorage.clear(); + // Clear session data from previous unauthenticated session + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); dispatch({ type: LOGIN_SUCCESS, @@ -175,8 +176,9 @@ export const login = }; export const logout = () => async (dispatch: AppDispatch) => { - // Clear all session data on logout for privacy - sessionStorage.clear(); + // Clear session data on logout for privacy + sessionStorage.removeItem('currentConversation'); + sessionStorage.removeItem('patientInfos'); dispatch({ type: LOGOUT, From 4ebf265b6912ddd9dcac1ca4a9bb8d4096f7a8f6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 3 Dec 2025 12:07:10 -0500 Subject: [PATCH 037/181] Simplify ProtectedRoute.tsx --- .../src/components/ProtectedRoute/ProtectedRoute.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 590e4ddd..6d4d05ac 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -9,15 +9,7 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); - const { isAuthenticated, loading } = useSelector((state: RootState) => state.auth); - - if (loading) { - return ( -
    -
    Loading...
    -
    - ); - } + const { isAuthenticated } = useSelector((state: RootState) => state.auth); // If not authenticated, redirect to login and include the original location if (!isAuthenticated) { From 57212620038bdc7e91a66b25b1bb4be667b910f0 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 5 Dec 2025 18:54:44 -0500 Subject: [PATCH 038/181] chore: Update Kind configuration, refactor database and allowed hosts environment variable handling, and adjust Kustomize dev overlay. --- config/env/dev.env.example | 4 ++-- deploy/manifests/balancer/base/secret.template.yaml | 1 - server/balancer_backend/settings.py | 2 +- server/entrypoint.prod.sh | 2 +- server/entrypoint.sh | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 38d8e382..f596affc 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -1,13 +1,13 @@ DEBUG=True SECRET_KEY=foo -DJANGO_ALLOWED_HOSTS=localhost 127.0.0.1 [::1] + SQL_ENGINE=django.db.backends.postgresql SQL_DATABASE=balancer_dev SQL_USER=balancer SQL_PASSWORD=balancer SQL_HOST=db SQL_PORT=5432 -DATABASE=postgres + LOGIN_REDIRECT_URL= OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/deploy/manifests/balancer/base/secret.template.yaml b/deploy/manifests/balancer/base/secret.template.yaml index 9abb6e3b..e003a6ce 100644 --- a/deploy/manifests/balancer/base/secret.template.yaml +++ b/deploy/manifests/balancer/base/secret.template.yaml @@ -13,7 +13,6 @@ metadata: namespace: balancer type: Opaque stringData: - DATABASE: postgres DEBUG: '1' LOGIN_REDIRECT_URL: '' OPENAI_API_KEY: openapi_key_here diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index df62d198..16764f0e 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -29,7 +29,7 @@ # Fetching the value from the environment and splitting to list if necessary. # Fallback to '*' if the environment variable is not set. -ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split() +ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "*").split() # If the environment variable contains '*', the split method would create a list with an empty string. # So you need to check for this case and adjust accordingly. diff --git a/server/entrypoint.prod.sh b/server/entrypoint.prod.sh index 68dfbc88..3ab59e2e 100755 --- a/server/entrypoint.prod.sh +++ b/server/entrypoint.prod.sh @@ -1,6 +1,6 @@ #!/bin/sh -if [ "$DATABASE" = "postgres" ] +if [ "$SQL_ENGINE" = "django.db.backends.postgresql" ] then echo "Waiting for postgres..." diff --git a/server/entrypoint.sh b/server/entrypoint.sh index 2d2c872f..f81a0b8f 100755 --- a/server/entrypoint.sh +++ b/server/entrypoint.sh @@ -1,6 +1,6 @@ #!/bin/sh -if [ "$DATABASE" = "postgres" ] +if [ "$SQL_ENGINE" = "django.db.backends.postgresql" ] then echo "Waiting for postgres..." From a9fbde5a9955e51cf02aa761f76a0b4f59837757 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 8 Dec 2025 16:39:18 -0500 Subject: [PATCH 039/181] WIP --- server/api/views/assistant/views.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 32089c58..cdd54773 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -196,8 +196,15 @@ def search_documents(query: str, user=user) -> str: return f"Error searching documents: {str(e)}. Please try again if the issue persists." INSTRUCTIONS = """ + When you are asked a question, respond as if you are a chatbot with a library of sources that the user can't see. + The user did not upload these sources, so they don't know about them. + You have to explain what is in the sources and give references to the sources. + + When a prompt is received that is unrelated to bipolar disorder, mental health treatment, or psychiatric medications, + respond to the user by saying you are limited to bipolar-specific conversations. + You are an AI assistant that helps users find and understand information about bipolar disorder - from their uploaded bipolar disorder research documents using semantic search. + from your internal library of bipolar disorder research sources using semantic search. SEMANTIC SEARCH STRATEGY: - Always perform semantic search using the search_documents function when users ask questions @@ -206,18 +213,19 @@ def search_documents(query: str, user=user) -> str: - Consider medical terminology, lay terms, and related conditions when searching FUNCTION USAGE: - - When a user asks about information that might be in their documents ALWAYS use the search_documents function first + - When a user asks about information that might be in your source library ALWAYS use the search_documents function first - Perform semantic searches using concepts, symptoms, treatments, and related terms from the user's question - - Only provide answers based on information found through document searches + - Only provide answers based on information found through your source searches RESPONSE FORMAT: After gathering information through semantic searches, provide responses that: 1. Answer the user's question directly using only the found information 2. Structure responses with clear sections and paragraphs - 3. Include citations using this exact format: ***[Name {name}, Page {page_number}]*** - 4. Only cite information that directly supports your statements + 3. Explain what information you found in your sources and provide context + 4. Include citations using this exact format: ***[Name {name}, Page {page_number}]*** + 5. Only cite information that directly supports your statements - If no relevant information is found in the documents, clearly state that the information is not available in the uploaded documents. + If no relevant information is found in your source library, clearly state that the information is not available in your current sources. """ MODEL_DEFAULTS = { From f165aeb388b9cb27a95ad5460aa36168b2bbd7d1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:21:00 -0500 Subject: [PATCH 040/181] Handle the initial auth check state --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 6d4d05ac..182b2b20 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -10,6 +10,11 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); const { isAuthenticated } = useSelector((state: RootState) => state.auth); + + // Wait for auth check to complete (null means not checked yet) + if (isAuthenticated === null) { + return
    Loading...
    ; // or a spinner component + } // If not authenticated, redirect to login and include the original location if (!isAuthenticated) { From 5df35a87c50a8711e1a40924076c487c4e1ffc61 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:34:54 -0500 Subject: [PATCH 041/181] Replace Loading Text with spinner --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 182b2b20..97f0db6a 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux'; import { RootState } from '../../services/actions/types'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; interface ProtectedRouteProps { children: ReactNode; @@ -13,7 +14,7 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { // Wait for auth check to complete (null means not checked yet) if (isAuthenticated === null) { - return
    Loading...
    ; // or a spinner component + return ; } // If not authenticated, redirect to login and include the original location From 8190390aec707824ab44f760ea4b545da56b5687 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:42:05 -0500 Subject: [PATCH 042/181] Fix the Type mismatch in RootState --- frontend/src/services/actions/types.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/services/actions/types.tsx b/frontend/src/services/actions/types.tsx index add0dad9..d4b12f5e 100644 --- a/frontend/src/services/actions/types.tsx +++ b/frontend/src/services/actions/types.tsx @@ -21,7 +21,9 @@ export const LOGOUT = "LOGOUT"; export interface RootState { auth: { error: any; - isAuthenticated: boolean; + // Catch any code that doesn't handle the null case by + // matching the actual reducer state defined in auth.ts + isAuthenticated: boolean | null; isSuperuser: boolean; }; } From 3b82d634088a928d633c40930fa573e9ccd7f0d7 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 15:48:32 -0500 Subject: [PATCH 043/181] Add TODO comments for improvements --- frontend/src/components/ProtectedRoute/ProtectedRoute.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 97f0db6a..27b55c1d 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -10,10 +10,15 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); + // TODO: Optimize useSelector to prevent unnecessary re-renders + // Use: const isAuthenticated = useSelector((state: RootState) => state.auth.isAuthenticated); const { isAuthenticated } = useSelector((state: RootState) => state.auth); // Wait for auth check to complete (null means not checked yet) + // TODO: Add error handling for auth check failures if (isAuthenticated === null) { + // TODO: Add accessibility attributes (role="status", aria-live="polite", aria-label) + // TODO: Prevent Loading State Flash by adding 200ms delay before showing spinner return ; } @@ -22,6 +27,7 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { return ; } + // TODO: Remove unnecessary fragment wrapper - just return children directly return <>{children}; }; From 109966584a0f223043db3ef00741e9f34bbc09fb Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 16:05:35 -0500 Subject: [PATCH 044/181] Remov unecessary fragment --- .../src/components/ProtectedRoute/ProtectedRoute.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index 27b55c1d..dc323217 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -10,15 +10,13 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); - // TODO: Optimize useSelector to prevent unnecessary re-renders - // Use: const isAuthenticated = useSelector((state: RootState) => state.auth.isAuthenticated); const { isAuthenticated } = useSelector((state: RootState) => state.auth); // Wait for auth check to complete (null means not checked yet) - // TODO: Add error handling for auth check failures + // TODO: Consider adding error handling for auth check failures if (isAuthenticated === null) { - // TODO: Add accessibility attributes (role="status", aria-live="polite", aria-label) - // TODO: Prevent Loading State Flash by adding 200ms delay before showing spinner + // TODO: Consider adding accessibility attributes (role="status", aria-live="polite", aria-label) + // TODO: Consider preventing Loading State Flash by adding delay before showing spinner return ; } @@ -27,8 +25,7 @@ const ProtectedRoute = ({ children }: ProtectedRouteProps) => { return ; } - // TODO: Remove unnecessary fragment wrapper - just return children directly - return <>{children}; + return children; }; export default ProtectedRoute; \ No newline at end of file From 412e90bdf24c1368422ef2c8949af431b7815105 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 9 Dec 2025 16:09:17 -0500 Subject: [PATCH 045/181] STYLE: Format line length of comment --- frontend/src/services/actions/types.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/services/actions/types.tsx b/frontend/src/services/actions/types.tsx index d4b12f5e..c7f73b94 100644 --- a/frontend/src/services/actions/types.tsx +++ b/frontend/src/services/actions/types.tsx @@ -21,8 +21,7 @@ export const LOGOUT = "LOGOUT"; export interface RootState { auth: { error: any; - // Catch any code that doesn't handle the null case by - // matching the actual reducer state defined in auth.ts + // Catch any code that doesn't handle the null case by matching the actual reducer state defined in auth.ts isAuthenticated: boolean | null; isSuperuser: boolean; }; From abe7e3725fb2fc0a760b534601c3f3ebc2830a7d Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 11 Dec 2025 16:20:38 -0500 Subject: [PATCH 046/181] WIP --- frontend/src/components/Header/Chat.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Header/Chat.tsx b/frontend/src/components/Header/Chat.tsx index c6315068..a6258865 100644 --- a/frontend/src/components/Header/Chat.tsx +++ b/frontend/src/components/Header/Chat.tsx @@ -310,9 +310,9 @@ const Chat: React.FC = ({ showChat, setShowChat }) => {
    Hi there, I'm {CHATBOT_NAME}!

    - You can ask me questions about your uploaded documents. - I'll search through them to provide accurate, cited - answers. + You can ask me questions about bipolar medications. + I'll search through our database of verified medical + journal articles to provide accurate, cited answers.

    Learn more about my sources. From 5189253e4db391d6c0de44fce34db777defb35e1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 11 Dec 2025 18:48:25 -0500 Subject: [PATCH 047/181] Remind the chatbot of the context at the end of the prompt --- server/api/views/assistant/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index cdd54773..2784f9cd 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -226,6 +226,8 @@ def search_documents(query: str, user=user) -> str: 5. Only cite information that directly supports your statements If no relevant information is found in your source library, clearly state that the information is not available in your current sources. + + You are an AI assistant that helps users find and understand information about bipolar disorder from your internal library of bipolar disorder research sources using semantic search. """ MODEL_DEFAULTS = { From 4dc2a0f1f020da2da8a7a598386a9f42510c8276 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 11 Dec 2025 21:09:49 -0500 Subject: [PATCH 048/181] Restructure chatbot prompt for clarity and context reinforcement --- server/api/views/assistant/views.py | 36 ++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index 2784f9cd..e612d0d5 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -196,38 +196,42 @@ def search_documents(query: str, user=user) -> str: return f"Error searching documents: {str(e)}. Please try again if the issue persists." INSTRUCTIONS = """ - When you are asked a question, respond as if you are a chatbot with a library of sources that the user can't see. - The user did not upload these sources, so they don't know about them. - You have to explain what is in the sources and give references to the sources. - - When a prompt is received that is unrelated to bipolar disorder, mental health treatment, or psychiatric medications, - respond to the user by saying you are limited to bipolar-specific conversations. - - You are an AI assistant that helps users find and understand information about bipolar disorder + You are an AI assistant that helps users find and understand information about bipolar disorder from your internal library of bipolar disorder research sources using semantic search. - + + IMPORTANT CONTEXT: + - You have access to a library of sources that the user CANNOT see + - The user did not upload these sources and doesn't know about them + - You must explain what information exists in your sources and provide clear references + + TOPIC RESTRICTIONS: + When a prompt is received that is unrelated to bipolar disorder, mental health treatment, + or psychiatric medications, respond by saying you are limited to bipolar-specific conversations. + SEMANTIC SEARCH STRATEGY: - Always perform semantic search using the search_documents function when users ask questions - Use conceptually related terms and synonyms, not just exact keyword matches - Search for the meaning and context of the user's question, not just literal words - Consider medical terminology, lay terms, and related conditions when searching - + FUNCTION USAGE: - - When a user asks about information that might be in your source library ALWAYS use the search_documents function first + - When a user asks about information that might be in your source library, ALWAYS use the search_documents function first - Perform semantic searches using concepts, symptoms, treatments, and related terms from the user's question - Only provide answers based on information found through your source searches - + RESPONSE FORMAT: After gathering information through semantic searches, provide responses that: 1. Answer the user's question directly using only the found information 2. Structure responses with clear sections and paragraphs 3. Explain what information you found in your sources and provide context - 4. Include citations using this exact format: ***[Name {name}, Page {page_number}]*** + 4. Include citations using this exact format: [Name {name}, Page {page_number}] 5. Only cite information that directly supports your statements - - If no relevant information is found in your source library, clearly state that the information is not available in your current sources. - You are an AI assistant that helps users find and understand information about bipolar disorder from your internal library of bipolar disorder research sources using semantic search. + If no relevant information is found in your source library, clearly state that the information + is not available in your current sources. + + REMEMBER: You are working with an internal library of bipolar disorder sources that the user + cannot see. Always search these sources first, explain what you found, and provide proper citations. """ MODEL_DEFAULTS = { From 8ca3f0f6b141ad829cfac22f53635591a5439f06 Mon Sep 17 00:00:00 2001 From: taichan03 Date: Fri, 12 Dec 2025 09:49:59 -0500 Subject: [PATCH 049/181] this is for switching the button from view pdf to download pdf when the users is not logged in. --- CHANGELOG.md | 165 +++++++++ CLAUDE.md | 318 ++++++++++++++++++ docs/MIGRATION_PDF_AUTH.md | 307 +++++++++++++++++ frontend/src/components/Header/Header.tsx | 35 +- frontend/src/components/Header/MdNavBar.tsx | 2 +- frontend/src/pages/Layout/Layout.tsx | 2 +- .../src/pages/Layout/Layout_V2_Header.tsx | 2 +- frontend/src/pages/Layout/Layout_V2_Main.tsx | 2 +- frontend/src/pages/Login/LoginForm.tsx | 2 +- frontend/src/pages/Login/ResetPassword.tsx | 2 +- .../src/pages/Login/ResetPasswordConfirm.tsx | 2 +- .../pages/PatientManager/PatientManager.tsx | 4 + .../pages/PatientManager/PatientSummary.tsx | 74 +++- 13 files changed, 889 insertions(+), 28 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 docs/MIGRATION_PDF_AUTH.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d7b1fd77 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,165 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- **Conditional PDF Access Based on Authentication** (2025-01-XX) + - Logged-in users see "View PDF" button that opens PDF viewer in new tab + - Non-logged-in users see "Download PDF" button that directly downloads the file + - Backend: Added `upload_file_guid` field to risk/source API responses + - Frontend: Conditional rendering based on Redux authentication state + - Fallback GUID extraction from URL if backend field is missing + + **Backend Changes:** + + *File: `server/api/views/risk/views_riskWithSources.py`* + ```python + # Added to source_info dictionary in 3 locations (lines ~138, ~252, ~359): + source_info = { + 'filename': filename, + 'title': getattr(embedding, 'title', None), + 'publication': getattr(embedding, 'publication', ''), + 'text': getattr(embedding, 'text', ''), + 'rule_type': medrule.rule_type, + 'history_type': medrule.history_type, + 'upload_fileid': getattr(embedding, 'upload_file_id', None), + 'page': getattr(embedding, 'page_num', None), + 'link_url': self._build_pdf_link(embedding), + 'upload_file_guid': str(embedding.upload_file.guid) if embedding.upload_file else None # NEW + } + ``` + + **Frontend Changes:** + + *File: `frontend/src/pages/PatientManager/PatientManager.tsx`* + ```typescript + // Added imports: + import { useSelector } from "react-redux"; + import { RootState } from "../../services/actions/types"; + + // Added hook to get auth state: + const { isAuthenticated } = useSelector((state: RootState) => state.auth); + + // Passed to PatientSummary: + + ``` + + *File: `frontend/src/pages/PatientManager/PatientSummary.tsx`* + ```typescript + // Updated interface: + interface PatientSummaryProps { + // ... existing props + isAuthenticated?: boolean; // NEW + } + + // Updated SourceItem type: + type SourceItem = { + // ... existing fields + upload_file_guid?: string | null; // NEW + }; + + // Added helper function: + const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } + }; + + // Updated component: + const PatientSummary = ({ + // ... existing props + isAuthenticated = false, // NEW + }: PatientSummaryProps) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || ''; // NEW + + // Updated MedicationItem props: + const MedicationItem = ({ + // ... existing props + isAuthenticated, // NEW + baseURL, // NEW + }: { + // ... existing types + isAuthenticated: boolean; // NEW + baseURL: string; // NEW + }) => { + + // Updated MedicationTier props: + const MedicationTier = ({ + // ... existing props + isAuthenticated, // NEW + baseURL, // NEW + }: { + // ... existing types + isAuthenticated: boolean; // NEW + baseURL: string; // NEW + }) => ( + // ... passes to MedicationItem + + ); + + // Conditional button rendering: + {s.link_url && (() => { + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + return isAuthenticated ? ( + + View PDF + + ) : ( + + Download PDF + + ); + })()} + + // Updated all MedicationTier calls to pass new props: + + ``` + +### Fixed +- **URL Route Case Consistency** (2025-01-XX) + - Fixed case mismatch between backend URL generation (`/drugsummary`) and frontend route (`/drugSummary`) + - Updated all references to use consistent camelCase `/drugSummary` route + - Affected files: `views_riskWithSources.py`, `Layout_V2_Sidebar.tsx`, `Layout_V2_Header.tsx`, `FileRow.tsx` + +- **Protected Route Authentication Flow** (2025-01-XX) + - Fixed blank page issue when opening protected routes in new tab + - `ProtectedRoute` now waits for authentication check to complete before redirecting + - Added `useAuth()` hook to `Layout_V2_Main` to trigger auth verification + +### Changed +- **PatientSummary Component** (2025-01-XX) + - Now receives `isAuthenticated` prop from Redux state + - Props passed through component hierarchy: `PatientManager` → `PatientSummary` → `MedicationTier` → `MedicationItem` + - Added `baseURL` constant for API endpoint construction + +## [Previous versions would go here] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..8562eb0d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,318 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Balancer is a web application designed to help prescribers choose suitable medications for patients with bipolar disorder. It's a Code for Philly project built with a PostgreSQL + Django REST Framework + React stack, running on Docker. + +Live site: https://balancertestsite.com + +## Development Setup + +### Prerequisites +- Docker Desktop +- Node.js and npm +- API keys for OpenAI and Anthropic (request from team) + +### Initial Setup +```bash +# Clone the repository +git clone + +# Install frontend dependencies +cd frontend +npm install +cd .. + +# Configure environment variables +# Copy config/env/dev.env.example and fill in API keys: +# - OPENAI_API_KEY +# - ANTHROPIC_API_KEY +# - PINECONE_API_KEY (if needed) + +# Start all services +docker compose up --build +``` + +### Services +- **Frontend**: React + Vite dev server at http://localhost:3000 +- **Backend**: Django REST Framework at http://localhost:8000 +- **Database**: PostgreSQL at localhost:5433 +- **pgAdmin**: Commented out by default (port 5050) + +## Common Development Commands + +### Docker Operations +```bash +# Start all services +docker compose up --build + +# Start in detached mode +docker compose up -d + +# View logs +docker compose logs -f [service_name] + +# Stop all services +docker compose down + +# Rebuild a specific service +docker compose build [frontend|backend|db] + +# Access Django shell in backend container +docker compose exec backend python manage.py shell + +# Run Django migrations +docker compose exec backend python manage.py makemigrations +docker compose exec backend python manage.py migrate +``` + +### Frontend Development +```bash +cd frontend + +# Start dev server (outside Docker) +npm run dev + +# Build for production +npm run build + +# Lint TypeScript/TSX files +npm run lint + +# Preview production build +npm run preview +``` + +### Backend Development +```bash +cd server + +# Create Django superuser (credentials in api/management/commands/createsu.py) +docker compose exec backend python manage.py createsuperuser + +# Access Django admin +# Navigate to http://localhost:8000/admin + +# Run database migrations +docker compose exec backend python manage.py makemigrations +docker compose exec backend python manage.py migrate + +# Django shell +docker compose exec backend python manage.py shell +``` + +### Git Workflow +- Main development branch: `develop` +- Production branch: `listOfMed` (used for PRs) +- Create feature branches from `develop` +- PRs should target `listOfMed` branch + +## Architecture + +### Backend Architecture (Django REST Framework) + +#### URL Routing Pattern +Django uses **dynamic URL importing** (see `server/balancer_backend/urls.py`). API endpoints are organized by feature modules in `server/api/views/`: +- `conversations/` - Patient conversation management +- `feedback/` - User feedback +- `listMeds/` - Medication catalog +- `risk/` - Risk assessment endpoints +- `uploadFile/` - PDF document uploads +- `ai_promptStorage/` - AI prompt templates +- `ai_settings/` - AI configuration +- `embeddings/` - Vector embeddings for RAG +- `medRules/` - Medication rules management +- `text_extraction/` - PDF text extraction +- `assistant/` - AI assistant endpoints + +Each module contains: +- `views.py` or `views_*.py` - API endpoints +- `models.py` - Django ORM models +- `urls.py` - URL patterns +- `serializers.py` - DRF serializers (if present) + +#### Authentication +- Uses **JWT authentication** with `rest_framework_simplejwt` +- Default: All endpoints require authentication (`IsAuthenticated`) +- To make an endpoint public, add to the view class: + ```python + from rest_framework.permissions import AllowAny + + class MyView(APIView): + permission_classes = [AllowAny] + authentication_classes = [] # Optional: disable auth entirely + ``` +- Auth endpoints via Djoser: `/auth/` +- JWT token lifetime: 60 minutes (access), 1 day (refresh) + +#### Key Data Models +- **Medication** (`api.views.listMeds.models`) - Medication catalog with benefits/risks +- **MedRule** (`api.models.model_medRule`) - Include/Exclude rules for medications based on patient history +- **MedRuleSource** - Junction table linking MedRules → Embeddings → Medications +- **Embeddings** (`api.models.model_embeddings`) - Vector embeddings from uploaded PDFs for RAG +- **UploadFile** (`api.views.uploadFile.models`) - Uploaded PDF documents with GUID references + +#### RAG (Retrieval Augmented Generation) System +The application uses embeddings from medical literature PDFs to provide evidence-based medication recommendations: +1. PDFs uploaded via `uploadFile` → text extracted → chunked → embedded (OpenAI/Pinecone) +2. MedRules created linking medications to specific evidence (embeddings) +3. API endpoints return recommendations with source citations (filename, page number, text excerpt) + +### Frontend Architecture (React + TypeScript) + +#### Project Structure +- **`src/components/`** - Reusable React components (Header, forms, etc.) +- **`src/pages/`** - Page-level components +- **`src/routes/routes.tsx`** - React Router configuration +- **`src/services/`** - Redux store, actions, reducers, API clients +- **`src/contexts/`** - React Context providers (GlobalContext for app state) +- **`src/api/`** - API client functions using Axios +- **`src/utils/`** - Utility functions + +#### State Management +- **Redux** for auth state and global application data + - Store: `src/services/store.tsx` + - Actions: `src/services/actions/` + - Reducers: `src/services/reducers/` +- **React Context** (`GlobalContext`) for UI state: + - `showSummary` - Display medication summary + - `enterNewPatient` - New patient form state + - `isEditing` - Form edit mode + - `showMetaPanel` - Metadata panel visibility + +#### Routing +Routes defined in `src/routes/routes.tsx`: +- `/` - Medication Suggester (main tool) +- `/medications` - Medication List +- `/about` - About page +- `/help` - Help documentation +- `/feedback` - Feedback form +- `/logout` - Logout handler +- Admin routes (superuser only): + - `/rulesmanager` - Manage medication rules + - `/ManageMeds` - Manage medication database + +#### Styling +- **Tailwind CSS** for utility-first styling +- **PostCSS** with nesting support +- Custom CSS in component directories (e.g., `Header/header.css`) +- Fonts: Quicksand (branding), Satoshi (body text) + +### Database Schema Notes +- **pgvector extension** enabled for vector similarity search +- Custom Dockerfile for PostgreSQL (`db/Dockerfile`) - workaround for ARM64 compatibility +- Database connection: + - Host: `db` (Docker internal) or `localhost:5433` (external) + - Credentials: `balancer/balancer` (dev environment) + - Database: `balancer_dev` + +### Environment Configuration +- **Development**: `config/env/dev.env` (used by Docker Compose) +- **Frontend Production**: `frontend/.env.production` + - Contains `VITE_API_BASE_URL` for production API endpoint +- **Never commit** actual API keys - use `.env.example` as template +- Django `SECRET_KEY` should be a long random string in production (not "foo") + +## Important Development Patterns + +### Adding a New API Endpoint +1. Create view in appropriate `server/api/views/{module}/views.py` +2. Add URL pattern to `server/api/views/{module}/urls.py` +3. If new module, add to `urls` list in `server/balancer_backend/urls.py` +4. Consider authentication requirements (add `permission_classes` if needed) + +### Working with MedRules +MedRules use a many-to-many relationship with medications and embeddings: +- `rule_type`: "INCLUDE" (beneficial) or "EXCLUDE" (contraindicated) +- `history_type`: Patient diagnosis state (e.g., "DIAGNOSIS_DEPRESSED", "DIAGNOSIS_MANIC") +- Access sources via `MedRuleSource` intermediate model +- API returns benefits/risks with source citations (filename, page, text, **upload_file_guid**) + +### PDF Access and Authentication +**Feature**: Conditional PDF viewing/downloading based on authentication state + +**Behavior**: +- **Logged-in users**: See "View PDF" button (blue) that opens `/drugSummary` page in new tab +- **Non-logged-in users**: See "Download PDF" button (green) that directly downloads via `/v1/api/uploadFile/` endpoint + +**Implementation Details**: +- Backend: `upload_file_guid` field added to source_info in `views_riskWithSources.py` (3 locations) +- Frontend: `isAuthenticated` prop passed through component hierarchy: + - `PatientManager` (gets from Redux) → `PatientSummary` → `MedicationTier` → `MedicationItem` +- Download endpoint: `/v1/api/uploadFile/` is **public** (AllowAny permission) +- Fallback: If `upload_file_guid` missing from API, GUID is extracted from `link_url` query parameter +- Route: `/drugSummary` (camelCase) - fixed from inconsistent `/drugsummary` usage + +**Files Modified**: +- Backend: `server/api/views/risk/views_riskWithSources.py` +- Frontend: `frontend/src/pages/PatientManager/PatientManager.tsx`, `PatientSummary.tsx` +- Routes: Multiple files updated for consistent `/drugSummary` casing +- Auth: `ProtectedRoute.tsx` and `Layout_V2_Main.tsx` fixed for proper auth checking + +### Frontend API Calls +- API client functions in `src/api/` +- Use Axios with base URL from environment +- JWT tokens managed by Redux auth state +- Error handling should check for 401 (unauthorized) and redirect to login + +### Docker Networking +Services use a custom network (192.168.0.0/24): +- db: 192.168.0.2 +- backend: 192.168.0.3 +- frontend: 192.168.0.5 +- Services communicate using service names (e.g., `http://backend:8000`) + +## Testing + +### Backend Tests +Limited test coverage currently. Example test: +- `server/api/views/uploadFile/test_title.py` + +To run tests: +```bash +docker compose exec backend python manage.py test +``` + +### Frontend Tests +No test framework currently configured. Consider adding Jest/Vitest for future testing. + +## Deployment + +### Local Kubernetes (using Devbox) +```bash +# Install Devbox first: https://www.jetify.com/devbox + +# Add balancertestsite.com to /etc/hosts +sudo sh -c 'echo "127.0.0.1 balancertestsite.com" >> /etc/hosts' + +# Deploy to local k8s cluster +devbox shell +devbox create:cluster +devbox run deploy:balancer + +# Access at https://balancertestsite.com:30219/ +``` + +### Production +- Manifests: `deploy/manifests/balancer/` +- ConfigMap: `deploy/manifests/balancer/base/configmap.yml` +- Secrets: `deploy/manifests/balancer/base/secret.template.yaml` + +## Key Files Reference + +- `server/balancer_backend/settings.py` - Django configuration (auth, database, CORS) +- `server/balancer_backend/urls.py` - Root URL configuration with dynamic imports +- `frontend/src/routes/routes.tsx` - React Router configuration +- `frontend/src/services/store.tsx` - Redux store setup +- `docker-compose.yml` - Local development environment +- `config/env/dev.env.example` - Environment variables template + +## Project Conventions + +- Python: Follow Django conventions, use class-based views (APIView) +- TypeScript: Use functional components with hooks, avoid default exports except for pages +- CSS: Prefer Tailwind utilities, use custom CSS only when necessary +- Git: Feature branches from `develop`, PRs to `listOfMed` +- Code formatting: Prettier for frontend (with Tailwind plugin) diff --git a/docs/MIGRATION_PDF_AUTH.md b/docs/MIGRATION_PDF_AUTH.md new file mode 100644 index 00000000..d5f7df26 --- /dev/null +++ b/docs/MIGRATION_PDF_AUTH.md @@ -0,0 +1,307 @@ +# Migration Guide: Conditional PDF Access Feature + +**Date**: January 2025 +**Feature**: Authentication-based PDF viewing and downloading +**PR/Issue**: [Link to PR if applicable] + +## Overview + +This migration adds conditional behavior to PDF source buttons based on user authentication status: +- **Authenticated users**: "View PDF" button opens PDF viewer in new tab +- **Unauthenticated users**: "Download PDF" button triggers direct file download + +## How It Works + +### Button Logic Flow + +The button checks the user's authentication state and uses the `upload_file_guid` to determine behavior: + +``` +User clicks medication → Expands to show sources + ↓ + Check: isAuthenticated? + ↓ + ┌─────────────────┴─────────────────┐ + ↓ ↓ + YES (Logged In) NO (Not Logged In) + ↓ ↓ + "View PDF" (Blue Button) "Download PDF" (Green Button) + ↓ ↓ + Opens /drugSummary page Direct download via + with PDF viewer /v1/api/uploadFile/ + (target="_blank") (download attribute) +``` + +### When User is NOT Authenticated: + +```typescript + + Download PDF + +``` + +- Uses `upload_file_guid` to construct download URL: `/v1/api/uploadFile/` +- The `download` attribute forces browser to download instead of opening +- Endpoint is **public** (AllowAny permission) - no authentication required +- File downloads directly with original filename from database + +### When User IS Authenticated: + +```typescript + + View PDF + +``` + +- Uses `link_url` which points to `/drugSummary` page +- Opens in new tab with `target="_blank"` +- The drugSummary page renders a PDF viewer with navigation controls +- User can navigate between pages, zoom, etc. + +### Key Points: + +1. ✅ **Both auth types can access PDFs** - the download endpoint (`/v1/api/uploadFile/`) is public +2. ✅ The difference is **presentation**: + - **Authenticated**: Rich PDF viewer experience with navigation + - **Unauthenticated**: Simple direct download to local machine +3. ✅ The `upload_file_guid` is the primary identifier for fetching files from the database +4. ✅ **Fallback mechanism**: If `upload_file_guid` is missing from API response, it's extracted from the `link_url` query parameter + +### Code Location: + +The conditional logic is in `frontend/src/pages/PatientManager/PatientSummary.tsx` around line 165-180: + +```typescript +{s.link_url && (() => { + // Get GUID from API or extract from URL as fallback + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + // Render different button based on authentication + return isAuthenticated ? ( + // Blue "View PDF" button for authenticated users + View PDF + ) : ( + // Green "Download PDF" button for unauthenticated users + Download PDF + ); +})()} +``` + +## Breaking Changes + +⚠️ **None** - This is a backward-compatible enhancement + +## Database Changes + +✅ **None** - No migrations required + +## API Changes + +### Backend: `POST /v1/api/riskWithSources` + +**Response Schema Update**: +```python +# New field added to each item in sources array: +{ + "sources": [ + { + "filename": "example.pdf", + "title": "Example Document", + "publication": "Journal Name", + "text": "...", + "rule_type": "INCLUDE", + "history_type": "DIAGNOSIS_MANIC", + "upload_fileid": 123, + "page": 5, + "link_url": "/drugSummary?guid=xxx&page=5", + "upload_file_guid": "xxx-xxx-xxx" // NEW FIELD + } + ] +} +``` + +**File**: `server/api/views/risk/views_riskWithSources.py` +**Lines Modified**: ~138-149, ~252-263, ~359-370 + +## Frontend Changes + +### 1. Component Prop Changes + +**PatientManager** now retrieves and passes authentication state: +```typescript +// Added imports +import { useSelector } from "react-redux"; +import { RootState } from "../../services/actions/types"; + +// New hook call +const { isAuthenticated } = useSelector((state: RootState) => state.auth); + +// New prop passed + +``` + +**PatientSummary** interface updated: +```typescript +interface PatientSummaryProps { + // ... existing props + isAuthenticated?: boolean; // NEW +} + +type SourceItem = { + // ... existing fields + upload_file_guid?: string | null; // NEW +} +``` + +### 2. New Helper Function + +```typescript +/** + * Fallback to extract GUID from URL if API doesn't provide upload_file_guid + */ +const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } +}; +``` + +### 3. Component Hierarchy Updates + +Props now flow through: `PatientManager` → `PatientSummary` → `MedicationTier` → `MedicationItem` + +Each intermediate component needs `isAuthenticated` and `baseURL` props added. + +## Route Changes + +### URL Consistency Fix + +**Old (inconsistent)**: +- Backend: `/drugsummary` (lowercase) +- Frontend route: `/drugSummary` (camelCase) + +**New (consistent)**: +- All references now use: `/drugSummary` (camelCase) + +**Files Updated**: +- `server/api/views/risk/views_riskWithSources.py` +- `frontend/src/pages/Layout/Layout_V2_Sidebar.tsx` +- `frontend/src/pages/Layout/Layout_V2_Header.tsx` +- `frontend/src/pages/Files/FileRow.tsx` + +## Authentication Flow Fixes + +### ProtectedRoute Component + +**Problem**: Opening protected routes in new tab caused immediate redirect to login + +**Solution**: Wait for auth check to complete +```typescript +if (isAuthenticated === null) { + return null; // Wait for auth verification +} +``` + +### Layout_V2_Main Component + +**Added**: `useAuth()` hook to trigger authentication check on mount + +## Testing Checklist + +### Manual Testing Steps + +1. **As unauthenticated user**: + - [ ] Navigate to medication suggester + - [ ] Submit patient information + - [ ] Expand medication to view sources + - [ ] Verify "Download PDF" button appears (green) + - [ ] Click button and verify file downloads + - [ ] Verify no redirect to login occurs + +2. **As authenticated user**: + - [ ] Log in to application + - [ ] Navigate to medication suggester + - [ ] Submit patient information + - [ ] Expand medication to view sources + - [ ] Verify "View PDF" button appears (blue) + - [ ] Click button and verify PDF viewer opens in new tab + - [ ] Verify new tab doesn't redirect to login + +3. **Edge cases**: + - [ ] Test with sources that have no link_url + - [ ] Test with sources that have link_url but no upload_file_guid + - [ ] Test opening protected route directly in new tab + - [ ] Test authentication state persistence across tabs + +### Automated Tests + +**TODO**: Add integration tests for: +- PDF button conditional rendering +- GUID extraction fallback +- Protected route authentication flow + +## Deployment Notes + +### Backend Deployment + +1. Deploy updated Django code +2. **No database migrations required** +3. Restart Django application server +4. Verify API response includes `upload_file_guid` field + +### Frontend Deployment + +1. Build frontend with updated code: `npm run build` +2. Deploy built assets +3. Clear CDN/browser cache if applicable +4. Verify button behavior for both auth states + +### Rollback Plan + +If issues occur: +1. Revert backend to previous version (API still compatible) +2. Frontend will use fallback GUID extraction from URL +3. Feature will degrade gracefully - button may show for all users but behavior remains functional + +## Environment Variables + +No new environment variables required. Uses existing: +- `VITE_API_BASE_URL` - Frontend API base URL + +## Known Issues / Limitations + +1. **GUID Fallback**: If both `upload_file_guid` and `link_url` are missing/invalid, no button appears +2. **Download Naming**: Downloaded files use server-provided filename, not customizable per-user +3. **Public Access**: Download endpoint is public - PDFs accessible to anyone with GUID + +## Future Enhancements + +- [ ] Add loading spinner while PDF downloads +- [ ] Add analytics tracking for PDF views/downloads +- [ ] Implement PDF access permissions/restrictions +- [ ] Add rate limiting to download endpoint + +## Support + +For questions or issues: +- GitHub Issues: [Repository Issues Link] +- Team Contact: balancerteam@codeforphilly.org + +## References + +- CHANGELOG.md - High-level changes +- CLAUDE.md - Updated project documentation +- Code comments in PatientSummary.tsx diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 6a983a08..cbbd2c93 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -10,7 +10,7 @@ import { FaChevronDown, FaSignOutAlt } from "react-icons/fa"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; isSuperuser: boolean; } @@ -24,9 +24,12 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { useGlobalContext(); const authLinks = () => ( - - Sign Out - + + Sign Out + ); @@ -70,22 +73,29 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => {

    - Welcome to Balancer’s first release! Found a bug or have feedback? Let us know {" "} + Welcome to Balancer’s first release! Found a bug or have feedback? Let + us know{" "} - here {" "} + here{" "} - or email {" "} - + or email{" "} + balancerteam@codeforphilly.org .

    - App is in beta; report issues to {" "} - + App is in beta; report issues to{" "} + balancerteam@codeforphilly.org . @@ -161,7 +171,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { > Donate - {(isAuthenticated && isSuperuser) && ( + {isAuthenticated && isSuperuser && (

    = ({ isAuthenticated, isSuperuser }) => { )} + + Balancer + {isAuthenticated && authLinks()}
    diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index f2cfc67b..5a8d5bce 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -7,7 +7,7 @@ import closeLogo from "../../assets/close.svg"; import hamburgerLogo from "../../assets/hamburger.svg"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; handleForm: () => void; } diff --git a/frontend/src/pages/Layout/Layout.tsx b/frontend/src/pages/Layout/Layout.tsx index 84f9c215..02274b78 100644 --- a/frontend/src/pages/Layout/Layout.tsx +++ b/frontend/src/pages/Layout/Layout.tsx @@ -11,7 +11,7 @@ interface LayoutProps { } interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } export const Layout = ({ diff --git a/frontend/src/pages/Layout/Layout_V2_Header.tsx b/frontend/src/pages/Layout/Layout_V2_Header.tsx index 3371cef5..c896b7b1 100644 --- a/frontend/src/pages/Layout/Layout_V2_Header.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Header.tsx @@ -3,7 +3,7 @@ import { useAuth } from "./authHooks.ts"; import { useGlobalContext } from "../../../src/contexts/GlobalContext.tsx"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const Header: React.FC = () => { diff --git a/frontend/src/pages/Layout/Layout_V2_Main.tsx b/frontend/src/pages/Layout/Layout_V2_Main.tsx index 132482b6..2ebad75c 100644 --- a/frontend/src/pages/Layout/Layout_V2_Main.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Main.tsx @@ -7,7 +7,7 @@ import Sidebar from "./Layout_V2_Sidebar"; interface LayoutProps { children: ReactNode; - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const Layout: React.FC = ({ children, isAuthenticated }) => { diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index ce28c62c..d0d08184 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -9,7 +9,7 @@ import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; loginError?: string | null; // Align this with the mapped state } diff --git a/frontend/src/pages/Login/ResetPassword.tsx b/frontend/src/pages/Login/ResetPassword.tsx index ba57f601..61345aa8 100644 --- a/frontend/src/pages/Login/ResetPassword.tsx +++ b/frontend/src/pages/Login/ResetPassword.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; import Layout from "../Layout/Layout"; interface ResetPasswordProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } function ResetPassword(props: ResetPasswordProps) { diff --git a/frontend/src/pages/Login/ResetPasswordConfirm.tsx b/frontend/src/pages/Login/ResetPasswordConfirm.tsx index 8f497817..533669bb 100644 --- a/frontend/src/pages/Login/ResetPasswordConfirm.tsx +++ b/frontend/src/pages/Login/ResetPasswordConfirm.tsx @@ -10,7 +10,7 @@ import { useEffect, useState } from "react"; import Layout from "../Layout/Layout"; interface ResetPasswordConfirmProps { - isAuthenticated: boolean; + isAuthenticated: boolean | null; } const ResetPasswordConfirm: React.FC = ({ diff --git a/frontend/src/pages/PatientManager/PatientManager.tsx b/frontend/src/pages/PatientManager/PatientManager.tsx index f49dfa48..00b94050 100644 --- a/frontend/src/pages/PatientManager/PatientManager.tsx +++ b/frontend/src/pages/PatientManager/PatientManager.tsx @@ -1,5 +1,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; +import { useSelector } from "react-redux"; +import { RootState } from "../../services/actions/types"; import NewPatientForm from "./NewPatientForm.tsx"; import PatientHistory from "./PatientHistory.tsx"; // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -11,6 +13,7 @@ import Welcome from "../../components/Welcome/Welcome.tsx"; import { useGlobalContext } from "../../contexts/GlobalContext.tsx"; const PatientManager = () => { + const { isAuthenticated } = useSelector((state: RootState) => state.auth); const [patientInfo, setPatientInfo] = useState({ ID: "", Diagnosis: Diagnosis.Manic, @@ -116,6 +119,7 @@ const PatientManager = () => { patientInfo={patientInfo} isPatientDeleted={isPatientDeleted} setPatientInfo={setPatientInfo} + isAuthenticated={isAuthenticated} /> >; + isAuthenticated: boolean | null; } type SourceItem = { @@ -27,6 +28,7 @@ type SourceItem = { guid?: string | null; page?: number | null; link_url?: string | null; + upload_file_guid?: string | null; }; type RiskData = { benefits: string[]; @@ -43,12 +45,29 @@ type MedicationWithSource = { const truncate = (s = "", n = 220) => s.length > n ? s.slice(0, n).trim() + "…" : s; +/** + * Extracts the GUID from a drugSummary URL query parameter + * Used as fallback when upload_file_guid is not provided by the API + * @param url - URL string like "/drugSummary?guid=xxx&page=1" + * @returns GUID string or null if not found + */ +const extractGuidFromUrl = (url: string): string | null => { + try { + const urlObj = new URL(url, window.location.origin); + return urlObj.searchParams.get('guid'); + } catch { + return null; + } +}; + const MedicationItem = ({ medication, isClicked, riskData, loading, onTierClick, + isAuthenticated, + baseURL, }: { medication: string; source: string; @@ -56,6 +75,8 @@ const MedicationItem = ({ riskData: RiskData | null; loading: boolean; onTierClick: () => void; + isAuthenticated: boolean | null; + baseURL: string; }) => { if (medication === "None") { return ( @@ -141,16 +162,35 @@ const MedicationItem = ({
    {s.title || "Untitled source"} - {s.link_url && ( - - View PDF - - )} + {/* + Conditional PDF Button: + - Logged in: "View PDF" (blue) → Opens /drugSummary in new tab + - Not logged in: "Download PDF" (green) → Direct download via /v1/api/uploadFile/ + - Fallback: Extracts GUID from link_url if upload_file_guid is missing + */} + {s.link_url && (() => { + const guid = s.upload_file_guid || extractGuidFromUrl(s.link_url); + if (!guid) return null; + + return isAuthenticated ? ( + + View PDF + + ) : ( + + Download PDF + + ); + })()}
    {s.publication && ( @@ -192,6 +232,8 @@ const MedicationTier = ({ riskData, loading, onTierClick, + isAuthenticated, + baseURL, }: { title: string; tier: string; @@ -200,6 +242,8 @@ const MedicationTier = ({ riskData: RiskData | null; loading: boolean; onTierClick: (medication: MedicationWithSource) => void; + isAuthenticated: boolean | null; + baseURL: string; }) => ( <>
    @@ -216,6 +260,8 @@ const MedicationTier = ({ riskData={riskData} loading={loading} onTierClick={() => onTierClick(medicationObj)} + isAuthenticated={isAuthenticated} + baseURL={baseURL} /> ))} @@ -232,7 +278,9 @@ const PatientSummary = ({ setIsEditing, patientInfo, isPatientDeleted, + isAuthenticated = false, }: PatientSummaryProps) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || ''; const [loading, setLoading] = useState(false); const [riskData, setRiskData] = useState(null); const [clickedMedication, setClickedMedication] = useState( @@ -374,6 +422,8 @@ const PatientSummary = ({ riskData={riskData} loading={loading} onTierClick={handleTierClick} + isAuthenticated={isAuthenticated} + baseURL={baseURL} />
    @@ -395,6 +447,8 @@ const PatientSummary = ({ riskData={riskData} loading={loading} onTierClick={handleTierClick} + isAuthenticated={isAuthenticated} + baseURL={baseURL} />
    From 4297e40432dcca3e57b7e99917dd507bf6f900fe Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Sun, 14 Dec 2025 19:01:11 -0500 Subject: [PATCH 050/181] Fix stuck spinner on protected routes by triggering auth verification --- .../components/ProtectedRoute/ProtectedRoute.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx index dc323217..66333556 100644 --- a/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx +++ b/frontend/src/components/ProtectedRoute/ProtectedRoute.tsx @@ -1,7 +1,8 @@ -import { ReactNode } from 'react'; +import { ReactNode, useEffect } from 'react'; import { Navigate, useLocation } from 'react-router-dom'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; import Spinner from '../LoadingSpinner/LoadingSpinner'; interface ProtectedRouteProps { @@ -10,8 +11,16 @@ interface ProtectedRouteProps { const ProtectedRoute = ({ children }: ProtectedRouteProps) => { const location = useLocation(); + const dispatch = useDispatch(); const { isAuthenticated } = useSelector((state: RootState) => state.auth); - + + // Check authentication status when component mounts + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + // Wait for auth check to complete (null means not checked yet) // TODO: Consider adding error handling for auth check failures if (isAuthenticated === null) { From bf79b4a9c4a9e9582d79de2a37690f0e18980493 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 19 Dec 2025 16:03:11 -0500 Subject: [PATCH 051/181] Fix User logged as None --- frontend/src/api/apiClient.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 915226d6..0a566613 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -289,7 +289,9 @@ const sendAssistantMessage = async ( previousResponseId?: string, ) => { try { - const response = await publicApi.post(`/v1/api/assistant`, { + // The adminApi interceptor will automatically include your JWT token + // if you're authenticated, and gracefully omit it if you're not + const response = await adminApi.post(`/v1/api/assistant`, { message, previous_response_id: previousResponseId, }); From 163e1072ef2e51dd87773e47c61d35355812b397 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 19 Dec 2025 18:23:50 -0500 Subject: [PATCH 052/181] Address the user tracking issue without a 401 error --- frontend/src/api/apiClient.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 0a566613..08719bb4 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -289,9 +289,9 @@ const sendAssistantMessage = async ( previousResponseId?: string, ) => { try { - // The adminApi interceptor will automatically include your JWT token - // if you're authenticated, and gracefully omit it if you're not - const response = await adminApi.post(`/v1/api/assistant`, { + // The adminApi interceptor doesn't gracefully omit the JWT token if you're not authenticated + const api = localStorage.getItem("access") ? adminApi : publicApi; + const response = await api.post(`/v1/api/assistant`, { message, previous_response_id: previousResponseId, }); From 764048cc4e0e3721d309205027b94bd0f6147cfc Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 28 Dec 2025 11:54:59 -0500 Subject: [PATCH 053/181] Update VITE_API_BASE_URL to point to the new prod url --- frontend/.env.production | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/.env.production b/frontend/.env.production index a05a022d..71adcf10 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1 +1 @@ -VITE_API_BASE_URL=https://balancer.live.k8s.phl.io/ \ No newline at end of file +VITE_API_BASE_URL=https://balancerproject.org/ \ No newline at end of file From 901d06532cdd51c8db8d6bfd11e137adbc5b82af Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 28 Dec 2025 12:13:25 -0500 Subject: [PATCH 054/181] Add GitHub Actions workflow for deploying downstream clusters --- .github/workflows/deploy-downstream.yml | 87 +++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/deploy-downstream.yml diff --git a/.github/workflows/deploy-downstream.yml b/.github/workflows/deploy-downstream.yml new file mode 100644 index 00000000..2557ff17 --- /dev/null +++ b/.github/workflows/deploy-downstream.yml @@ -0,0 +1,87 @@ +name: "Deploy: Downstream Clusters" + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Image tag to deploy (e.g. 1.1.0)' + required: true + default: 'latest' + +jobs: + update-sandbox: + name: Update Sandbox Cluster + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.get_tag.outputs.TAG }} + steps: + - name: Checkout App + uses: actions/checkout@v4 + + - name: Get Release Tag + id: get_tag + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "TAG=${{ inputs.tag }}" >> $GITHUB_OUTPUT + else + echo "TAG=${GITHUB_REF:11}" >> $GITHUB_OUTPUT + fi + + - name: Checkout Sandbox Cluster + uses: actions/checkout@v4 + with: + repository: CodeForPhilly/cfp-sandbox-cluster + token: ${{ secrets.BOT_GITHUB_TOKEN }} + path: sandbox + + - name: Update Sandbox Image Tag + working-directory: sandbox/balancer + run: | + curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash + ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ steps.get_tag.outputs.TAG }} + rm kustomize + + - name: Create Sandbox PR + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.BOT_GITHUB_TOKEN }} + path: sandbox + commit-message: "Deploy balancer ${{ steps.get_tag.outputs.TAG }} to sandbox" + title: "Deploy balancer ${{ steps.get_tag.outputs.TAG }}" + body: "Updates balancer image tag to ${{ steps.get_tag.outputs.TAG }}" + branch: "deploy/balancer-${{ steps.get_tag.outputs.TAG }}" + base: main + delete-branch: true + + update-live: + name: Update Live Cluster + needs: update-sandbox + runs-on: ubuntu-latest + steps: + - name: Checkout Live Cluster + uses: actions/checkout@v4 + with: + repository: CodeForPhilly/cfp-live-cluster + token: ${{ secrets.BOT_GITHUB_TOKEN }} + path: live + + - name: Update Live Image Tag + working-directory: live/balancer + run: | + curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash + ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ needs.update-sandbox.outputs.tag }} + rm kustomize + + - name: Create Live PR + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.BOT_GITHUB_TOKEN }} + path: live + commit-message: "Deploy balancer ${{ needs.update-sandbox.outputs.tag }} to live" + title: "Deploy balancer ${{ needs.update-sandbox.outputs.tag }}" + body: "Updates balancer image tag to ${{ needs.update-sandbox.outputs.tag }}" + branch: "deploy/balancer-${{ needs.update-sandbox.outputs.tag }}" + base: main + delete-branch: true From 8ca20aaf3c3e34f135c10cb68f2382eeafde606e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 5 Jan 2026 06:29:20 -0500 Subject: [PATCH 055/181] Enhance database connection documentation and configuration - Updated README.md to include support for PostgreSQL databases via CloudNativePG, AWS RDS, and local Docker Compose. - Added detailed database connection configuration in a new DATABASE_CONNECTION.md file, covering connection type detection, environment variables, and SSL configuration. - Modified dev.env.example to provide examples for different database connection types and SSL mode options. - Updated settings.py to dynamically configure database settings based on the connection type detected from SQL_HOST. --- README.md | 10 ++ config/env/dev.env.example | 22 ++++ docs/DATABASE_CONNECTION.md | 174 ++++++++++++++++++++++++++++ server/balancer_backend/settings.py | 49 ++++++-- 4 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 docs/DATABASE_CONNECTION.md diff --git a/README.md b/README.md index 0b48973e..f1cea06b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ Tools used for development: Start the Postgres, Django REST, and React services by starting Docker Desktop and running `docker compose up --build` #### Postgres + +The application supports connecting to PostgreSQL databases via: + +1. **CloudNativePG** - Kubernetes-managed PostgreSQL cluster (for production/sandbox) +2. **AWS RDS** - External PostgreSQL database (AWS managed) +3. **Local Docker Compose** - For local development + +See [Database Connection Documentation](./docs/DATABASE_CONNECTION.md) for detailed configuration. + +**Local Development:** - Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) - The email and password of `pgAdmin` are specified in `balancer-main/docker-compose.yml` - The first time you use `pgAdmin` after building the Docker containers you will need to register the server. diff --git a/config/env/dev.env.example b/config/env/dev.env.example index f596affc..4b40294b 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -1,13 +1,35 @@ DEBUG=True SECRET_KEY=foo +# Database Configuration +# Supports both CloudNativePG (Kubernetes service) and AWS RDS (external host) SQL_ENGINE=django.db.backends.postgresql SQL_DATABASE=balancer_dev SQL_USER=balancer SQL_PASSWORD=balancer + +# Connection Type Examples: +# +# CloudNativePG (Kubernetes service within cluster): +# SQL_HOST=balancer-postgres-rw +# SQL_HOST=balancer-postgres-rw.balancer.svc.cluster.local +# (SSL typically not required within cluster) +# +# AWS RDS (External database): +# SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com +# (SSL typically required - set SQL_SSL_MODE if needed) +# +# Local development: +# SQL_HOST=localhost +# SQL_HOST=db # Docker Compose service name SQL_HOST=db SQL_PORT=5432 +# Optional: SSL mode for PostgreSQL connections +# Options: disable, allow, prefer, require, verify-ca, verify-full +# Default: require for external hosts (AWS RDS), disabled for CloudNativePG +# SQL_SSL_MODE=require + LOGIN_REDIRECT_URL= OPENAI_API_KEY= ANTHROPIC_API_KEY= diff --git a/docs/DATABASE_CONNECTION.md b/docs/DATABASE_CONNECTION.md new file mode 100644 index 00000000..57ac3fac --- /dev/null +++ b/docs/DATABASE_CONNECTION.md @@ -0,0 +1,174 @@ +# Database Connection Configuration + +The balancer application supports connecting to PostgreSQL databases via two methods: + +1. **CloudNativePG** - Kubernetes-managed PostgreSQL cluster (within cluster) +2. **AWS RDS** - External PostgreSQL database (AWS managed) + +The application automatically detects the connection type based on the `SQL_HOST` environment variable format. + +## Connection Type Detection + +The application determines the connection type by analyzing the `SQL_HOST` value: + +- **CloudNativePG**: + - Contains `.svc.cluster.local` (Kubernetes service DNS) + - Short service name (e.g., `balancer-postgres-rw`) + - Typically no SSL required within cluster + +- **AWS RDS**: + - Full domain name (e.g., `balancer-db.xxxxx.us-east-1.rds.amazonaws.com`) + - External IP address + - Typically requires SSL + +## Configuration + +### Environment Variables + +All database configuration is done via environment variables: + +- `SQL_ENGINE`: Database engine (default: `django.db.backends.postgresql`) +- `SQL_DATABASE`: Database name +- `SQL_USER`: Database username +- `SQL_PASSWORD`: Database password +- `SQL_HOST`: Database host (see examples below) +- `SQL_PORT`: Database port (default: `5432`) +- `SQL_SSL_MODE`: Optional SSL mode (see SSL Configuration below) + +### CloudNativePG Configuration + +When using CloudNativePG, the application connects to the Kubernetes service created by the operator. + +**Example Configuration:** +```bash +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer +SQL_USER=balancer +SQL_PASSWORD= +SQL_HOST=balancer-postgres-rw +SQL_PORT=5432 +``` + +**Service Names:** +- `{cluster-name}-rw`: Read-write service (primary instance) +- `{cluster-name}-r`: Read service (replicas) +- `{cluster-name}-ro`: Read-only service + +**Full DNS Name:** +```bash +SQL_HOST=balancer-postgres-rw.balancer.svc.cluster.local +``` + +### AWS RDS Configuration + +When using AWS RDS, the application connects to the external RDS endpoint. + +**Example Configuration:** +```bash +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer +SQL_USER=balancer +SQL_PASSWORD= +SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com +SQL_PORT=5432 +SQL_SSL_MODE=require +``` + +## SSL Configuration + +### CloudNativePG + +SSL is typically **not required** for connections within the Kubernetes cluster. The application will not use SSL by default for CloudNativePG connections. + +### AWS RDS + +SSL is typically **required** for AWS RDS connections. The application defaults to `require` mode for external hosts, but you can override this: + +**SSL Mode Options:** +- `disable`: No SSL +- `allow`: Try non-SSL first, then SSL +- `prefer`: Try SSL first, then non-SSL (default for external) +- `require`: Require SSL +- `verify-ca`: Require SSL and verify CA +- `verify-full`: Require SSL and verify CA and hostname + +**Example:** +```bash +SQL_SSL_MODE=require +``` + +## Migration Guide + +### From AWS RDS to CloudNativePG + +1. Update the `SQL_HOST` environment variable in your SealedSecret: + ```bash + # Old (AWS RDS) + SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com + + # New (CloudNativePG) + SQL_HOST=balancer-postgres-rw + ``` + +2. Update database credentials to match CloudNativePG secret + +3. Remove or set `SQL_SSL_MODE` to `disable` (optional, as it's auto-detected) + +4. Restart the application pods + +### From CloudNativePG to AWS RDS + +1. Update the `SQL_HOST` environment variable: + ```bash + # Old (CloudNativePG) + SQL_HOST=balancer-postgres-rw + + # New (AWS RDS) + SQL_HOST=balancer-db.xxxxx.us-east-1.rds.amazonaws.com + ``` + +2. Update database credentials to match RDS credentials + +3. Set `SQL_SSL_MODE=require` (or appropriate mode) + +4. Ensure network connectivity (VPC peering, security groups, etc.) + +5. Restart the application pods + +## Troubleshooting + +### Connection Issues + +1. **Verify host format**: Check that `SQL_HOST` matches the expected format for your connection type + +2. **Check network connectivity**: + - CloudNativePG: Ensure pods are in the same namespace + - AWS RDS: Verify VPC peering, security groups, and network ACLs + +3. **Verify credentials**: Ensure username, password, and database name are correct + +4. **Check SSL configuration**: For AWS RDS, ensure SSL is properly configured + +### Common Errors + +**"Connection refused"** +- Verify the host and port are correct +- Check if the database service is running +- Verify network connectivity + +**"SSL required"** +- Add `SQL_SSL_MODE=require` for AWS RDS connections +- Verify SSL certificates are available + +**"Authentication failed"** +- Verify username and password +- Check database user permissions +- Ensure the database exists + +## References + +- [Django Database Configuration](https://docs.djangoproject.com/en/4.2/ref/settings/#databases) +- [CloudNativePG Documentation](https://cloudnative-pg.io/) +- [AWS RDS PostgreSQL](https://docs.aws.amazon.com/rds/latest/userguide/CHAP_PostgreSQL.html) +- [PostgreSQL SSL Configuration](https://www.postgresql.org/docs/current/libpq-ssl.html) + diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 16764f0e..58148617 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -94,15 +94,48 @@ # Database # https://docs.djangoproject.com/en/4.2/ref/settings/#databases +# Detect connection type based on SQL_HOST +# CloudNativePG: Kubernetes service names (e.g., "balancer-postgres-rw" or contains ".svc.cluster.local") +# AWS RDS: External hostnames (e.g., "balancer-db.xxxxx.us-east-1.rds.amazonaws.com") +SQL_HOST = os.environ.get("SQL_HOST", "localhost") +is_cloudnativepg = ( + ".svc.cluster.local" in SQL_HOST + or not ("." in SQL_HOST and len(SQL_HOST.split(".")) > 2) + or SQL_HOST.count(".") <= 1 +) + +# Build database configuration +db_config = { + "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), + "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), + "USER": os.environ.get("SQL_USER", "user"), + "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), + "HOST": SQL_HOST, + "PORT": os.environ.get("SQL_PORT", "5432"), +} + +# Configure SSL/TLS based on connection type +# CloudNativePG within cluster typically doesn't require SSL +# AWS RDS typically requires SSL +if db_config["ENGINE"] == "django.db.backends.postgresql": + # Check if SSL is explicitly configured + ssl_mode = os.environ.get("SQL_SSL_MODE", None) + + if ssl_mode: + # Use explicit SSL configuration + db_config["OPTIONS"] = { + "sslmode": ssl_mode, + } + elif not is_cloudnativepg: + # For external databases (AWS RDS), default to require SSL + # This can be overridden by setting SQL_SSL_MODE + db_config["OPTIONS"] = { + "sslmode": "require", + } + # For CloudNativePG (within cluster), no SSL by default + DATABASES = { - "default": { - "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), - "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), - "USER": os.environ.get("SQL_USER", "user"), - "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), - "HOST": os.environ.get("SQL_HOST", "localhost"), - "PORT": os.environ.get("SQL_PORT", "5432"), - } + "default": db_config, } EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" From b8a3619435a2a8dea31798a933e2c4d5282a44b9 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Tue, 6 Jan 2026 18:54:03 -0500 Subject: [PATCH 056/181] Add centralized API endpoints configuration and refactor API calls - Introduced a new `endpoints.ts` file to centralize all API endpoint paths for better maintainability and type safety. - Updated various components and services to utilize the new centralized endpoints, enhancing consistency across the codebase. - Created a comprehensive `API_ENDPOINTS_REFACTORING.md` guide detailing the refactoring process and usage patterns for the new endpoints. - Removed hardcoded URLs in favor of the centralized configuration, improving code readability and reducing the risk of errors. --- frontend/API_ENDPOINTS_REFACTORING.md | 216 ++++++++++++++++++ frontend/src/api/apiClient.ts | 37 +-- frontend/src/api/endpoints.ts | 137 +++++++++++ .../src/pages/DocumentManager/UploadFile.tsx | 3 +- frontend/src/pages/DrugSummary/PDFViewer.tsx | 6 +- frontend/src/pages/Files/FileRow.tsx | 4 +- frontend/src/pages/Files/ListOfFiles.tsx | 6 +- .../src/pages/Layout/Layout_V2_Sidebar.tsx | 3 +- .../src/pages/ListMeds/useMedications.tsx | 6 +- frontend/src/pages/ManageMeds/ManageMeds.tsx | 7 +- .../pages/PatientManager/NewPatientForm.tsx | 3 +- .../pages/PatientManager/PatientSummary.tsx | 12 +- .../src/pages/RulesManager/RulesManager.tsx | 6 +- .../src/pages/Settings/SettingsManager.tsx | 7 +- frontend/src/services/actions/auth.tsx | 19 +- server/balancer_backend/urls.py | 26 ++- 16 files changed, 420 insertions(+), 78 deletions(-) create mode 100644 frontend/API_ENDPOINTS_REFACTORING.md create mode 100644 frontend/src/api/endpoints.ts diff --git a/frontend/API_ENDPOINTS_REFACTORING.md b/frontend/API_ENDPOINTS_REFACTORING.md new file mode 100644 index 00000000..a765fd71 --- /dev/null +++ b/frontend/API_ENDPOINTS_REFACTORING.md @@ -0,0 +1,216 @@ +# API Endpoints Refactoring Guide + +This document explains how to refactor API URLs to use the centralized endpoints configuration. + +## Overview + +All API endpoints are now centralized in `src/api/endpoints.ts`. This makes it: +- **Maintainable**: Change URLs in one place +- **Type-safe**: TypeScript ensures correct usage +- **Discoverable**: All endpoints are documented in one file +- **Consistent**: No more typos or inconsistent paths + +## Usage Patterns + +### 1. Simple Static Endpoints + +**Before:** +```typescript +const url = `/api/v1/api/feedback/`; +await publicApi.post(url, data); +``` + +**After:** +```typescript +import { V1_API_ENDPOINTS } from "../api/endpoints"; + +await publicApi.post(V1_API_ENDPOINTS.FEEDBACK, data); +``` + +### 2. Dynamic Endpoints with Parameters + +**Before:** +```typescript +const url = `/api/v1/api/uploadFile/${guid}`; +await fetch(url); +``` + +**After:** +```typescript +import { endpoints } from "../api/endpoints"; + +const url = endpoints.uploadFile(guid); +await fetch(url); +``` + +### 3. Endpoints with Query Parameters + +**Before:** +```typescript +const endpoint = guid + ? `/api/v1/api/embeddings/ask_embeddings?guid=${guid}` + : '/api/v1/api/embeddings/ask_embeddings'; +``` + +**After:** +```typescript +import { endpoints } from "../api/endpoints"; + +const endpoint = endpoints.embeddingsAsk(guid); +``` + +## Available Endpoint Groups + +### Authentication Endpoints +```typescript +import { AUTH_ENDPOINTS } from "../api/endpoints"; + +AUTH_ENDPOINTS.JWT_VERIFY +AUTH_ENDPOINTS.JWT_CREATE +AUTH_ENDPOINTS.USER_ME +AUTH_ENDPOINTS.RESET_PASSWORD +AUTH_ENDPOINTS.RESET_PASSWORD_CONFIRM +``` + +### V1 API Endpoints +```typescript +import { V1_API_ENDPOINTS } from "../api/endpoints"; + +V1_API_ENDPOINTS.FEEDBACK +V1_API_ENDPOINTS.UPLOAD_FILE +V1_API_ENDPOINTS.GET_FULL_LIST_MED +V1_API_ENDPOINTS.MED_RULES +// ... and more +``` + +### Conversation Endpoints +```typescript +import { CONVERSATION_ENDPOINTS } from "../api/endpoints"; + +CONVERSATION_ENDPOINTS.CONVERSATIONS +CONVERSATION_ENDPOINTS.EXTRACT_TEXT +``` + +### AI Settings Endpoints +```typescript +import { AI_SETTINGS_ENDPOINTS } from "../api/endpoints"; + +AI_SETTINGS_ENDPOINTS.SETTINGS +``` + +### Helper Functions +```typescript +import { endpoints } from "../api/endpoints"; + +endpoints.embeddingsAsk(guid?) +endpoints.embeddingsAskStream(guid?) +endpoints.ruleExtraction(guid) +endpoints.conversation(id) +endpoints.continueConversation(id) +endpoints.updateConversationTitle(id) +endpoints.uploadFile(guid) +endpoints.editMetadata(guid) +``` + +## Files to Refactor + +The following files still need to be updated to use the centralized endpoints: + +1. `src/pages/Settings/SettingsManager.tsx` - Use `AI_SETTINGS_ENDPOINTS.SETTINGS` +2. `src/pages/RulesManager/RulesManager.tsx` - Use `V1_API_ENDPOINTS.MED_RULES` +3. `src/pages/PatientManager/NewPatientForm.tsx` - Use `V1_API_ENDPOINTS.GET_MED_RECOMMEND` +4. `src/pages/ManageMeds/ManageMeds.tsx` - Use `V1_API_ENDPOINTS.*` for all medication endpoints +5. `src/pages/ListMeds/useMedications.tsx` - Use `V1_API_ENDPOINTS.GET_FULL_LIST_MED` +6. `src/pages/Layout/Layout_V2_Sidebar.tsx` - Use `V1_API_ENDPOINTS.UPLOAD_FILE` +7. `src/pages/Files/ListOfFiles.tsx` - Use `V1_API_ENDPOINTS.UPLOAD_FILE` +8. `src/pages/DocumentManager/UploadFile.tsx` - Use `V1_API_ENDPOINTS.UPLOAD_FILE` +9. `src/pages/Files/FileRow.tsx` - Use `endpoints.editMetadata(guid)` +10. `src/pages/DrugSummary/PDFViewer.tsx` - Use `endpoints.uploadFile(guid)` +11. `src/pages/PatientManager/PatientSummary.tsx` - Use `endpoints.uploadFile(guid)` + +## Example Refactoring + +### Example 1: SettingsManager.tsx + +**Before:** +```typescript +const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; +const url = `${baseUrl}/ai_settings/settings/`; +``` + +**After:** +```typescript +import { AI_SETTINGS_ENDPOINTS } from "../../api/endpoints"; + +const url = AI_SETTINGS_ENDPOINTS.SETTINGS; +``` + +### Example 2: FileRow.tsx + +**Before:** +```typescript +const baseUrl = import.meta.env.VITE_API_BASE_URL as string; +await fetch(`${baseUrl}/v1/api/editmetadata/${file.guid}`, { +``` + +**After:** +```typescript +import { endpoints } from "../../api/endpoints"; + +await fetch(endpoints.editMetadata(file.guid), { +``` + +### Example 3: ManageMeds.tsx + +**Before:** +```typescript +const baseUrl = import.meta.env.VITE_API_BASE_URL; +const url = `${baseUrl}/v1/api/get_full_list_med`; +await adminApi.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); +await adminApi.post(`${baseUrl}/v1/api/add_medication`, { ... }); +``` + +**After:** +```typescript +import { V1_API_ENDPOINTS } from "../../api/endpoints"; + +const url = V1_API_ENDPOINTS.GET_FULL_LIST_MED; +await adminApi.delete(V1_API_ENDPOINTS.DELETE_MED, { data: { name } }); +await adminApi.post(V1_API_ENDPOINTS.ADD_MEDICATION, { ... }); +``` + +## Benefits + +1. **Single Source of Truth**: All endpoints defined in one place +2. **Easy Updates**: Change an endpoint once, updates everywhere +3. **Type Safety**: TypeScript catches typos and incorrect usage +4. **Better IDE Support**: Autocomplete for all available endpoints +5. **Documentation**: Endpoints are self-documenting with clear names +6. **Refactoring Safety**: Rename endpoints safely across the codebase + +## Adding New Endpoints + +When adding a new endpoint: + +1. Add it to the appropriate group in `src/api/endpoints.ts` +2. If it needs dynamic parameters, add a helper function to `endpoints` object +3. Use the new endpoint in your code +4. Update this guide if needed + +Example: +```typescript +// In endpoints.ts +export const V1_API_ENDPOINTS = { + // ... existing endpoints + NEW_ENDPOINT: `${API_BASE}/v1/api/new_endpoint`, +} as const; + +// If it needs parameters: +export const endpoints = { + // ... existing helpers + newEndpoint: (id: string, param: string): string => { + return `${V1_API_ENDPOINTS.NEW_ENDPOINT}/${id}?param=${param}`; + }, +} as const; +``` + diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 915226d6..81859828 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -1,7 +1,14 @@ import axios from "axios"; import { FormValues } from "../pages/Feedback/FeedbackForm"; import { Conversation } from "../components/Header/Chat"; -const baseURL = import.meta.env.VITE_API_BASE_URL; +import { + V1_API_ENDPOINTS, + CONVERSATION_ENDPOINTS, + endpoints, +} from "./endpoints"; + +// Use empty string for relative URLs - all API calls will be relative to current domain +const baseURL = ""; export const publicApi = axios.create({ baseURL }); @@ -31,7 +38,7 @@ const handleSubmitFeedback = async ( message: FormValues["message"], ) => { try { - const response = await publicApi.post(`/v1/api/feedback/`, { + const response = await publicApi.post(V1_API_ENDPOINTS.FEEDBACK, { feedbacktype: feedbackType, name, email, @@ -49,7 +56,7 @@ const handleSendDrugSummary = async ( guid: string, ) => { try { - const endpoint = guid ? `/v1/api/embeddings/ask_embeddings?guid=${guid}` : '/v1/api/embeddings/ask_embeddings'; + const endpoint = endpoints.embeddingsAsk(guid); const response = await adminApi.post(endpoint, { message, }); @@ -63,7 +70,7 @@ const handleSendDrugSummary = async ( const handleRuleExtraction = async (guid: string) => { try { - const response = await adminApi.get(`/v1/api/rule_extraction_openai?guid=${guid}`); + const response = await adminApi.get(endpoints.ruleExtraction(guid)); // console.log("Rule extraction response:", JSON.stringify(response.data, null, 2)); return response.data; } catch (error) { @@ -77,7 +84,7 @@ const fetchRiskDataWithSources = async ( source: "include" | "diagnosis" | "diagnosis_depressed" = "include", ) => { try { - const response = await publicApi.post(`/v1/api/riskWithSources`, { + const response = await publicApi.post(V1_API_ENDPOINTS.RISK_WITH_SOURCES, { drug: medication, source: source, }); @@ -101,12 +108,10 @@ const handleSendDrugSummaryStream = async ( callbacks: StreamCallbacks, ): Promise => { const token = localStorage.getItem("access"); - const endpoint = `/v1/api/embeddings/ask_embeddings?stream=true${ - guid ? `&guid=${guid}` : "" - }`; + const endpoint = endpoints.embeddingsAskStream(guid); try { - const response = await fetch(baseURL + endpoint, { + const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", @@ -206,7 +211,7 @@ const handleSendDrugSummaryStreamLegacy = async ( const fetchConversations = async (): Promise => { try { - const response = await publicApi.get(`/chatgpt/conversations/`); + const response = await publicApi.get(CONVERSATION_ENDPOINTS.CONVERSATIONS); return response.data; } catch (error) { console.error("Error(s) during getConversations: ", error); @@ -216,7 +221,7 @@ const fetchConversations = async (): Promise => { const fetchConversation = async (id: string): Promise => { try { - const response = await publicApi.get(`/chatgpt/conversations/${id}/`); + const response = await publicApi.get(endpoints.conversation(id)); return response.data; } catch (error) { console.error("Error(s) during getConversation: ", error); @@ -226,7 +231,7 @@ const fetchConversation = async (id: string): Promise => { const newConversation = async (): Promise => { try { - const response = await adminApi.post(`/chatgpt/conversations/`, { + const response = await adminApi.post(CONVERSATION_ENDPOINTS.CONVERSATIONS, { messages: [], }); return response.data; @@ -243,7 +248,7 @@ const continueConversation = async ( ): Promise<{ response: string; title: Conversation["title"] }> => { try { const response = await adminApi.post( - `/chatgpt/conversations/${id}/continue_conversation/`, + endpoints.continueConversation(id), { message, page_context, @@ -258,7 +263,7 @@ const continueConversation = async ( const deleteConversation = async (id: string) => { try { - const response = await adminApi.delete(`/chatgpt/conversations/${id}/`); + const response = await adminApi.delete(endpoints.conversation(id)); return response.data; } catch (error) { console.error("Error(s) during deleteConversation: ", error); @@ -273,7 +278,7 @@ const updateConversationTitle = async ( { status: string; title: Conversation["title"] } | { error: string } > => { try { - const response = await adminApi.patch(`/chatgpt/conversations/${id}/update_title/`, { + const response = await adminApi.patch(endpoints.updateConversationTitle(id), { title: newTitle, }); return response.data; @@ -289,7 +294,7 @@ const sendAssistantMessage = async ( previousResponseId?: string, ) => { try { - const response = await publicApi.post(`/v1/api/assistant`, { + const response = await publicApi.post(V1_API_ENDPOINTS.ASSISTANT, { message, previous_response_id: previousResponseId, }); diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 00000000..6066b2ce --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,137 @@ +/** + * Centralized API endpoints configuration + * + * This file contains all API endpoint paths used throughout the application. + * Update endpoints here to change them across the entire frontend. + */ + +const API_BASE = '/api'; + +/** + * Authentication endpoints + */ +export const AUTH_ENDPOINTS = { + JWT_VERIFY: `${API_BASE}/auth/jwt/verify/`, + JWT_CREATE: `${API_BASE}/auth/jwt/create/`, + USER_ME: `${API_BASE}/auth/users/me/`, + RESET_PASSWORD: `${API_BASE}/auth/users/reset_password/`, + RESET_PASSWORD_CONFIRM: `${API_BASE}/auth/users/reset_password_confirm/`, +} as const; + +/** + * V1 API endpoints + */ +export const V1_API_ENDPOINTS = { + // Feedback + FEEDBACK: `${API_BASE}/v1/api/feedback/`, + + // Embeddings + EMBEDDINGS_ASK: `${API_BASE}/v1/api/embeddings/ask_embeddings`, + RULE_EXTRACTION: `${API_BASE}/v1/api/rule_extraction_openai`, + + // Risk + RISK_WITH_SOURCES: `${API_BASE}/v1/api/riskWithSources`, + + // Assistant + ASSISTANT: `${API_BASE}/v1/api/assistant`, + + // File Management + UPLOAD_FILE: `${API_BASE}/v1/api/uploadFile`, + EDIT_METADATA: `${API_BASE}/v1/api/editmetadata`, + + // Medications + GET_FULL_LIST_MED: `${API_BASE}/v1/api/get_full_list_med`, + GET_MED_RECOMMEND: `${API_BASE}/v1/api/get_med_recommend`, + ADD_MEDICATION: `${API_BASE}/v1/api/add_medication`, + DELETE_MED: `${API_BASE}/v1/api/delete_med`, + + // Medication Rules + MED_RULES: `${API_BASE}/v1/api/medRules`, +} as const; + +/** + * ChatGPT/Conversations endpoints + */ +export const CONVERSATION_ENDPOINTS = { + CONVERSATIONS: `${API_BASE}/chatgpt/conversations/`, + EXTRACT_TEXT: `${API_BASE}/chatgpt/extract_text/`, +} as const; + +/** + * AI Settings endpoints + */ +export const AI_SETTINGS_ENDPOINTS = { + SETTINGS: `${API_BASE}/ai_settings/settings/`, +} as const; + +/** + * Helper functions for dynamic endpoints + */ +export const endpoints = { + /** + * Get embeddings endpoint with optional GUID + */ + embeddingsAsk: (guid?: string): string => { + const base = V1_API_ENDPOINTS.EMBEDDINGS_ASK; + return guid ? `${base}?guid=${guid}` : base; + }, + + /** + * Get embeddings streaming endpoint + */ + embeddingsAskStream: (guid?: string): string => { + const base = `${V1_API_ENDPOINTS.EMBEDDINGS_ASK}?stream=true`; + return guid ? `${base}&guid=${guid}` : base; + }, + + /** + * Get rule extraction endpoint with GUID + */ + ruleExtraction: (guid: string): string => { + return `${V1_API_ENDPOINTS.RULE_EXTRACTION}?guid=${guid}`; + }, + + /** + * Get conversation by ID + */ + conversation: (id: string): string => { + return `${CONVERSATION_ENDPOINTS.CONVERSATIONS}${id}/`; + }, + + /** + * Continue conversation endpoint + */ + continueConversation: (id: string): string => { + return `${CONVERSATION_ENDPOINTS.CONVERSATIONS}${id}/continue_conversation/`; + }, + + /** + * Update conversation title endpoint + */ + updateConversationTitle: (id: string): string => { + return `${CONVERSATION_ENDPOINTS.CONVERSATIONS}${id}/update_title/`; + }, + + /** + * Get upload file endpoint with GUID + */ + uploadFile: (guid: string): string => { + return `${V1_API_ENDPOINTS.UPLOAD_FILE}/${guid}`; + }, + + /** + * Edit metadata endpoint with GUID + */ + editMetadata: (guid: string): string => { + return `${V1_API_ENDPOINTS.EDIT_METADATA}/${guid}`; + }, +} as const; + +/** + * Type-safe endpoint values + */ +export type AuthEndpoint = typeof AUTH_ENDPOINTS[keyof typeof AUTH_ENDPOINTS]; +export type V1ApiEndpoint = typeof V1_API_ENDPOINTS[keyof typeof V1_API_ENDPOINTS]; +export type ConversationEndpoint = typeof CONVERSATION_ENDPOINTS[keyof typeof CONVERSATION_ENDPOINTS]; +export type AiSettingsEndpoint = typeof AI_SETTINGS_ENDPOINTS[keyof typeof AI_SETTINGS_ENDPOINTS]; + diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index f3d0f477..2ee7b5db 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -22,9 +22,8 @@ const UploadFile: React.FC = () => { formData.append("file", file); try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; const response = await axios.post( - `${baseUrl}/v1/api/uploadFile`, + `/api/v1/api/uploadFile`, formData, { headers: { diff --git a/frontend/src/pages/DrugSummary/PDFViewer.tsx b/frontend/src/pages/DrugSummary/PDFViewer.tsx index 39ddfbfc..e4aae111 100644 --- a/frontend/src/pages/DrugSummary/PDFViewer.tsx +++ b/frontend/src/pages/DrugSummary/PDFViewer.tsx @@ -10,6 +10,7 @@ import { import { Document, Page, pdfjs } from "react-pdf"; import { useLocation, useNavigate } from "react-router-dom"; import axios from "axios"; +import { endpoints } from "../../api/endpoints"; import "react-pdf/dist/esm/Page/AnnotationLayer.css"; import "react-pdf/dist/esm/Page/TextLayer.css"; import ZoomMenu from "./ZoomMenu"; @@ -50,11 +51,10 @@ const PDFViewer = () => { const params = new URLSearchParams(location.search); const guid = params.get("guid"); const pageParam = params.get("page"); - const baseURL = import.meta.env.VITE_API_BASE_URL as string | undefined; const pdfUrl = useMemo(() => { - return guid && baseURL ? `${baseURL}/v1/api/uploadFile/${guid}` : null; - }, [guid, baseURL]); + return guid ? endpoints.uploadFile(guid) : null; + }, [guid]); useEffect(() => setUiScalePct(Math.round(scale * 100)), [scale]); diff --git a/frontend/src/pages/Files/FileRow.tsx b/frontend/src/pages/Files/FileRow.tsx index 19665855..57ed66bf 100644 --- a/frontend/src/pages/Files/FileRow.tsx +++ b/frontend/src/pages/Files/FileRow.tsx @@ -1,5 +1,6 @@ import React, { useState } from "react"; import { Link } from "react-router-dom"; +import { endpoints } from "../../api/endpoints"; interface File { id: number; @@ -42,8 +43,7 @@ const FileRow: React.FC = ({ const handleSave = async () => { setLoading(true); try { - const baseUrl = import.meta.env.VITE_API_BASE_URL as string; - await fetch(`${baseUrl}/v1/api/editmetadata/${file.guid}`, { + await fetch(endpoints.editMetadata(file.guid), { method: "PATCH", headers: { "Content-Type": "application/json", diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index efed19e5..b6fff4ee 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -30,12 +30,10 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const [downloading, setDownloading] = useState(null); const [opening, setOpening] = useState(null); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - useEffect(() => { const fetchFiles = async () => { try { - const url = `${baseUrl}/v1/api/uploadFile`; + const url = `/api/v1/api/uploadFile`; const { data } = await publicApi.get(url); @@ -50,7 +48,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ }; fetchFiles(); - }, [baseUrl]); + }, []); const updateFileName = (guid: string, updatedFile: Partial) => { setFiles((prevFiles) => diff --git a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx index bec32d50..b947c2d6 100644 --- a/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx +++ b/frontend/src/pages/Layout/Layout_V2_Sidebar.tsx @@ -24,8 +24,7 @@ const Sidebar: React.FC = () => { useEffect(() => { const fetchFiles = async () => { try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const response = await axios.get(`${baseUrl}/v1/api/uploadFile`); + const response = await axios.get(`/api/v1/api/uploadFile`); if (Array.isArray(response.data)) { setFiles(response.data); } diff --git a/frontend/src/pages/ListMeds/useMedications.tsx b/frontend/src/pages/ListMeds/useMedications.tsx index 022eb07a..d78702db 100644 --- a/frontend/src/pages/ListMeds/useMedications.tsx +++ b/frontend/src/pages/ListMeds/useMedications.tsx @@ -11,12 +11,10 @@ export function useMedications() { const [medications, setMedications] = useState([]); const [errors, setErrors] = useState([]); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - useEffect(() => { const fetchMedications = async () => { try { - const url = `${baseUrl}/v1/api/get_full_list_med`; + const url = `/api/v1/api/get_full_list_med`; const { data } = await publicApi.get(url); @@ -44,7 +42,7 @@ export function useMedications() { }; fetchMedications(); - }, [baseUrl]); + }, []); console.log(medications); diff --git a/frontend/src/pages/ManageMeds/ManageMeds.tsx b/frontend/src/pages/ManageMeds/ManageMeds.tsx index 23493f7e..c2372b9e 100644 --- a/frontend/src/pages/ManageMeds/ManageMeds.tsx +++ b/frontend/src/pages/ManageMeds/ManageMeds.tsx @@ -18,11 +18,10 @@ function ManageMedications() { const [newMedRisks, setNewMedRisks] = useState(""); const [showAddMed, setShowAddMed] = useState(false); const [hoveredMed, setHoveredMed] = useState(null); - const baseUrl = import.meta.env.VITE_API_BASE_URL; // Fetch Medications const fetchMedications = async () => { try { - const url = `${baseUrl}/v1/api/get_full_list_med`; + const url = `/api/v1/api/get_full_list_med`; const { data } = await adminApi.get(url); data.sort((a: MedData, b: MedData) => a.name.localeCompare(b.name)); setMedications(data); @@ -36,7 +35,7 @@ function ManageMedications() { // Handle Delete Medication const handleDelete = async (name: string) => { try { - await adminApi.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); + await adminApi.delete(`/api/v1/api/delete_med`, { data: { name } }); setMedications((prev) => prev.filter((med) => med.name !== name)); setConfirmDelete(null); } catch (e: unknown) { @@ -56,7 +55,7 @@ function ManageMedications() { return; } try { - await adminApi.post(`${baseUrl}/v1/api/add_medication`, { + await adminApi.post(`/api/v1/api/add_medication`, { name: newMedName, benefits: newMedBenefits, risks: newMedRisks, diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index b2ff2e01..94c718de 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -152,8 +152,7 @@ const NewPatientForm = ({ setIsLoading(true); // Start loading try { - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/v1/api/get_med_recommend`; + const url = `/api/v1/api/get_med_recommend`; const { data } = await publicApi.post(url, payload); diff --git a/frontend/src/pages/PatientManager/PatientSummary.tsx b/frontend/src/pages/PatientManager/PatientSummary.tsx index 9b8c462c..faab5e6a 100644 --- a/frontend/src/pages/PatientManager/PatientSummary.tsx +++ b/frontend/src/pages/PatientManager/PatientSummary.tsx @@ -67,7 +67,6 @@ const MedicationItem = ({ loading, onTierClick, isAuthenticated, - baseURL, }: { medication: string; source: string; @@ -76,7 +75,6 @@ const MedicationItem = ({ loading: boolean; onTierClick: () => void; isAuthenticated: boolean | null; - baseURL: string; }) => { if (medication === "None") { return ( @@ -183,7 +181,7 @@ const MedicationItem = ({ ) : ( @@ -233,7 +231,6 @@ const MedicationTier = ({ loading, onTierClick, isAuthenticated, - baseURL, }: { title: string; tier: string; @@ -243,7 +240,6 @@ const MedicationTier = ({ loading: boolean; onTierClick: (medication: MedicationWithSource) => void; isAuthenticated: boolean | null; - baseURL: string; }) => ( <>
    @@ -261,7 +257,6 @@ const MedicationTier = ({ loading={loading} onTierClick={() => onTierClick(medicationObj)} isAuthenticated={isAuthenticated} - baseURL={baseURL} /> ))} @@ -280,7 +275,7 @@ const PatientSummary = ({ isPatientDeleted, isAuthenticated = false, }: PatientSummaryProps) => { - const baseURL = import.meta.env.VITE_API_BASE_URL || ''; + // Using relative URLs - no baseURL needed const [loading, setLoading] = useState(false); const [riskData, setRiskData] = useState(null); const [clickedMedication, setClickedMedication] = useState( @@ -423,7 +418,6 @@ const PatientSummary = ({ loading={loading} onTierClick={handleTierClick} isAuthenticated={isAuthenticated} - baseURL={baseURL} />
    @@ -448,7 +441,6 @@ const PatientSummary = ({ loading={loading} onTierClick={handleTierClick} isAuthenticated={isAuthenticated} - baseURL={baseURL} />
    diff --git a/frontend/src/pages/RulesManager/RulesManager.tsx b/frontend/src/pages/RulesManager/RulesManager.tsx index 0268a4c8..e77b39cd 100644 --- a/frontend/src/pages/RulesManager/RulesManager.tsx +++ b/frontend/src/pages/RulesManager/RulesManager.tsx @@ -63,12 +63,10 @@ function RulesManager() { const [isLoading, setIsLoading] = useState(true); const [expandedMeds, setExpandedMeds] = useState>(new Set()); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - useEffect(() => { const fetchMedRules = async () => { try { - const url = `${baseUrl}/v1/api/medRules`; + const url = `/api/v1/api/medRules`; const { data } = await adminApi.get(url); if (!data || !Array.isArray(data.results)) { @@ -86,7 +84,7 @@ function RulesManager() { }; fetchMedRules(); - }, [baseUrl]); + }, []); const toggleMedication = (ruleId: number, medName: string) => { const medKey = `${ruleId}-${medName}`; diff --git a/frontend/src/pages/Settings/SettingsManager.tsx b/frontend/src/pages/Settings/SettingsManager.tsx index c16ded96..3854298c 100644 --- a/frontend/src/pages/Settings/SettingsManager.tsx +++ b/frontend/src/pages/Settings/SettingsManager.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; import axios from "axios"; +import { AI_SETTINGS_ENDPOINTS } from "../../api/endpoints"; // Define an interface for the setting items interface SettingItem { @@ -36,10 +37,8 @@ const SettingsManager: React.FC = () => { }, }; - // Use an environment variable for the base URL or directly insert the URL if not available - const baseUrl = - import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; - const url = `${baseUrl}/ai_settings/settings/`; + // Use centralized endpoint + const url = AI_SETTINGS_ENDPOINTS.SETTINGS; try { const response = await axios.get(url, config); setSettings(response.data); diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index 3dcfcac5..a6a30ff3 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -20,6 +20,7 @@ import { FACEBOOK_AUTH_FAIL, LOGOUT, } from "./types"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; import { ThunkAction } from "redux-thunk"; import { RootState } from "../reducers"; @@ -75,9 +76,7 @@ export const checkAuthenticated = () => async (dispatch: AppDispatch) => { }; const body = JSON.stringify({ token: localStorage.getItem("access") }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - console.log(baseUrl); - const url = `${baseUrl}/auth/jwt/verify/`; + const url = AUTH_ENDPOINTS.JWT_VERIFY; try { const res = await axios.post(url, body, config); @@ -113,9 +112,7 @@ export const load_user = (): ThunkType => async (dispatch: AppDispatch) => { Accept: "application/json", }, }; - const baseUrl = import.meta.env.VITE_API_BASE_URL; - console.log(baseUrl); - const url = `${baseUrl}/auth/users/me/`; + const url = AUTH_ENDPOINTS.USER_ME; try { const res = await axios.get(url, config); @@ -145,9 +142,7 @@ export const login = }; const body = JSON.stringify({ email, password }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - console.log(baseUrl); - const url = `${baseUrl}/auth/jwt/create/`; + const url = AUTH_ENDPOINTS.JWT_CREATE; try { const res = await axios.post(url, body, config); @@ -195,8 +190,7 @@ export const reset_password = }; console.log("yes"); const body = JSON.stringify({ email }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/users/reset_password/`; + const url = AUTH_ENDPOINTS.RESET_PASSWORD; try { await axios.post(url, body, config); @@ -225,8 +219,7 @@ export const reset_password_confirm = }; const body = JSON.stringify({ uid, token, new_password, re_new_password }); - const baseUrl = import.meta.env.VITE_API_BASE_URL; - const url = `${baseUrl}/auth/users/reset_password_confirm/`; + const url = AUTH_ENDPOINTS.RESET_PASSWORD_CONFIRM; try { const response = await axios.post(url, body, config); dispatch({ diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 56f307e4..d34c532f 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -8,15 +8,10 @@ import importlib # Import the importlib module for dynamic module importing # Define a list of URL patterns for the application +# Keep admin outside /api/ prefix urlpatterns = [ # Map 'admin/' URL to the Django admin interface path("admin/", admin.site.urls), - # Include Djoser's URL patterns under 'auth/' for basic auth - path("auth/", include("djoser.urls")), - # Include Djoser's JWT auth URL patterns under 'auth/' - path("auth/", include("djoser.urls.jwt")), - # Include Djoser's social auth URL patterns under 'auth/' - path("auth/", include("djoser.social.urls")), ] # List of application names for which URL patterns will be dynamically added @@ -34,15 +29,30 @@ "assistant", ] +# Build API URL patterns to be included under /api/ prefix +api_urlpatterns = [ + # Include Djoser's URL patterns under 'auth/' for basic auth + path("auth/", include("djoser.urls")), + # Include Djoser's JWT auth URL patterns under 'auth/' + path("auth/", include("djoser.urls.jwt")), + # Include Djoser's social auth URL patterns under 'auth/' + path("auth/", include("djoser.social.urls")), +] + # Loop through each application name and dynamically import and add its URL patterns for url in urls: # Dynamically import the URL module for each app url_module = importlib.import_module(f"api.views.{url}.urls") # Append the URL patterns from each imported module - urlpatterns += getattr(url_module, "urlpatterns", []) + api_urlpatterns += getattr(url_module, "urlpatterns", []) + +# Wrap all API routes under /api/ prefix +urlpatterns += [ + path("api/", include(api_urlpatterns)), +] # Add a catch-all URL pattern for handling SPA (Single Page Application) routing -# Serve 'index.html' for any unmatched URL +# Serve 'index.html' for any unmatched URL (must come after /api/ routes) urlpatterns += [ re_path(r"^.*$", TemplateView.as_view(template_name="index.html")), ] From 7a590e502800a29aad8ce710e9ef3e2cfb2a3f24 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Tue, 6 Jan 2026 18:54:54 -0500 Subject: [PATCH 057/181] refactor: use relative URLs and centralize API endpoints - Update Django URLs to serve all APIs under /api/ prefix - Change frontend to use relative URLs (empty baseURL) instead of environment-specific domains - Create centralized endpoints.ts for maintainable API URL management - Update all frontend components to use centralized endpoints - Remove all VITE_API_BASE_URL and REACT_APP_API_BASE_URL dependencies - Add helper functions for dynamic endpoints with parameters This ensures the same Docker image works in both production and sandbox environments without requiring environment-specific configuration. Fixes: - Frontend calling old domain (balancer.live.k8s.phl.io) - API calls failing after domain migration - /login and /adminportal pages not working Closes #431 --- server/balancer_backend/settings.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 58148617..9f917a94 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -106,13 +106,13 @@ # Build database configuration db_config = { - "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), - "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), - "USER": os.environ.get("SQL_USER", "user"), - "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), + "ENGINE": os.environ.get("SQL_ENGINE", "django.db.backends.sqlite3"), + "NAME": os.environ.get("SQL_DATABASE", BASE_DIR / "db.sqlite3"), + "USER": os.environ.get("SQL_USER", "user"), + "PASSWORD": os.environ.get("SQL_PASSWORD", "password"), "HOST": SQL_HOST, - "PORT": os.environ.get("SQL_PORT", "5432"), -} + "PORT": os.environ.get("SQL_PORT", "5432"), + } # Configure SSL/TLS based on connection type # CloudNativePG within cluster typically doesn't require SSL From 70e26efe188ebc6a81d15218dcb8ec5e653a4427 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Tue, 6 Jan 2026 18:56:40 -0500 Subject: [PATCH 058/181] remove fiel --- frontend/API_ENDPOINTS_REFACTORING.md | 216 -------------------------- 1 file changed, 216 deletions(-) delete mode 100644 frontend/API_ENDPOINTS_REFACTORING.md diff --git a/frontend/API_ENDPOINTS_REFACTORING.md b/frontend/API_ENDPOINTS_REFACTORING.md deleted file mode 100644 index a765fd71..00000000 --- a/frontend/API_ENDPOINTS_REFACTORING.md +++ /dev/null @@ -1,216 +0,0 @@ -# API Endpoints Refactoring Guide - -This document explains how to refactor API URLs to use the centralized endpoints configuration. - -## Overview - -All API endpoints are now centralized in `src/api/endpoints.ts`. This makes it: -- **Maintainable**: Change URLs in one place -- **Type-safe**: TypeScript ensures correct usage -- **Discoverable**: All endpoints are documented in one file -- **Consistent**: No more typos or inconsistent paths - -## Usage Patterns - -### 1. Simple Static Endpoints - -**Before:** -```typescript -const url = `/api/v1/api/feedback/`; -await publicApi.post(url, data); -``` - -**After:** -```typescript -import { V1_API_ENDPOINTS } from "../api/endpoints"; - -await publicApi.post(V1_API_ENDPOINTS.FEEDBACK, data); -``` - -### 2. Dynamic Endpoints with Parameters - -**Before:** -```typescript -const url = `/api/v1/api/uploadFile/${guid}`; -await fetch(url); -``` - -**After:** -```typescript -import { endpoints } from "../api/endpoints"; - -const url = endpoints.uploadFile(guid); -await fetch(url); -``` - -### 3. Endpoints with Query Parameters - -**Before:** -```typescript -const endpoint = guid - ? `/api/v1/api/embeddings/ask_embeddings?guid=${guid}` - : '/api/v1/api/embeddings/ask_embeddings'; -``` - -**After:** -```typescript -import { endpoints } from "../api/endpoints"; - -const endpoint = endpoints.embeddingsAsk(guid); -``` - -## Available Endpoint Groups - -### Authentication Endpoints -```typescript -import { AUTH_ENDPOINTS } from "../api/endpoints"; - -AUTH_ENDPOINTS.JWT_VERIFY -AUTH_ENDPOINTS.JWT_CREATE -AUTH_ENDPOINTS.USER_ME -AUTH_ENDPOINTS.RESET_PASSWORD -AUTH_ENDPOINTS.RESET_PASSWORD_CONFIRM -``` - -### V1 API Endpoints -```typescript -import { V1_API_ENDPOINTS } from "../api/endpoints"; - -V1_API_ENDPOINTS.FEEDBACK -V1_API_ENDPOINTS.UPLOAD_FILE -V1_API_ENDPOINTS.GET_FULL_LIST_MED -V1_API_ENDPOINTS.MED_RULES -// ... and more -``` - -### Conversation Endpoints -```typescript -import { CONVERSATION_ENDPOINTS } from "../api/endpoints"; - -CONVERSATION_ENDPOINTS.CONVERSATIONS -CONVERSATION_ENDPOINTS.EXTRACT_TEXT -``` - -### AI Settings Endpoints -```typescript -import { AI_SETTINGS_ENDPOINTS } from "../api/endpoints"; - -AI_SETTINGS_ENDPOINTS.SETTINGS -``` - -### Helper Functions -```typescript -import { endpoints } from "../api/endpoints"; - -endpoints.embeddingsAsk(guid?) -endpoints.embeddingsAskStream(guid?) -endpoints.ruleExtraction(guid) -endpoints.conversation(id) -endpoints.continueConversation(id) -endpoints.updateConversationTitle(id) -endpoints.uploadFile(guid) -endpoints.editMetadata(guid) -``` - -## Files to Refactor - -The following files still need to be updated to use the centralized endpoints: - -1. `src/pages/Settings/SettingsManager.tsx` - Use `AI_SETTINGS_ENDPOINTS.SETTINGS` -2. `src/pages/RulesManager/RulesManager.tsx` - Use `V1_API_ENDPOINTS.MED_RULES` -3. `src/pages/PatientManager/NewPatientForm.tsx` - Use `V1_API_ENDPOINTS.GET_MED_RECOMMEND` -4. `src/pages/ManageMeds/ManageMeds.tsx` - Use `V1_API_ENDPOINTS.*` for all medication endpoints -5. `src/pages/ListMeds/useMedications.tsx` - Use `V1_API_ENDPOINTS.GET_FULL_LIST_MED` -6. `src/pages/Layout/Layout_V2_Sidebar.tsx` - Use `V1_API_ENDPOINTS.UPLOAD_FILE` -7. `src/pages/Files/ListOfFiles.tsx` - Use `V1_API_ENDPOINTS.UPLOAD_FILE` -8. `src/pages/DocumentManager/UploadFile.tsx` - Use `V1_API_ENDPOINTS.UPLOAD_FILE` -9. `src/pages/Files/FileRow.tsx` - Use `endpoints.editMetadata(guid)` -10. `src/pages/DrugSummary/PDFViewer.tsx` - Use `endpoints.uploadFile(guid)` -11. `src/pages/PatientManager/PatientSummary.tsx` - Use `endpoints.uploadFile(guid)` - -## Example Refactoring - -### Example 1: SettingsManager.tsx - -**Before:** -```typescript -const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000"; -const url = `${baseUrl}/ai_settings/settings/`; -``` - -**After:** -```typescript -import { AI_SETTINGS_ENDPOINTS } from "../../api/endpoints"; - -const url = AI_SETTINGS_ENDPOINTS.SETTINGS; -``` - -### Example 2: FileRow.tsx - -**Before:** -```typescript -const baseUrl = import.meta.env.VITE_API_BASE_URL as string; -await fetch(`${baseUrl}/v1/api/editmetadata/${file.guid}`, { -``` - -**After:** -```typescript -import { endpoints } from "../../api/endpoints"; - -await fetch(endpoints.editMetadata(file.guid), { -``` - -### Example 3: ManageMeds.tsx - -**Before:** -```typescript -const baseUrl = import.meta.env.VITE_API_BASE_URL; -const url = `${baseUrl}/v1/api/get_full_list_med`; -await adminApi.delete(`${baseUrl}/v1/api/delete_med`, { data: { name } }); -await adminApi.post(`${baseUrl}/v1/api/add_medication`, { ... }); -``` - -**After:** -```typescript -import { V1_API_ENDPOINTS } from "../../api/endpoints"; - -const url = V1_API_ENDPOINTS.GET_FULL_LIST_MED; -await adminApi.delete(V1_API_ENDPOINTS.DELETE_MED, { data: { name } }); -await adminApi.post(V1_API_ENDPOINTS.ADD_MEDICATION, { ... }); -``` - -## Benefits - -1. **Single Source of Truth**: All endpoints defined in one place -2. **Easy Updates**: Change an endpoint once, updates everywhere -3. **Type Safety**: TypeScript catches typos and incorrect usage -4. **Better IDE Support**: Autocomplete for all available endpoints -5. **Documentation**: Endpoints are self-documenting with clear names -6. **Refactoring Safety**: Rename endpoints safely across the codebase - -## Adding New Endpoints - -When adding a new endpoint: - -1. Add it to the appropriate group in `src/api/endpoints.ts` -2. If it needs dynamic parameters, add a helper function to `endpoints` object -3. Use the new endpoint in your code -4. Update this guide if needed - -Example: -```typescript -// In endpoints.ts -export const V1_API_ENDPOINTS = { - // ... existing endpoints - NEW_ENDPOINT: `${API_BASE}/v1/api/new_endpoint`, -} as const; - -// If it needs parameters: -export const endpoints = { - // ... existing helpers - newEndpoint: (id: string, param: string): string => { - return `${V1_API_ENDPOINTS.NEW_ENDPOINT}/${id}?param=${param}`; - }, -} as const; -``` - From 76cee0221bffb4d0b0ce80d0e8be4d6a79a968b4 Mon Sep 17 00:00:00 2001 From: Akhil Bolla Date: Wed, 14 Jan 2026 12:08:17 -0500 Subject: [PATCH 059/181] sanitizer --- server/api/views/assistant/sanitizer.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 server/api/views/assistant/sanitizer.py diff --git a/server/api/views/assistant/sanitizer.py b/server/api/views/assistant/sanitizer.py new file mode 100644 index 00000000..bdbbc77f --- /dev/null +++ b/server/api/views/assistant/sanitizer.py @@ -0,0 +1,26 @@ +import re +import logging +logger = logging.getLogger(__name__) +def sanitize_input(user_input:str) -> str: + """ + Sanitize user input to prevent injection attacks and remove unwanted characters. + Args: + user_input (str): The raw input string from the user. + Returns: + str: The sanitized input string. + """ + try: + # Remove any script tags + sanitized = re.sub(r'.*?', '', user_input, flags=re.IGNORECASE) + # Remove any HTML tags + sanitized = re.sub(r'<.*?>', '', sanitized) + # Escape special characters + sanitized = re.sub(r'["\'\\]', '', sanitized) + # Limit length to prevent buffer overflow attacks + max_length = 1000 + if len(sanitized) > max_length: + sanitized = sanitized[:max_length] + return sanitized.strip() + except Exception as e: + logger.error(f"Error sanitizing input: {e}") + return "" \ No newline at end of file From 1c458f0248e912b79e6bead1c1685d57c057d01b Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Wed, 14 Jan 2026 22:47:57 -0500 Subject: [PATCH 060/181] ci: refactor pipelines for continuous deployment to sandbox --- .github/workflows/containers-publish.yml | 19 ++++++++++- .github/workflows/deploy-downstream.yml | 41 ++++++++++++++++++------ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 64758fe9..834f0da9 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -3,6 +3,8 @@ name: "Containers: Publish" on: release: types: [published] + push: + branches: [develop] permissions: packages: write @@ -24,7 +26,13 @@ jobs: - name: Compute Docker container image addresses run: | DOCKER_REPOSITORY="ghcr.io/${GITHUB_REPOSITORY,,}" - DOCKER_TAG="${GITHUB_REF:11}" + + if [[ "${{ github.event_name }}" == "release" ]]; then + DOCKER_TAG="${GITHUB_REF:11}" + else + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + DOCKER_TAG="dev-${SHORT_SHA}" + fi echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_ENV echo "DOCKER_TAG=${DOCKER_TAG}" >> $GITHUB_ENV @@ -51,3 +59,12 @@ jobs: - name: "Push Docker container image app:v*" run: docker push "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" + + - name: Save Docker Tag + run: echo "${DOCKER_TAG}" > docker_tag.txt + + - name: Upload Docker Tag + uses: actions/upload-artifact@v4 + with: + name: docker-tag + path: docker_tag.txt diff --git a/.github/workflows/deploy-downstream.yml b/.github/workflows/deploy-downstream.yml index 2557ff17..e13309e8 100644 --- a/.github/workflows/deploy-downstream.yml +++ b/.github/workflows/deploy-downstream.yml @@ -1,8 +1,10 @@ name: "Deploy: Downstream Clusters" on: - release: - types: [published] + workflow_run: + workflows: ["Containers: Publish"] + types: + - completed workflow_dispatch: inputs: tag: @@ -14,6 +16,7 @@ jobs: update-sandbox: name: Update Sandbox Cluster runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'develop') }} outputs: tag: ${{ steps.get_tag.outputs.TAG }} steps: @@ -26,8 +29,12 @@ jobs: if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then echo "TAG=${{ inputs.tag }}" >> $GITHUB_OUTPUT else - echo "TAG=${GITHUB_REF:11}" >> $GITHUB_OUTPUT + gh run download ${{ github.event.workflow_run.id }} -n docker-tag + TAG=$(cat docker_tag.txt) + echo "TAG=${TAG}" >> $GITHUB_OUTPUT fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Checkout Sandbox Cluster uses: actions/checkout@v4 @@ -57,9 +64,25 @@ jobs: update-live: name: Update Live Cluster - needs: update-sandbox runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release') }} steps: + - name: Checkout App + uses: actions/checkout@v4 + + - name: Get Release Tag + id: get_tag + run: | + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + echo "TAG=${{ inputs.tag }}" >> $GITHUB_OUTPUT + else + gh run download ${{ github.event.workflow_run.id }} -n docker-tag + TAG=$(cat docker_tag.txt) + echo "TAG=${TAG}" >> $GITHUB_OUTPUT + fi + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout Live Cluster uses: actions/checkout@v4 with: @@ -71,7 +94,7 @@ jobs: working-directory: live/balancer run: | curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash - ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ needs.update-sandbox.outputs.tag }} + ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ steps.get_tag.outputs.TAG }} rm kustomize - name: Create Live PR @@ -79,9 +102,9 @@ jobs: with: token: ${{ secrets.BOT_GITHUB_TOKEN }} path: live - commit-message: "Deploy balancer ${{ needs.update-sandbox.outputs.tag }} to live" - title: "Deploy balancer ${{ needs.update-sandbox.outputs.tag }}" - body: "Updates balancer image tag to ${{ needs.update-sandbox.outputs.tag }}" - branch: "deploy/balancer-${{ needs.update-sandbox.outputs.tag }}" + commit-message: "Deploy balancer ${{ steps.get_tag.outputs.TAG }} to live" + title: "Deploy balancer ${{ steps.get_tag.outputs.TAG }}" + body: "Updates balancer image tag to ${{ steps.get_tag.outputs.TAG }}" + branch: "deploy/balancer-${{ steps.get_tag.outputs.TAG }}" base: main delete-branch: true From 76a99a98be7711b8a2fde67d617df13cdae1a872 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 15 Jan 2026 09:09:15 -0500 Subject: [PATCH 061/181] build: use cpu-only torch to reduce image size and fix CI build --- Dockerfile.prod | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile.prod b/Dockerfile.prod index cd1f3604..f2fc5a20 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -32,9 +32,11 @@ ENV PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y netcat && rm -rf /var/lib/apt/lists/* # Install Python dependencies -RUN pip install --upgrade pip +RUN pip install --upgrade pip --no-cache-dir COPY server/requirements.txt . -RUN pip install -r requirements.txt +# Install CPU-only torch to save space (avoids ~4GB of CUDA libs) +RUN pip install torch --index-url https://download.pytorch.org/whl/cpu --no-cache-dir +RUN pip install -r requirements.txt --no-cache-dir # Copy backend application code COPY server/ . From ca8c21f52b3a0d3ee5ab520b908626942b2f6784 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 15 Jan 2026 09:46:57 -0500 Subject: [PATCH 062/181] fix(ci): use actions/checkout@v4 instead of non-existent v5 --- .github/workflows/containers-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 834f0da9..e7293376 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -14,7 +14,7 @@ jobs: name: Build and Push runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v4 - name: Login to ghcr.io Docker registry uses: docker/login-action@v3 From 54f02c6c59a45d8cdae110474c8c39541fa1209f Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 19 Jan 2026 15:32:30 -0500 Subject: [PATCH 063/181] Add a PR template --- pull_request_template.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 pull_request_template.md diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 00000000..48225088 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,33 @@ +## Description + + + +## Manual Tests + + +## Automated Tests + + + +## Documentation + + + + + + +## Related Issue + + +Related to # + + + +## Reviewers + + +@ + + +## Notes + \ No newline at end of file From d972a7fb44eb0d11301cc77a1cc2b8a3b2f46080 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 19 Jan 2026 15:36:04 -0500 Subject: [PATCH 064/181] Clean up the PR template --- pull_request_template.md | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/pull_request_template.md b/pull_request_template.md index 48225088..ede07e70 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -2,32 +2,25 @@ +## Related Issue + + + ## Manual Tests + ## Automated Tests ## Documentation - - - - - - -## Related Issue - - -Related to # - + ## Reviewers -@ - ## Notes \ No newline at end of file From ee0e76c3341f2c90476acd428ffa00cea4b13201 Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Mon, 19 Jan 2026 16:25:23 -0500 Subject: [PATCH 065/181] Update GitHub Sponsors username in FUNDING.yml --- .github/FUNDING.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..45e1c1ce --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [sahilds1] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] From 0accb30525fb54a6f7195bfa2442130ad5bf31f3 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 20 Jan 2026 15:03:56 -0500 Subject: [PATCH 066/181] Fix failing local dev frontend API calls --- frontend/vite.config.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1d907506..1f02c51f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,5 +15,11 @@ export default defineConfig({ host: "0.0.0.0", strictPort: true, port: 3000, + proxy: { + '/api': { + target: 'http://backend:8000', + changeOrigin: true, + }, + }, }, }); \ No newline at end of file From 31efe1a75f17df29556e7653c04702e829469d2b Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 26 Jan 2026 08:17:46 -0500 Subject: [PATCH 067/181] fix: local environment --- README.md | 227 ++++++++++++++++++++++++++++++++------------- db/Dockerfile | 26 ------ devbox.json | 10 +- docker-compose.yml | 48 +++++----- 4 files changed, 194 insertions(+), 117 deletions(-) delete mode 100644 db/Dockerfile diff --git a/README.md b/README.md index f1cea06b..88591113 100644 --- a/README.md +++ b/README.md @@ -1,100 +1,145 @@ # Balancer -Balancer is a website of digital tools designed to help prescribers choose the most suitable medications -for patients with bipolar disorder, helping them shorten their journey to stability and well-being - -## Usage - -You can view the current build of the website here: [https://balancertestsite.com](https://balancertestsite.com/) - -## Contributing - -### Join the Balancer community - -Balancer is a [Code for Philly](https://www.codeforphilly.org/) project +[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://choosealicense.com/licenses/agpl-3.0/) +[![Code for Philly](https://img.shields.io/badge/Code%20for%20Philly-Project-orange)](https://codeforphilly.org/projects/balancer) +[![Stack](https://img.shields.io/badge/Stack-Django%20%7C%20React%20%7C%20PostgreSQL%20%7C%20K8s-green)](https://github.com/CodeForPhilly/balancer) + +**Balancer** is a digital clinical decision support tool designed to assist prescribers in selecting the most suitable medications for patients with bipolar disorder. By providing evidence-based insights, Balancer aims to shorten the patient's journey to stability and well-being. + +This is an open-source project maintained by the **[Code for Philly](https://www.codeforphilly.org/)** community. + +--- + +## 📋 Table of Contents + +- [Architecture](#-architecture) +- [Prerequisites](#-prerequisites) +- [Environment Configuration](#-environment-configuration) +- [Quick Start: Local Development](#-quick-start-local-development) +- [Advanced: Local Kubernetes Deployment](#-advanced-local-kubernetes-deployment) +- [Data Layer](#-data-layer) +- [Contributing](#-contributing) +- [License](#-license) + +--- + +## 🏗 Architecture + +Balancer follows a modern containerized 3-tier architecture: + +1. **Frontend**: React (Vite) application serving the user interface. +2. **Backend**: Django REST Framework API handling business logic, authentication, and AI orchestration. +3. **Data & AI**: PostgreSQL (with `pgvector` for RAG) and integrations with LLM providers (OpenAI/Anthropic). + +```mermaid +graph TD + User[User / Prescriber] -->|HTTPS| Frontend[React Frontend] + Frontend -->|REST API| Backend[Django Backend] + + subgraph "Data Layer" + Backend -->|Read/Write| DB[(PostgreSQL + pgvector)] + end + + subgraph "External AI Services" + Backend -->|LLM Queries| OpenAI[OpenAI API] + Backend -->|LLM Queries| Anthropic[Anthropic API] + end + + subgraph "Infrastructure" + Docker[Docker Compose (Local)] + K8s[Kubernetes / Kind (Dev/Prod)] + end +``` -Join the [Code for Philly Slack and introduce yourself](https://codeforphilly.org/projects/balancer) in the #balancer channel +--- -The project kanban board is [on GitHub here](https://github.com/orgs/CodeForPhilly/projects/2) +## 🛠 Prerequisites -### Code for Philly Code of Conduct +Before you start, ensure you have the following installed: -The Code for Philly Code of Conduct is [here](https://codeforphilly.org/pages/code_of_conduct/) +* **[Docker Desktop](https://www.docker.com/products/docker-desktop/)**: Required for running the application containers. +* **[Node.js & npm](https://nodejs.org/)**: Required if you plan to do frontend development outside of Docker. +* **[Devbox](https://www.jetify.com/devbox)** (Optional): Required only for the Local Kubernetes workflow. +* **Postman** (Optional): Useful for API testing. Ask in Slack to join the `balancer_dev` team. -### Setting up a development environment +--- -Get the code using git by either forking or cloning `CodeForPhilly/balancer-main` +## 🔐 Environment Configuration -Tools used to run Balancer: -1. `OpenAI API`: Ask for an API key and add it to `config/env/env.dev` -2. `Anthropic API`: Ask for an API key and add it to `config/env/env.dev` +To run the application, you need to configure your environment variables. -Tools used for development: -1. `Docker`: Install Docker Desktop -2. `Postman`: Ask to get invited to the Balancer Postman team `balancer_dev` -3. `npm`: In the terminal run 1) 'cd frontend' 2) 'npm install' 3) 'cd ..' +1. **Backend Config**: + * Navigate to `config/env/`. + * Copy the example file: `cp dev.env.example dev.env` + * **Action Required**: Open `dev.env` and populate your API keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). Ask the project leads in Slack if you need shared development keys. -### Running Balancer for development + > **⚠️ SECURITY WARNING**: Never commit `config/env/dev.env` to version control. It is already ignored by `.gitignore`. -Start the Postgres, Django REST, and React services by starting Docker Desktop and running `docker compose up --build` +2. **Frontend Config**: + * The frontend uses `frontend/.env` (or `.env.production` for builds). + * Key variable: `VITE_API_BASE_URL` (Defaults to `http://localhost:8000` for local dev). -#### Postgres +--- -The application supports connecting to PostgreSQL databases via: +## 🚀 Quick Start: Local Development -1. **CloudNativePG** - Kubernetes-managed PostgreSQL cluster (for production/sandbox) -2. **AWS RDS** - External PostgreSQL database (AWS managed) -3. **Local Docker Compose** - For local development +This is the standard workflow for contributors working on features or bug fixes. -See [Database Connection Documentation](./docs/DATABASE_CONNECTION.md) for detailed configuration. +1. **Clone the Repository** + ```bash + git clone https://github.com/CodeForPhilly/balancer.git + cd balancer + ``` -**Local Development:** -- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) -- The email and password of `pgAdmin` are specified in `balancer-main/docker-compose.yml` -- The first time you use `pgAdmin` after building the Docker containers you will need to register the server. - - The `Host name/address` is the Postgres server service name in the Docker Compose file - - The `Username` and `Password` are the Postgres server environment variables in the Docker Compose file -- You can use the below code snippet to query the database from a Jupyter notebook: +2. **Install Frontend Dependencies** (Optional but recommended for IDE support) + ```bash + cd frontend + npm install + cd .. + ``` -``` -from sqlalchemy import create_engine -import pandas as pd +3. **Start Services** + Run the full stack (db, backend, frontend) using Docker Compose: + ```bash + docker compose up --build + ``` -engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") +4. **Access the Application** + * **Frontend**: [http://localhost:3000](http://localhost:3000) + * **Backend API**: [http://localhost:8000](http://localhost:8000) + * **Django Admin**: [http://localhost:8000/admin](http://localhost:8000/admin) -query = "SELECT * FROM api_embeddings;" + > **Default Superuser Credentials:** + > * **Email**: `admin@example.com` + > * **Password**: `adminpassword` + > * *(Defined in `server/api/management/commands/createsu.py`)* -df = pd.read_sql(query, engine) -``` +--- -#### Django REST -- The email and password are set in `server/api/management/commands/createsu.py` +## ☸️ Advanced: Local Kubernetes Deployment -## Local Kubernetes Deployment +Use this workflow if you are working on DevOps tasks, Helm charts, or Kubernetes manifests. -### Prereqs +### 1. Configure Hostname +We map a local domain to your machine to simulate production routing. -- Fill the configmap with the [env vars](./deploy/manifests/balancer/base/configmap.yml) -- Install [Devbox](https://www.jetify.com/devbox) -- Run the following script with admin privileges: +Run this script to update your `/etc/hosts` file (requires `sudo`): ```bash +#!/bin/bash HOSTNAME="balancertestsite.com" LOCAL_IP="127.0.0.1" -# Check if the correct line already exists if grep -q "^$LOCAL_IP[[:space:]]\+$HOSTNAME" /etc/hosts; then - echo "Entry for $HOSTNAME with IP $LOCAL_IP already exists in /etc/hosts" + echo "✅ Entry for $HOSTNAME already exists." else - echo "Updating /etc/hosts for $HOSTNAME" - sudo sed -i "/[[:space:]]$HOSTNAME/d" /etc/hosts + echo "Updating /etc/hosts..." echo "$LOCAL_IP $HOSTNAME" | sudo tee -a /etc/hosts fi ``` -### Steps to reproduce - -Inside root dir of balancer +### 2. Deploy with Devbox +We use `devbox` to manage the local Kind cluster and deployments. ```bash devbox shell @@ -102,14 +147,62 @@ devbox create:cluster devbox run deploy:balancer ``` -The website should be available in [https://balancertestsite.com:30219/](https://balancertestsite.com:30219/) +The application will be available at: **[https://balancertestsite.com:30219/](https://balancertestsite.com:30219/)** + +--- + +## 💾 Data Layer + +Balancer supports multiple PostgreSQL configurations depending on the environment: + +| Environment | Database Technology | Description | +| :--- | :--- | :--- | +| **Local Dev** | **Docker Compose** | Standard postgres container. Access at `localhost:5433`. | +| **Kubernetes** | **CloudNativePG** | Operator-managed HA cluster. Used in Kind and Prod. | +| **AWS** | **RDS** | Managed PostgreSQL for scalable cloud deployments. | + +### Querying the Local Database +You can connect via any SQL client using: +* **Host**: `localhost` +* **Port**: `5433` +* **User/Pass**: `balancer` / `balancer` +* **DB Name**: `balancer_dev` + +**Python Example (Jupyter):** +```python +from sqlalchemy import create_engine +import pandas as pd + +# Connect to local docker database +engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") + +# Query embeddings table +df = pd.read_sql("SELECT * FROM api_embeddings;", engine) +print(df.head()) +``` + +--- + +## 🤝 Contributing + +We welcome contributors of all skill levels! -## Architecture +1. **Join the Community**: + * Join the [Code for Philly Slack](https://codeforphilly.org/chat). + * Say hello in the **#balancer** channel. +2. **Find a Task**: + * Check our [GitHub Project Board](https://github.com/orgs/CodeForPhilly/projects/2). +3. **Code of Conduct**: + * Please review the [Code for Philly Code of Conduct](https://codeforphilly.org/pages/code_of_conduct/). -The Balancer website is a Postgres, Django REST, and React project. The source code layout is: +### Pull Request Workflow +1. Fork the repo. +2. Create a feature branch (`git checkout -b feature/amazing-feature`). +3. Commit your changes. +4. Open a Pull Request against the `develop` branch. -![Architecture Drawing](Architecture.png) +--- -## License +## 📄 License -Balancer is licensed under the [AGPL-3.0 license](https://choosealicense.com/licenses/agpl-3.0/) +Balancer is open-source software licensed under the **[AGPL-3.0 License](https://choosealicense.com/licenses/agpl-3.0/)**. \ No newline at end of file diff --git a/db/Dockerfile b/db/Dockerfile deleted file mode 100644 index 71264cbd..00000000 --- a/db/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Use the official PostgreSQL 15 image as a parent image -FROM postgres:15 - -# Install build dependencies and update CA certificates -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - git \ - build-essential \ - postgresql-server-dev-15 \ - && update-ca-certificates \ - && rm -rf /var/lib/apt/lists/* - -# Clone, build and install pgvector -RUN cd /tmp \ - && git clone --branch v0.6.1 https://github.com/pgvector/pgvector.git \ - && cd pgvector \ - && make \ - && make install - -# Clean up unnecessary packages and files -RUN apt-get purge -y --auto-remove git build-essential postgresql-server-dev-15 \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* /tmp/pgvector - -COPY init-vector-extension.sql /docker-entrypoint-initdb.d/ diff --git a/devbox.json b/devbox.json index 87e91159..cfe202ad 100644 --- a/devbox.json +++ b/devbox.json @@ -15,7 +15,7 @@ ], "scripts": { "create:cluster": [ - "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yml", + "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yaml", "kubectl cluster-info" ], "deploy:balancer": [ @@ -24,7 +24,8 @@ ], "install:prereqs": [ "devbox run install:cert-manager", - "devbox run install:ingress-nginx" + "devbox run install:ingress-nginx", + "devbox run install:cnpg" ], "install:balancer": [ "kubectl create namespace balancer || true", @@ -33,6 +34,11 @@ "echo 'You can access the balancer site at:'", "echo \"HTTPS: https://balancertestsite.com:$(kubectl get svc -n ingress-nginx -o json ingress-nginx-controller | jq .spec.ports[1].nodePort)\"" ], + "install:cnpg": [ + "helm repo add cnpg https://cloudnative-pg.io/charts || true", + "helm repo update cnpg", + "helm upgrade --install cnpg cnpg/cloudnative-pg --namespace cnpg-system --create-namespace --wait" + ], "install:cert-manager": [ "helm repo add jetstack https://charts.jetstack.io || true", "helm repo update jetstack", diff --git a/docker-compose.yml b/docker-compose.yml index 5d2d5884..000960d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,9 @@ services: db: - # Workaround for PostgreSQL crash with pgvector v0.6.1 on ARM64 - # image: pgvector/pgvector:pg15 - # volumes: - # - postgres_data:/var/lib/postgresql/data/ - # - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql - build: - context: ./db - dockerfile: Dockerfile + image: pgvector/pgvector:pg15 volumes: - postgres_data:/var/lib/postgresql/data/ + - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql environment: - POSTGRES_USER=balancer - POSTGRES_PASSWORD=balancer @@ -19,17 +13,12 @@ services: networks: app_net: ipv4_address: 192.168.0.2 - # pgadmin: - # container_name: pgadmin4 - # image: dpage/pgadmin4 - # environment: - # PGADMIN_DEFAULT_EMAIL: balancer-noreply@codeforphilly.org - # PGADMIN_DEFAULT_PASSWORD: balancer - # ports: - # - "5050:80" - # networks: - # app_net: - # ipv4_address: 192.168.0.4 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] + interval: 5s + timeout: 5s + retries: 5 + backend: image: balancer-backend build: ./server @@ -39,12 +28,20 @@ services: env_file: - ./config/env/dev.env depends_on: - - db + db: + condition: service_healthy volumes: - ./server:/usr/src/server networks: app_net: ipv4_address: 192.168.0.3 + healthcheck: + test: ["CMD-SHELL", "python3 -c 'import http.client;conn=http.client.HTTPConnection(\"localhost:8000\");conn.request(\"GET\",\"/admin/login/\");res=conn.getresponse();exit(0 if res.status in [200,301,302,401] else 1)'"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + frontend: image: balancer-frontend build: @@ -60,10 +57,17 @@ services: - "./frontend:/usr/src/app:delegated" - "/usr/src/app/node_modules/" depends_on: - - backend + backend: + condition: service_healthy networks: app_net: ipv4_address: 192.168.0.5 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + volumes: postgres_data: networks: @@ -72,4 +76,4 @@ networks: driver: default config: - subnet: "192.168.0.0/24" - gateway: 192.168.0.1 + gateway: 192.168.0.1 \ No newline at end of file From 4548ad87e2bb761ae808fe8c03ab97bda72e7e95 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 26 Jan 2026 08:35:16 -0500 Subject: [PATCH 068/181] fix: STATICFILES_DIRS setting does not exist error --- server/balancer_backend/settings.py | 7 ++++--- server/balancer_backend/urls.py | 10 +++++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 9f917a94..bdc465ca 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -180,9 +180,10 @@ # https://docs.djangoproject.com/en/4.2/howto/static-files/ STATIC_URL = "/static/" -STATICFILES_DIRS = [ - os.path.join(BASE_DIR, "build/static"), -] +STATICFILES_DIRS = [] +if os.path.exists(os.path.join(BASE_DIR, "build/static")): + STATICFILES_DIRS.append(os.path.join(BASE_DIR, "build/static")) + STATIC_ROOT = os.path.join(BASE_DIR, "static") AUTHENTICATION_BACKENDS = [ diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index d34c532f..5a1fdcde 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -51,8 +51,12 @@ path("api/", include(api_urlpatterns)), ] +import os +from django.conf import settings + # Add a catch-all URL pattern for handling SPA (Single Page Application) routing # Serve 'index.html' for any unmatched URL (must come after /api/ routes) -urlpatterns += [ - re_path(r"^.*$", TemplateView.as_view(template_name="index.html")), -] +if os.path.exists(os.path.join(settings.BASE_DIR, "build", "index.html")): + urlpatterns += [ + re_path(r"^(?!api|admin|static).*$", TemplateView.as_view(template_name="index.html")), + ] From a90efd90ec7782b4357ba3ecb3edba048233d6c0 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 26 Jan 2026 08:50:32 -0500 Subject: [PATCH 069/181] undo --- devbox.json | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/devbox.json b/devbox.json index cfe202ad..87e91159 100644 --- a/devbox.json +++ b/devbox.json @@ -15,7 +15,7 @@ ], "scripts": { "create:cluster": [ - "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yaml", + "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yml", "kubectl cluster-info" ], "deploy:balancer": [ @@ -24,8 +24,7 @@ ], "install:prereqs": [ "devbox run install:cert-manager", - "devbox run install:ingress-nginx", - "devbox run install:cnpg" + "devbox run install:ingress-nginx" ], "install:balancer": [ "kubectl create namespace balancer || true", @@ -34,11 +33,6 @@ "echo 'You can access the balancer site at:'", "echo \"HTTPS: https://balancertestsite.com:$(kubectl get svc -n ingress-nginx -o json ingress-nginx-controller | jq .spec.ports[1].nodePort)\"" ], - "install:cnpg": [ - "helm repo add cnpg https://cloudnative-pg.io/charts || true", - "helm repo update cnpg", - "helm upgrade --install cnpg cnpg/cloudnative-pg --namespace cnpg-system --create-namespace --wait" - ], "install:cert-manager": [ "helm repo add jetstack https://charts.jetstack.io || true", "helm repo update jetstack", From 89d9c6bea444f54cb6ac8ba0f7e4290357a3d598 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:29:26 -0500 Subject: [PATCH 070/181] fix: deploy API and CI for sandbox + live - Add trailing newline to frontend/.env.production (lint) - Clarify apiClient baseURL comment for sandbox/production - Add Frontend: Lint and Build workflow on develop - Add docs/DEPLOY_RESOLUTION_STEPS.md for PR follow-up --- .github/workflows/frontend-ci.yml | 33 ++++++++++++++++++++++ docs/DEPLOY_RESOLUTION_STEPS.md | 47 +++++++++++++++++++++++++++++++ frontend/.env.production | 2 +- frontend/src/api/apiClient.ts | 2 +- 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/frontend-ci.yml create mode 100644 docs/DEPLOY_RESOLUTION_STEPS.md diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 00000000..3e2929c5 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,33 @@ +name: "Frontend: Lint and Build" + +on: + push: + branches: [develop] + pull_request: + branches: [develop] + +jobs: + frontend: + name: Lint and Build + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build diff --git a/docs/DEPLOY_RESOLUTION_STEPS.md b/docs/DEPLOY_RESOLUTION_STEPS.md new file mode 100644 index 00000000..3509d202 --- /dev/null +++ b/docs/DEPLOY_RESOLUTION_STEPS.md @@ -0,0 +1,47 @@ +# Resolution steps for current balancer environments + +Use this as a **follow-up comment or PR body section** after merging the deploy/API/CI fix PR. It walks through fixing the current issues and ensuring future deploys are fully automated. + +--- + +## Step 1 – GitHub Actions token + +Deploy Downstream uses `BOT_GITHUB_TOKEN` to open PRs in `CodeForPhilly/cfp-sandbox-cluster` and `CodeForPhilly/cfp-live-cluster`. If workflows fail with permission or authentication errors, the token may be expired. + +- **Action**: An org admin (e.g. **@chris** or repo admin) updates the `BOT_GITHUB_TOKEN` secret in the balancer-main repo: **Settings → Secrets and variables → Actions**. +- **Ping**: @chris (or the dev who manages GitHub secrets) to update the token. + +--- + +## Step 2 – Re-run or trigger a new build + +After merging this PR (and optionally after updating the token), get a green run of **Containers: Publish** and then **Deploy: Downstream**. + +- **Action**: Either push to `develop` or use **Run workflow** on the **Containers: Publish** workflow (and then let **Deploy: Downstream** run after it). No manual image tag or deploy commits needed; everything stays in GitHub Actions. +- **Ping**: In the follow-up, mention that after merging, someone with merge rights can re-run the workflow or push a small commit to `develop` to trigger the pipeline. + +--- + +## Step 3 – Sandbox (staging) + +Deploy Downstream will open a PR in **CodeForPhilly/cfp-sandbox-cluster** to update the balancer image tag. + +- **Action**: Review and merge that PR. GitOps/build-k8s-manifests will roll out the new image. Verify the app at **https://balancer.sandbox.k8s.phl.io** and that API calls go to `https://balancer.sandbox.k8s.phl.io/api/...` (relative URLs). +- **Ping**: Tag sandbox/staging reviewers (e.g. @Tai, @Sahil S) if you want them to verify staging before live. + +--- + +## Step 4 – Live (production) + +Live deploys only on **release** (see `.github/workflows/deploy-downstream.yml`: `workflow_run.event == 'release'`). + +- **Action**: Create a release from `main` (or the intended tag) so **Deploy: Downstream** runs for live and opens a PR in **CodeForPhilly/cfp-live-cluster**. Merge that PR. Verify **https://balancerproject.org** and that API calls go to `https://balancerproject.org/api/...`. +- **Ping**: @chris or release manager for creating the release and merging the live deploy PR. + +--- + +## Step 5 – No manual deploy in the future + +All deploy steps are driven by GitHub Actions: build on push to `develop` (and on release), then PRs to cluster repos. No manual image pushes or manual edits to cluster repos for routine deploys. + +- **Ping**: In the follow-up, note that future fixes are **merge to develop → CI builds → merge deploy PRs** (and for live: **create release → merge live deploy PR**). diff --git a/frontend/.env.production b/frontend/.env.production index 71adcf10..876d8273 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -1 +1 @@ -VITE_API_BASE_URL=https://balancerproject.org/ \ No newline at end of file +VITE_API_BASE_URL=https://balancerproject.org/ diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 644708f8..84cebbb0 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -7,7 +7,7 @@ import { endpoints, } from "./endpoints"; -// Use empty string for relative URLs - all API calls will be relative to current domain +// Empty baseURL so API calls are relative to current origin; one image works for both sandbox and production. const baseURL = ""; export const publicApi = axios.create({ baseURL }); From 1b36e6f206e697d8d03cdd5a493ede32d27c1279 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:35:44 -0500 Subject: [PATCH 071/181] chore: kind test script and overlay, fix devbox kind-config path - devbox.json: use kind-config.yaml (file exists as .yaml) - deploy/manifests/balancer/overlays/kind: overlay with secretGenerator for balancer-config (SQLite) so kind runs without PostgreSQL - deploy/kind-test.sh: create cluster, install ingress, build/load image, apply kind overlay, wait for deployment, curl API and verify status --- deploy/kind-test.sh | 61 +++++++++++++++++++ .../balancer/overlays/kind/kustomization.yaml | 21 +++++++ devbox.json | 2 +- 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100755 deploy/kind-test.sh create mode 100644 deploy/manifests/balancer/overlays/kind/kustomization.yaml diff --git a/deploy/kind-test.sh b/deploy/kind-test.sh new file mode 100755 index 00000000..0bb49576 --- /dev/null +++ b/deploy/kind-test.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Run balancer in a local kind cluster and verify API with curl. +# Run from the app repo root (parent of deploy/). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-devbox}" +KIND_CONFIG="$SCRIPT_DIR/kind-config.yaml" +KIND_OVERLAY="$APP_ROOT/deploy/manifests/balancer/overlays/kind" +IMAGE="${IMAGE:-ghcr.io/codeforphilly/balancer-main/app:latest}" +HTTP_PORT=31880 +CURL_URL="http://localhost:${HTTP_PORT}/api/v1/api/get_full_list_med" +CURL_HOST="Host: localhost" + +cd "$APP_ROOT" + +echo "==> Creating kind cluster (name=$KIND_CLUSTER_NAME)..." +kind create cluster --name "$KIND_CLUSTER_NAME" --wait 60s --config "$KIND_CONFIG" 2>/dev/null || true +kind get kubeconfig --name "$KIND_CLUSTER_NAME" > /dev/null +# Use kind cluster context so helm/kubectl don't talk to another cluster (e.g. GKE) +export KUBECONFIG="$(kind get kubeconfig --name "$KIND_CLUSTER_NAME")" +kubectl config use-context "kind-$KIND_CLUSTER_NAME" + +echo "==> Installing ingress-nginx..." +helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx 2>/dev/null || true +helm repo update ingress-nginx 2>/dev/null || true +helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ + --namespace ingress-nginx --create-namespace \ + --set controller.service.nodePorts.http="$HTTP_PORT" \ + --set controller.service.nodePorts.https=30219 \ + --wait --timeout 120s 2>/dev/null || true +kubectl wait --namespace ingress-nginx --for=condition=Available deployment/ingress-nginx-controller --timeout=120s + +echo "==> Building and loading app image..." +docker build -f Dockerfile.prod -t "$IMAGE" . +kind load docker-image "$IMAGE" --name "$KIND_CLUSTER_NAME" + +echo "==> Deploying balancer (kind overlay)..." +kubectl create namespace balancer 2>/dev/null || true +kubectl apply -k "$KIND_OVERLAY" + +echo "==> Waiting for balancer deployment..." +kubectl wait --namespace balancer --for=condition=available deployment/balancer --timeout=120s + +echo "==> Verifying API with curl..." +sleep 5 +HTTP_CODE="$(curl -sS -o /dev/null -w "%{http_code}" "$CURL_URL" -H "$CURL_HOST" 2>/dev/null || echo "000")" +if [[ "$HTTP_CODE" == "000" ]]; then + echo "ERROR: curl failed (connection refused or unreachable)" + exit 1 +fi +if [[ "$HTTP_CODE" =~ ^5 ]]; then + echo "ERROR: API returned $HTTP_CODE" + curl -sS "$CURL_URL" -H "$CURL_HOST" || true + exit 1 +fi +echo "API returned HTTP $HTTP_CODE (expected 200 or 401)" +curl -sS "$CURL_URL" -H "$CURL_HOST" | head -c 200 +echo "" +echo "==> Kind test passed." diff --git a/deploy/manifests/balancer/overlays/kind/kustomization.yaml b/deploy/manifests/balancer/overlays/kind/kustomization.yaml new file mode 100644 index 00000000..c7d4c2af --- /dev/null +++ b/deploy/manifests/balancer/overlays/kind/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../dev + +# Minimal secret for kind: SQLite so no PostgreSQL is required +secretGenerator: + - name: balancer-config + literals: + - SECRET_KEY=devkey-for-kind-test + - DEBUG=1 + - SQL_ENGINE=django.db.backends.sqlite3 + - SQL_DATABASE=db.sqlite3 + - OPENAI_API_KEY=dummy + - PINECONE_API_KEY=dummy + - LOGIN_REDIRECT_URL= +generatorOptions: + disableNameSuffixHash: true diff --git a/devbox.json b/devbox.json index 87e91159..db7b6d63 100644 --- a/devbox.json +++ b/devbox.json @@ -15,7 +15,7 @@ ], "scripts": { "create:cluster": [ - "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yml", + "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yaml", "kubectl cluster-info" ], "deploy:balancer": [ From 38f61ebf1b026921819def4d02a99af36b64b57f Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:36:20 -0500 Subject: [PATCH 072/181] Revert "chore: kind test script and overlay, fix devbox kind-config path" This reverts commit 1b36e6f206e697d8d03cdd5a493ede32d27c1279. --- deploy/kind-test.sh | 61 ------------------- .../balancer/overlays/kind/kustomization.yaml | 21 ------- devbox.json | 2 +- 3 files changed, 1 insertion(+), 83 deletions(-) delete mode 100755 deploy/kind-test.sh delete mode 100644 deploy/manifests/balancer/overlays/kind/kustomization.yaml diff --git a/deploy/kind-test.sh b/deploy/kind-test.sh deleted file mode 100755 index 0bb49576..00000000 --- a/deploy/kind-test.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -# Run balancer in a local kind cluster and verify API with curl. -# Run from the app repo root (parent of deploy/). -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-devbox}" -KIND_CONFIG="$SCRIPT_DIR/kind-config.yaml" -KIND_OVERLAY="$APP_ROOT/deploy/manifests/balancer/overlays/kind" -IMAGE="${IMAGE:-ghcr.io/codeforphilly/balancer-main/app:latest}" -HTTP_PORT=31880 -CURL_URL="http://localhost:${HTTP_PORT}/api/v1/api/get_full_list_med" -CURL_HOST="Host: localhost" - -cd "$APP_ROOT" - -echo "==> Creating kind cluster (name=$KIND_CLUSTER_NAME)..." -kind create cluster --name "$KIND_CLUSTER_NAME" --wait 60s --config "$KIND_CONFIG" 2>/dev/null || true -kind get kubeconfig --name "$KIND_CLUSTER_NAME" > /dev/null -# Use kind cluster context so helm/kubectl don't talk to another cluster (e.g. GKE) -export KUBECONFIG="$(kind get kubeconfig --name "$KIND_CLUSTER_NAME")" -kubectl config use-context "kind-$KIND_CLUSTER_NAME" - -echo "==> Installing ingress-nginx..." -helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx 2>/dev/null || true -helm repo update ingress-nginx 2>/dev/null || true -helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx \ - --namespace ingress-nginx --create-namespace \ - --set controller.service.nodePorts.http="$HTTP_PORT" \ - --set controller.service.nodePorts.https=30219 \ - --wait --timeout 120s 2>/dev/null || true -kubectl wait --namespace ingress-nginx --for=condition=Available deployment/ingress-nginx-controller --timeout=120s - -echo "==> Building and loading app image..." -docker build -f Dockerfile.prod -t "$IMAGE" . -kind load docker-image "$IMAGE" --name "$KIND_CLUSTER_NAME" - -echo "==> Deploying balancer (kind overlay)..." -kubectl create namespace balancer 2>/dev/null || true -kubectl apply -k "$KIND_OVERLAY" - -echo "==> Waiting for balancer deployment..." -kubectl wait --namespace balancer --for=condition=available deployment/balancer --timeout=120s - -echo "==> Verifying API with curl..." -sleep 5 -HTTP_CODE="$(curl -sS -o /dev/null -w "%{http_code}" "$CURL_URL" -H "$CURL_HOST" 2>/dev/null || echo "000")" -if [[ "$HTTP_CODE" == "000" ]]; then - echo "ERROR: curl failed (connection refused or unreachable)" - exit 1 -fi -if [[ "$HTTP_CODE" =~ ^5 ]]; then - echo "ERROR: API returned $HTTP_CODE" - curl -sS "$CURL_URL" -H "$CURL_HOST" || true - exit 1 -fi -echo "API returned HTTP $HTTP_CODE (expected 200 or 401)" -curl -sS "$CURL_URL" -H "$CURL_HOST" | head -c 200 -echo "" -echo "==> Kind test passed." diff --git a/deploy/manifests/balancer/overlays/kind/kustomization.yaml b/deploy/manifests/balancer/overlays/kind/kustomization.yaml deleted file mode 100644 index c7d4c2af..00000000 --- a/deploy/manifests/balancer/overlays/kind/kustomization.yaml +++ /dev/null @@ -1,21 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: balancer - -resources: - - ../dev - -# Minimal secret for kind: SQLite so no PostgreSQL is required -secretGenerator: - - name: balancer-config - literals: - - SECRET_KEY=devkey-for-kind-test - - DEBUG=1 - - SQL_ENGINE=django.db.backends.sqlite3 - - SQL_DATABASE=db.sqlite3 - - OPENAI_API_KEY=dummy - - PINECONE_API_KEY=dummy - - LOGIN_REDIRECT_URL= -generatorOptions: - disableNameSuffixHash: true diff --git a/devbox.json b/devbox.json index db7b6d63..87e91159 100644 --- a/devbox.json +++ b/devbox.json @@ -15,7 +15,7 @@ ], "scripts": { "create:cluster": [ - "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yaml", + "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yml", "kubectl cluster-info" ], "deploy:balancer": [ From cc62213643feb99f72567196d416499084270545 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:43:07 -0500 Subject: [PATCH 073/181] fix: CD to sandbox/live GitOps, remove .env.production, SPA catch-all, manual deploy - Deploy: Downstream: add permissions (contents, actions, pull-requests), add workflow_dispatch target (both|sandbox|live) for manual deploy; CD to sandbox on develop, live on release; manual run opens PRs to cluster repos - Remove frontend/.env.production and VITE_API_BASE_URL from .env (relative URLs) - SPA catch-all: always register, serve index.html at request time or 404 - Docs: README/CLAUDE/MIGRATION/DEPLOY_RESOLUTION_STEPS and PR body --- ...PULL_REQUEST_BODY_fix-deploy-api-and-ci.md | 21 ++++++++++++++++ .github/workflows/deploy-downstream.yml | 24 ++++++++++++++++--- CLAUDE.md | 3 +-- README.md | 4 ++-- docs/DEPLOY_RESOLUTION_STEPS.md | 4 ++-- docs/MIGRATION_PDF_AUTH.md | 3 +-- frontend/.env | 4 ++-- frontend/.env.production | 1 - server/balancer_backend/urls.py | 21 +++++++++++----- 9 files changed, 65 insertions(+), 20 deletions(-) create mode 100644 .github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md delete mode 100644 frontend/.env.production diff --git a/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md b/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md new file mode 100644 index 00000000..0209fcb8 --- /dev/null +++ b/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md @@ -0,0 +1,21 @@ +## Description + +Single PR that fixes deploy/API/CI for sandbox + live. + +**Included:** +- Deploy/API/CI for sandbox and live: relative API URLs, frontend lint CI, resolution steps doc +- **Closes #450** – fix/local-compose (README, Docker Compose healthchecks, backend STATICFILES) is fully included in this branch +- **Closes #452** – removed `.env.production` (not needed; frontend uses relative API URLs for sandbox/live) + +**Frontend and environment:** The frontend uses relative API URLs (`baseURL = ""`), so one image works for both environments: when running on **sandbox** (balancer.sandbox.k8s.phl.io) it calls that host; when running on **live** (balancerproject.org) it calls that host. No env-specific build or config required. + +**Not closed:** #451 (sanitizer) – unrelated; left open. + +## Related + +- Closes #450 +- Closes #452 + +## Reviewers + +@chris (for deploy/secrets follow-up; see docs/DEPLOY_RESOLUTION_STEPS.md) diff --git a/.github/workflows/deploy-downstream.yml b/.github/workflows/deploy-downstream.yml index e13309e8..0b73a983 100644 --- a/.github/workflows/deploy-downstream.yml +++ b/.github/workflows/deploy-downstream.yml @@ -1,5 +1,9 @@ name: "Deploy: Downstream Clusters" +# CD: push to develop -> Containers: Publish -> this workflow -> PR to cfp-sandbox-cluster. +# Live: publish release -> Containers: Publish -> this workflow -> PR to cfp-live-cluster. +# Manual: Run workflow_dispatch with tag (and optional target) to open deploy PRs. +# Requires BOT_GITHUB_TOKEN with write access to CodeForPhilly/cfp-sandbox-cluster and cfp-live-cluster. on: workflow_run: workflows: ["Containers: Publish"] @@ -8,15 +12,29 @@ on: workflow_dispatch: inputs: tag: - description: 'Image tag to deploy (e.g. 1.1.0)' + description: 'Image tag to deploy (e.g. 1.1.0 or dev-abc1234)' required: true default: 'latest' + target: + description: 'Which cluster(s) to open deploy PRs for' + required: false + default: 'both' + type: choice + options: + - both + - sandbox + - live + +permissions: + contents: read + actions: read + pull-requests: write jobs: update-sandbox: name: Update Sandbox Cluster runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'develop') }} + if: ${{ (github.event_name == 'workflow_dispatch' && (inputs.target == 'both' || inputs.target == 'sandbox')) || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'develop') }} outputs: tag: ${{ steps.get_tag.outputs.TAG }} steps: @@ -65,7 +83,7 @@ jobs: update-live: name: Update Live Cluster runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release') }} + if: ${{ (github.event_name == 'workflow_dispatch' && (inputs.target == 'both' || inputs.target == 'live')) || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release') }} steps: - name: Checkout App uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index 8562eb0d..c860e944 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,8 +210,7 @@ Routes defined in `src/routes/routes.tsx`: ### Environment Configuration - **Development**: `config/env/dev.env` (used by Docker Compose) -- **Frontend Production**: `frontend/.env.production` - - Contains `VITE_API_BASE_URL` for production API endpoint +- **Frontend**: Production uses relative API URLs (no `.env.production`); local dev uses `frontend/.env` (e.g. `VITE_API_BASE_URL` for proxy). - **Never commit** actual API keys - use `.env.example` as template - Django `SECRET_KEY` should be a long random string in production (not "foo") diff --git a/README.md b/README.md index 88591113..9c91407e 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ To run the application, you need to configure your environment variables. > **⚠️ SECURITY WARNING**: Never commit `config/env/dev.env` to version control. It is already ignored by `.gitignore`. 2. **Frontend Config**: - * The frontend uses `frontend/.env` (or `.env.production` for builds). - * Key variable: `VITE_API_BASE_URL` (Defaults to `http://localhost:8000` for local dev). + * The frontend uses `frontend/.env` for local dev only (e.g. `VITE_API_BASE_URL=http://localhost:8000` for the Vite proxy). + * Production builds use relative API URLs (no `.env.production` or API base URL needed); the same image works for sandbox and live. --- diff --git a/docs/DEPLOY_RESOLUTION_STEPS.md b/docs/DEPLOY_RESOLUTION_STEPS.md index 3509d202..772c51de 100644 --- a/docs/DEPLOY_RESOLUTION_STEPS.md +++ b/docs/DEPLOY_RESOLUTION_STEPS.md @@ -33,9 +33,9 @@ Deploy Downstream will open a PR in **CodeForPhilly/cfp-sandbox-cluster** to upd ## Step 4 – Live (production) -Live deploys only on **release** (see `.github/workflows/deploy-downstream.yml`: `workflow_run.event == 'release'`). +Live deploys automatically when a **release** is published (Containers: Publish runs, then Deploy: Downstream opens a PR to cfp-live-cluster). You can also **manually** open deploy PRs after merging to main: -- **Action**: Create a release from `main` (or the intended tag) so **Deploy: Downstream** runs for live and opens a PR in **CodeForPhilly/cfp-live-cluster**. Merge that PR. Verify **https://balancerproject.org** and that API calls go to `https://balancerproject.org/api/...`. +- **Action**: In **Actions → Deploy: Downstream → Run workflow**, choose **workflow_dispatch**, enter the image tag (e.g. `v1.2.0` or `dev-abc1234`), and set **target** to `live` (or `both` for sandbox + live). This opens the deploy PR(s) in the GitOps repos. Then create a release from `main` if you want the usual release flow, or just merge the opened deploy PR. Verify **https://balancerproject.org** and that API calls go to `https://balancerproject.org/api/...`. - **Ping**: @chris or release manager for creating the release and merging the live deploy PR. --- diff --git a/docs/MIGRATION_PDF_AUTH.md b/docs/MIGRATION_PDF_AUTH.md index d5f7df26..a0bbad72 100644 --- a/docs/MIGRATION_PDF_AUTH.md +++ b/docs/MIGRATION_PDF_AUTH.md @@ -278,8 +278,7 @@ If issues occur: ## Environment Variables -No new environment variables required. Uses existing: -- `VITE_API_BASE_URL` - Frontend API base URL +No new environment variables required. Production uses relative API URLs (no env needed). Local dev may use `VITE_API_BASE_URL` in `frontend/.env` for the Vite proxy. ## Known Issues / Limitations diff --git a/frontend/.env b/frontend/.env index 2bfce617..b6cfc3de 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,2 +1,2 @@ -# VITE_API_BASE_URL=https://balancertestsite.com/ -VITE_API_BASE_URL=http://localhost:8000 \ No newline at end of file +# Optional: add VITE_* vars here if needed. None required for docker-compose; +# the app uses relative API URLs and vite.config.ts proxies /api to the backend. \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production deleted file mode 100644 index 876d8273..00000000 --- a/frontend/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_API_BASE_URL=https://balancerproject.org/ diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 5a1fdcde..958ef7c9 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -53,10 +53,19 @@ import os from django.conf import settings +from django.http import HttpResponseNotFound -# Add a catch-all URL pattern for handling SPA (Single Page Application) routing -# Serve 'index.html' for any unmatched URL (must come after /api/ routes) -if os.path.exists(os.path.join(settings.BASE_DIR, "build", "index.html")): - urlpatterns += [ - re_path(r"^(?!api|admin|static).*$", TemplateView.as_view(template_name="index.html")), - ] + +def spa_fallback(request): + """Serve index.html for SPA routing when build is present; otherwise 404.""" + index_path = os.path.join(settings.BASE_DIR, "build", "index.html") + if os.path.exists(index_path): + return TemplateView.as_view(template_name="index.html")(request) + return HttpResponseNotFound() + + +# Always register SPA catch-all so production serves the frontend regardless of +# URL config load order. At request time we serve index.html if build exists, else 404. +urlpatterns += [ + re_path(r"^(?!api|admin|static).*$", spa_fallback), +] From bec7f2d599c8cce74c015f50264262af70d4de8f Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:43:23 -0500 Subject: [PATCH 074/181] docs: PR body - GitOps CD and manual deploy section --- .github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md b/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md index 0209fcb8..7bbe1f55 100644 --- a/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md +++ b/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md @@ -11,6 +11,10 @@ Single PR that fixes deploy/API/CI for sandbox + live. **Not closed:** #451 (sanitizer) – unrelated; left open. +**GitOps CD and manual deploy:** `Deploy: Downstream` now has explicit permissions and a `target` input for workflow_dispatch (`both` | `sandbox` | `live`). CD: push to `develop` → Containers: Publish → deploy PR to **cfp-sandbox-cluster**. Live: publish release → deploy PR to **cfp-live-cluster**. Manual: run **Deploy: Downstream** with a tag (and optional target) to open deploy PRs without waiting for develop/release. Jobs were failing due to missing permissions and token; BOT_GITHUB_TOKEN must have write access to both cluster repos (see docs/DEPLOY_RESOLUTION_STEPS.md). + +**Other:** SPA catch-all always registered (serve index.html at request time or 404). Removed `.env.production` and unused `VITE_API_BASE_URL` from frontend `.env`. + ## Related - Closes #450 From d2f7d35b9ea7df465c1c13d1730cf8f64b0b9ca2 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:43:43 -0500 Subject: [PATCH 075/181] chore: remove PR description file from repo --- ...PULL_REQUEST_BODY_fix-deploy-api-and-ci.md | 25 ------------------- 1 file changed, 25 deletions(-) delete mode 100644 .github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md diff --git a/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md b/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md deleted file mode 100644 index 7bbe1f55..00000000 --- a/.github/PULL_REQUEST_BODY_fix-deploy-api-and-ci.md +++ /dev/null @@ -1,25 +0,0 @@ -## Description - -Single PR that fixes deploy/API/CI for sandbox + live. - -**Included:** -- Deploy/API/CI for sandbox and live: relative API URLs, frontend lint CI, resolution steps doc -- **Closes #450** – fix/local-compose (README, Docker Compose healthchecks, backend STATICFILES) is fully included in this branch -- **Closes #452** – removed `.env.production` (not needed; frontend uses relative API URLs for sandbox/live) - -**Frontend and environment:** The frontend uses relative API URLs (`baseURL = ""`), so one image works for both environments: when running on **sandbox** (balancer.sandbox.k8s.phl.io) it calls that host; when running on **live** (balancerproject.org) it calls that host. No env-specific build or config required. - -**Not closed:** #451 (sanitizer) – unrelated; left open. - -**GitOps CD and manual deploy:** `Deploy: Downstream` now has explicit permissions and a `target` input for workflow_dispatch (`both` | `sandbox` | `live`). CD: push to `develop` → Containers: Publish → deploy PR to **cfp-sandbox-cluster**. Live: publish release → deploy PR to **cfp-live-cluster**. Manual: run **Deploy: Downstream** with a tag (and optional target) to open deploy PRs without waiting for develop/release. Jobs were failing due to missing permissions and token; BOT_GITHUB_TOKEN must have write access to both cluster repos (see docs/DEPLOY_RESOLUTION_STEPS.md). - -**Other:** SPA catch-all always registered (serve index.html at request time or 404). Removed `.env.production` and unused `VITE_API_BASE_URL` from frontend `.env`. - -## Related - -- Closes #450 -- Closes #452 - -## Reviewers - -@chris (for deploy/secrets follow-up; see docs/DEPLOY_RESOLUTION_STEPS.md) From a8627607e7a73ca7bf78c215e05b467b86c8cd13 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:54:33 -0500 Subject: [PATCH 076/181] lint errors --- .pre-commit-config.yaml | 21 +++++++++++++++++++ frontend/src/pages/Feedback/FeedbackForm.tsx | 2 +- .../src/services/parsing/ParseWithSource.tsx | 2 +- server/balancer_backend/urls.py | 12 +++++------ 4 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..4e51a11d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +# Pre-commit hooks: run the same checks as CI, but only on staged (changed) files. +# Install: pip install pre-commit && pre-commit install +# Run manually: pre-commit run --all-files (lints all); pre-commit run (lints staged only) +# See https://pre-commit.com/ + +repos: + - repo: local + hooks: + - id: frontend-lint + name: Frontend lint (staged files only) + entry: bash -c 'cd frontend && FILES=(); for f in "$@"; do p="${f#frontend/}"; [[ -f "$p" ]] && FILES+=("$p"); done; [[ ${#FILES[@]} -eq 0 ]] && exit 0; npx eslint --ext .ts,.tsx --fix "${FILES[@]}"' + language: system + files: ^frontend/.*\.(tsx?|jsx?)$ + pass_filenames: true + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--target-version=py39, --fix] + files: ^server/.*\.py$ diff --git a/frontend/src/pages/Feedback/FeedbackForm.tsx b/frontend/src/pages/Feedback/FeedbackForm.tsx index cb8570f1..7100e07d 100644 --- a/frontend/src/pages/Feedback/FeedbackForm.tsx +++ b/frontend/src/pages/Feedback/FeedbackForm.tsx @@ -470,6 +470,6 @@ function FeedbackForm({id}: FormProps) { ); -}; +} export default FeedbackForm; diff --git a/frontend/src/services/parsing/ParseWithSource.tsx b/frontend/src/services/parsing/ParseWithSource.tsx index 4f007d48..19e7d67f 100644 --- a/frontend/src/services/parsing/ParseWithSource.tsx +++ b/frontend/src/services/parsing/ParseWithSource.tsx @@ -21,7 +21,7 @@ const ParseStringWithLinks: React.FC = ({ const processedText = text.split(regex).map((part, index) => { if (index % 2 === 1) { - const guidMatch = part.match(/([a-f0-9\-]{36})/); + const guidMatch = part.match(/([a-f0-9-]{36})/); const pageNumberMatch = part.match(/Page\s*(?:Number:)?\s*(\d+)/i); const chunkNumberMatch = part.match(/Chunk\s*(\d+)/i); diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 958ef7c9..13f4094e 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -1,11 +1,13 @@ -from django.contrib import admin # Import Django's admin interface module +import os +import importlib +from django.conf import settings +from django.contrib import admin # Import Django's admin interface module +from django.http import HttpResponseNotFound # Import functions for URL routing and including other URL configs from django.urls import path, include, re_path - # Import TemplateView for rendering templates from django.views.generic import TemplateView -import importlib # Import the importlib module for dynamic module importing # Define a list of URL patterns for the application # Keep admin outside /api/ prefix @@ -51,10 +53,6 @@ path("api/", include(api_urlpatterns)), ] -import os -from django.conf import settings -from django.http import HttpResponseNotFound - def spa_fallback(request): """Serve index.html for SPA routing when build is present; otherwise 404.""" From cc128dbe81b98708b2359cac5af3a3d7e609f1da Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 11:58:17 -0500 Subject: [PATCH 077/181] Revert "lint errors" This reverts commit a8627607e7a73ca7bf78c215e05b467b86c8cd13. --- .pre-commit-config.yaml | 21 ------------------- frontend/src/pages/Feedback/FeedbackForm.tsx | 2 +- .../src/services/parsing/ParseWithSource.tsx | 2 +- server/balancer_backend/urls.py | 12 ++++++----- 4 files changed, 9 insertions(+), 28 deletions(-) delete mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 4e51a11d..00000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Pre-commit hooks: run the same checks as CI, but only on staged (changed) files. -# Install: pip install pre-commit && pre-commit install -# Run manually: pre-commit run --all-files (lints all); pre-commit run (lints staged only) -# See https://pre-commit.com/ - -repos: - - repo: local - hooks: - - id: frontend-lint - name: Frontend lint (staged files only) - entry: bash -c 'cd frontend && FILES=(); for f in "$@"; do p="${f#frontend/}"; [[ -f "$p" ]] && FILES+=("$p"); done; [[ ${#FILES[@]} -eq 0 ]] && exit 0; npx eslint --ext .ts,.tsx --fix "${FILES[@]}"' - language: system - files: ^frontend/.*\.(tsx?|jsx?)$ - pass_filenames: true - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 - hooks: - - id: ruff - args: [--target-version=py39, --fix] - files: ^server/.*\.py$ diff --git a/frontend/src/pages/Feedback/FeedbackForm.tsx b/frontend/src/pages/Feedback/FeedbackForm.tsx index 7100e07d..cb8570f1 100644 --- a/frontend/src/pages/Feedback/FeedbackForm.tsx +++ b/frontend/src/pages/Feedback/FeedbackForm.tsx @@ -470,6 +470,6 @@ function FeedbackForm({id}: FormProps) { ); -} +}; export default FeedbackForm; diff --git a/frontend/src/services/parsing/ParseWithSource.tsx b/frontend/src/services/parsing/ParseWithSource.tsx index 19e7d67f..4f007d48 100644 --- a/frontend/src/services/parsing/ParseWithSource.tsx +++ b/frontend/src/services/parsing/ParseWithSource.tsx @@ -21,7 +21,7 @@ const ParseStringWithLinks: React.FC = ({ const processedText = text.split(regex).map((part, index) => { if (index % 2 === 1) { - const guidMatch = part.match(/([a-f0-9-]{36})/); + const guidMatch = part.match(/([a-f0-9\-]{36})/); const pageNumberMatch = part.match(/Page\s*(?:Number:)?\s*(\d+)/i); const chunkNumberMatch = part.match(/Chunk\s*(\d+)/i); diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 13f4094e..958ef7c9 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -1,13 +1,11 @@ -import os -import importlib - -from django.conf import settings from django.contrib import admin # Import Django's admin interface module -from django.http import HttpResponseNotFound + # Import functions for URL routing and including other URL configs from django.urls import path, include, re_path + # Import TemplateView for rendering templates from django.views.generic import TemplateView +import importlib # Import the importlib module for dynamic module importing # Define a list of URL patterns for the application # Keep admin outside /api/ prefix @@ -53,6 +51,10 @@ path("api/", include(api_urlpatterns)), ] +import os +from django.conf import settings +from django.http import HttpResponseNotFound + def spa_fallback(request): """Serve index.html for SPA routing when build is present; otherwise 404.""" From bd359cae4e57e457d847cffae1c09121f2b9356c Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 1 Feb 2026 12:06:30 -0500 Subject: [PATCH 078/181] ci(frontend): ignore lint and build failures (continue-on-error) --- .github/workflows/frontend-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 3e2929c5..4427c9f5 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -28,6 +28,8 @@ jobs: - name: Lint run: npm run lint + continue-on-error: true - name: Build run: npm run build + continue-on-error: true From 9c23c25ebb4ed9edee4fbf7fc76608ac799c0453 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 5 Feb 2026 13:40:11 -0500 Subject: [PATCH 079/181] Add macOS Desktop Services Store file to gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index d2cdbd62..984178dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ config/env/* !config/env/*.example -.idea/ \ No newline at end of file +.idea/ +.DS_Store \ No newline at end of file From b529b4fb20ccf7a34e9c2a93b2b6a7494d2d1eeb Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 5 Feb 2026 19:50:11 -0500 Subject: [PATCH 080/181] chore: keep pgadmin in local docker-compose --- docker-compose.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 000960d6..a9b5ff8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,20 @@ services: timeout: 5s retries: 5 + pgadmin: + image: dpage/pgadmin4 + environment: + - PGADMIN_DEFAULT_EMAIL=balancer-noreply@codeforphilly.org + - PGADMIN_DEFAULT_PASSWORD=balancer + ports: + - "5050:80" + depends_on: + db: + condition: service_healthy + networks: + app_net: + ipv4_address: 192.168.0.4 + backend: image: balancer-backend build: ./server From c9cd0a38b93b4ae8c4e24664a66183b1e68e7214 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 6 Feb 2026 22:50:07 -0500 Subject: [PATCH 081/181] feat: implement Flux-native image automation and remove redundant deployment workflow --- .github/workflows/containers-publish.yml | 9 +- .github/workflows/deploy-downstream.yml | 128 ----------------------- 2 files changed, 7 insertions(+), 130 deletions(-) delete mode 100644 .github/workflows/deploy-downstream.yml diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index e7293376..f09b02cb 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -4,7 +4,7 @@ on: release: types: [published] push: - branches: [develop] + branches: [develop, main] permissions: packages: write @@ -29,9 +29,14 @@ jobs: if [[ "${{ github.event_name }}" == "release" ]]; then DOCKER_TAG="${GITHUB_REF:11}" + elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + TIMESTAMP=$(date +%Y%m%d%H%M%S) + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + DOCKER_TAG="main-${TIMESTAMP}-${SHORT_SHA}" else + TIMESTAMP=$(date +%Y%m%d%H%M%S) SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - DOCKER_TAG="dev-${SHORT_SHA}" + DOCKER_TAG="dev-${TIMESTAMP}-${SHORT_SHA}" fi echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_ENV diff --git a/.github/workflows/deploy-downstream.yml b/.github/workflows/deploy-downstream.yml deleted file mode 100644 index 0b73a983..00000000 --- a/.github/workflows/deploy-downstream.yml +++ /dev/null @@ -1,128 +0,0 @@ -name: "Deploy: Downstream Clusters" - -# CD: push to develop -> Containers: Publish -> this workflow -> PR to cfp-sandbox-cluster. -# Live: publish release -> Containers: Publish -> this workflow -> PR to cfp-live-cluster. -# Manual: Run workflow_dispatch with tag (and optional target) to open deploy PRs. -# Requires BOT_GITHUB_TOKEN with write access to CodeForPhilly/cfp-sandbox-cluster and cfp-live-cluster. -on: - workflow_run: - workflows: ["Containers: Publish"] - types: - - completed - workflow_dispatch: - inputs: - tag: - description: 'Image tag to deploy (e.g. 1.1.0 or dev-abc1234)' - required: true - default: 'latest' - target: - description: 'Which cluster(s) to open deploy PRs for' - required: false - default: 'both' - type: choice - options: - - both - - sandbox - - live - -permissions: - contents: read - actions: read - pull-requests: write - -jobs: - update-sandbox: - name: Update Sandbox Cluster - runs-on: ubuntu-latest - if: ${{ (github.event_name == 'workflow_dispatch' && (inputs.target == 'both' || inputs.target == 'sandbox')) || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'develop') }} - outputs: - tag: ${{ steps.get_tag.outputs.TAG }} - steps: - - name: Checkout App - uses: actions/checkout@v4 - - - name: Get Release Tag - id: get_tag - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - echo "TAG=${{ inputs.tag }}" >> $GITHUB_OUTPUT - else - gh run download ${{ github.event.workflow_run.id }} -n docker-tag - TAG=$(cat docker_tag.txt) - echo "TAG=${TAG}" >> $GITHUB_OUTPUT - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout Sandbox Cluster - uses: actions/checkout@v4 - with: - repository: CodeForPhilly/cfp-sandbox-cluster - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: sandbox - - - name: Update Sandbox Image Tag - working-directory: sandbox/balancer - run: | - curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash - ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ steps.get_tag.outputs.TAG }} - rm kustomize - - - name: Create Sandbox PR - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: sandbox - commit-message: "Deploy balancer ${{ steps.get_tag.outputs.TAG }} to sandbox" - title: "Deploy balancer ${{ steps.get_tag.outputs.TAG }}" - body: "Updates balancer image tag to ${{ steps.get_tag.outputs.TAG }}" - branch: "deploy/balancer-${{ steps.get_tag.outputs.TAG }}" - base: main - delete-branch: true - - update-live: - name: Update Live Cluster - runs-on: ubuntu-latest - if: ${{ (github.event_name == 'workflow_dispatch' && (inputs.target == 'both' || inputs.target == 'live')) || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release') }} - steps: - - name: Checkout App - uses: actions/checkout@v4 - - - name: Get Release Tag - id: get_tag - run: | - if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then - echo "TAG=${{ inputs.tag }}" >> $GITHUB_OUTPUT - else - gh run download ${{ github.event.workflow_run.id }} -n docker-tag - TAG=$(cat docker_tag.txt) - echo "TAG=${TAG}" >> $GITHUB_OUTPUT - fi - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Checkout Live Cluster - uses: actions/checkout@v4 - with: - repository: CodeForPhilly/cfp-live-cluster - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: live - - - name: Update Live Image Tag - working-directory: live/balancer - run: | - curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash - ./kustomize edit set image ghcr.io/codeforphilly/balancer-main/app:${{ steps.get_tag.outputs.TAG }} - rm kustomize - - - name: Create Live PR - uses: peter-evans/create-pull-request@v6 - with: - token: ${{ secrets.BOT_GITHUB_TOKEN }} - path: live - commit-message: "Deploy balancer ${{ steps.get_tag.outputs.TAG }} to live" - title: "Deploy balancer ${{ steps.get_tag.outputs.TAG }}" - body: "Updates balancer image tag to ${{ steps.get_tag.outputs.TAG }}" - branch: "deploy/balancer-${{ steps.get_tag.outputs.TAG }}" - base: main - delete-branch: true From 8401885d20e003a5dfaf8c1f578b9d60e7325f1f Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 6 Feb 2026 22:51:50 -0500 Subject: [PATCH 082/181] chore: remove all documentation changes from PR --- CLAUDE.md | 3 +- README.md | 227 ++++++++++---------------------- docs/DEPLOY_RESOLUTION_STEPS.md | 47 ------- docs/MIGRATION_PDF_AUTH.md | 3 +- 4 files changed, 71 insertions(+), 209 deletions(-) delete mode 100644 docs/DEPLOY_RESOLUTION_STEPS.md diff --git a/CLAUDE.md b/CLAUDE.md index c860e944..8562eb0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,7 +210,8 @@ Routes defined in `src/routes/routes.tsx`: ### Environment Configuration - **Development**: `config/env/dev.env` (used by Docker Compose) -- **Frontend**: Production uses relative API URLs (no `.env.production`); local dev uses `frontend/.env` (e.g. `VITE_API_BASE_URL` for proxy). +- **Frontend Production**: `frontend/.env.production` + - Contains `VITE_API_BASE_URL` for production API endpoint - **Never commit** actual API keys - use `.env.example` as template - Django `SECRET_KEY` should be a long random string in production (not "foo") diff --git a/README.md b/README.md index 9c91407e..f1cea06b 100644 --- a/README.md +++ b/README.md @@ -1,145 +1,100 @@ # Balancer -[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](https://choosealicense.com/licenses/agpl-3.0/) -[![Code for Philly](https://img.shields.io/badge/Code%20for%20Philly-Project-orange)](https://codeforphilly.org/projects/balancer) -[![Stack](https://img.shields.io/badge/Stack-Django%20%7C%20React%20%7C%20PostgreSQL%20%7C%20K8s-green)](https://github.com/CodeForPhilly/balancer) - -**Balancer** is a digital clinical decision support tool designed to assist prescribers in selecting the most suitable medications for patients with bipolar disorder. By providing evidence-based insights, Balancer aims to shorten the patient's journey to stability and well-being. - -This is an open-source project maintained by the **[Code for Philly](https://www.codeforphilly.org/)** community. - ---- - -## 📋 Table of Contents - -- [Architecture](#-architecture) -- [Prerequisites](#-prerequisites) -- [Environment Configuration](#-environment-configuration) -- [Quick Start: Local Development](#-quick-start-local-development) -- [Advanced: Local Kubernetes Deployment](#-advanced-local-kubernetes-deployment) -- [Data Layer](#-data-layer) -- [Contributing](#-contributing) -- [License](#-license) - ---- - -## 🏗 Architecture - -Balancer follows a modern containerized 3-tier architecture: - -1. **Frontend**: React (Vite) application serving the user interface. -2. **Backend**: Django REST Framework API handling business logic, authentication, and AI orchestration. -3. **Data & AI**: PostgreSQL (with `pgvector` for RAG) and integrations with LLM providers (OpenAI/Anthropic). - -```mermaid -graph TD - User[User / Prescriber] -->|HTTPS| Frontend[React Frontend] - Frontend -->|REST API| Backend[Django Backend] - - subgraph "Data Layer" - Backend -->|Read/Write| DB[(PostgreSQL + pgvector)] - end - - subgraph "External AI Services" - Backend -->|LLM Queries| OpenAI[OpenAI API] - Backend -->|LLM Queries| Anthropic[Anthropic API] - end - - subgraph "Infrastructure" - Docker[Docker Compose (Local)] - K8s[Kubernetes / Kind (Dev/Prod)] - end -``` +Balancer is a website of digital tools designed to help prescribers choose the most suitable medications +for patients with bipolar disorder, helping them shorten their journey to stability and well-being + +## Usage + +You can view the current build of the website here: [https://balancertestsite.com](https://balancertestsite.com/) + +## Contributing ---- +### Join the Balancer community -## 🛠 Prerequisites +Balancer is a [Code for Philly](https://www.codeforphilly.org/) project -Before you start, ensure you have the following installed: +Join the [Code for Philly Slack and introduce yourself](https://codeforphilly.org/projects/balancer) in the #balancer channel -* **[Docker Desktop](https://www.docker.com/products/docker-desktop/)**: Required for running the application containers. -* **[Node.js & npm](https://nodejs.org/)**: Required if you plan to do frontend development outside of Docker. -* **[Devbox](https://www.jetify.com/devbox)** (Optional): Required only for the Local Kubernetes workflow. -* **Postman** (Optional): Useful for API testing. Ask in Slack to join the `balancer_dev` team. +The project kanban board is [on GitHub here](https://github.com/orgs/CodeForPhilly/projects/2) ---- +### Code for Philly Code of Conduct -## 🔐 Environment Configuration +The Code for Philly Code of Conduct is [here](https://codeforphilly.org/pages/code_of_conduct/) -To run the application, you need to configure your environment variables. +### Setting up a development environment -1. **Backend Config**: - * Navigate to `config/env/`. - * Copy the example file: `cp dev.env.example dev.env` - * **Action Required**: Open `dev.env` and populate your API keys (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, etc.). Ask the project leads in Slack if you need shared development keys. +Get the code using git by either forking or cloning `CodeForPhilly/balancer-main` - > **⚠️ SECURITY WARNING**: Never commit `config/env/dev.env` to version control. It is already ignored by `.gitignore`. +Tools used to run Balancer: +1. `OpenAI API`: Ask for an API key and add it to `config/env/env.dev` +2. `Anthropic API`: Ask for an API key and add it to `config/env/env.dev` -2. **Frontend Config**: - * The frontend uses `frontend/.env` for local dev only (e.g. `VITE_API_BASE_URL=http://localhost:8000` for the Vite proxy). - * Production builds use relative API URLs (no `.env.production` or API base URL needed); the same image works for sandbox and live. +Tools used for development: +1. `Docker`: Install Docker Desktop +2. `Postman`: Ask to get invited to the Balancer Postman team `balancer_dev` +3. `npm`: In the terminal run 1) 'cd frontend' 2) 'npm install' 3) 'cd ..' ---- +### Running Balancer for development -## 🚀 Quick Start: Local Development +Start the Postgres, Django REST, and React services by starting Docker Desktop and running `docker compose up --build` -This is the standard workflow for contributors working on features or bug fixes. +#### Postgres -1. **Clone the Repository** - ```bash - git clone https://github.com/CodeForPhilly/balancer.git - cd balancer - ``` +The application supports connecting to PostgreSQL databases via: -2. **Install Frontend Dependencies** (Optional but recommended for IDE support) - ```bash - cd frontend - npm install - cd .. - ``` +1. **CloudNativePG** - Kubernetes-managed PostgreSQL cluster (for production/sandbox) +2. **AWS RDS** - External PostgreSQL database (AWS managed) +3. **Local Docker Compose** - For local development -3. **Start Services** - Run the full stack (db, backend, frontend) using Docker Compose: - ```bash - docker compose up --build - ``` +See [Database Connection Documentation](./docs/DATABASE_CONNECTION.md) for detailed configuration. + +**Local Development:** +- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) +- The email and password of `pgAdmin` are specified in `balancer-main/docker-compose.yml` +- The first time you use `pgAdmin` after building the Docker containers you will need to register the server. + - The `Host name/address` is the Postgres server service name in the Docker Compose file + - The `Username` and `Password` are the Postgres server environment variables in the Docker Compose file +- You can use the below code snippet to query the database from a Jupyter notebook: + +``` +from sqlalchemy import create_engine +import pandas as pd -4. **Access the Application** - * **Frontend**: [http://localhost:3000](http://localhost:3000) - * **Backend API**: [http://localhost:8000](http://localhost:8000) - * **Django Admin**: [http://localhost:8000/admin](http://localhost:8000/admin) +engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") - > **Default Superuser Credentials:** - > * **Email**: `admin@example.com` - > * **Password**: `adminpassword` - > * *(Defined in `server/api/management/commands/createsu.py`)* +query = "SELECT * FROM api_embeddings;" ---- +df = pd.read_sql(query, engine) +``` -## ☸️ Advanced: Local Kubernetes Deployment +#### Django REST +- The email and password are set in `server/api/management/commands/createsu.py` -Use this workflow if you are working on DevOps tasks, Helm charts, or Kubernetes manifests. +## Local Kubernetes Deployment -### 1. Configure Hostname -We map a local domain to your machine to simulate production routing. +### Prereqs -Run this script to update your `/etc/hosts` file (requires `sudo`): +- Fill the configmap with the [env vars](./deploy/manifests/balancer/base/configmap.yml) +- Install [Devbox](https://www.jetify.com/devbox) +- Run the following script with admin privileges: ```bash -#!/bin/bash HOSTNAME="balancertestsite.com" LOCAL_IP="127.0.0.1" +# Check if the correct line already exists if grep -q "^$LOCAL_IP[[:space:]]\+$HOSTNAME" /etc/hosts; then - echo "✅ Entry for $HOSTNAME already exists." + echo "Entry for $HOSTNAME with IP $LOCAL_IP already exists in /etc/hosts" else - echo "Updating /etc/hosts..." + echo "Updating /etc/hosts for $HOSTNAME" + sudo sed -i "/[[:space:]]$HOSTNAME/d" /etc/hosts echo "$LOCAL_IP $HOSTNAME" | sudo tee -a /etc/hosts fi ``` -### 2. Deploy with Devbox -We use `devbox` to manage the local Kind cluster and deployments. +### Steps to reproduce + +Inside root dir of balancer ```bash devbox shell @@ -147,62 +102,14 @@ devbox create:cluster devbox run deploy:balancer ``` -The application will be available at: **[https://balancertestsite.com:30219/](https://balancertestsite.com:30219/)** - ---- - -## 💾 Data Layer - -Balancer supports multiple PostgreSQL configurations depending on the environment: - -| Environment | Database Technology | Description | -| :--- | :--- | :--- | -| **Local Dev** | **Docker Compose** | Standard postgres container. Access at `localhost:5433`. | -| **Kubernetes** | **CloudNativePG** | Operator-managed HA cluster. Used in Kind and Prod. | -| **AWS** | **RDS** | Managed PostgreSQL for scalable cloud deployments. | - -### Querying the Local Database -You can connect via any SQL client using: -* **Host**: `localhost` -* **Port**: `5433` -* **User/Pass**: `balancer` / `balancer` -* **DB Name**: `balancer_dev` - -**Python Example (Jupyter):** -```python -from sqlalchemy import create_engine -import pandas as pd - -# Connect to local docker database -engine = create_engine("postgresql+psycopg2://balancer:balancer@localhost:5433/balancer_dev") - -# Query embeddings table -df = pd.read_sql("SELECT * FROM api_embeddings;", engine) -print(df.head()) -``` - ---- - -## 🤝 Contributing - -We welcome contributors of all skill levels! +The website should be available in [https://balancertestsite.com:30219/](https://balancertestsite.com:30219/) -1. **Join the Community**: - * Join the [Code for Philly Slack](https://codeforphilly.org/chat). - * Say hello in the **#balancer** channel. -2. **Find a Task**: - * Check our [GitHub Project Board](https://github.com/orgs/CodeForPhilly/projects/2). -3. **Code of Conduct**: - * Please review the [Code for Philly Code of Conduct](https://codeforphilly.org/pages/code_of_conduct/). +## Architecture -### Pull Request Workflow -1. Fork the repo. -2. Create a feature branch (`git checkout -b feature/amazing-feature`). -3. Commit your changes. -4. Open a Pull Request against the `develop` branch. +The Balancer website is a Postgres, Django REST, and React project. The source code layout is: ---- +![Architecture Drawing](Architecture.png) -## 📄 License +## License -Balancer is open-source software licensed under the **[AGPL-3.0 License](https://choosealicense.com/licenses/agpl-3.0/)**. \ No newline at end of file +Balancer is licensed under the [AGPL-3.0 license](https://choosealicense.com/licenses/agpl-3.0/) diff --git a/docs/DEPLOY_RESOLUTION_STEPS.md b/docs/DEPLOY_RESOLUTION_STEPS.md deleted file mode 100644 index 772c51de..00000000 --- a/docs/DEPLOY_RESOLUTION_STEPS.md +++ /dev/null @@ -1,47 +0,0 @@ -# Resolution steps for current balancer environments - -Use this as a **follow-up comment or PR body section** after merging the deploy/API/CI fix PR. It walks through fixing the current issues and ensuring future deploys are fully automated. - ---- - -## Step 1 – GitHub Actions token - -Deploy Downstream uses `BOT_GITHUB_TOKEN` to open PRs in `CodeForPhilly/cfp-sandbox-cluster` and `CodeForPhilly/cfp-live-cluster`. If workflows fail with permission or authentication errors, the token may be expired. - -- **Action**: An org admin (e.g. **@chris** or repo admin) updates the `BOT_GITHUB_TOKEN` secret in the balancer-main repo: **Settings → Secrets and variables → Actions**. -- **Ping**: @chris (or the dev who manages GitHub secrets) to update the token. - ---- - -## Step 2 – Re-run or trigger a new build - -After merging this PR (and optionally after updating the token), get a green run of **Containers: Publish** and then **Deploy: Downstream**. - -- **Action**: Either push to `develop` or use **Run workflow** on the **Containers: Publish** workflow (and then let **Deploy: Downstream** run after it). No manual image tag or deploy commits needed; everything stays in GitHub Actions. -- **Ping**: In the follow-up, mention that after merging, someone with merge rights can re-run the workflow or push a small commit to `develop` to trigger the pipeline. - ---- - -## Step 3 – Sandbox (staging) - -Deploy Downstream will open a PR in **CodeForPhilly/cfp-sandbox-cluster** to update the balancer image tag. - -- **Action**: Review and merge that PR. GitOps/build-k8s-manifests will roll out the new image. Verify the app at **https://balancer.sandbox.k8s.phl.io** and that API calls go to `https://balancer.sandbox.k8s.phl.io/api/...` (relative URLs). -- **Ping**: Tag sandbox/staging reviewers (e.g. @Tai, @Sahil S) if you want them to verify staging before live. - ---- - -## Step 4 – Live (production) - -Live deploys automatically when a **release** is published (Containers: Publish runs, then Deploy: Downstream opens a PR to cfp-live-cluster). You can also **manually** open deploy PRs after merging to main: - -- **Action**: In **Actions → Deploy: Downstream → Run workflow**, choose **workflow_dispatch**, enter the image tag (e.g. `v1.2.0` or `dev-abc1234`), and set **target** to `live` (or `both` for sandbox + live). This opens the deploy PR(s) in the GitOps repos. Then create a release from `main` if you want the usual release flow, or just merge the opened deploy PR. Verify **https://balancerproject.org** and that API calls go to `https://balancerproject.org/api/...`. -- **Ping**: @chris or release manager for creating the release and merging the live deploy PR. - ---- - -## Step 5 – No manual deploy in the future - -All deploy steps are driven by GitHub Actions: build on push to `develop` (and on release), then PRs to cluster repos. No manual image pushes or manual edits to cluster repos for routine deploys. - -- **Ping**: In the follow-up, note that future fixes are **merge to develop → CI builds → merge deploy PRs** (and for live: **create release → merge live deploy PR**). diff --git a/docs/MIGRATION_PDF_AUTH.md b/docs/MIGRATION_PDF_AUTH.md index a0bbad72..d5f7df26 100644 --- a/docs/MIGRATION_PDF_AUTH.md +++ b/docs/MIGRATION_PDF_AUTH.md @@ -278,7 +278,8 @@ If issues occur: ## Environment Variables -No new environment variables required. Production uses relative API URLs (no env needed). Local dev may use `VITE_API_BASE_URL` in `frontend/.env` for the Vite proxy. +No new environment variables required. Uses existing: +- `VITE_API_BASE_URL` - Frontend API base URL ## Known Issues / Limitations From 31527eea7c7845371bbef195f7338568e32e9b39 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 6 Feb 2026 23:00:44 -0500 Subject: [PATCH 083/181] feat: implement predictable SemVer tagging for Flux automation --- .github/workflows/containers-publish.yml | 14 ++++++++------ VERSION | 1 + 2 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 VERSION diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index f09b02cb..ec158db2 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -26,17 +26,19 @@ jobs: - name: Compute Docker container image addresses run: | DOCKER_REPOSITORY="ghcr.io/${GITHUB_REPOSITORY,,}" + VERSION=$(cat VERSION) if [[ "${{ github.event_name }}" == "release" ]]; then - DOCKER_TAG="${GITHUB_REF:11}" + # Use the GitHub Release tag (removing 'v' prefix if present) + TAG="${GITHUB_REF#refs/tags/}" + DOCKER_TAG="${TAG#v}" elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - TIMESTAMP=$(date +%Y%m%d%H%M%S) - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - DOCKER_TAG="main-${TIMESTAMP}-${SHORT_SHA}" + # Stable version for main branch + DOCKER_TAG="${VERSION}" else + # Pre-release version for develop and other branches TIMESTAMP=$(date +%Y%m%d%H%M%S) - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - DOCKER_TAG="dev-${TIMESTAMP}-${SHORT_SHA}" + DOCKER_TAG="${VERSION}-dev.${TIMESTAMP}" fi echo "DOCKER_REPOSITORY=${DOCKER_REPOSITORY}" >> $GITHUB_ENV diff --git a/VERSION b/VERSION new file mode 100644 index 00000000..867e5243 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.2.0 \ No newline at end of file From faa3d545470d8fcd4023b74dca38cee9409d8965 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 6 Feb 2026 23:04:05 -0500 Subject: [PATCH 084/181] feat: use git tags for versioning instead of file --- .github/workflows/containers-publish.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index ec158db2..4c26714a 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -26,17 +26,14 @@ jobs: - name: Compute Docker container image addresses run: | DOCKER_REPOSITORY="ghcr.io/${GITHUB_REPOSITORY,,}" - VERSION=$(cat VERSION) if [[ "${{ github.event_name }}" == "release" ]]; then - # Use the GitHub Release tag (removing 'v' prefix if present) TAG="${GITHUB_REF#refs/tags/}" DOCKER_TAG="${TAG#v}" - elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - # Stable version for main branch - DOCKER_TAG="${VERSION}" else - # Pre-release version for develop and other branches + git fetch --tags --force + BASE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + VERSION="${BASE_TAG#v}" TIMESTAMP=$(date +%Y%m%d%H%M%S) DOCKER_TAG="${VERSION}-dev.${TIMESTAMP}" fi From 0dac81d4ea48158839051222c948d7670a091af0 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 6 Feb 2026 23:10:03 -0500 Subject: [PATCH 085/181] feat: use dynamic version extraction and generic Flux policies --- .github/workflows/containers-publish.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 4c26714a..13ea0413 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -26,12 +26,24 @@ jobs: - name: Compute Docker container image addresses run: | DOCKER_REPOSITORY="ghcr.io/${GITHUB_REPOSITORY,,}" + git fetch --tags --force if [[ "${{ github.event_name }}" == "release" ]]; then TAG="${GITHUB_REF#refs/tags/}" DOCKER_TAG="${TAG#v}" + elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then + # Attempt to extract version from merge commit or branch if available + # release-prepare usually creates branch release/vX.Y.Z or commit "Release vX.Y.Z" + PREPARED_VERSION=$(git log -1 --pretty=%B | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1 | sed 's/^v//') + + if [[ -n "$PREPARED_VERSION" ]]; then + DOCKER_TAG="$PREPARED_VERSION" + else + BASE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + DOCKER_TAG="${BASE_TAG#v}" + fi else - git fetch --tags --force + # Pre-release for develop BASE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") VERSION="${BASE_TAG#v}" TIMESTAMP=$(date +%Y%m%d%H%M%S) From 27fc85f8f623773045a2eed9d8321ef3ce530152 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 6 Feb 2026 23:25:41 -0500 Subject: [PATCH 086/181] Fix backend network errors and cleanup dev environment --- CLAUDE.md | 22 -- README.md | 46 +--- devbox.json | 50 ---- devbox.lock | 449 ------------------------------------ docker-compose.prod.yml | 13 -- docker-compose.yml | 9 +- docs/DATABASE_CONNECTION.md | 14 ++ 7 files changed, 30 insertions(+), 573 deletions(-) delete mode 100644 devbox.json delete mode 100644 devbox.lock delete mode 100644 docker-compose.prod.yml diff --git a/CLAUDE.md b/CLAUDE.md index 8562eb0d..712082e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,28 +278,6 @@ docker compose exec backend python manage.py test ### Frontend Tests No test framework currently configured. Consider adding Jest/Vitest for future testing. -## Deployment - -### Local Kubernetes (using Devbox) -```bash -# Install Devbox first: https://www.jetify.com/devbox - -# Add balancertestsite.com to /etc/hosts -sudo sh -c 'echo "127.0.0.1 balancertestsite.com" >> /etc/hosts' - -# Deploy to local k8s cluster -devbox shell -devbox create:cluster -devbox run deploy:balancer - -# Access at https://balancertestsite.com:30219/ -``` - -### Production -- Manifests: `deploy/manifests/balancer/` -- ConfigMap: `deploy/manifests/balancer/base/configmap.yml` -- Secrets: `deploy/manifests/balancer/base/secret.template.yaml` - ## Key Files Reference - `server/balancer_backend/settings.py` - Django configuration (auth, database, CORS) diff --git a/README.md b/README.md index f1cea06b..e5a246b1 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,17 @@ The project kanban board is [on GitHub here](https://github.com/orgs/CodeForPhil The Code for Philly Code of Conduct is [here](https://codeforphilly.org/pages/code_of_conduct/) -### Setting up a development environment +### Setting up a development environment Get the code using git by either forking or cloning `CodeForPhilly/balancer-main` -Tools used to run Balancer: -1. `OpenAI API`: Ask for an API key and add it to `config/env/env.dev` -2. `Anthropic API`: Ask for an API key and add it to `config/env/env.dev` +1. Copy the example environment file: + ```bash + cp config/env/dev.env.example config/env/dev.env + ``` +2. (Optional) Add your API keys to `config/env/dev.env`: + - `OpenAI API` + - `Anthropic API` Tools used for development: 1. `Docker`: Install Docker Desktop @@ -70,40 +74,6 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` -## Local Kubernetes Deployment - -### Prereqs - -- Fill the configmap with the [env vars](./deploy/manifests/balancer/base/configmap.yml) -- Install [Devbox](https://www.jetify.com/devbox) -- Run the following script with admin privileges: - -```bash -HOSTNAME="balancertestsite.com" -LOCAL_IP="127.0.0.1" - -# Check if the correct line already exists -if grep -q "^$LOCAL_IP[[:space:]]\+$HOSTNAME" /etc/hosts; then - echo "Entry for $HOSTNAME with IP $LOCAL_IP already exists in /etc/hosts" -else - echo "Updating /etc/hosts for $HOSTNAME" - sudo sed -i "/[[:space:]]$HOSTNAME/d" /etc/hosts - echo "$LOCAL_IP $HOSTNAME" | sudo tee -a /etc/hosts -fi -``` - -### Steps to reproduce - -Inside root dir of balancer - -```bash -devbox shell -devbox create:cluster -devbox run deploy:balancer -``` - -The website should be available in [https://balancertestsite.com:30219/](https://balancertestsite.com:30219/) - ## Architecture The Balancer website is a Postgres, Django REST, and React project. The source code layout is: diff --git a/devbox.json b/devbox.json deleted file mode 100644 index 87e91159..00000000 --- a/devbox.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.2/.schema/devbox.schema.json", - "packages": [ - "kubectl@latest", - "argocd@latest", - "kubernetes-helm@latest", - "kind@latest", - "k9s@latest", - "kustomize@latest", - "jq@latest" - ], - "shell": { - "init_hook": [ - "echo 'Welcome to devbox!' > /dev/null" - ], - "scripts": { - "create:cluster": [ - "kind create cluster --name devbox --wait 60s --config ./deploy/kind-config.yml", - "kubectl cluster-info" - ], - "deploy:balancer": [ - "devbox run install:prereqs", - "devbox run install:balancer" - ], - "install:prereqs": [ - "devbox run install:cert-manager", - "devbox run install:ingress-nginx" - ], - "install:balancer": [ - "kubectl create namespace balancer || true", - "kubectl apply -k ./deploy/manifests/balancer/overlays/dev", - "echo 'Balancer deployed successfully!'", - "echo 'You can access the balancer site at:'", - "echo \"HTTPS: https://balancertestsite.com:$(kubectl get svc -n ingress-nginx -o json ingress-nginx-controller | jq .spec.ports[1].nodePort)\"" - ], - "install:cert-manager": [ - "helm repo add jetstack https://charts.jetstack.io || true", - "helm repo update jetstack", - "helm upgrade --install cert-manager jetstack/cert-manager --namespace cert-manager --create-namespace --set crds.enabled=true", - "kubectl apply -f ./deploy/manifests/cert-manager" - ], - "install:ingress-nginx": [ - "helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx || true", - "helm repo update ingress-nginx", - "helm upgrade --install ingress-nginx ingress-nginx/ingress-nginx --namespace ingress-nginx --create-namespace --set controller.service.nodePorts.http=31880 --set controller.service.nodePorts.https=30219", - "kubectl wait --namespace ingress-nginx --for=condition=Available deployment/ingress-nginx-controller --timeout=120s" - ] - } - } -} \ No newline at end of file diff --git a/devbox.lock b/devbox.lock deleted file mode 100644 index a47830e5..00000000 --- a/devbox.lock +++ /dev/null @@ -1,449 +0,0 @@ -{ - "lockfile_version": "1", - "packages": { - "argocd@latest": { - "last_modified": "2025-05-16T20:19:48Z", - "resolved": "github:NixOS/nixpkgs/12a55407652e04dcf2309436eb06fef0d3713ef3#argocd", - "source": "devbox-search", - "version": "2.14.11", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/yw33qpp6rg4r176yvdmvp4zwswynrmsl-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/yw33qpp6rg4r176yvdmvp4zwswynrmsl-argocd-2.14.11" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/qi3z0kl0w9cscw76g6x34927n1dfbjjh-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/qi3z0kl0w9cscw76g6x34927n1dfbjjh-argocd-2.14.11" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/s4cf6hh4qpmyywfkdm9z75i5yxx72qq7-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/s4cf6hh4qpmyywfkdm9z75i5yxx72qq7-argocd-2.14.11" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/c1cx9j19132wr5rbhldwvkvnc1xh0hgi-argocd-2.14.11", - "default": true - } - ], - "store_path": "/nix/store/c1cx9j19132wr5rbhldwvkvnc1xh0hgi-argocd-2.14.11" - } - } - }, - "github:NixOS/nixpkgs/nixpkgs-unstable": { - "last_modified": "2025-06-20T02:24:11Z", - "resolved": "github:NixOS/nixpkgs/076e8c6678d8c54204abcb4b1b14c366835a58bb?lastModified=1750386251&narHash=sha256-1ovgdmuDYVo5OUC5NzdF%2BV4zx2uT8RtsgZahxidBTyw%3D" - }, - "jq@latest": { - "last_modified": "2025-06-25T15:38:15Z", - "resolved": "github:NixOS/nixpkgs/61c0f513911459945e2cb8bf333dc849f1b976ff#jq", - "source": "devbox-search", - "version": "1.8.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/04gj0cpc6mv0pkyz114p23fq65zx8mbx-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/7zdrvbyc5pgq9by1wzpn0q28iqsd0lx7-jq-1.8.0-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/glkhwajjprqny359z1awxll8vnsa66lf-jq-1.8.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/yygyqari7g4kz9j0yyyl2lq6v2bg3dw2-jq-1.8.0-doc" - }, - { - "name": "out", - "path": "/nix/store/78wqqi0zdlrgadz3nmd909axh5182k7v-jq-1.8.0" - } - ], - "store_path": "/nix/store/04gj0cpc6mv0pkyz114p23fq65zx8mbx-jq-1.8.0-bin" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/k9mybm2b3yr0v9fsm8vi0319diai4flj-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/v8lgx3i8v7kjqzgs8x75v0ysrlylfhg1-jq-1.8.0-man", - "default": true - }, - { - "name": "dev", - "path": "/nix/store/rzzhwmzryil6g7pl5i7jb4fs54nkkrm4-jq-1.8.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/xjcyd1pjjzja918407x5hvsa6sa3k4mj-jq-1.8.0-doc" - }, - { - "name": "out", - "path": "/nix/store/8p4cdklsb5kn1w4ycq9na07ja19j6d87-jq-1.8.0" - } - ], - "store_path": "/nix/store/k9mybm2b3yr0v9fsm8vi0319diai4flj-jq-1.8.0-bin" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/4d5y298s33gi9vcvviq8xah06203395s-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/drgz0ky78p3c6raccn7xsb5m9f91ba3x-jq-1.8.0-man", - "default": true - }, - { - "name": "doc", - "path": "/nix/store/0122gf5v7922213mkjp3vlij53fkqvir-jq-1.8.0-doc" - }, - { - "name": "out", - "path": "/nix/store/akq414spg0yr5rdba7mbbvz8s945gmya-jq-1.8.0" - }, - { - "name": "dev", - "path": "/nix/store/zsmngm14i76pv54z4n8sj7dcwy6x10kn-jq-1.8.0-dev" - } - ], - "store_path": "/nix/store/4d5y298s33gi9vcvviq8xah06203395s-jq-1.8.0-bin" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "bin", - "path": "/nix/store/2n9hfcfqdszxgsmi4qyqq6rv947dwwg9-jq-1.8.0-bin", - "default": true - }, - { - "name": "man", - "path": "/nix/store/njrgxwqnifcyh3x0v18v83ig179zccx0-jq-1.8.0-man", - "default": true - }, - { - "name": "out", - "path": "/nix/store/qqx05qwhhmbrviw3iskgaigjxhczqhvx-jq-1.8.0" - }, - { - "name": "dev", - "path": "/nix/store/dvy119mx8ab0yjxblaaippb2js6nbzkn-jq-1.8.0-dev" - }, - { - "name": "doc", - "path": "/nix/store/5qly4lwxrq5r3x472g2w35rz50b54a6n-jq-1.8.0-doc" - } - ], - "store_path": "/nix/store/2n9hfcfqdszxgsmi4qyqq6rv947dwwg9-jq-1.8.0-bin" - } - } - }, - "k9s@latest": { - "last_modified": "2025-06-01T15:36:18Z", - "resolved": "github:NixOS/nixpkgs/5929de975bcf4c7c8d8b5ca65c8cd9ef9e44523e#k9s", - "source": "devbox-search", - "version": "0.50.6", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/0kjbnz4vyqv50xmidkf3a9fd9xkv7qnx-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/0kjbnz4vyqv50xmidkf3a9fd9xkv7qnx-k9s-0.50.6" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/cy9v8qdf8y1g45774rm9jzw03pf0866d-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/cy9v8qdf8y1g45774rm9jzw03pf0866d-k9s-0.50.6" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/33wpwmnd235m388diiky223sm2g1gf9g-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/33wpwmnd235m388diiky223sm2g1gf9g-k9s-0.50.6" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/ym871cb8337ph62j517586skc6ya7znp-k9s-0.50.6", - "default": true - } - ], - "store_path": "/nix/store/ym871cb8337ph62j517586skc6ya7znp-k9s-0.50.6" - } - } - }, - "kind@latest": { - "last_modified": "2025-06-12T07:29:08Z", - "resolved": "github:NixOS/nixpkgs/d202f48f1249f013aa2660c6733e251c85712cbe#kind", - "source": "devbox-search", - "version": "0.29.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/81jc2zsdv4zhdniyyggpxm56lpl88cxb-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/81jc2zsdv4zhdniyyggpxm56lpl88cxb-kind-0.29.0" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/dwzvvmcignd20dg6kgizzn71vkj9la91-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/dwzvvmcignd20dg6kgizzn71vkj9la91-kind-0.29.0" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/shydfb0h27gbdrmwhjbfg354xc22vxg2-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/shydfb0h27gbdrmwhjbfg354xc22vxg2-kind-0.29.0" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/52vfnn1wcqn3d5jzrqvcd6yzp3i1gw2m-kind-0.29.0", - "default": true - } - ], - "store_path": "/nix/store/52vfnn1wcqn3d5jzrqvcd6yzp3i1gw2m-kind-0.29.0" - } - } - }, - "kubectl@latest": { - "last_modified": "2025-05-24T21:46:02Z", - "resolved": "github:NixOS/nixpkgs/edb3633f9100d9277d1c9af245a4e9337a980c07#kubectl", - "source": "devbox-search", - "version": "1.33.1", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/vcq5gsn9rp26xbz14b5b2fd8map8qnvj-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/20v8bx884m4i34zdkksdq5qpkm966m65-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/cjm9i86w7is18g3cpsgfc0c3jmsnp0s8-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/vcq5gsn9rp26xbz14b5b2fd8map8qnvj-kubectl-1.33.1" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/m8406nxn25y7a80jxq6mdk70p1xl8xrc-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/gy8hdpwiqcy35zp0a9imbv4fqqy3cwn8-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/kh7b55lvpwfrdfbq3qrzcj9qjanfqn7c-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/m8406nxn25y7a80jxq6mdk70p1xl8xrc-kubectl-1.33.1" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/g8r4y54jpdyrvnrbhqyg60sr1wpqx0ff-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/0n7ik9w8sjrhanv7yb1ijhwyawx7xcz2-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/fdpw2205wf6qq7h271nzbhxdmx561vq0-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/g8r4y54jpdyrvnrbhqyg60sr1wpqx0ff-kubectl-1.33.1" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/lrfm3r4z5iqyn5fqf085bdyp7b5ghhdr-kubectl-1.33.1", - "default": true - }, - { - "name": "man", - "path": "/nix/store/hhank6pxbzwzm6b6gphpc1rj2jjdpmmk-kubectl-1.33.1-man", - "default": true - }, - { - "name": "convert", - "path": "/nix/store/yqlm8fmchxsxzica482r16sfm8x84hck-kubectl-1.33.1-convert" - } - ], - "store_path": "/nix/store/lrfm3r4z5iqyn5fqf085bdyp7b5ghhdr-kubectl-1.33.1" - } - } - }, - "kubernetes-helm@latest": { - "last_modified": "2025-06-12T07:29:08Z", - "resolved": "github:NixOS/nixpkgs/d202f48f1249f013aa2660c6733e251c85712cbe#kubernetes-helm", - "source": "devbox-search", - "version": "3.18.2", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/jlp184pfj4sr13bynvhh2xdr2kcqki6s-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/jlp184pfj4sr13bynvhh2xdr2kcqki6s-kubernetes-helm-3.18.2" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/iyc7rs8vwp0dgjsjbkln1aa32gfls80l-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/iyc7rs8vwp0dgjsjbkln1aa32gfls80l-kubernetes-helm-3.18.2" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/hxwfq2n2shcwvg0mz967d12clys1i2hd-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/hxwfq2n2shcwvg0mz967d12clys1i2hd-kubernetes-helm-3.18.2" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/i7ak9gjj38s29k5lxjnak735713caf6f-kubernetes-helm-3.18.2", - "default": true - } - ], - "store_path": "/nix/store/i7ak9gjj38s29k5lxjnak735713caf6f-kubernetes-helm-3.18.2" - } - } - }, - "kustomize@latest": { - "last_modified": "2025-06-20T02:24:11Z", - "resolved": "github:NixOS/nixpkgs/076e8c6678d8c54204abcb4b1b14c366835a58bb#kustomize", - "source": "devbox-search", - "version": "5.6.0", - "systems": { - "aarch64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/j3fhq0sjgibzg128f55sa7yyxs26qiik-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/j3fhq0sjgibzg128f55sa7yyxs26qiik-kustomize-5.6.0" - }, - "aarch64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/li5cccrjxgig3jqaycrrbzs7n6xwvpqp-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/li5cccrjxgig3jqaycrrbzs7n6xwvpqp-kustomize-5.6.0" - }, - "x86_64-darwin": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/3sa5673n6ah9fry8yzz94fscqjk8xxb4-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/3sa5673n6ah9fry8yzz94fscqjk8xxb4-kustomize-5.6.0" - }, - "x86_64-linux": { - "outputs": [ - { - "name": "out", - "path": "/nix/store/vkaya31s09dj8xyy9xyrjqwgaixjq160-kustomize-5.6.0", - "default": true - } - ], - "store_path": "/nix/store/vkaya31s09dj8xyy9xyrjqwgaixjq160-kustomize-5.6.0" - } - } - } - } -} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml deleted file mode 100644 index 0bba34b1..00000000 --- a/docker-compose.prod.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: balancer-prod -version: "3.8" - -services: - app: - image: balancer-app - build: - context: . - dockerfile: Dockerfile.prod - ports: - - "8000:8000" - env_file: - - ./config/env/prod.env diff --git a/docker-compose.yml b/docker-compose.yml index 5d2d5884..3022603a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: - POSTGRES_USER=balancer - POSTGRES_PASSWORD=balancer - POSTGRES_DB=balancer_dev + healthcheck: + test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] + interval: 10s + timeout: 5s + retries: 5 ports: - "5433:5432" networks: @@ -34,12 +39,14 @@ services: image: balancer-backend build: ./server command: python manage.py runserver 0.0.0.0:8000 + restart: on-failure ports: - "8000:8000" env_file: - ./config/env/dev.env depends_on: - - db + db: + condition: service_healthy volumes: - ./server:/usr/src/server networks: diff --git a/docs/DATABASE_CONNECTION.md b/docs/DATABASE_CONNECTION.md index 57ac3fac..7f2c298e 100644 --- a/docs/DATABASE_CONNECTION.md +++ b/docs/DATABASE_CONNECTION.md @@ -74,6 +74,20 @@ SQL_PORT=5432 SQL_SSL_MODE=require ``` +### Local Docker Compose Configuration + +When using Docker Compose for local development, the application connects to the `db` service container. + +**Example Configuration:** +```bash +SQL_ENGINE=django.db.backends.postgresql +SQL_DATABASE=balancer_dev +SQL_USER=balancer +SQL_PASSWORD=balancer +SQL_HOST=db +SQL_PORT=5432 +``` + ## SSL Configuration ### CloudNativePG From abfb24ace2c0059fb429dd13730b174a379fd762 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 8 Feb 2026 12:51:45 -0500 Subject: [PATCH 087/181] feat: make frontend & backend version-aware --- Dockerfile.prod | 4 ++ VERSION | 1 - frontend/src/api/apiClient.ts | 12 ++++++ frontend/src/api/endpoints.ts | 42 ++++++++++++--------- frontend/src/components/Footer/Footer.tsx | 6 ++- frontend/src/components/Version/Version.tsx | 37 ++++++++++++++++++ frontend/src/pages/About/About.tsx | 5 +++ server/api/views/version/urls.py | 6 +++ server/api/views/version/views.py | 13 +++++++ server/balancer_backend/settings.py | 7 ++-- server/balancer_backend/urls.py | 1 + 11 files changed, 110 insertions(+), 24 deletions(-) delete mode 100644 VERSION create mode 100644 frontend/src/components/Version/Version.tsx create mode 100644 server/api/views/version/urls.py create mode 100644 server/api/views/version/views.py diff --git a/Dockerfile.prod b/Dockerfile.prod index f2fc5a20..21a24ecd 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -21,6 +21,10 @@ RUN npm run build # Stage 2: Build Backend FROM python:3.11.4-slim-bullseye +# Receive version argument from build command +ARG VERSION +ENV VERSION=${VERSION} + # Set work directory WORKDIR /usr/src/app diff --git a/VERSION b/VERSION deleted file mode 100644 index 867e5243..00000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -1.2.0 \ No newline at end of file diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 84cebbb0..856f78a9 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -306,6 +306,17 @@ const sendAssistantMessage = async ( } }; +export interface VersionResponse { + version: string; +} + +const fetchVersion = async (): Promise => { + const response = await publicApi.get( + V1_API_ENDPOINTS.VERSION, + ); + return response.data; +}; + export { handleSubmitFeedback, handleSendDrugSummary, @@ -320,4 +331,5 @@ export { handleSendDrugSummaryStreamLegacy, fetchRiskDataWithSources, sendAssistantMessage, + fetchVersion, }; diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 6066b2ce..3f8585f0 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -7,6 +7,9 @@ const API_BASE = '/api'; +/** Base path for v1 API (avoids repeating /api/v1/api in every endpoint) */ +const V1_API_BASE = `${API_BASE}/v1/api`; + /** * Authentication endpoints */ @@ -23,30 +26,33 @@ export const AUTH_ENDPOINTS = { */ export const V1_API_ENDPOINTS = { // Feedback - FEEDBACK: `${API_BASE}/v1/api/feedback/`, - + FEEDBACK: `${V1_API_BASE}/feedback/`, + // Embeddings - EMBEDDINGS_ASK: `${API_BASE}/v1/api/embeddings/ask_embeddings`, - RULE_EXTRACTION: `${API_BASE}/v1/api/rule_extraction_openai`, - + EMBEDDINGS_ASK: `${V1_API_BASE}/embeddings/ask_embeddings`, + RULE_EXTRACTION: `${V1_API_BASE}/rule_extraction_openai`, + // Risk - RISK_WITH_SOURCES: `${API_BASE}/v1/api/riskWithSources`, - + RISK_WITH_SOURCES: `${V1_API_BASE}/riskWithSources`, + // Assistant - ASSISTANT: `${API_BASE}/v1/api/assistant`, - + ASSISTANT: `${V1_API_BASE}/assistant`, + // File Management - UPLOAD_FILE: `${API_BASE}/v1/api/uploadFile`, - EDIT_METADATA: `${API_BASE}/v1/api/editmetadata`, - + UPLOAD_FILE: `${V1_API_BASE}/uploadFile`, + EDIT_METADATA: `${V1_API_BASE}/editmetadata`, + // Medications - GET_FULL_LIST_MED: `${API_BASE}/v1/api/get_full_list_med`, - GET_MED_RECOMMEND: `${API_BASE}/v1/api/get_med_recommend`, - ADD_MEDICATION: `${API_BASE}/v1/api/add_medication`, - DELETE_MED: `${API_BASE}/v1/api/delete_med`, - + GET_FULL_LIST_MED: `${V1_API_BASE}/get_full_list_med`, + GET_MED_RECOMMEND: `${V1_API_BASE}/get_med_recommend`, + ADD_MEDICATION: `${V1_API_BASE}/add_medication`, + DELETE_MED: `${V1_API_BASE}/delete_med`, + // Medication Rules - MED_RULES: `${API_BASE}/v1/api/medRules`, + MED_RULES: `${V1_API_BASE}/medRules`, + + // Version (build/deploy info) + VERSION: `${V1_API_BASE}/version`, } as const; /** diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 68a22263..d656f5ad 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -2,6 +2,7 @@ import { useState, useRef, KeyboardEvent } from "react"; import { Link } from "react-router-dom"; +import Version from "../Version/Version"; import "../../App.css"; // Import the common Tailwind CSS styles function Footer() { @@ -108,7 +109,10 @@ function Footer() {
    -

    © 2025 Balancer. All rights reserved. V1 2-04-2025

    +

    + © 2025 Balancer. All rights reserved. + +

    diff --git a/frontend/src/components/Version/Version.tsx b/frontend/src/components/Version/Version.tsx new file mode 100644 index 00000000..ba54f64c --- /dev/null +++ b/frontend/src/components/Version/Version.tsx @@ -0,0 +1,37 @@ +import { useState, useEffect } from "react"; +import { fetchVersion } from "../../api/apiClient"; + +type VersionProps = { + /** Text before the version number (e.g. "Version " or " Version ") */ + prefix?: string; + /** Rendered when version is loading or failed (e.g. " —") */ + fallback?: React.ReactNode; + /** Optional class name for the wrapper element */ + className?: string; + /** Wrapper element (span for inline, p for block) */ + as?: "span" | "p"; +}; + +function Version({ + prefix = "Version ", + fallback = null, + className, + as: Wrapper = "span", +}: VersionProps) { + const [version, setVersion] = useState(null); + + useEffect(() => { + fetchVersion() + .then((data) => setVersion(data.version)) + .catch(() => setVersion(null)); + }, []); + + const content = version != null ? prefix + version : fallback; + if (content === null || content === undefined) { + return null; + } + + return {content}; +} + +export default Version; diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index b8170333..c50f6705 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -1,5 +1,6 @@ //import Welcome from "../../components/Welcome/Welcome.tsx"; import Layout from "../Layout/Layout"; +import Version from "../../components/Version/Version"; // import image from "./OIP.jpeg"; import image from "./OIP2.png"; @@ -88,6 +89,10 @@ function About() {

    +
    diff --git a/server/api/views/version/urls.py b/server/api/views/version/urls.py new file mode 100644 index 00000000..6fb34919 --- /dev/null +++ b/server/api/views/version/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from .views import VersionView + +urlpatterns = [ + path("v1/api/version", VersionView.as_view(), name="version"), +] diff --git a/server/api/views/version/views.py b/server/api/views/version/views.py new file mode 100644 index 00000000..b79d6577 --- /dev/null +++ b/server/api/views/version/views.py @@ -0,0 +1,13 @@ +import os + +from rest_framework.permissions import AllowAny +from rest_framework.views import APIView +from rest_framework.response import Response + + +class VersionView(APIView): + permission_classes = [AllowAny] + + def get(self, request, *args, **kwargs): + version = os.environ.get("VERSION") or "dev" + return Response({"version": version}) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index bdc465ca..9f917a94 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -180,10 +180,9 @@ # https://docs.djangoproject.com/en/4.2/howto/static-files/ STATIC_URL = "/static/" -STATICFILES_DIRS = [] -if os.path.exists(os.path.join(BASE_DIR, "build/static")): - STATICFILES_DIRS.append(os.path.join(BASE_DIR, "build/static")) - +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "build/static"), +] STATIC_ROOT = os.path.join(BASE_DIR, "static") AUTHENTICATION_BACKENDS = [ diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 958ef7c9..c8bd290d 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -18,6 +18,7 @@ urls = [ "conversations", "feedback", + "version", "listMeds", "risk", "uploadFile", From e25375bd4f2460f9fbff6079ca5648d683b23f08 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sun, 8 Feb 2026 13:10:22 -0500 Subject: [PATCH 088/181] fix: add db to the docker-compose.prod --- .github/workflows/containers-publish.yml | 1 + docker-compose.prod.yml | 29 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 13ea0413..3c2dd40d 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -68,6 +68,7 @@ jobs: --file Dockerfile.prod \ --tag "${DOCKER_REPOSITORY}/app:latest" \ --tag "${DOCKER_REPOSITORY}/app:${DOCKER_TAG}" \ + --build-arg VERSION="${DOCKER_TAG}" \ . - name: "Push Docker container image app:latest" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 0bba34b1..4b4868e4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,6 +2,23 @@ name: balancer-prod version: "3.8" services: + db: + image: pgvector/pgvector:pg15 + volumes: + - postgres_data_prod:/var/lib/postgresql/data/ + - ./db/init-vector-extension.sql:/docker-entrypoint-initdb.d/init-vector-extension.sql + environment: + - POSTGRES_USER=balancer + - POSTGRES_PASSWORD=balancer + - POSTGRES_DB=balancer_dev + networks: + - app_net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] + interval: 5s + timeout: 5s + retries: 5 + app: image: balancer-app build: @@ -11,3 +28,15 @@ services: - "8000:8000" env_file: - ./config/env/prod.env + depends_on: + db: + condition: service_healthy + networks: + - app_net + +volumes: + postgres_data_prod: + +networks: + app_net: + driver: bridge From 0430a24f9a6d4806c213f873e7cfee754a1822c6 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 9 Feb 2026 19:52:44 -0500 Subject: [PATCH 089/181] ci: simplify container-publish triggers and versioning logic --- .github/workflows/containers-publish.yml | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/.github/workflows/containers-publish.yml b/.github/workflows/containers-publish.yml index 3c2dd40d..9d3435b6 100644 --- a/.github/workflows/containers-publish.yml +++ b/.github/workflows/containers-publish.yml @@ -4,7 +4,7 @@ on: release: types: [published] push: - branches: [develop, main] + branches: [develop] permissions: packages: write @@ -31,17 +31,6 @@ jobs: if [[ "${{ github.event_name }}" == "release" ]]; then TAG="${GITHUB_REF#refs/tags/}" DOCKER_TAG="${TAG#v}" - elif [[ "${{ github.ref }}" == "refs/heads/main" ]]; then - # Attempt to extract version from merge commit or branch if available - # release-prepare usually creates branch release/vX.Y.Z or commit "Release vX.Y.Z" - PREPARED_VERSION=$(git log -1 --pretty=%B | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -1 | sed 's/^v//') - - if [[ -n "$PREPARED_VERSION" ]]; then - DOCKER_TAG="$PREPARED_VERSION" - else - BASE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") - DOCKER_TAG="${BASE_TAG#v}" - fi else # Pre-release for develop BASE_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") From 59b40f0eeff3b87b207a8598dbb9a79023afd01b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 13 Feb 2026 15:51:21 -0500 Subject: [PATCH 090/181] REFACTOR Pull apart get_closest_embeddings to make testing easier --- server/api/services/embedding_services.py | 161 +++++++++++++++------- 1 file changed, 112 insertions(+), 49 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index e35f7965..0720a9c8 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -11,18 +11,17 @@ logger = logging.getLogger(__name__) -def get_closest_embeddings( - user, message_data, document_name=None, guid=None, num_results=10 -): + +def build_query(user, embedding_vector, document_name=None, guid=None, num_results=10): """ - Find the closest embeddings to a given message for a specific user. + Build an unevaluated QuerySet for the closest embeddings. Parameters ---------- user : User The user whose uploaded documents will be searched - message_data : str - The input message to find similar embeddings for + embedding_vector : array-like + Pre-computed embedding vector to compare against document_name : str, optional Filter results to a specific document name guid : str, optional @@ -32,59 +31,52 @@ def get_closest_embeddings( Returns ------- - list[dict] - List of dictionaries containing embedding results with keys: - - name: document name - - text: embedded text content - - page_number: page number in source document - - chunk_number: chunk number within the document - - distance: L2 distance from query embedding - - file_id: GUID of the source file + QuerySet + Unevaluated Django QuerySet ordered by L2 distance, sliced to num_results """ - - encoding_start = time.time() - transformerModel = TransformerModel.get_instance().model - embedding_message = transformerModel.encode(message_data) - encoding_time = time.time() - encoding_start - - db_query_start = time.time() - # Django QuerySets are lazily evaluated if user.is_authenticated: # User sees their own files + files uploaded by superusers - closest_embeddings_query = ( - Embeddings.objects.filter( - Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) - ) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) - ) - .order_by("distance") + queryset = Embeddings.objects.filter( + Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) ) else: # Unauthenticated users only see superuser-uploaded files - closest_embeddings_query = ( - Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) - .annotate( - distance=L2Distance("embedding_sentence_transformers", embedding_message) - ) - .order_by("distance") - ) + queryset = Embeddings.objects.filter(upload_file__uploaded_by__is_superuser=True) + + queryset = ( + queryset + .annotate(distance=L2Distance("embedding_sentence_transformers", embedding_vector)) + .order_by("distance") + ) # Filtering to a document GUID takes precedence over a document name if guid: - closest_embeddings_query = closest_embeddings_query.filter( - upload_file__guid=guid - ) + queryset = queryset.filter(upload_file__guid=guid) elif document_name: - closest_embeddings_query = closest_embeddings_query.filter(name=document_name) + queryset = queryset.filter(name=document_name) # Slicing is equivalent to SQL's LIMIT clause - closest_embeddings_query = closest_embeddings_query[:num_results] + return queryset[:num_results] + + +def format_results(queryset): + """ + Evaluate a QuerySet and return a list of result dicts. + + Parameters + ---------- + queryset : iterable + Iterable of Embeddings objects (or any objects with the expected attributes) + Returns + ------- + list[dict] + List of dicts with keys: name, text, page_number, chunk_number, distance, file_id + """ # Iterating evaluates the QuerySet and hits the database # TODO: Research improving the query evaluation performance - results = [ + return [ { "name": obj.name, "text": obj.text, @@ -93,13 +85,36 @@ def get_closest_embeddings( "distance": obj.distance, "file_id": obj.upload_file.guid if obj.upload_file else None, } - for obj in closest_embeddings_query + for obj in queryset ] - db_query_time = time.time() - db_query_start +def log_search_usage( + results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time +): + """ + Create a SemanticSearchUsage record. Swallows exceptions so search isn't interrupted. + + Parameters + ---------- + results : list[dict] + The search results, each containing a "distance" key + message_data : str + The original search query text + user : User + The user who performed the search + guid : str or None + Document GUID filter used in the search + document_name : str or None + Document name filter used in the search + num_results : int + Number of results requested + encoding_time : float + Time in seconds to encode the query + db_query_time : float + Time in seconds for the database query + """ try: - # Handle user having no uploaded docs or doc filtering returning no matches if results: distances = [r["distance"] for r in results] SemanticSearchUsage.objects.create( @@ -113,11 +128,10 @@ def get_closest_embeddings( num_results_returned=len(results), max_distance=max(distances), median_distance=median(distances), - min_distance=min(distances) + min_distance=min(distances), ) else: logger.warning("Semantic search returned no results") - SemanticSearchUsage.objects.create( query_text=message_data, user=user if (user and user.is_authenticated) else None, @@ -129,9 +143,58 @@ def get_closest_embeddings( num_results_returned=0, max_distance=None, median_distance=None, - min_distance=None + min_distance=None, ) except Exception as e: logger.error(f"Failed to create semantic search usage database record: {e}") + +def get_closest_embeddings( + user, message_data, document_name=None, guid=None, num_results=10 +): + """ + Find the closest embeddings to a given message for a specific user. + + Parameters + ---------- + user : User + The user whose uploaded documents will be searched + message_data : str + The input message to find similar embeddings for + document_name : str, optional + Filter results to a specific document name + guid : str, optional + Filter results to a specific document GUID (takes precedence over document_name) + num_results : int, default 10 + Maximum number of results to return + + Returns + ------- + list[dict] + List of dictionaries containing embedding results with keys: + - name: document name + - text: embedded text content + - page_number: page number in source document + - chunk_number: chunk number within the document + - distance: L2 distance from query embedding + - file_id: GUID of the source file + + Notes + ----- + Creates a SemanticSearchUsage record. Swallows exceptions so search isn't interrupted. + """ + encoding_start = time.time() + model = TransformerModel.get_instance().model + embedding_vector = model.encode(message_data) + encoding_time = time.time() - encoding_start + + db_query_start = time.time() + queryset = build_query(user, embedding_vector, document_name, guid, num_results) + results = format_results(queryset) + db_query_time = time.time() - db_query_start + + log_search_usage( + results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time + ) + return results From 3ffb74af318cc927d9c11ba37e63a3093d5ecfc6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 13 Feb 2026 16:12:49 -0500 Subject: [PATCH 091/181] ADD Add infra required to run pytest --- .github/workflows/python-app.yml | 5 +++++ server/pytest.ini | 3 +++ server/requirements.txt | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 server/pytest.ini diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index dcb7a4bb..41bc74d9 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -27,3 +27,8 @@ jobs: run: pipx install ruff - name: Lint code with Ruff run: ruff check --output-format=github --target-version=py39 + - name: Install test dependencies + run: pip install -r server/requirements.txt + # Discover and run all files matching test_*.py or *_test.py under server/ + - name: Run tests + run: pytest server/ -v diff --git a/server/pytest.ini b/server/pytest.ini new file mode 100644 index 00000000..235b9752 --- /dev/null +++ b/server/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = balancer_backend.settings +pythonpath = . diff --git a/server/requirements.txt b/server/requirements.txt index bbaf7bc9..001708e9 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -18,4 +18,6 @@ sentence_transformers PyMuPDF==1.24.0 Pillow pytesseract -anthropic \ No newline at end of file +anthropic +pytest +pytest-django \ No newline at end of file From 12b09a733a1dc79b8fbec63d358d4354764a5116 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 13 Feb 2026 16:33:41 -0500 Subject: [PATCH 092/181] ADD Start adding tests for embedding_services" --- server/api/services/embedding_services.py | 8 +- .../api/services/test_embedding_services.py | 85 +++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 server/api/services/test_embedding_services.py diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 0720a9c8..3fa9bb68 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -60,7 +60,7 @@ def build_query(user, embedding_vector, document_name=None, guid=None, num_resul return queryset[:num_results] -def format_results(queryset): +def evaluate_query(queryset): """ Evaluate a QuerySet and return a list of result dicts. @@ -89,7 +89,7 @@ def format_results(queryset): ] -def log_search_usage( +def log_usage( results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time ): """ @@ -190,10 +190,10 @@ def get_closest_embeddings( db_query_start = time.time() queryset = build_query(user, embedding_vector, document_name, guid, num_results) - results = format_results(queryset) + results = evaluate_query(queryset) db_query_time = time.time() - db_query_start - log_search_usage( + log_usage( results, message_data, user, guid, document_name, num_results, encoding_time, db_query_time ) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py new file mode 100644 index 00000000..677c1e7b --- /dev/null +++ b/server/api/services/test_embedding_services.py @@ -0,0 +1,85 @@ +from unittest.mock import MagicMock, patch + +from api.services.embedding_services import evaluate_query, log_usage + + +def test_evaluate_query_maps_fields(): + obj = MagicMock() + obj.name = "doc.pdf" + obj.text = "some text" + obj.page_num = 3 + obj.chunk_number = 1 + obj.distance = 0.42 + obj.upload_file.guid = "abc-123" + + results = evaluate_query([obj]) + + assert results == [ + { + "name": "doc.pdf", + "text": "some text", + "page_number": 3, + "chunk_number": 1, + "distance": 0.42, + "file_id": "abc-123", + } + ] + + +def test_evaluate_query_none_upload_file(): + obj = MagicMock() + obj.name = "doc.pdf" + obj.text = "some text" + obj.page_num = 1 + obj.chunk_number = 0 + obj.distance = 1.0 + obj.upload_file = None + + results = evaluate_query([obj]) + + assert results[0]["file_id"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_computes_distance_stats(mock_create): + results = [{"distance": 1.0}, {"distance": 3.0}, {"distance": 2.0}] + user = MagicMock(is_authenticated=True) + + log_usage( + results, + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + mock_create.assert_called_once() + kwargs = mock_create.call_args.kwargs + assert kwargs["min_distance"] == 1.0 + assert kwargs["max_distance"] == 3.0 + assert kwargs["median_distance"] == 2.0 + assert kwargs["num_results_returned"] == 3 + + +@patch( + "api.services.embedding_services.SemanticSearchUsage.objects.create", + side_effect=Exception("DB error"), +) +def test_log_usage_swallows_exceptions(mock_create): + results = [{"distance": 1.0}] + user = MagicMock(is_authenticated=True) + + # pytest fails the test if it catches unhandled Exception + log_usage( + results, + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) From 2d2ccd10911291061d23bcdf9b56855590477494 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 16 Feb 2026 16:33:54 -0500 Subject: [PATCH 093/181] Fix duplicate healthcheck key for db service build --- docker-compose.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 9182cdb6..7a6e7fe9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,11 +18,6 @@ services: networks: app_net: ipv4_address: 192.168.0.2 - healthcheck: - test: ["CMD-SHELL", "pg_isready -U balancer -d balancer_dev"] - interval: 5s - timeout: 5s - retries: 5 pgadmin: image: dpage/pgadmin4 From 01ccf9ab0995645e0ad0c0782eb9bc5efb6ed595 Mon Sep 17 00:00:00 2001 From: Akhil Bolla <129509874+AkhilRB0204@users.noreply.github.com> Date: Mon, 16 Feb 2026 22:01:40 -0500 Subject: [PATCH 094/181] Enhance input sanitization and normalize pronouns Updated the sanitizer function to improve input sanitization by removing style tags, normalizing pronouns, and increasing the maximum length limit. --- server/api/views/assistant/sanitizer.py | 62 ++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/server/api/views/assistant/sanitizer.py b/server/api/views/assistant/sanitizer.py index bdbbc77f..fd851df6 100644 --- a/server/api/views/assistant/sanitizer.py +++ b/server/api/views/assistant/sanitizer.py @@ -1,26 +1,76 @@ import re import logging + logger = logging.getLogger(__name__) def sanitize_input(user_input:str) -> str: """ Sanitize user input to prevent injection attacks and remove unwanted characters. + Args: user_input (str): The raw input string from the user. + Returns: str: The sanitized input string. """ try: - # Remove any script tags - sanitized = re.sub(r'.*?', '', user_input, flags=re.IGNORECASE) - # Remove any HTML tags + sanitized = user_input + + # Remove any style tags + sanitized = re.sub(r'.*?', '', sanitized, flags=re.IGNORECASE) + + # Remove any HTML/script tags sanitized = re.sub(r'<.*?>', '', sanitized) + + # Remove Phone Numbers + sanitized = re.sub(r'\+?\d[\d -]{8,}\d', '[Phone Number]', sanitized) + + # Remove Email Addresses + sanitized = re.sub(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', '[Email Address]', sanitized) + + # Remove Medical Record Numbers (simple pattern) + sanitized = re.sub(r'\bMRN[:\s]*\d+\b', '[Medical Record Number]', sanitized, flags=re.IGNORECASE) + + # Normalize pronouns + sanitized = normalize_pronouns(sanitized) + # Escape special characters - sanitized = re.sub(r'["\'\\]', '', sanitized) + sanitized = re.sub(r'\s+', '', sanitized) + # Limit length to prevent buffer overflow attacks - max_length = 1000 + max_length = 5000 if len(sanitized) > max_length: sanitized = sanitized[:max_length] + return sanitized.strip() except Exception as e: logger.error(f"Error sanitizing input: {e}") - return "" \ No newline at end of file + return "" + +def normalize_pronouns(text:str) -> str: + """ + Normalize first and second person pronouns to third person clinical language. + + Converts patient centric pronouns to a more neutral form. + Args: + text (str): The input text containing pronouns. + Returns: + str: The text with normalized pronouns. + """ + # Normalize first person possessives: I, me, my, mine -> the patient + text = re.sub(r'\bMy\b', 'The patient\'s', text) + text = re.sub(r'\bmy\b', 'the patient\'s', text) + + # First person subject: I -> the patient + text = re.sub(r'\bI\b', 'the patient', text) + + # First person object: me -> the patient + text = re.sub(r'\bme\b', 'the patient', text) + + # First person reflexive: myself -> the patient + text = re.sub(r'\bmyself\b', 'the patient', text) + + # Second person: you, your -> the clinician + text = re.sub(r'\bYour\b', 'the clinician', text) + return text + + From da9afaa905a43218e1f947360aa141662359e1aa Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 17 Feb 2026 14:40:13 -0500 Subject: [PATCH 095/181] DOC Add a note about running pytest in the README --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index f1cea06b..15018d37 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` +- Backend tests can be run using `pytest` by running the below command inside the running backend container: + +``` +docker compose exec backend pytest api/ -v +``` ## Local Kubernetes Deployment From b08152f94a079b24681e79203476dcbbbdcb0d83 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 18 Feb 2026 14:18:12 -0500 Subject: [PATCH 096/181] fix: changed link to direct to balancer github page --- frontend/src/components/Footer/Footer.tsx | 2 +- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/components/Header/MdNavBar.tsx | 2 +- frontend/src/pages/About/About.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index d656f5ad..2f68ec49 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -62,7 +62,7 @@ function Footer() { > Leave feedback - diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index cbbd2c93..a0edc859 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -165,7 +165,7 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Leave Feedback diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index 5a8d5bce..00d45f55 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -120,7 +120,7 @@ const MdNavBar = (props: LoginFormProps) => {
  • - diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index c50f6705..e1c7242e 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -77,7 +77,7 @@ function About() {
    - + From b94e998d8c355eb19153b2d8af5527c3fa17ff14 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Fri, 20 Feb 2026 15:33:35 -0500 Subject: [PATCH 097/181] Fix error 1, added unit tests and more logging --- server/api/views/uploadFile/test_title.py | 30 +++++++++++++++++++++++ server/api/views/uploadFile/title.py | 3 ++- server/api/views/uploadFile/views.py | 4 +++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 69979620..5391ed05 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -67,3 +67,33 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): title.generate_title(doc) self.assertTrue(mock_openAI.called) + + @patch("api.services.openai_services.openAIServices.openAI") + def test_strips_quotes_from_openai_title(self, mock_openAI): + doc = MagicMock() + doc.metadata = {"title": None} + doc.get_text.return_value = [] + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' + mock_openAI.return_value = mock_response + + result = title.generate_title(doc) + + self.assertEqual(result, "Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder") + + @patch("api.services.openai_services.openAIServices.openAI") + def test_truncates_long_openai_title(self, mock_openAI): + doc = MagicMock() + doc.metadata = {"title": None} + doc.get_text.return_value = [] + + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "A" * 300 + mock_openAI.return_value = mock_response + + result = title.generate_title(doc) + + self.assertLessEqual(len(result), 255) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 06e0ce0c..b3f8aded 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -58,4 +58,5 @@ def summarize_pdf(pdf: fitz.Document) -> str: prompt = "Please provide a title for this document. The title should be less than 256 characters and will be displayed on a webpage." response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) - return response.choices[0].message.content + title = response.choices[0].message.content.strip().strip('"').strip("'") + return title[:255] diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 69dfb996..58bd8752 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -12,6 +12,9 @@ import fitz from django.db import transaction from .title import generate_title +import logging + +logger = logging.getLogger(__name__) class UploadFileView(APIView): @@ -124,6 +127,7 @@ def post(self, request, format=None): ) except Exception as e: # Handle potential errors + logger.exception("File upload failed for '%s': %s", uploaded_file.name, e) return Response({"message": f"Error processing file and embeddings: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) From 530b90a17afa6136e6c9de9abd472794c4058193 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 23 Feb 2026 12:19:50 -0500 Subject: [PATCH 098/181] Changed button text from "donate" to "Support Developoment" --- frontend/src/components/Footer/Footer.tsx | 4 ++-- frontend/src/components/Header/Header.tsx | 2 +- frontend/src/components/Header/MdNavBar.tsx | 2 +- frontend/src/pages/About/About.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/Footer/Footer.tsx b/frontend/src/components/Footer/Footer.tsx index 2f68ec49..977c59d4 100644 --- a/frontend/src/components/Footer/Footer.tsx +++ b/frontend/src/components/Footer/Footer.tsx @@ -64,9 +64,9 @@ function Footer() { - Donate + Support Development = ({ isAuthenticated, isSuperuser }) => { target="_blank" className="header-nav-item" > - Donate + Support Development {isAuthenticated && isSuperuser && (
    { target="_blank" className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline" > - Donate + Support Development
  • {isAuthenticated && diff --git a/frontend/src/pages/About/About.tsx b/frontend/src/pages/About/About.tsx index e1c7242e..9481c74d 100644 --- a/frontend/src/pages/About/About.tsx +++ b/frontend/src/pages/About/About.tsx @@ -79,7 +79,7 @@ function About() {
    From f96606d18b0f9fa2aca25033ba26079c290b54ac Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 23 Feb 2026 16:46:38 -0500 Subject: [PATCH 099/181] Fix 401 by using adminApi instead of raw axios --- frontend/src/pages/DocumentManager/UploadFile.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/frontend/src/pages/DocumentManager/UploadFile.tsx b/frontend/src/pages/DocumentManager/UploadFile.tsx index 2ee7b5db..32b727e8 100644 --- a/frontend/src/pages/DocumentManager/UploadFile.tsx +++ b/frontend/src/pages/DocumentManager/UploadFile.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from "react"; -import axios from "axios"; +import { adminApi } from "../../api/apiClient"; import TypingAnimation from "../../components/Header/components/TypingAnimation.tsx"; import Layout from "../Layout/Layout.tsx"; @@ -22,14 +22,9 @@ const UploadFile: React.FC = () => { formData.append("file", file); try { - const response = await axios.post( + const response = await adminApi.post( `/api/v1/api/uploadFile`, formData, - { - headers: { - "Content-Type": "multipart/form-data" - }, - } ); console.log("File uploaded successfully", response.data); } catch (error) { From bbf1034d065774a252bee3cae07300584e84fbe6 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 15:48:51 -0500 Subject: [PATCH 100/181] Fixed wrong API url path in handleDownload --- frontend/src/pages/Files/ListOfFiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index b6fff4ee..cac0836c 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -61,7 +61,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleDownload = async (guid: string, fileName: string) => { try { setDownloading(guid); - const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); + const { data } = await publicApi.get(`/api/v1/api/uploadFile/${guid}`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([data])); const link = document.createElement("a"); From 128418b5cc05a867c0419d504be42cdbd6b7cf51 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 16:22:45 -0500 Subject: [PATCH 101/181] Fixed API URL in handleOpen as well --- frontend/src/pages/Files/ListOfFiles.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/Files/ListOfFiles.tsx b/frontend/src/pages/Files/ListOfFiles.tsx index cac0836c..37bd459a 100644 --- a/frontend/src/pages/Files/ListOfFiles.tsx +++ b/frontend/src/pages/Files/ListOfFiles.tsx @@ -82,7 +82,7 @@ const ListOfFiles: React.FC<{ showTable?: boolean }> = ({ const handleOpen = async (guid: string) => { try { setOpening(guid); - const { data } = await publicApi.get(`/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); + const { data } = await publicApi.get(`/api/v1/api/uploadFile/${guid}`, { responseType: 'arraybuffer' }); const file = new Blob([data], { type: 'application/pdf' }); const fileURL = window.URL.createObjectURL(file); From 332af9fca17085196bdad7db35d7fbe46c981274 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 16:57:36 -0500 Subject: [PATCH 102/181] drf-spectacular configuration --- server/balancer_backend/settings.py | 8 ++++++++ server/requirements.txt | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 9f917a94..cfa90dce 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -51,6 +51,7 @@ "corsheaders", "rest_framework", "djoser", + 'drf_spectacular', ] MIDDLEWARE = [ @@ -195,8 +196,15 @@ "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework_simplejwt.authentication.JWTAuthentication", ), + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } +SPECTACULAR_SETTINGS = { + 'TITLE': 'Balancer API', + 'DESCRIPTION': 'API for the Balancer medication decision support tool', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, +} SIMPLE_JWT = { "AUTH_HEADER_TYPES": ("JWT",), diff --git a/server/requirements.txt b/server/requirements.txt index bbaf7bc9..880500c6 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -18,4 +18,5 @@ sentence_transformers PyMuPDF==1.24.0 Pillow pytesseract -anthropic \ No newline at end of file +anthropic +drf-spectacular \ No newline at end of file From a34a9f8ceda2358cebd8607c9dcb04136e590cb3 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 17:10:03 -0500 Subject: [PATCH 103/181] Added URL routes for API docs generation --- server/balancer_backend/urls.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index c8bd290d..55bd2032 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -6,6 +6,9 @@ # Import TemplateView for rendering templates from django.views.generic import TemplateView import importlib # Import the importlib module for dynamic module importing +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView + + # Define a list of URL patterns for the application # Keep admin outside /api/ prefix @@ -50,6 +53,9 @@ # Wrap all API routes under /api/ prefix urlpatterns += [ path("api/", include(api_urlpatterns)), + path("api/schema/", SpectacularAPIView.as_view(), name="schema"), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] import os From fe660d2eb03db708b30b782f4fab787610640a90 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 17:26:44 -0500 Subject: [PATCH 104/181] Added OpenAPI security scheme --- server/balancer_backend/settings.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index cfa90dce..a4ccaaae 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -204,6 +204,10 @@ 'DESCRIPTION': 'API for the Balancer medication decision support tool', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, + 'SECURITY': [{'jwtAuth': []}], + 'SWAGGER_UI_SETTINGS': { + 'persistAuthorization': True, + }, } SIMPLE_JWT = { From 3c83abdc2a32cc6c300fc833671e8f7a937ba88e Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 26 Feb 2026 18:12:52 -0500 Subject: [PATCH 105/181] Added extend_schema and serializer_class to endpoints that drf-spectacular missed with default settings --- server/api/views/ai_promptStorage/views.py | 3 ++ server/api/views/ai_settings/views.py | 2 + server/api/views/assistant/views.py | 17 +++++++ server/api/views/conversations/views.py | 31 ++++++++++++ server/api/views/embeddings/embeddingsView.py | 23 ++++++++- server/api/views/feedback/views.py | 1 + server/api/views/listMeds/views.py | 48 ++++++++++++++++++- server/api/views/medRules/serializers.py | 2 + server/api/views/medRules/views.py | 25 +++++++++- .../api/views/risk/views_riskWithSources.py | 25 +++++++++- server/api/views/text_extraction/views.py | 29 +++++++++++ server/api/views/uploadFile/views.py | 46 +++++++++++++++++- server/api/views/version/views.py | 7 +++ 13 files changed, 254 insertions(+), 5 deletions(-) diff --git a/server/api/views/ai_promptStorage/views.py b/server/api/views/ai_promptStorage/views.py index 7354feb3..cc50f22e 100644 --- a/server/api/views/ai_promptStorage/views.py +++ b/server/api/views/ai_promptStorage/views.py @@ -1,10 +1,12 @@ from rest_framework import status from rest_framework.decorators import api_view from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from .models import AI_PromptStorage from .serializers import AI_PromptStorageSerializer +@extend_schema(request=AI_PromptStorageSerializer, responses={201: AI_PromptStorageSerializer}) @api_view(['POST']) # @permission_classes([IsAuthenticated]) def store_prompt(request): @@ -21,6 +23,7 @@ def store_prompt(request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) +@extend_schema(responses={200: AI_PromptStorageSerializer(many=True)}) @api_view(['GET']) def get_all_prompts(request): """ diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 349b9fd9..9ee6aad7 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -2,10 +2,12 @@ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from drf_spectacular.utils import extend_schema from .models import AI_Settings from .serializers import AISettingsSerializer +@extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) def settings_view(request): diff --git a/server/api/views/assistant/views.py b/server/api/views/assistant/views.py index f31ab475..e3e8d6f7 100644 --- a/server/api/views/assistant/views.py +++ b/server/api/views/assistant/views.py @@ -10,6 +10,8 @@ from rest_framework.permissions import AllowAny from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers from openai import OpenAI @@ -113,6 +115,21 @@ def invoke_functions_from_response( class Assistant(APIView): permission_classes = [AllowAny] + @extend_schema( + request=inline_serializer(name='AssistantRequest', fields={ + 'message': drf_serializers.CharField(help_text='User message to send to the assistant'), + 'previous_response_id': drf_serializers.CharField(required=False, allow_null=True, help_text='ID of previous response for conversation continuity'), + }), + responses={ + 200: inline_serializer(name='AssistantResponse', fields={ + 'response_output_text': drf_serializers.CharField(), + 'final_response_id': drf_serializers.CharField(), + }), + 500: inline_serializer(name='AssistantError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): try: user = request.user diff --git a/server/api/views/conversations/views.py b/server/api/views/conversations/views.py index eeb68809..de927cf1 100644 --- a/server/api/views/conversations/views.py +++ b/server/api/views/conversations/views.py @@ -16,6 +16,8 @@ from .models import Conversation, Message from .serializers import ConversationSerializer from ...services.tools.tools import tools, execute_tool +from drf_spectacular.utils import extend_schema, inline_serializer +from rest_framework import serializers as drf_serializers @csrf_exempt @@ -95,6 +97,21 @@ def destroy(self, request, *args, **kwargs): self.perform_destroy(instance) return Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema( + request=inline_serializer(name='ContinueConversationRequest', fields={ + 'message': drf_serializers.CharField(help_text='User message to continue the conversation'), + 'page_context': drf_serializers.CharField(required=False, help_text='Optional page context'), + }), + responses={ + 200: inline_serializer(name='ContinueConversationResponse', fields={ + 'response': drf_serializers.CharField(), + 'title': drf_serializers.CharField(), + }), + 400: inline_serializer(name='ContinueConversationBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) @action(detail=True, methods=['post']) def continue_conversation(self, request, pk=None): conversation = self.get_object() @@ -123,6 +140,20 @@ def continue_conversation(self, request, pk=None): return Response({"response": chatgpt_response, "title": conversation.title}) + @extend_schema( + request=inline_serializer(name='UpdateTitleRequest', fields={ + 'title': drf_serializers.CharField(help_text='New conversation title'), + }), + responses={ + 200: inline_serializer(name='UpdateTitleResponse', fields={ + 'status': drf_serializers.CharField(), + 'title': drf_serializers.CharField(), + }), + 400: inline_serializer(name='UpdateTitleBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) @action(detail=True, methods=['patch']) def update_title(self, request, pk=None): conversation = self.get_object() diff --git a/server/api/views/embeddings/embeddingsView.py b/server/api/views/embeddings/embeddingsView.py index d0bdd8ca..ebcf0774 100644 --- a/server/api/views/embeddings/embeddingsView.py +++ b/server/api/views/embeddings/embeddingsView.py @@ -1,8 +1,9 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from django.http import StreamingHttpResponse +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter from ...services.embedding_services import get_closest_embeddings from ...services.conversions_services import convert_uuids from ...services.openai_services import openAIServices @@ -15,6 +16,26 @@ class AskEmbeddingsAPIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=False, description='Optional file GUID to filter embeddings'), + OpenApiParameter(name='stream', type=bool, location=OpenApiParameter.QUERY, required=False, description='Enable streaming response'), + ], + request=inline_serializer(name='AskEmbeddingsRequest', fields={ + 'message': drf_serializers.CharField(help_text='Question to ask against embedded documents'), + }), + responses={ + 200: inline_serializer(name='AskEmbeddingsResponse', fields={ + 'question': drf_serializers.CharField(), + 'llm_response': drf_serializers.CharField(), + 'embeddings_info': drf_serializers.CharField(), + 'sent_to_llm': drf_serializers.CharField(), + }), + 400: inline_serializer(name='AskEmbeddingsBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request, *args, **kwargs): try: user = request.user diff --git a/server/api/views/feedback/views.py b/server/api/views/feedback/views.py index d0f0e1da..424e0758 100644 --- a/server/api/views/feedback/views.py +++ b/server/api/views/feedback/views.py @@ -9,6 +9,7 @@ class FeedbackView(APIView): permission_classes = [AllowAny] + serializer_class = FeedbackSerializer def post(self, request, *args, **kwargs): serializer = FeedbackSerializer(data=request.data) diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index fcd0edf2..1b199a7e 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,7 +1,8 @@ -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.views import APIView +from drf_spectacular.utils import extend_schema, inline_serializer from .models import Diagnosis, Medication, Suggestion from .serializers import MedicationSerializer @@ -24,6 +25,33 @@ class GetMedication(APIView): permission_classes = [AllowAny] + @extend_schema( + request=inline_serializer( + name='GetMedicationRequest', + fields={ + 'state': drf_serializers.CharField(help_text='Diagnosis state, e.g. "depressed", "manic"'), + 'suicideHistory': drf_serializers.BooleanField(default=False), + 'kidneyHistory': drf_serializers.BooleanField(default=False), + 'liverHistory': drf_serializers.BooleanField(default=False), + 'bloodPressureHistory': drf_serializers.BooleanField(default=False), + 'weightGainConcern': drf_serializers.BooleanField(default=False), + 'priorMedications': drf_serializers.CharField(required=False, default='', help_text='Comma-separated medication names'), + } + ), + responses={ + 200: inline_serializer( + name='GetMedicationResponse', + fields={ + 'first': drf_serializers.ListField(child=drf_serializers.DictField()), + 'second': drf_serializers.ListField(child=drf_serializers.DictField()), + 'third': drf_serializers.ListField(child=drf_serializers.DictField()), + } + ), + 404: inline_serializer(name='GetMedicationNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): data = request.data state_query = data.get('state', '') @@ -75,6 +103,7 @@ def post(self, request): class ListOrDetailMedication(APIView): permission_classes = [AllowAny] + serializer_class = MedicationSerializer def get(self, request): name_query = request.query_params.get('name', None) @@ -98,6 +127,7 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + serializer_class = MedicationSerializer def post(self, request): data = request.data @@ -129,6 +159,22 @@ class DeleteMedication(APIView): API endpoint to delete medication if medication in database. """ + @extend_schema( + request=inline_serializer(name='DeleteMedicationRequest', fields={ + 'name': drf_serializers.CharField(), + }), + responses={ + 200: inline_serializer(name='DeleteMedicationSuccess', fields={ + 'success': drf_serializers.CharField(), + }), + 400: inline_serializer(name='DeleteMedicationBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='DeleteMedicationNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def delete(self, request): data = request.data name = data.get('name', '').strip() diff --git a/server/api/views/medRules/serializers.py b/server/api/views/medRules/serializers.py index df5e3663..e0d7d3f3 100644 --- a/server/api/views/medRules/serializers.py +++ b/server/api/views/medRules/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field from ...models.model_medRule import MedRule, MedRuleSource from ..listMeds.serializers import MedicationSerializer from ...models.model_embeddings import Embeddings @@ -30,6 +31,7 @@ class Meta: "medication_sources", ] + @extend_schema_field(MedicationWithSourcesSerializer(many=True)) def get_medication_sources(self, obj): medrule_sources = MedRuleSource.objects.filter(medrule=obj).select_related( "medication", "embedding" diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2fae140b..2f80f8f3 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,9 +1,10 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt +from drf_spectacular.utils import extend_schema, inline_serializer from ...models.model_medRule import MedRule from .serializers import MedRuleSerializer # You'll need to create this from ..listMeds.models import Medication @@ -13,6 +14,7 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): permission_classes = [IsAuthenticated] + serializer_class = MedRuleSerializer def get(self, request, format=None): # Get all med rules @@ -29,6 +31,27 @@ def get(self, request, format=None): return Response(data, status=status.HTTP_200_OK) + @extend_schema( + request=inline_serializer(name='MedRuleCreateRequest', fields={ + 'rule_type': drf_serializers.CharField(help_text='INCLUDE or EXCLUDE'), + 'history_type': drf_serializers.CharField(help_text='e.g. DIAGNOSIS_DEPRESSED, DIAGNOSIS_MANIC'), + 'reason': drf_serializers.CharField(), + 'label': drf_serializers.CharField(), + 'explanation': drf_serializers.CharField(), + 'medication_names': drf_serializers.ListField(child=drf_serializers.CharField()), + 'chunk_ids': drf_serializers.ListField(child=drf_serializers.IntegerField()), + 'file_guid': drf_serializers.CharField(), + }), + responses={ + 201: MedRuleSerializer, + 400: inline_serializer(name='MedRuleCreateBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='MedRuleCreateNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): data = request.data diff --git a/server/api/views/risk/views_riskWithSources.py b/server/api/views/risk/views_riskWithSources.py index c02908fc..26cad9f8 100644 --- a/server/api/views/risk/views_riskWithSources.py +++ b/server/api/views/risk/views_riskWithSources.py @@ -1,7 +1,8 @@ from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny +from drf_spectacular.utils import extend_schema, inline_serializer from api.views.listMeds.models import Medication from api.models.model_medRule import MedRule, MedRuleSource import openai @@ -11,6 +12,28 @@ class RiskWithSourcesView(APIView): permission_classes = [AllowAny] + @extend_schema( + request=inline_serializer(name='RiskWithSourcesRequest', fields={ + 'drug': drf_serializers.CharField(help_text='Medication name'), + 'source': drf_serializers.CharField(required=False, help_text='One of: include, diagnosis, diagnosis_depressed, diagnosis_manic, diagnosis_hypomanic, diagnosis_euthymic'), + }), + responses={ + 200: inline_serializer(name='RiskWithSourcesResponse', fields={ + 'benefits': drf_serializers.ListField(child=drf_serializers.CharField()), + 'risks': drf_serializers.ListField(child=drf_serializers.CharField()), + 'sources': drf_serializers.ListField(child=drf_serializers.DictField()), + 'medrules_found': drf_serializers.IntegerField(required=False), + 'source_type': drf_serializers.CharField(required=False), + 'note': drf_serializers.CharField(required=False), + }), + 400: inline_serializer(name='RiskWithSourcesBadRequest', fields={ + 'error': drf_serializers.CharField(), + }), + 404: inline_serializer(name='RiskWithSourcesNotFound', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def post(self, request): openai.api_key = os.environ.get("OPENAI_API_KEY") diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index e4122851..020740ad 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -9,6 +9,8 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt import anthropic +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter +from rest_framework import serializers as drf_serializers from ...services.openai_services import openAIServices from api.models.model_embeddings import Embeddings @@ -97,6 +99,20 @@ class RuleExtractionAPIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=True, description='File GUID to extract rules from'), + ], + responses={ + 200: inline_serializer(name='RuleExtractionResponse', fields={ + 'texts': drf_serializers.CharField(), + 'cited_texts': drf_serializers.CharField(), + }), + 500: inline_serializer(name='RuleExtractionError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def get(self, request): try: @@ -141,6 +157,19 @@ def openai_extraction(content_chunks, user_prompt): class RuleExtractionAPIOpenAIView(APIView): permission_classes = [IsAuthenticated] + @extend_schema( + parameters=[ + OpenApiParameter(name='guid', type=str, location=OpenApiParameter.QUERY, required=True, description='File GUID to extract rules from'), + ], + responses={ + 200: inline_serializer(name='RuleExtractionOpenAIResponse', fields={ + 'rules': drf_serializers.ListField(child=drf_serializers.DictField()), + }), + 500: inline_serializer(name='RuleExtractionOpenAIError', fields={ + 'error': drf_serializers.CharField(), + }), + } + ) def get(self, request): try: user_prompt = """ diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index 69dfb996..b9f0cd3d 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,8 +1,9 @@ from rest_framework.views import APIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response -from rest_framework import status +from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiResponse import pdfplumber from .models import UploadFile # Import your UploadFile model from .serializers import UploadFileSerializer @@ -15,6 +16,8 @@ class UploadFileView(APIView): + serializer_class = UploadFileSerializer + def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] # Public access @@ -28,6 +31,23 @@ def get(self, request, format=None): serializer = UploadFileSerializer(files, many=True) return Response(serializer.data) + @extend_schema( + request={'multipart/form-data': inline_serializer( + name='UploadFileRequest', + fields={ + 'file': drf_serializers.FileField(help_text='PDF file to upload'), + } + )}, + responses={ + 201: inline_serializer(name='UploadFileSuccess', fields={ + 'message': drf_serializers.CharField(), + 'file_id': drf_serializers.IntegerField(), + }), + 400: inline_serializer(name='UploadFileBadRequest', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def post(self, request, format=None): print(request.auth) print(f"UploadFileView post called. Path: {request.path}") @@ -127,6 +147,22 @@ def post(self, request, format=None): return Response({"message": f"Error processing file and embeddings: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST) + @extend_schema( + request=inline_serializer(name='DeleteFileRequest', fields={ + 'guid': drf_serializers.CharField(help_text='GUID of file to delete'), + }), + responses={ + 200: inline_serializer(name='DeleteFileSuccess', fields={ + 'message': drf_serializers.CharField(), + }), + 403: inline_serializer(name='DeleteFileForbidden', fields={ + 'message': drf_serializers.CharField(), + }), + 404: inline_serializer(name='DeleteFileNotFound', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def delete(self, request, format=None): guid = request.data.get('guid') if not guid: @@ -157,6 +193,14 @@ def delete(self, request, format=None): class RetrieveUploadFileView(APIView): permission_classes = [AllowAny] + @extend_schema( + responses={ + (200, 'application/pdf'): OpenApiResponse(description='PDF file binary content'), + 404: inline_serializer(name='RetrieveFileNotFound', fields={ + 'message': drf_serializers.CharField(), + }), + } + ) def get(self, request, guid, format=None): try: file = UploadFile.objects.get(guid=guid) diff --git a/server/api/views/version/views.py b/server/api/views/version/views.py index b79d6577..af59e9e0 100644 --- a/server/api/views/version/views.py +++ b/server/api/views/version/views.py @@ -3,11 +3,18 @@ from rest_framework.permissions import AllowAny from rest_framework.views import APIView from rest_framework.response import Response +from rest_framework import serializers as drf_serializers +from drf_spectacular.utils import extend_schema, inline_serializer class VersionView(APIView): permission_classes = [AllowAny] + @extend_schema( + responses={200: inline_serializer(name='VersionResponse', fields={ + 'version': drf_serializers.CharField(), + })} + ) def get(self, request, *args, **kwargs): version = os.environ.get("VERSION") or "dev" return Response({"version": version}) From 5ce77823c25f0a45fbcdc828e13214b515496bc2 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 27 Feb 2026 16:09:05 -0500 Subject: [PATCH 106/181] Preload SentenceTransformer model at Django startup before traffic is routed to the application instance --- server/api/apps.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/api/apps.py b/server/api/apps.py index 66656fd2..4d502cba 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -4,3 +4,7 @@ class ApiConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' name = 'api' + + def ready(self): + from .services.sentencetTransformer_model import TransformerModel + TransformerModel.get_instance() From 7085aa0c71f210bbe450034deb5ae5af1ef6cbef Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 2 Mar 2026 11:59:48 -0500 Subject: [PATCH 107/181] Requested changes: fix patch decorators to point to where openAI is used, not where it's defined --- server/api/views/uploadFile/test_title.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 5391ed05..07e1b1ba 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -53,7 +53,7 @@ def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(se expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} @@ -68,7 +68,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): self.assertTrue(mock_openAI.called) - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_strips_quotes_from_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} @@ -83,7 +83,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): self.assertEqual(result, "Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder") - @patch("api.services.openai_services.openAIServices.openAI") + @patch("api.views.uploadFile.title.openAIServices.openAI") def test_truncates_long_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} From e6754df366d1762a1b2028f0ec296c2bbd5eb3b6 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 2 Mar 2026 12:07:57 -0500 Subject: [PATCH 108/181] Requested changes: added comments explaining title truncation --- server/api/views/uploadFile/test_title.py | 1 + server/api/views/uploadFile/title.py | 1 + 2 files changed, 2 insertions(+) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 07e1b1ba..d5945da8 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -96,4 +96,5 @@ def test_truncates_long_openai_title(self, mock_openAI): result = title.generate_title(doc) + # Ensure the title is truncated to fit the UploadFile model's title field (max_length=255), since OpenAI responses may exceed this limit self.assertLessEqual(len(result), 255) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index b3f8aded..17f52a74 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -59,4 +59,5 @@ def summarize_pdf(pdf: fitz.Document) -> str: response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) title = response.choices[0].message.content.strip().strip('"').strip("'") + # Truncate to fit UploadFile model's max_length=255 title field as a final safeguard return title[:255] From 4b4d7275ed7d580c9ee7b7d51287cdb99a78b9bc Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Mon, 2 Mar 2026 12:15:32 -0500 Subject: [PATCH 109/181] Fix mock setups to match how generate_title accesses title --- server/api/views/uploadFile/test_title.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index d5945da8..0ec9e1bc 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -57,7 +57,7 @@ def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(se def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc[0].get_text.return_value = [] mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -72,7 +72,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): def test_strips_quotes_from_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc[0].get_text.return_value = [] mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -87,7 +87,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): def test_truncates_long_openai_title(self, mock_openAI): doc = MagicMock() doc.metadata = {"title": None} - doc.get_text.return_value = [] + doc[0].get_text.return_value = [] mock_response = MagicMock() mock_response.choices = [MagicMock()] From e8b0fc1de6be8f89c500a117a4533ad26e632200 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 18:56:12 -0500 Subject: [PATCH 110/181] fix: treat openAIServices.openAI() return value as string --- server/api/views/uploadFile/title.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 17f52a74..41e67757 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -58,6 +58,6 @@ def summarize_pdf(pdf: fitz.Document) -> str: prompt = "Please provide a title for this document. The title should be less than 256 characters and will be displayed on a webpage." response = openAIServices.openAI( first_page_content, prompt, model='gpt-4o', temp=0.0) - title = response.choices[0].message.content.strip().strip('"').strip("'") + title = response.strip().strip('"').strip("'") # Truncate to fit UploadFile model's max_length=255 title field as a final safeguard return title[:255] From e0b7c23bae2a1284202a23a19b16293cecdf4411 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 19:01:34 -0500 Subject: [PATCH 111/181] fix mock test setup to return string instead of mocked response object --- server/api/views/uploadFile/test_title.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 0ec9e1bc..6d95905c 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -59,10 +59,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): doc.metadata = {"title": None} doc[0].get_text.return_value = [] - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = "A Study Regarding The Efficacy of Drugs" - mock_openAI.return_value = mock_response + mock_openAI.return_value = "A Study Regarding The Efficacy of Drugs" title.generate_title(doc) @@ -74,10 +71,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): doc.metadata = {"title": None} doc[0].get_text.return_value = [] - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' - mock_openAI.return_value = mock_response + mock_openAI.return_value = '"Updated CANMAT/ISBD Guidelines for Treating Mixed Features in Bipolar Disorder"' result = title.generate_title(doc) @@ -89,10 +83,7 @@ def test_truncates_long_openai_title(self, mock_openAI): doc.metadata = {"title": None} doc[0].get_text.return_value = [] - mock_response = MagicMock() - mock_response.choices = [MagicMock()] - mock_response.choices[0].message.content = "A" * 300 - mock_openAI.return_value = mock_response + mock_openAI.return_value = "A" * 300 result = title.generate_title(doc) From d68fa62b03e9890cab17b46164a4bda6511a5c2f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 19:05:46 -0500 Subject: [PATCH 112/181] fix to make test_falls_back_to_chatgpt_if_no_title_found more robust --- server/api/views/uploadFile/test_title.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 6d95905c..e57ee507 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -61,9 +61,10 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): mock_openAI.return_value = "A Study Regarding The Efficacy of Drugs" - title.generate_title(doc) + result = title.generate_title(doc) self.assertTrue(mock_openAI.called) + self.assertEqual(result, "A Study Regarding The Efficacy of Drugs") @patch("api.views.uploadFile.title.openAIServices.openAI") def test_strips_quotes_from_openai_title(self, mock_openAI): From 03b7639bcddf837a78f542ee3eda29c75bc669c3 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 22:25:50 -0500 Subject: [PATCH 113/181] Replace block-position title extraction with font-size-based approach The old "scan first couple pages" logic used get_text("blocks") and picked the first block matching a title regex, which frequently selected preambles, journal names, and article headers instead of the actual title. The new approach uses get_text("dict") to find the largest font size across the first few pages and collects contiguous runs of text at that size, since research paper titles are typically the largest font. --- server/api/views/uploadFile/title.py | 91 +++++++++++++++++++++------- 1 file changed, 68 insertions(+), 23 deletions(-) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 17f52a74..76afbfd3 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -15,35 +15,80 @@ def generate_title(pdf: fitz.Document) -> str | None: document_metadata_title = pdf.metadata["title"] if document_metadata_title is not None and document_metadata_title != "": if title_regex.match(document_metadata_title): - print("suitable title was found in metadata") return document_metadata_title.strip() - else: - print("metadata title did not match regex") - print("Looking for title in first page text") - first_page = pdf[0] - first_page_blocks = first_page.get_text("blocks") - text_blocks = [ - block[4].strip().replace("\n", " ") - for block in first_page_blocks - if block[6] == 0 # only include text blocks. - ] - - # For some reason, extracted PDF text has extra spaces. Collapse them here. - regex = r"\s{2,}" - text_blocks = [re.sub(regex, " ", text) for text in text_blocks] - - if len(text_blocks) != 0: - for text in text_blocks: - if title_regex.match(text): - return text - - print( - "no suitable title found in first page text. Using GPT-4 to summarize the PDF") + font_title = extract_title_by_font_size(pdf) + if font_title: + return font_title + gpt_title = summarize_pdf(pdf) return gpt_title or None +def extract_title_by_font_size(pdf: fitz.Document, max_pages: int = 3) -> str | None: + """ + Extract the title by finding the largest font size across the first few pages + and collecting contiguous runs of text at that size. + """ + pages_to_scan = min(max_pages, len(pdf)) + + # First pass: collect all spans with their font size, and find the max font size. + all_spans = [] + max_font_size = 0.0 + + for page_idx in range(pages_to_scan): + page_dict = pdf[page_idx].get_text("dict") + for block in page_dict["blocks"]: + if block.get("type") != 0: + continue + for line in block["lines"]: + for span in line["spans"]: + text = span["text"].strip() + size = span["size"] + if len(text) < 2 or size < 6.0: + continue + all_spans.append({"text": text, "size": size}) + if size > max_font_size: + max_font_size = size + + if max_font_size == 0.0: + return None + + # Second pass: gather contiguous runs of spans at the max font size. + # Runs continue across block boundaries so multi-block titles (e.g., + # "BIPOLAR DISORDER IN PRIMARY CARE:" in one block and "DIAGNOSIS AND + # MANAGEMENT" in the next) are joined into a single candidate. + # A run only ends when a non-max-size span interrupts it. + candidates = [] + current_run = [] + + for span in all_spans: + if span["size"] == max_font_size: + current_run.append(span["text"]) + else: + if current_run: + candidates.append(" ".join(current_run)) + current_run = [] + + if current_run: + candidates.append(" ".join(current_run)) + + # Collapse extra whitespace, validate against title regex, and pick the longest match. + # Longest wins because real titles are typically longer than section headers + # (e.g., "About the Author") that may share the same max font size. + best = None + for candidate in candidates: + cleaned = re.sub(r"\s{2,}", " ", candidate).strip() + if title_regex.match(cleaned): + if best is None or len(cleaned) > len(best): + best = cleaned + + if best: + return best[:255] + + return None + + def summarize_pdf(pdf: fitz.Document) -> str: """ Summarize a PDF document using OpenAI's GPT-4 model. From 2a822f60f86857672af5a8d5f688ed3ee5208326 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 22:26:27 -0500 Subject: [PATCH 114/181] loosens the title regex to allow years, question marks, apostrophes, and non-breaking spaces in titles. --- server/api/views/uploadFile/title.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/api/views/uploadFile/title.py b/server/api/views/uploadFile/title.py index 76afbfd3..f4e562c1 100644 --- a/server/api/views/uploadFile/title.py +++ b/server/api/views/uploadFile/title.py @@ -6,9 +6,9 @@ # regular expression to match common research white paper titles. Created by Chat-gpt -# requires at least 3 words, no dates, no version numbers. +# requires at least 3 words, no version numbers. title_regex = re.compile( - r'^(?=(?:\b\w+\b[\s:,\-\(\)]*){3,})(?!.*\b(?:19|20)\d{2}\b)(?!.*\bv\d+\b)[A-Za-z0-9][\w\s:,\-\(\)]*[A-Za-z\)]$', re.IGNORECASE) + r"^(?=(?:\b\w+\b[^A-Za-z0-9]*){3,})(?!.*\bv\d+\b)[A-Za-z0-9].+[A-Za-z\)?!]$", re.IGNORECASE) def generate_title(pdf: fitz.Document) -> str | None: From edf1eb64a77f4dd153a0c075933693831dfaed9f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Thu, 5 Mar 2026 22:26:58 -0500 Subject: [PATCH 115/181] Update tests for font-size-based title extraction Refactor test helpers to use get_text("dict") structure instead of get_text("blocks"). Add tests for multi-span joining, short span filtering, regex rejection, and multi-page title detection. --- server/api/views/uploadFile/test_title.py | 153 +++++++++++++++------- 1 file changed, 108 insertions(+), 45 deletions(-) diff --git a/server/api/views/uploadFile/test_title.py b/server/api/views/uploadFile/test_title.py index 0ec9e1bc..531a3f07 100644 --- a/server/api/views/uploadFile/test_title.py +++ b/server/api/views/uploadFile/test_title.py @@ -4,6 +4,39 @@ from . import title +def make_page_dict(blocks): + """Helper to build a get_text("dict") return value from a simple list of blocks. + Each block is a list of (text, font_size) tuples representing spans. + """ + dict_blocks = [] + for spans in blocks: + dict_blocks.append({ + "type": 0, + "lines": [{ + "spans": [{"text": text, "size": size} for text, size in spans] + }] + }) + return {"blocks": dict_blocks} + + +def make_mock_doc(pages_data, metadata=None): + """Build a mock fitz.Document. + pages_data: list of block lists, one per page. Each block is a list of (text, size) tuples. + """ + doc = MagicMock() + doc.metadata = metadata or {"title": None} + doc.__len__ = lambda self: len(pages_data) + + mock_pages = [] + for page_blocks in pages_data: + page = MagicMock() + page.get_text.return_value = make_page_dict(page_blocks) + mock_pages.append(page) + + doc.__getitem__ = lambda self, idx: mock_pages[idx] + return doc + + class TestGenerateTitle(unittest.TestCase): def test_prefers_metadata_title_if_valid(self): doc = MagicMock() @@ -11,53 +44,35 @@ def test_prefers_metadata_title_if_valid(self): self.assertEqual( "A Study Regarding The Efficacy of Drugs", title.generate_title(doc)) - def test_falls_back_to_first_page_text_if_metadata_title_is_empty(self): - doc = MagicMock() - doc.metadata = {"title": ""} - doc[0].get_text = MagicMock() - - foo_block = [None] * 7 - foo_block[4] = "foo" - foo_block[6] = 0 - - title_block = [None] * 7 - title_block[4] = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" - title_block[6] = 0 - - bar_block = [None] * 7 - bar_block[4] = "bar" - bar_block[6] = 0 - doc[0].get_text.return_value = [foo_block, title_block, bar_block] - + def test_falls_back_to_font_size_if_metadata_title_is_empty(self): + doc = make_mock_doc( + pages_data=[[ + [("foo", 10.0)], + [("Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia", 18.0)], + [("bar", 10.0)], + ]], + metadata={"title": ""}, + ) expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) - def test_falls_back_to_first_page_text_if_metadata_title_does_not_match_regex(self): - doc = MagicMock() - doc.metadata = {"title": "abcd1234"} - doc[0].get_text = MagicMock() - - foo_block = [None] * 7 - foo_block[4] = "foo" - foo_block[6] = 0 - - title_block = [None] * 7 - title_block[4] = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" - title_block[6] = 0 - - bar_block = [None] * 7 - bar_block[4] = "bar" - bar_block[6] = 0 - doc[0].get_text.return_value = [foo_block, title_block, bar_block] - + def test_falls_back_to_font_size_if_metadata_title_does_not_match_regex(self): + doc = make_mock_doc( + pages_data=[[ + [("foo", 10.0)], + [("Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia", 18.0)], + [("bar", 10.0)], + ]], + metadata={"title": "abcd1234"}, + ) expected_title = "Advances in Mood Disorder Pharmacotherapy: Evaluating New Antipsychotics and Mood Stabilizers for Bipolar Disorder and Schizophrenia" self.assertEqual(expected_title, title.generate_title(doc)) @patch("api.views.uploadFile.title.openAIServices.openAI") def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc[0].get_text.return_value = [] + doc = make_mock_doc( + pages_data=[[]] # no blocks at all + ) mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -70,9 +85,7 @@ def test_falls_back_to_chatgpt_if_no_title_found(self, mock_openAI): @patch("api.views.uploadFile.title.openAIServices.openAI") def test_strips_quotes_from_openai_title(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc[0].get_text.return_value = [] + doc = make_mock_doc(pages_data=[[]]) mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -85,9 +98,7 @@ def test_strips_quotes_from_openai_title(self, mock_openAI): @patch("api.views.uploadFile.title.openAIServices.openAI") def test_truncates_long_openai_title(self, mock_openAI): - doc = MagicMock() - doc.metadata = {"title": None} - doc[0].get_text.return_value = [] + doc = make_mock_doc(pages_data=[[]]) mock_response = MagicMock() mock_response.choices = [MagicMock()] @@ -98,3 +109,55 @@ def test_truncates_long_openai_title(self, mock_openAI): # Ensure the title is truncated to fit the UploadFile model's title field (max_length=255), since OpenAI responses may exceed this limit self.assertLessEqual(len(result), 255) + + def test_font_size_joins_adjacent_spans_in_same_block(self): + """A title split across multiple spans in the same block should be joined.""" + doc = make_mock_doc( + pages_data=[[ + [("Author Name", 10.0)], + [("Advances in Mood Disorder", 18.0), ("Pharmacotherapy", 18.0)], + [("Some journal info", 10.0)], + ]], + ) + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") + + def test_font_size_ignores_short_spans(self): + """Superscript markers and other tiny spans should be filtered out.""" + doc = make_mock_doc( + pages_data=[[ + [("Advances in Mood Disorder Pharmacotherapy", 18.0), ("*", 18.0)], + [("Author Name et al.", 10.0)], + ]], + ) + # The "*" span is < 2 chars, so it should be ignored; title is just the real text + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") + + def test_font_size_returns_none_when_no_regex_match(self): + """If the largest-font text doesn't match the title regex, return None.""" + doc = make_mock_doc( + pages_data=[[ + # Only 2 words — regex requires at least 3 + [("Psychiatry Research", 18.0)], + [("Author Name et al.", 10.0)], + ]], + ) + result = title.extract_title_by_font_size(doc) + self.assertIsNone(result) + + def test_font_size_finds_title_on_later_page(self): + """Title on page 2 should still be found if it has the largest font.""" + doc = make_mock_doc( + pages_data=[ + [ # page 1: cover page with smaller text + [("Some preamble text here", 12.0)], + ], + [ # page 2: actual title in larger font + [("Advances in Mood Disorder Pharmacotherapy", 18.0)], + [("Author Name et al.", 10.0)], + ], + ], + ) + result = title.extract_title_by_font_size(doc) + self.assertEqual(result, "Advances in Mood Disorder Pharmacotherapy") From 4bae7460cc4a2f4805a3e49286dc49d340fd8163 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 10 Mar 2026 12:28:50 -0400 Subject: [PATCH 116/181] update documentation to include instructions about how to use the API docs --- CLAUDE.md | 10 ++++++++++ README.md | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 712082e7..b9f417e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -147,6 +147,16 @@ Each module contains: - Auth endpoints via Djoser: `/auth/` - JWT token lifetime: 60 minutes (access), 1 day (refresh) +#### API Documentation +- Auto-generated using **drf-spectacular** (OpenAPI 3.0) +- **Swagger UI**: `http://localhost:8000/api/docs/` — interactive API explorer +- **ReDoc**: `http://localhost:8000/api/redoc/` — readable reference docs +- **Raw schema**: `http://localhost:8000/api/schema/` +- Configuration in `SPECTACULAR_SETTINGS` in `settings.py` +- Views use `@extend_schema` decorators and `serializer_class` attributes for schema generation +- JWT auth is configured in the schema — use `JWT ` (not `Bearer`) in Swagger UI's Authorize dialog +- To document a new endpoint: add `serializer_class` to the view if it has one, or add `@extend_schema` with `inline_serializer` for views returning raw dicts + #### Key Data Models - **Medication** (`api.views.listMeds.models`) - Medication catalog with benefits/risks - **MedRule** (`api.models.model_medRule`) - Include/Exclude rules for medications based on patient history diff --git a/README.md b/README.md index e5a246b1..8988ea75 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,23 @@ df = pd.read_sql(query, engine) #### Django REST - The email and password are set in `server/api/management/commands/createsu.py` +## API Documentation + +Interactive API docs are auto-generated using [drf-spectacular](https://drf-spectacular.readthedocs.io/) and available at: + +- **Swagger UI**: [http://localhost:8000/api/docs/](http://localhost:8000/api/docs/) — interactive explorer with "Try it out" functionality +- **ReDoc**: [http://localhost:8000/api/redoc/](http://localhost:8000/api/redoc/) — clean, readable reference docs +- **Raw schema**: [http://localhost:8000/api/schema/](http://localhost:8000/api/schema/) — OpenAPI 3.0 JSON/YAML + +### Testing authenticated endpoints + +Most endpoints require JWT authentication. To test them in Swagger UI: + +1. **Get a token**: Find the `POST /auth/jwt/create/` endpoint in Swagger UI, click **Try it out**, enter an authorized `email` and `password`, and click **Execute**. Copy the `access` token from the response. +2. **Authorize**: Click the **Authorize** button (lock icon) at the top of the page. Enter `JWT ` in the value field. The prefix must be `JWT`, not `Bearer`. +3. **Test endpoints**: All subsequent requests will include your token. Use **Try it out** on any protected endpoint. +4. **Token refresh**: Access tokens expire after 60 minutes. Use `POST /auth/jwt/refresh/` with your `refresh` token, or repeat step 1. + ## Architecture The Balancer website is a Postgres, Django REST, and React project. The source code layout is: From 6f0deedb5505abf5917320daa9539167adda0447 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 10 Mar 2026 19:40:16 -0400 Subject: [PATCH 117/181] update site links on README The links were for the old site and needed an update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e5a246b1..4603df49 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage -You can view the current build of the website here: [https://balancertestsite.com](https://balancertestsite.com/) +You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) ## Contributing @@ -53,7 +53,7 @@ The application supports connecting to PostgreSQL databases via: See [Database Connection Documentation](./docs/DATABASE_CONNECTION.md) for detailed configuration. **Local Development:** -- Download a sample of papers to upload from [https://balancertestsite.com](https://balancertestsite.com/) +- Download a sample of papers to upload from [https://balancerproject.org/](https://balancerproject.org/) - The email and password of `pgAdmin` are specified in `balancer-main/docker-compose.yml` - The first time you use `pgAdmin` after building the Docker containers you will need to register the server. - The `Host name/address` is the Postgres server service name in the Docker Compose file From 795f21885e766de9f6379319c5555ef7db48d989 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 11 Mar 2026 13:09:14 -0400 Subject: [PATCH 118/181] Run python-app workflow on pushes and PRs to develop branch --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 41bc74d9..a6c07075 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,9 +5,9 @@ name: Python application on: push: - branches: [ "listOfMed" ] + branches: [ "develop" ] pull_request: - branches: [ "listOfMed" ] + branches: [ "develop" ] permissions: contents: read From d498a0057008b1b2c80bdb176e5940940899639b Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Mar 2026 13:15:47 -0400 Subject: [PATCH 119/181] =?UTF-8?q?Pytest=20won=E2=80=99t=20automatically?= =?UTF-8?q?=20discover=20config=20files=20in=20subdirectories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/python-app.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index a6c07075..2afa2828 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,6 +29,6 @@ jobs: run: ruff check --output-format=github --target-version=py39 - name: Install test dependencies run: pip install -r server/requirements.txt - # Discover and run all files matching test_*.py or *_test.py under server/ + # Pytest won’t automatically discover config files in subdirectories - name: Run tests - run: pytest server/ -v + run: pytest -c server/pytest.ini server/ -v From 3824d81ae0fb722383f7e80df07ca7ba28d0c4e6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 19 Mar 2026 13:47:52 -0400 Subject: [PATCH 120/181] Suppress E402 import violations --- evaluation/evals.py | 16 ++++++++-------- server/balancer_backend/urls.py | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/evaluation/evals.py b/evaluation/evals.py index 8eb7e9e6..5110076f 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -21,18 +21,18 @@ # Ensure the parent directory is in the path to import ModelFactory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import argparse -import logging -import asyncio -import time +import argparse # noqa: E402 +import logging # noqa: E402 +import asyncio # noqa: E402 +import time # noqa: E402 -import pandas as pd +import pandas as pd # noqa: E402 # lighteval depends on `sentencepiece` and it only has prebuilt wheels for Python 3.11 or below -from lighteval.tasks.requests import Doc -from lighteval.metrics.metrics_sample import Extractiveness +from lighteval.tasks.requests import Doc # noqa: E402 +from lighteval.metrics.metrics_sample import Extractiveness # noqa: E402 -from server.api.services.llm_services import ModelFactory +from server.api.services.llm_services import ModelFactory # noqa: E402 logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index 55bd2032..cdb92dbb 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -58,9 +58,9 @@ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] -import os -from django.conf import settings -from django.http import HttpResponseNotFound +import os # noqa: E402 +from django.conf import settings # noqa: E402 +from django.http import HttpResponseNotFound # noqa: E402 def spa_fallback(request): From 46e9969dade55777098286cd6316bc18444e5b1f Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 20 Mar 2026 12:00:44 -0400 Subject: [PATCH 121/181] Add build_query tests and document coverage gaps in embedding_services --- server/api/services/embedding_services.py | 1 + .../api/services/test_embedding_services.py | 202 +++++++++++++++++- 2 files changed, 201 insertions(+), 2 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index 3fa9bb68..aca99133 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -2,6 +2,7 @@ import logging from statistics import median +# filter() only does ADD logic from django.db.models import Q from pgvector.django import L2Distance diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index 677c1e7b..ea322645 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -1,9 +1,175 @@ from unittest.mock import MagicMock, patch -from api.services.embedding_services import evaluate_query, log_usage +from django.db.models import Q +from api.services.embedding_services import build_query, evaluate_query, log_usage + +# --------------------------------------------------------------------------- +# build_query tests +# +# build_query only constructs a lazy Django QuerySet — it never evaluates it +# (no iteration, .get(), .exists(), etc.), so no database is needed. +# +# We patch Embeddings.objects so every chained ORM call (.filter, .annotate, +# .order_by, __getitem__) returns a MagicMock instead of hitting the DB. +# All assertions inspect which methods were called with which arguments. +# --------------------------------------------------------------------------- + +# Only forwarded to L2Distance +EMBEDDING_VECTOR = [0.1, 0.2, 0.3] + +# Test authenticated/unauthenticated user access control + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_authenticated_uses_or_filter(mock_objects): + # An authenticated user should see their own files OR files uploaded by a + # superuser. The initial filter must use an OR-connected Q expression. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR) + + # Q objects support equality comparison in pure Python — no DB needed. + expected_q = Q(upload_file__uploaded_by=user) | Q(upload_file__uploaded_by__is_superuser=True) + actual_q = mock_objects.filter.call_args.args[0] + assert actual_q == expected_q + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): + # An unauthenticated user may only see files uploaded by superusers. + # The OR branch for the user's own files must NOT be present. + user = MagicMock(is_authenticated=False) + + build_query(user, EMBEDDING_VECTOR) + + expected_q = Q(upload_file__uploaded_by__is_superuser=True) + actual_q = mock_objects.filter.call_args.args[0] + assert actual_q == expected_q + +# Test application of annotate and order_by + +# TODO: Strengthen test_build_query_annotates_and_orders_by_distance to also +# assert the *arguments* to annotate — specifically that it receives +# distance=L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR). +# Currently only the call count is checked, so a wrong field name or a +# dropped vector would go undetected. + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_annotates_and_orders_by_distance(mock_objects): + # Regardless of other arguments, annotate(distance=L2Distance(...)) and + # order_by("distance") must always be applied to the queryset. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR) + + # Retrieve the mock chain that .filter() returned, then check its methods. + filtered_qs = mock_objects.filter.return_value + filtered_qs.annotate.assert_called_once() + filtered_qs.annotate.return_value.order_by.assert_called_once_with("distance") + +# Test guid-over-document precedence logic + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_no_document_filter_when_both_none(mock_objects): + # When neither guid nor document_name is provided, only the access-control + # filter should fire — no secondary filter call for a document. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, document_name=None, guid=None) + + # Exactly one filter call: the auth/access-control filter. + assert mock_objects.filter.call_count == 1 + + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_guid_takes_precedence_over_document_name(mock_objects): + # When both guid and document_name are provided, the guid branch runs and + # the document_name branch is skipped entirely (only two filter calls total). + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="abc-123", document_name="study.pdf") + + # Two calls: auth filter + guid filter. No third call for document_name. + assert mock_objects.filter.call_count == 2 + + # The second filter must use upload_file__guid, not name. + # We follow the mock chain to the queryset that .annotate().order_by() returned. + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(upload_file__guid="abc-123") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_guid_filter_applied(mock_objects): + # When only guid is given, a second filter on upload_file__guid is applied. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="doc-guid-456") + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(upload_file__guid="doc-guid-456") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_document_name_filter_applied(mock_objects): + # When only document_name is given (guid is None), a second filter on + # name is applied instead of upload_file__guid. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, document_name="study.pdf", guid=None) + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(name="study.pdf") + + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_empty_string_guid_falls_back_to_document_name(mock_objects): + # An empty-string guid is falsy in Python, so it should not trigger the + # guid branch. The document_name filter should fire instead. This guards + # against callers passing guid="" from an unset form field. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, guid="", document_name="fallback.pdf") + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.filter.assert_called_once_with(name="fallback.pdf") + +# Cover LIMIT slicing + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_respects_num_results(mock_objects): + # num_results controls the SQL LIMIT via queryset slicing. Verify that a + # non-default value propagates correctly to the __getitem__ call. + user = MagicMock(is_authenticated=True) + + build_query(user, EMBEDDING_VECTOR, num_results=5) + + # Django translates qs[:5] into qs.__getitem__(slice(None, 5, None)). + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + ordered_qs.__getitem__.assert_called_once_with(slice(None, 5, None)) + +@patch("api.services.embedding_services.Embeddings.objects") +def test_build_query_returns_unevaluated_queryset(mock_objects): + # build_query must NOT evaluate the queryset (no list(), no iteration). + # The return value should be the mock produced by the final __getitem__ call. + user = MagicMock(is_authenticated=True) + + result = build_query(user, EMBEDDING_VECTOR) + + ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value + assert result is ordered_qs.__getitem__.return_value + assert not isinstance(result, list) + + +# --------------------------------------------------------------------------- +# evaluate_query tests +# --------------------------------------------------------------------------- + +# TODO: Add test for empty queryset — evaluate_query([]) should return []. def test_evaluate_query_maps_fields(): + # Verify that each Embeddings model attribute is mapped to the correct + # output dict key. Note the rename: obj.page_num -> result["page_number"]. obj = MagicMock() obj.name = "doc.pdf" obj.text = "some text" @@ -27,6 +193,8 @@ def test_evaluate_query_maps_fields(): def test_evaluate_query_none_upload_file(): + # When upload_file is None (e.g. the FK was deleted), file_id must be None + # rather than raising an AttributeError on None.guid. obj = MagicMock() obj.name = "doc.pdf" obj.text = "some text" @@ -39,9 +207,26 @@ def test_evaluate_query_none_upload_file(): assert results[0]["file_id"] is None +# --------------------------------------------------------------------------- +# log_usage tests +# --------------------------------------------------------------------------- + +# TODO: Add test for empty results list — log_usage([]) hits the else branch and +# should call SemanticSearchUsage.objects.create with num_results_returned=0 +# and max_distance=None, median_distance=None, min_distance=None. + +# TODO: Add test for unauthenticated user — user.is_authenticated=False should +# result in user=None being stored in the SemanticSearchUsage record. + +# TODO: Add test for user=None — passing None directly as the user argument +# should also store user=None (the expression `user if (user and +# user.is_authenticated) else None` handles both cases, but only the +# authenticated path is currently exercised). @patch("api.services.embedding_services.SemanticSearchUsage.objects.create") def test_log_usage_computes_distance_stats(mock_create): + # Verify min, max, and median are computed correctly from the distance + # values in the results list and forwarded to the DB record. results = [{"distance": 1.0}, {"distance": 3.0}, {"distance": 2.0}] user = MagicMock(is_authenticated=True) @@ -69,10 +254,12 @@ def test_log_usage_computes_distance_stats(mock_create): side_effect=Exception("DB error"), ) def test_log_usage_swallows_exceptions(mock_create): + # log_usage must not propagate exceptions — a logging failure should never + # interrupt the caller's search flow. + # pytest fails the test if it catches unhandled Exception results = [{"distance": 1.0}] user = MagicMock(is_authenticated=True) - # pytest fails the test if it catches unhandled Exception log_usage( results, message_data="test query", @@ -83,3 +270,14 @@ def test_log_usage_swallows_exceptions(mock_create): encoding_time=0.1, db_query_time=0.2, ) + + +# --------------------------------------------------------------------------- +# get_closest_embeddings tests +# --------------------------------------------------------------------------- + +# TODO: Add smoke test for get_closest_embeddings verifying the wiring between +# its three steps: encode → build_query → evaluate_query → log_usage. +# Patch TransformerModel.get_instance, build_query, evaluate_query, and +# log_usage. Assert that evaluate_query receives the queryset returned by +# build_query, and that the function returns evaluate_query's result. From 64a19ef56dad988d1a2ea61a0b409c7878d0370c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 20 Mar 2026 12:39:51 -0400 Subject: [PATCH 122/181] Fill test gaps in test_embedding_services --- server/api/services/embedding_services.py | 2 +- .../api/services/test_embedding_services.py | 149 ++++++++++++++---- 2 files changed, 119 insertions(+), 32 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index aca99133..dada28a2 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -2,7 +2,7 @@ import logging from statistics import median -# filter() only does ADD logic +# Django filter() only does ADD logic from django.db.models import Q from pgvector.django import L2Distance diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index ea322645..8cbc1be9 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -1,20 +1,21 @@ from unittest.mock import MagicMock, patch from django.db.models import Q +from pgvector.django import L2Distance -from api.services.embedding_services import build_query, evaluate_query, log_usage +from api.services.embedding_services import ( + build_query, + evaluate_query, + get_closest_embeddings, + log_usage, +) # --------------------------------------------------------------------------- # build_query tests -# -# build_query only constructs a lazy Django QuerySet — it never evaluates it -# (no iteration, .get(), .exists(), etc.), so no database is needed. -# -# We patch Embeddings.objects so every chained ORM call (.filter, .annotate, -# .order_by, __getitem__) returns a MagicMock instead of hitting the DB. -# All assertions inspect which methods were called with which arguments. # --------------------------------------------------------------------------- +# All assertions inspect which methods and arguments were called on Embeddings.objects + # Only forwarded to L2Distance EMBEDDING_VECTOR = [0.1, 0.2, 0.3] @@ -48,12 +49,6 @@ def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): # Test application of annotate and order_by -# TODO: Strengthen test_build_query_annotates_and_orders_by_distance to also -# assert the *arguments* to annotate — specifically that it receives -# distance=L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR). -# Currently only the call count is checked, so a wrong field name or a -# dropped vector would go undetected. - @patch("api.services.embedding_services.Embeddings.objects") def test_build_query_annotates_and_orders_by_distance(mock_objects): # Regardless of other arguments, annotate(distance=L2Distance(...)) and @@ -67,6 +62,12 @@ def test_build_query_annotates_and_orders_by_distance(mock_objects): filtered_qs.annotate.assert_called_once() filtered_qs.annotate.return_value.order_by.assert_called_once_with("distance") + # L2Distance is a Django Func subclass, which implements __eq__ by comparing + # class and source expressions — so we can assert the exact field name and + # vector without patching L2Distance itself. + actual_distance_expr = filtered_qs.annotate.call_args.kwargs["distance"] + assert actual_distance_expr == L2Distance("embedding_sentence_transformers", EMBEDDING_VECTOR) + # Test guid-over-document precedence logic @patch("api.services.embedding_services.Embeddings.objects") @@ -165,7 +166,10 @@ def test_build_query_returns_unevaluated_queryset(mock_objects): # evaluate_query tests # --------------------------------------------------------------------------- -# TODO: Add test for empty queryset — evaluate_query([]) should return []. +def test_evaluate_query_empty_queryset(): + # An empty iterable should return an empty list, not raise an exception. + assert evaluate_query([]) == [] + def test_evaluate_query_maps_fields(): # Verify that each Embeddings model attribute is mapped to the correct @@ -193,8 +197,8 @@ def test_evaluate_query_maps_fields(): def test_evaluate_query_none_upload_file(): - # When upload_file is None (e.g. the FK was deleted), file_id must be None - # rather than raising an AttributeError on None.guid. + # When upload_file is None, file_id must be None rather than raising + # an AttributeError on None.guid. obj = MagicMock() obj.name = "doc.pdf" obj.text = "some text" @@ -211,17 +215,71 @@ def test_evaluate_query_none_upload_file(): # log_usage tests # --------------------------------------------------------------------------- -# TODO: Add test for empty results list — log_usage([]) hits the else branch and -# should call SemanticSearchUsage.objects.create with num_results_returned=0 -# and max_distance=None, median_distance=None, min_distance=None. +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_empty_results(mock_create): + # Empty results hits the else branch. The record should still be created + # with num_results_returned=0 and all distance fields set to None. + user = MagicMock(is_authenticated=True) + + log_usage( + [], + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + mock_create.assert_called_once() + kwargs = mock_create.call_args.kwargs + assert kwargs["num_results_returned"] == 0 + assert kwargs["max_distance"] is None + assert kwargs["median_distance"] is None + assert kwargs["min_distance"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_unauthenticated_user_stored_as_none(mock_create): + # An unauthenticated user should be stored as None in the DB record, not as + # the user object itself, so the FK constraint is not violated. + user = MagicMock(is_authenticated=False) + + log_usage( + [{"distance": 1.0}], + message_data="test query", + user=user, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) + + kwargs = mock_create.call_args.kwargs + assert kwargs["user"] is None + + +@patch("api.services.embedding_services.SemanticSearchUsage.objects.create") +def test_log_usage_none_user_stored_as_none(mock_create): + # Passing user=None directly (e.g. from an anonymous request) should also + # store None — the expression `user if (user and user.is_authenticated)` + # short-circuits on the falsy None before accessing .is_authenticated. + log_usage( + [{"distance": 1.0}], + message_data="test query", + user=None, + guid=None, + document_name=None, + num_results=10, + encoding_time=0.1, + db_query_time=0.2, + ) -# TODO: Add test for unauthenticated user — user.is_authenticated=False should -# result in user=None being stored in the SemanticSearchUsage record. + kwargs = mock_create.call_args.kwargs + assert kwargs["user"] is None -# TODO: Add test for user=None — passing None directly as the user argument -# should also store user=None (the expression `user if (user and -# user.is_authenticated) else None` handles both cases, but only the -# authenticated path is currently exercised). @patch("api.services.embedding_services.SemanticSearchUsage.objects.create") def test_log_usage_computes_distance_stats(mock_create): @@ -276,8 +334,37 @@ def test_log_usage_swallows_exceptions(mock_create): # get_closest_embeddings tests # --------------------------------------------------------------------------- -# TODO: Add smoke test for get_closest_embeddings verifying the wiring between -# its three steps: encode → build_query → evaluate_query → log_usage. -# Patch TransformerModel.get_instance, build_query, evaluate_query, and -# log_usage. Assert that evaluate_query receives the queryset returned by -# build_query, and that the function returns evaluate_query's result. +@patch("api.services.embedding_services.log_usage") +@patch("api.services.embedding_services.evaluate_query") +@patch("api.services.embedding_services.build_query") +@patch("api.services.embedding_services.TransformerModel") +def test_get_closest_embeddings_wiring(mock_transformer, mock_build, mock_evaluate, mock_log): + # Smoke test verifying that get_closest_embeddings correctly wires together + # encode → build_query → evaluate_query → log_usage and returns the results. + user = MagicMock(is_authenticated=True) + + # Simulate the model encoding the message to a vector. + fake_vector = [0.1, 0.2, 0.3] + mock_transformer.get_instance.return_value.model.encode.return_value = fake_vector + + # build_query returns a queryset; evaluate_query turns it into a results list. + fake_queryset = MagicMock() + mock_build.return_value = fake_queryset + fake_results = [{"name": "doc.pdf", "distance": 0.5}] + mock_evaluate.return_value = fake_results + + result = get_closest_embeddings(user, "some query", document_name="doc.pdf", guid=None, num_results=5) + + # The encoded vector must be forwarded to build_query. + mock_build.assert_called_once_with(user, fake_vector, "doc.pdf", None, 5) + + # evaluate_query must receive the queryset that build_query returned. + mock_evaluate.assert_called_once_with(fake_queryset) + + # log_usage must be called with the results and original parameters. + mock_log.assert_called_once() + log_kwargs = mock_log.call_args.args + assert log_kwargs[0] is fake_results + + # The function must return evaluate_query's result unchanged. + assert result is fake_results From dec3c12a71c1fefc81f30768fe2aec8e48df2fb8 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 20 Mar 2026 14:05:17 -0400 Subject: [PATCH 123/181] Fix incorrect build_query test assertions --- .../api/services/test_embedding_services.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index 8cbc1be9..dcbb2fc7 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -38,14 +38,13 @@ def test_build_query_authenticated_uses_or_filter(mock_objects): @patch("api.services.embedding_services.Embeddings.objects") def test_build_query_unauthenticated_uses_superuser_only_filter(mock_objects): # An unauthenticated user may only see files uploaded by superusers. - # The OR branch for the user's own files must NOT be present. + # The source uses a plain kwarg here (not a positional Q object), so the + # value lives in call_args.kwargs, not call_args.args. user = MagicMock(is_authenticated=False) build_query(user, EMBEDDING_VECTOR) - expected_q = Q(upload_file__uploaded_by__is_superuser=True) - actual_q = mock_objects.filter.call_args.args[0] - assert actual_q == expected_q + assert mock_objects.filter.call_args.kwargs == {"upload_file__uploaded_by__is_superuser": True} # Test application of annotate and order_by @@ -86,16 +85,18 @@ def test_build_query_no_document_filter_when_both_none(mock_objects): @patch("api.services.embedding_services.Embeddings.objects") def test_build_query_guid_takes_precedence_over_document_name(mock_objects): # When both guid and document_name are provided, the guid branch runs and - # the document_name branch is skipped entirely (only two filter calls total). + # the document_name branch is skipped entirely. user = MagicMock(is_authenticated=True) build_query(user, EMBEDDING_VECTOR, guid="abc-123", document_name="study.pdf") - # Two calls: auth filter + guid filter. No third call for document_name. - assert mock_objects.filter.call_count == 2 + # The auth filter fires on mock_objects.filter (call_count == 1). + # The document filter fires on the chained ordered_qs.filter — a different + # mock object — so mock_objects.filter.call_count stays at 1. + assert mock_objects.filter.call_count == 1 - # The second filter must use upload_file__guid, not name. - # We follow the mock chain to the queryset that .annotate().order_by() returned. + # The document filter must use upload_file__guid, not name, and must be + # called exactly once (confirming document_name branch was skipped). ordered_qs = mock_objects.filter.return_value.annotate.return_value.order_by.return_value ordered_qs.filter.assert_called_once_with(upload_file__guid="abc-123") From 25a16b99475f12675be137d25e19e92013f36cc1 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:07:48 -0400 Subject: [PATCH 124/181] feat: migrate manifests from Ingress to Gateway API (HTTPRoute) --- deploy/manifests/balancer/base/httproute.yaml | 18 +++++++++++++++ deploy/manifests/balancer/base/ingress.yaml | 23 ------------------- .../balancer/base/kustomization.yaml | 2 +- deploy/manifests/balancer/base/service.yaml | 2 +- .../balancer/overlays/dev/kustomization.yaml | 10 ++------ 5 files changed, 22 insertions(+), 33 deletions(-) create mode 100644 deploy/manifests/balancer/base/httproute.yaml delete mode 100644 deploy/manifests/balancer/base/ingress.yaml diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml new file mode 100644 index 00000000..f9ca0df7 --- /dev/null +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -0,0 +1,18 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: balancer +spec: + parentRefs: + - name: main-gateway + namespace: envoy-gateway-system + hostnames: + - HOSTNAME_PLACEHOLDER + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: balancer + port: 80 diff --git a/deploy/manifests/balancer/base/ingress.yaml b/deploy/manifests/balancer/base/ingress.yaml deleted file mode 100644 index fc98305b..00000000 --- a/deploy/manifests/balancer/base/ingress.yaml +++ /dev/null @@ -1,23 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: balancer - annotations: {} -spec: - ingressClassName: nginx - tls: - - hosts: - - HOSTNAME_PLACEHOLDER - secretName: balancer-tls - rules: - - host: HOSTNAME_PLACEHOLDER - http: - paths: - # All traffic routes to balancer service (which serves both API and frontend) - - path: / - pathType: Prefix - backend: - service: - name: balancer - port: - number: 8000 diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..ad95f5b1 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -5,4 +5,4 @@ resources: - namespace.yaml - deployment.yaml - service.yaml - - ingress.yaml + - httproute.yaml diff --git a/deploy/manifests/balancer/base/service.yaml b/deploy/manifests/balancer/base/service.yaml index 8f294d53..2c839248 100644 --- a/deploy/manifests/balancer/base/service.yaml +++ b/deploy/manifests/balancer/base/service.yaml @@ -7,7 +7,7 @@ metadata: spec: ports: - name: http - port: 8000 + port: 80 targetPort: 8000 selector: app: balancer diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yaml b/deploy/manifests/balancer/overlays/dev/kustomization.yaml index d3975eb2..f3e287fe 100644 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/dev/kustomization.yaml @@ -12,15 +12,9 @@ images: patches: - target: - kind: Ingress + kind: HTTPRoute name: balancer patch: |- - - op: add - path: /metadata/annotations/cert-manager.io~1cluster-issuer - value: letsencrypt-staging - op: replace - path: /spec/tls/0/hosts/0 - value: localhost - - op: replace - path: /spec/rules/0/host + path: /spec/hostnames/0 value: localhost From 1ea88947332a6f9e254ff589e09d083ccf71a788 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:09:09 -0400 Subject: [PATCH 125/181] feat: configure HTTPRoute for sandbox.balancerproject.org --- deploy/manifests/balancer/base/httproute.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index f9ca0df7..437d5d37 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -7,7 +7,7 @@ spec: - name: main-gateway namespace: envoy-gateway-system hostnames: - - HOSTNAME_PLACEHOLDER + - sandbox.balancerproject.org rules: - matches: - path: From 1484428bac3e67a655c1a35e4196da56d1af094b Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:38:48 -0400 Subject: [PATCH 126/181] feat: add sandbox and production overlays for HTTPRoute --- deploy/manifests/balancer/base/httproute.yaml | 2 +- .../overlays/production/kustomization.yaml | 20 +++++++++++++++++++ .../overlays/sandbox/kustomization.yaml | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 deploy/manifests/balancer/overlays/production/kustomization.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/kustomization.yaml diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index 437d5d37..f9ca0df7 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -7,7 +7,7 @@ spec: - name: main-gateway namespace: envoy-gateway-system hostnames: - - sandbox.balancerproject.org + - HOSTNAME_PLACEHOLDER rules: - matches: - path: diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml new file mode 100644 index 00000000..859020ae --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: latest + +patches: + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: replace + path: /spec/hostnames/0 + value: balancerproject.org diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..c609dc8b --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,20 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + newTag: latest + +patches: + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: replace + path: /spec/hostnames/0 + value: sandbox.balancerproject.org From 6f5088792bcd7bb3826b7b7889b021fe539a27bf Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 21 Mar 2026 11:41:44 -0400 Subject: [PATCH 127/181] cleanup: remove unused dev overlay --- .../balancer/overlays/dev/kustomization.yaml | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 deploy/manifests/balancer/overlays/dev/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/dev/kustomization.yaml b/deploy/manifests/balancer/overlays/dev/kustomization.yaml deleted file mode 100644 index f3e287fe..00000000 --- a/deploy/manifests/balancer/overlays/dev/kustomization.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: balancer - -resources: - - ../../base - -images: - - name: ghcr.io/codeforphilly/balancer-main/app - newTag: latest - -patches: - - target: - kind: HTTPRoute - name: balancer - patch: |- - - op: replace - path: /spec/hostnames/0 - value: localhost From f9e890a21a3c716fe2bcfb17b8dfef92fcebb905 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Mon, 23 Mar 2026 14:43:39 -0400 Subject: [PATCH 128/181] Guard TransformerModel preload to runserver processes only --- server/api/apps.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/api/apps.py b/server/api/apps.py index 4d502cba..d8b9eaa7 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -6,5 +6,28 @@ class ApiConfig(AppConfig): name = 'api' def ready(self): + import os + import sys + + # ready() runs in every Django process: migrate, test, shell, runserver, etc. + # Only preload the model when we're actually going to serve requests. + # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. + # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. + # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing + # runserver — the guard below correctly skips model loading for those commands too. + if sys.argv[1:2] != ['runserver']: + return + + # Dev's autoreloader spawns two processes: a parent file-watcher and a child + # server. ready() runs in both, but only the child (RUN_MAIN=true) serves + # requests. Skip the parent to avoid loading the model twice on each file change. + # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. + if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: + return + + # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first + # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. + # That cache is ephemeral — every container rebuild re-downloads the model unless + # a volume is mounted at that path. from .services.sentencetTransformer_model import TransformerModel TransformerModel.get_instance() From 67176a8541be4d2862d213783873e1651f6fe761 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Wed, 25 Mar 2026 16:42:20 -0400 Subject: [PATCH 129/181] Revert GitHub Workflow changes --- .github/workflows/python-app.yml | 9 ++------- evaluation/evals.py | 16 ++++++++-------- server/balancer_backend/urls.py | 6 +++--- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 2afa2828..dcb7a4bb 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -5,9 +5,9 @@ name: Python application on: push: - branches: [ "develop" ] + branches: [ "listOfMed" ] pull_request: - branches: [ "develop" ] + branches: [ "listOfMed" ] permissions: contents: read @@ -27,8 +27,3 @@ jobs: run: pipx install ruff - name: Lint code with Ruff run: ruff check --output-format=github --target-version=py39 - - name: Install test dependencies - run: pip install -r server/requirements.txt - # Pytest won’t automatically discover config files in subdirectories - - name: Run tests - run: pytest -c server/pytest.ini server/ -v diff --git a/evaluation/evals.py b/evaluation/evals.py index 5110076f..8eb7e9e6 100755 --- a/evaluation/evals.py +++ b/evaluation/evals.py @@ -21,18 +21,18 @@ # Ensure the parent directory is in the path to import ModelFactory sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) -import argparse # noqa: E402 -import logging # noqa: E402 -import asyncio # noqa: E402 -import time # noqa: E402 +import argparse +import logging +import asyncio +import time -import pandas as pd # noqa: E402 +import pandas as pd # lighteval depends on `sentencepiece` and it only has prebuilt wheels for Python 3.11 or below -from lighteval.tasks.requests import Doc # noqa: E402 -from lighteval.metrics.metrics_sample import Extractiveness # noqa: E402 +from lighteval.tasks.requests import Doc +from lighteval.metrics.metrics_sample import Extractiveness -from server.api.services.llm_services import ModelFactory # noqa: E402 +from server.api.services.llm_services import ModelFactory logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" diff --git a/server/balancer_backend/urls.py b/server/balancer_backend/urls.py index cdb92dbb..55bd2032 100644 --- a/server/balancer_backend/urls.py +++ b/server/balancer_backend/urls.py @@ -58,9 +58,9 @@ path("api/redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), ] -import os # noqa: E402 -from django.conf import settings # noqa: E402 -from django.http import HttpResponseNotFound # noqa: E402 +import os +from django.conf import settings +from django.http import HttpResponseNotFound def spa_fallback(request): From d27392190c45e465dc81bf363f7bb381ec9c7aad Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 26 Mar 2026 14:35:35 -0400 Subject: [PATCH 130/181] Add section header comments to all four test groups in test_embedding_services.py --- .../api/services/test_embedding_services.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index dcbb2fc7..d1095366 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -12,10 +12,14 @@ # --------------------------------------------------------------------------- # build_query tests +# +# build_query is responsible for access control, annotate/order, document filter +# and slicing and only constructs a lazy Django QuerySet without evaluating it +# +# We can test build_query by patching Embeddings.objects and inspecting which +# methods and arguments were called on Embeddings.objects # --------------------------------------------------------------------------- -# All assertions inspect which methods and arguments were called on Embeddings.objects - # Only forwarded to L2Distance EMBEDDING_VECTOR = [0.1, 0.2, 0.3] @@ -165,6 +169,13 @@ def test_build_query_returns_unevaluated_queryset(mock_objects): # --------------------------------------------------------------------------- # evaluate_query tests +# +# evaluate_query is responsible for iterating the queryset and mapping each +# Embeddings object's attributes to a result dict, including the rename +# page_num -> page_number and the None-safe file_id lookup +# +# We can test evaluate_query by passing plain MagicMock objects directly as +# the iterable and asserting on the shape and values of the returned list # --------------------------------------------------------------------------- def test_evaluate_query_empty_queryset(): @@ -214,6 +225,13 @@ def test_evaluate_query_none_upload_file(): # --------------------------------------------------------------------------- # log_usage tests +# +# log_usage is responsible for computing distance stats, storing the correct +# user (None for unauthenticated), handling empty results, and swallowing +# exceptions so search is never interrupted +# +# We can test log_usage by patching SemanticSearchUsage.objects.create and +# inspecting the keyword arguments it was called with # --------------------------------------------------------------------------- @patch("api.services.embedding_services.SemanticSearchUsage.objects.create") @@ -333,6 +351,12 @@ def test_log_usage_swallows_exceptions(mock_create): # --------------------------------------------------------------------------- # get_closest_embeddings tests +# +# get_closest_embeddings is responsible for wiring together encode, +# build_query, evaluate_query, and log_usage and returning the results +# +# We can test get_closest_embeddings by patching all four collaborators and +# asserting that each is called with the correct arguments in the correct order # --------------------------------------------------------------------------- @patch("api.services.embedding_services.log_usage") From 8198574dc63e04e03d0c7198dfa2186c458c206e Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Thu, 26 Mar 2026 15:24:12 -0400 Subject: [PATCH 131/181] Document why tests are split by responsibility --- server/api/services/test_embedding_services.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index d1095366..b1e5d5a7 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -10,6 +10,11 @@ log_usage, ) +# Each function is tested one responsibility at a time. One test for the whole +# function collapses all responsibilities into a single assertion block — when +# it fails you know something is broken but not which responsibility. You have +# to debug to find out. + # --------------------------------------------------------------------------- # build_query tests # From 5d8c8b37403ccc98e0b02dc0e491f0f30a61bd9c Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 11:00:08 -0400 Subject: [PATCH 132/181] Improve logging and comments --- server/api/services/embedding_services.py | 6 +-- .../api/services/test_embedding_services.py | 2 +- server/balancer_backend/settings.py | 48 +++++++++---------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/server/api/services/embedding_services.py b/server/api/services/embedding_services.py index dada28a2..213519e5 100644 --- a/server/api/services/embedding_services.py +++ b/server/api/services/embedding_services.py @@ -2,7 +2,7 @@ import logging from statistics import median -# Django filter() only does ADD logic +# Use Q objects to express OR conditions in Django queries from django.db.models import Q from pgvector.django import L2Distance @@ -146,8 +146,8 @@ def log_usage( median_distance=None, min_distance=None, ) - except Exception as e: - logger.error(f"Failed to create semantic search usage database record: {e}") + except Exception: + logger.exception("Failed to create semantic search usage database record") def get_closest_embeddings( diff --git a/server/api/services/test_embedding_services.py b/server/api/services/test_embedding_services.py index b1e5d5a7..e43c0d74 100644 --- a/server/api/services/test_embedding_services.py +++ b/server/api/services/test_embedding_services.py @@ -26,7 +26,7 @@ # --------------------------------------------------------------------------- # Only forwarded to L2Distance -EMBEDDING_VECTOR = [0.1, 0.2, 0.3] +EMBEDDING_VECTOR = [0.1, 0.2, 0.3] # Test authenticated/unauthenticated user access control diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..c56a3435 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -254,27 +254,27 @@ # Logging configuration -# LOGGING = { -# "version": 1, -# "disable_existing_loggers": False, -# "formatters": { -# "verbose": { -# "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", -# "style": "{", -# }, -# "simple": { -# "format": "{levelname} {message}", -# "style": "{", -# }, -# }, -# "handlers": { -# "console": { -# "class": "logging.StreamHandler", -# "formatter": "verbose", -# }, -# }, -# "root": { -# "handlers": ["console"], -# "level": "INFO", -# }, -# } +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + "simple": { + "format": "{levelname} {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, +} From 31498dcb62874899302acc72bfc9031d06800f05 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 11:06:03 -0400 Subject: [PATCH 133/181] Fall back to lazy load using try except block --- server/api/apps.py | 54 +++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/server/api/apps.py b/server/api/apps.py index d8b9eaa7..c7eccc53 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -6,28 +6,32 @@ class ApiConfig(AppConfig): name = 'api' def ready(self): - import os - import sys - - # ready() runs in every Django process: migrate, test, shell, runserver, etc. - # Only preload the model when we're actually going to serve requests. - # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. - # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. - # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing - # runserver — the guard below correctly skips model loading for those commands too. - if sys.argv[1:2] != ['runserver']: - return - - # Dev's autoreloader spawns two processes: a parent file-watcher and a child - # server. ready() runs in both, but only the child (RUN_MAIN=true) serves - # requests. Skip the parent to avoid loading the model twice on each file change. - # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. - if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: - return - - # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first - # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. - # That cache is ephemeral — every container rebuild re-downloads the model unless - # a volume is mounted at that path. - from .services.sentencetTransformer_model import TransformerModel - TransformerModel.get_instance() + + try: + import os + import sys + + # ready() runs in every Django process: migrate, test, shell, runserver, etc. + # Only preload the model when we're actually going to serve requests. + # Dev (docker-compose.yml) runs `manage.py runserver 0.0.0.0:8000`. + # Prod (Dockerfile.prod CMD) runs `manage.py runserver 0.0.0.0:8000 --noreload`. + # entrypoint.prod.sh also runs migrate, createsu, and populatedb before exec'ing + # runserver — the guard below correctly skips model loading for those commands too. + if sys.argv[1:2] != ['runserver']: + return + + # Dev's autoreloader spawns two processes: a parent file-watcher and a child + # server. ready() runs in both, but only the child (RUN_MAIN=true) serves + # requests. Skip the parent to avoid loading the model twice on each file change. + # Prod uses --noreload so RUN_MAIN is never set; 'noreload' in sys.argv handles that case. + if os.environ.get('RUN_MAIN') != 'true' and '--noreload' not in sys.argv: + return + + # Note: paraphrase-MiniLM-L6-v2 (~80MB) is downloaded from HuggingFace on first + # use and cached to ~/.cache/torch/sentence_transformers/ inside the container. + # That cache is ephemeral — every container rebuild re-downloads the model unless + # a volume is mounted at that path. + from .services.sentencetTransformer_model import TransformerModel + TransformerModel.get_instance() + except Exception: + logger.exception("Failed to preload the embedding model at startup") From a39d33c75564b564b3740134223eaa09333331b6 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 11:17:04 -0400 Subject: [PATCH 134/181] Revert settings.py to develop state --- server/balancer_backend/settings.py | 48 ++++++++++++++--------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index c56a3435..a4ccaaae 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -254,27 +254,27 @@ # Logging configuration -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "verbose": { - "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", - "style": "{", - }, - "simple": { - "format": "{levelname} {message}", - "style": "{", - }, - }, - "handlers": { - "console": { - "class": "logging.StreamHandler", - "formatter": "verbose", - }, - }, - "root": { - "handlers": ["console"], - "level": "INFO", - }, -} +# LOGGING = { +# "version": 1, +# "disable_existing_loggers": False, +# "formatters": { +# "verbose": { +# "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", +# "style": "{", +# }, +# "simple": { +# "format": "{levelname} {message}", +# "style": "{", +# }, +# }, +# "handlers": { +# "console": { +# "class": "logging.StreamHandler", +# "formatter": "verbose", +# }, +# }, +# "root": { +# "handlers": ["console"], +# "level": "INFO", +# }, +# } From fe1eecaf133c8e82f5df765fd2161874a0def2a7 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 31 Mar 2026 12:27:29 -0400 Subject: [PATCH 135/181] Manually test fall back to lazy loading --- server/api/apps.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/api/apps.py b/server/api/apps.py index c7eccc53..13977850 100644 --- a/server/api/apps.py +++ b/server/api/apps.py @@ -7,7 +7,7 @@ class ApiConfig(AppConfig): def ready(self): - try: + try: import os import sys @@ -34,4 +34,8 @@ def ready(self): from .services.sentencetTransformer_model import TransformerModel TransformerModel.get_instance() except Exception: + # TransformerModel._instance stays None on failure, so the first actual request + # that calls get_instance() will attempt to load the model again. + import logging + logger = logging.getLogger(__name__) logger.exception("Failed to preload the embedding model at startup") From b4d70f53f1b03e212e9326d565df724dca9f45b9 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 14:25:16 -0400 Subject: [PATCH 136/181] fix isSuperuser hardcoded to true on page reload --- frontend/src/services/reducers/auth.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/frontend/src/services/reducers/auth.ts b/frontend/src/services/reducers/auth.ts index 769f3071..9cc5d278 100644 --- a/frontend/src/services/reducers/auth.ts +++ b/frontend/src/services/reducers/auth.ts @@ -68,12 +68,15 @@ const initialState: StateType = { export default function authReducer(state = initialState, action: ActionType): StateType { switch(action.type) { - case AUTHENTICATED_SUCCESS: + case AUTHENTICATED_SUCCESS: { + const token = localStorage.getItem('access'); + const decoded: TokenClaims = token ? jwtDecode(token) : { is_superuser: false }; return { ...state, isAuthenticated: true, - isSuperuser: true + isSuperuser: decoded.is_superuser } + } case LOGIN_SUCCESS: case GOOGLE_AUTH_SUCCESS: case FACEBOOK_AUTH_SUCCESS:{ From 23a045b3e9994eb8404128aa92e2be736d490777 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 14:53:22 -0400 Subject: [PATCH 137/181] add IsSuperuser permission class and apply to admin-only endpoints --- server/api/permissions.py | 6 ++++++ server/api/views/ai_settings/views.py | 4 ++-- server/api/views/listMeds/views.py | 3 +++ server/api/views/medRules/views.py | 4 ++-- server/api/views/text_extraction/views.py | 6 +++--- server/api/views/uploadFile/views.py | 7 ++++--- 6 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 server/api/permissions.py diff --git a/server/api/permissions.py b/server/api/permissions.py new file mode 100644 index 00000000..0dbe0597 --- /dev/null +++ b/server/api/permissions.py @@ -0,0 +1,6 @@ +from rest_framework.permissions import BasePermission + + +class IsSuperUser(BasePermission): + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_superuser) diff --git a/server/api/views/ai_settings/views.py b/server/api/views/ai_settings/views.py index 9ee6aad7..7f453200 100644 --- a/server/api/views/ai_settings/views.py +++ b/server/api/views/ai_settings/views.py @@ -1,6 +1,6 @@ from rest_framework import status from rest_framework.decorators import api_view, permission_classes -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from drf_spectacular.utils import extend_schema from .models import AI_Settings @@ -9,7 +9,7 @@ @extend_schema(request=AISettingsSerializer, responses={200: AISettingsSerializer(many=True), 201: AISettingsSerializer}) @api_view(['GET', 'POST']) -@permission_classes([IsAuthenticated]) +@permission_classes([IsSuperUser]) def settings_view(request): if request.method == 'GET': settings = AI_Settings.objects.all() diff --git a/server/api/views/listMeds/views.py b/server/api/views/listMeds/views.py index 1b199a7e..4321615d 100644 --- a/server/api/views/listMeds/views.py +++ b/server/api/views/listMeds/views.py @@ -1,5 +1,6 @@ from rest_framework import status, serializers as drf_serializers from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework.views import APIView from drf_spectacular.utils import extend_schema, inline_serializer @@ -127,6 +128,7 @@ class AddMedication(APIView): """ API endpoint to add a medication to the database with its risks and benefits. """ + permission_classes = [IsSuperUser] serializer_class = MedicationSerializer def post(self, request): @@ -158,6 +160,7 @@ class DeleteMedication(APIView): """ API endpoint to delete medication if medication in database. """ + permission_classes = [IsSuperUser] @extend_schema( request=inline_serializer(name='DeleteMedicationRequest', fields={ diff --git a/server/api/views/medRules/views.py b/server/api/views/medRules/views.py index 2f80f8f3..7e4ecae5 100644 --- a/server/api/views/medRules/views.py +++ b/server/api/views/medRules/views.py @@ -1,7 +1,7 @@ from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers +from api.permissions import IsSuperUser from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from drf_spectacular.utils import extend_schema, inline_serializer @@ -13,7 +13,7 @@ @method_decorator(csrf_exempt, name='dispatch') class MedRules(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = MedRuleSerializer def get(self, request, format=None): diff --git a/server/api/views/text_extraction/views.py b/server/api/views/text_extraction/views.py index 020740ad..35abe976 100644 --- a/server/api/views/text_extraction/views.py +++ b/server/api/views/text_extraction/views.py @@ -3,7 +3,7 @@ import re from rest_framework.views import APIView -from rest_framework.permissions import IsAuthenticated +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status from django.utils.decorators import method_decorator @@ -97,7 +97,7 @@ def anthropic_citations(client: anthropic.Client, user_prompt: str, content_chun @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ @@ -155,7 +155,7 @@ def openai_extraction(content_chunks, user_prompt): @method_decorator(csrf_exempt, name='dispatch') class RuleExtractionAPIOpenAIView(APIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] @extend_schema( parameters=[ diff --git a/server/api/views/uploadFile/views.py b/server/api/views/uploadFile/views.py index eda43b76..6da092ce 100644 --- a/server/api/views/uploadFile/views.py +++ b/server/api/views/uploadFile/views.py @@ -1,5 +1,6 @@ from rest_framework.views import APIView -from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.permissions import AllowAny +from api.permissions import IsSuperUser from rest_framework.response import Response from rest_framework import status, serializers as drf_serializers from rest_framework.generics import UpdateAPIView @@ -24,7 +25,7 @@ class UploadFileView(APIView): def get_permissions(self): if self.request.method == 'GET': return [AllowAny()] # Public access - return [IsAuthenticated()] # Auth required for other methods + return [IsSuperUser()] # Superuser required for write methods def get(self, request, format=None): print("UploadFileView, get list") @@ -217,7 +218,7 @@ def get(self, request, guid, format=None): class EditFileMetadataView(UpdateAPIView): - permission_classes = [IsAuthenticated] + permission_classes = [IsSuperUser] serializer_class = UploadFileSerializer lookup_field = 'guid' From 21a42058624901963ca2bd037942d80a4f082080 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 15:13:21 -0400 Subject: [PATCH 138/181] add AdminRoute component and applly to admin-only pages --- .../components/ProtectedRoute/AdminRoute.tsx | 38 +++++++++++++++++++ frontend/src/routes/routes.tsx | 13 ++++--- 2 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ProtectedRoute/AdminRoute.tsx diff --git a/frontend/src/components/ProtectedRoute/AdminRoute.tsx b/frontend/src/components/ProtectedRoute/AdminRoute.tsx new file mode 100644 index 00000000..61195cb8 --- /dev/null +++ b/frontend/src/components/ProtectedRoute/AdminRoute.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../services/actions/types'; +import { AppDispatch, checkAuthenticated } from '../../services/actions/auth'; +import Spinner from '../LoadingSpinner/LoadingSpinner'; + +interface AdminRouteProps { + children: ReactNode; +} + +const AdminRoute = ({ children }: AdminRouteProps) => { + const location = useLocation(); + const dispatch = useDispatch(); + const { isAuthenticated, isSuperuser } = useSelector((state: RootState) => state.auth); + + useEffect(() => { + if (isAuthenticated === null) { + dispatch(checkAuthenticated()); + } + }, [dispatch, isAuthenticated]); + + if (isAuthenticated === null) { + return ; + } + + if (!isAuthenticated) { + return ; + } + + if (!isSuperuser) { + return ; + } + + return children; +}; + +export default AdminRoute; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index dc974e85..9dd99e97 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -19,6 +19,7 @@ import ListofFiles from "../pages/Files/ListOfFiles.tsx"; import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; +import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; const routes = [ { @@ -28,17 +29,17 @@ const routes = [ }, { path: "listoffiles", - element: , + element: , errorElement: , }, { path: "rulesmanager", - element: , + element: , errorElement: , }, { path: "uploadfile", - element: , + element: , }, { path: "drugSummary", @@ -86,11 +87,11 @@ const routes = [ }, { path: "adminportal", - element: , + element: , }, { path: "Settings", - element: , + element: , }, { path: "medications", @@ -98,7 +99,7 @@ const routes = [ }, { path: "managemeds", - element: , + element: , }, ]; From 8879f34d71eedccce2d467be39d4bf8d01fa9af2 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Tue, 31 Mar 2026 15:35:51 -0400 Subject: [PATCH 139/181] lock down CORS to environment-driven allowlist (only dev right now) --- config/env/dev.env.example | 1 + server/balancer_backend/settings.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 4b40294b..59713f64 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -31,6 +31,7 @@ SQL_PORT=5432 # SQL_SSL_MODE=require LOGIN_REDIRECT_URL= +CORS_ALLOWED_ORIGINS=http://localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..fdd31c10 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,7 +67,7 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") TEMPLATES = [ { From 06ce32e5d43a33fa14d74a35f7dbec9a91fab879 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 13:52:52 -0400 Subject: [PATCH 140/181] configure console email backend for local development --- server/balancer_backend/settings.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index fdd31c10..1eb68d0c 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -139,12 +139,15 @@ "default": db_config, } -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -EMAIL_HOST = "smtp.gmail.com" -EMAIL_PORT = 587 -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") -EMAIL_USE_TLS = True +if DEBUG: + EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +else: + EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST = "smtp.gmail.com" + EMAIL_PORT = 587 + EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") + EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") + EMAIL_USE_TLS = True # Password validation From e53291515d1a46cd71a3f08da3f3dc96c4c5930f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 14:19:23 -0400 Subject: [PATCH 141/181] wire up signup form and email activation Redux actions --- frontend/src/api/endpoints.ts | 3 + frontend/src/services/actions/auth.tsx | 106 ++++++++++++------------- 2 files changed, 53 insertions(+), 56 deletions(-) diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 3f8585f0..edc044b0 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -19,6 +19,9 @@ export const AUTH_ENDPOINTS = { USER_ME: `${API_BASE}/auth/users/me/`, RESET_PASSWORD: `${API_BASE}/auth/users/reset_password/`, RESET_PASSWORD_CONFIRM: `${API_BASE}/auth/users/reset_password_confirm/`, + USERS_CREATE: `${API_BASE}/auth/users/`, + USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, + USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, } as const; /** diff --git a/frontend/src/services/actions/auth.tsx b/frontend/src/services/actions/auth.tsx index a6a30ff3..43c95fd7 100644 --- a/frontend/src/services/actions/auth.tsx +++ b/frontend/src/services/actions/auth.tsx @@ -233,64 +233,58 @@ export const reset_password_confirm = } }; -// export const signup = -// (first_name, last_name, email, password, re_password) => -// async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; - -// const body = JSON.stringify({ -// first_name, -// last_name, -// email, -// password, -// re_password, -// }); - -// try { -// const res = await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/`, -// body, -// config -// ); +export const signup = + (first_name: string, last_name: string, email: string, password: string, re_password: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// dispatch({ -// type: SIGNUP_SUCCESS, -// payload: res.data, -// }); -// } catch (err) { -// dispatch({ -// type: SIGNUP_FAIL, -// }); -// } -// }; + const body = JSON.stringify({ first_name, last_name, email, password, re_password }); -// export const verify = -// (uid, token) => async (dispatch: Dispatch) => { -// const config = { -// headers: { -// "Content-Type": "application/json", -// }, -// }; + try { + const res = await axios.post(AUTH_ENDPOINTS.USERS_CREATE, body, config); + dispatch({ + type: SIGNUP_SUCCESS, + payload: res.data, + }); + } catch (err) { + let errorMessage = "Registration failed"; + if (isAxiosError(err) && err.response) { + const messages = Object.values(err.response.data as Record).flat(); + if (messages.length > 0) errorMessage = messages.join(" "); + } + dispatch({ + type: SIGNUP_FAIL, + payload: errorMessage, + }); + throw err; + } + }; -// const body = JSON.stringify({ uid, token }); +export const verify = + (uid: string, token: string): ThunkType => + async (dispatch: AppDispatch) => { + const config = { + headers: { + "Content-Type": "application/json", + }, + }; -// try { -// await axios.post( -// `${process.env.REACT_APP_API_URL}/auth/users/activation/`, -// body, -// config -// ); + const body = JSON.stringify({ uid, token }); -// dispatch({ -// type: ACTIVATION_SUCCESS, -// }); -// } catch (err) { -// dispatch({ -// type: ACTIVATION_FAIL, -// }); -// } -// }; + try { + await axios.post(AUTH_ENDPOINTS.USERS_ACTIVATION, body, config); + dispatch({ + type: ACTIVATION_SUCCESS, + payload: "", + }); + } catch (err) { + dispatch({ + type: ACTIVATION_FAIL, + }); + throw err; + } + }; From 99ac0777f6bcbda8bca8f56c4c517746a9aeda76 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 14:37:27 -0400 Subject: [PATCH 142/181] build registration form with validation and email sent success state --- .../src/pages/Register/RegistrationForm.tsx | 256 ++++++++++++++---- 1 file changed, 198 insertions(+), 58 deletions(-) diff --git a/frontend/src/pages/Register/RegistrationForm.tsx b/frontend/src/pages/Register/RegistrationForm.tsx index c1745b3d..8134c521 100644 --- a/frontend/src/pages/Register/RegistrationForm.tsx +++ b/frontend/src/pages/Register/RegistrationForm.tsx @@ -1,71 +1,211 @@ import { useFormik } from "formik"; +import * as Yup from "yup"; import { Link } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; +import { signup, AppDispatch } from "../../services/actions/auth"; +import { RootState } from "../../services/actions/types"; +import { useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; + +const validationSchema = Yup.object({ + first_name: Yup.string().required("First name is required"), + last_name: Yup.string().required("Last name is required"), + email: Yup.string().email("Enter a valid email").required("Email is required"), + password: Yup.string() + .min(8, "Password must be at least 8 characters") + .required("Password is required"), + re_password: Yup.string() + .oneOf([Yup.ref("password")], "Passwords must match") + .required("Please confirm your password"), +}); + +const RegistrationForm = () => { + const dispatch = useDispatch(); + const signupError = useSelector((state: RootState) => state.auth.error); + const [submitted, setSubmitted] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); + + const { handleSubmit, handleChange, handleBlur, values, errors, touched, isSubmitting } = + useFormik({ + initialValues: { + first_name: "", + last_name: "", + email: "", + password: "", + re_password: "", + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch(signup(values.first_name, values.last_name, values.email, values.password, values.re_password)); + setSubmittedEmail(values.email); + setSubmitted(true); + } catch { + // error is stored in Redux state and displayed via signupError + } finally { + setSubmitting(false); + } + }, + }); + + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.USERS_RESEND_ACTIVATION, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + + if (submitted) { + return ( +
    +
    +

    + Check your email +

    +

    + We sent an activation link to {submittedEmail}. Click the link to activate your account. +

    +
    + + Go to log in + + +
    +
    +
    + ); + } -const LoginForm = () => { - const { handleSubmit, handleChange, values } = useFormik({ - initialValues: { - email: "", - password: "", - }, - onSubmit: (values) => { - console.log("values", values); - // make registration post request here. - }, - }); return ( - <> -
    -

    - Register +
    +
    +

    + Create account

    - -
    - - -
    -
    - - -
    - -
    -
    -

    + {signupError && ( +

    {signupError}

    + )} + +
    + + + {touched.first_name && errors.first_name && ( +

    {errors.first_name}

    + )} +
    + +
    + + + {touched.last_name && errors.last_name && ( +

    {errors.last_name}

    + )} +
    + +
    + + + {touched.email && errors.email && ( +

    {errors.email}

    + )} +
    + +
    + + + {touched.password && errors.password && ( +

    {errors.password}

    + )} +
    + +
    + + + {touched.re_password && errors.re_password && ( +

    {errors.re_password}

    + )} +
    + + + +

    Already have an account?{" "} - {" "} - Login here. + Log in

    - +

    ); }; -export default LoginForm; +export default RegistrationForm; From 3d64d76aba32c197510bd59900d838941697336c Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Wed, 8 Apr 2026 15:30:52 -0400 Subject: [PATCH 143/181] build email activation page and add route --- config/env/dev.env.example | 2 + frontend/src/pages/Activate/Activate.tsx | 76 ++++++++++++++++++++++++ frontend/src/routes/routes.tsx | 5 ++ server/balancer_backend/settings.py | 6 ++ 4 files changed, 89 insertions(+) create mode 100644 frontend/src/pages/Activate/Activate.tsx diff --git a/config/env/dev.env.example b/config/env/dev.env.example index 59713f64..b8e195cf 100644 --- a/config/env/dev.env.example +++ b/config/env/dev.env.example @@ -32,6 +32,8 @@ SQL_PORT=5432 LOGIN_REDIRECT_URL= CORS_ALLOWED_ORIGINS=http://localhost:3000 +# Domain used by Djoser for activation and password reset email links (should be the frontend URL) +FRONTEND_DOMAIN=localhost:3000 OPENAI_API_KEY= ANTHROPIC_API_KEY= PINECONE_API_KEY= diff --git a/frontend/src/pages/Activate/Activate.tsx b/frontend/src/pages/Activate/Activate.tsx new file mode 100644 index 00000000..391ec04b --- /dev/null +++ b/frontend/src/pages/Activate/Activate.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useParams, Link } from "react-router-dom"; +import { useDispatch } from "react-redux"; +import { verify, AppDispatch } from "../../services/actions/auth"; +import Layout from "../Layout/Layout"; +import Spinner from "../../components/LoadingSpinner/LoadingSpinner"; + +const Activate = () => { + const { uid, token } = useParams<{ uid: string; token: string }>(); + const dispatch = useDispatch(); + const [status, setStatus] = useState<"loading" | "success" | "error">("loading"); + + useEffect(() => { + if (!uid || !token) { + setStatus("error"); + return; + } + + (async () => { + try { + await dispatch(verify(uid, token)); + setStatus("success"); + } catch { + setStatus("error"); + } + })(); + }, [dispatch, uid, token]); + + if (status === "loading") { + return ( + + + + ); + } + + if (status === "error") { + return ( + +
    +
    +

    + Activation failed +

    +

    + This activation link is invalid or has already been used. Please register again or request a new activation email. +

    + + Back to register + +
    +
    +
    + ); + } + + return ( + +
    +
    +

    + Email verified +

    +

    + Your account has been activated. You can now log in. +

    + + Continue to log in + +
    +
    +
    + ); +}; + +export default Activate; diff --git a/frontend/src/routes/routes.tsx b/frontend/src/routes/routes.tsx index 9dd99e97..b94cb64f 100644 --- a/frontend/src/routes/routes.tsx +++ b/frontend/src/routes/routes.tsx @@ -20,6 +20,7 @@ import RulesManager from "../pages/RulesManager/RulesManager.tsx"; import ManageMeds from "../pages/ManageMeds/ManageMeds.tsx"; import ProtectedRoute from "../components/ProtectedRoute/ProtectedRoute.tsx"; import AdminRoute from "../components/ProtectedRoute/AdminRoute.tsx"; +import Activate from "../pages/Activate/Activate.tsx"; const routes = [ { @@ -49,6 +50,10 @@ const routes = [ path: "register", element: , }, + { + path: "activate/:uid/:token", + element: , + }, { path: "login", element: , diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 1eb68d0c..070ac581 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -221,6 +221,12 @@ "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), } +# Domain used by Djoser to build activation and password reset links in emails. +# Should point to the frontend, not the backend, since the frontend handles these routes. +# Override in production via environment variable. +DOMAIN = os.environ.get("FRONTEND_DOMAIN", "localhost:3000") +SITE_NAME = "Balancer" + DJOSER = { "LOGIN_FIELD": "email", "USER_CREATE_PASSWORD_RETYPE": True, From bf2a6013c1359334cebe72f5174406c8d27b598f Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 12:59:25 -0400 Subject: [PATCH 144/181] update login page for non-admin users login page no longer says "This login page is for Code for philly administrators" page now includes options to reset password and create an account --- frontend/src/pages/Login/LoginForm.tsx | 32 ++++++++------------------ 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/frontend/src/pages/Login/LoginForm.tsx b/frontend/src/pages/Login/LoginForm.tsx index d0d08184..1d27aac5 100644 --- a/frontend/src/pages/Login/LoginForm.tsx +++ b/frontend/src/pages/Login/LoginForm.tsx @@ -6,7 +6,6 @@ import { RootState } from "../../services/actions/types"; import { useState, useEffect } from "react"; import ErrorMessage from "../../components/ErrorMessage"; import LoadingSpinner from "../../components/LoadingSpinner/LoadingSpinner"; -import { FaExclamationTriangle } from "react-icons/fa"; interface LoginFormProps { isAuthenticated: boolean | null; @@ -60,19 +59,9 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { className="mb-4 rounded-md bg-white px-3 pb-12 pt-6 shadow-md ring-1 md:px-12" >
    - {/* {errorMessage &&
    {errorMessage}
    } */}

    - Welcome + Log in

    - -
    -
    - -
    -
    -

    This login is for Code for Philly administrators. Providers can use all site features without logging in. Return to Homepage

    -
    -
    @@ -113,18 +102,17 @@ function LoginForm({ isAuthenticated, loginError }: LoginFormProps) { Sign In
    +
    + + Don't have an account? Sign up + + + Forgot password? + +
    - { loading && } - - {/*

    - Don't have an account?{" "} - - {" "} - Register here - - . -

    */} + { loading && } ); } From f55c1c375e4da88fdb443b14f16433ff4d020dea Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:02:27 -0400 Subject: [PATCH 145/181] add success states to password reset flow --- frontend/src/pages/Login/ResetPassword.tsx | 112 ++++++++----- .../src/pages/Login/ResetPasswordConfirm.tsx | 147 ++++++++++-------- 2 files changed, 161 insertions(+), 98 deletions(-) diff --git a/frontend/src/pages/Login/ResetPassword.tsx b/frontend/src/pages/Login/ResetPassword.tsx index 61345aa8..34ffc44b 100644 --- a/frontend/src/pages/Login/ResetPassword.tsx +++ b/frontend/src/pages/Login/ResetPassword.tsx @@ -1,9 +1,11 @@ import { useFormik } from "formik"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, Link } from "react-router-dom"; import { reset_password, AppDispatch } from "../../services/actions/auth"; import { connect, useDispatch } from "react-redux"; import { RootState } from "../../services/actions/types"; import { useEffect, useState } from "react"; +import axios from "axios"; +import { AUTH_ENDPOINTS } from "../../api/endpoints"; import Layout from "../Layout/Layout"; interface ResetPasswordProps { @@ -14,6 +16,8 @@ function ResetPassword(props: ResetPasswordProps) { const { isAuthenticated } = props; const dispatch = useDispatch(); const [requestSent, setRequestSent] = useState(false); + const [submittedEmail, setSubmittedEmail] = useState(""); + const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">("idle"); const navigate = useNavigate(); @@ -29,49 +33,86 @@ function ResetPassword(props: ResetPasswordProps) { }, onSubmit: (values) => { dispatch(reset_password(values.email)); + setSubmittedEmail(values.email); setRequestSent(true); }, }); + const handleResend = async () => { + try { + await axios.post(AUTH_ENDPOINTS.RESET_PASSWORD, { email: submittedEmail }); + setResendStatus("sent"); + } catch { + setResendStatus("error"); + } + }; + if (requestSent) { - navigate("/"); - } - return ( - <> + return ( -
    -

    - Reset Password -

    -
    -
    - - -
    -
    -
    -
    +
    - + ); + } + + return ( + +
    +
    +

    + Reset password +

    +
    + + +
    + +
    + + Back to log in + +
    +
    +
    +
    ); } @@ -79,8 +120,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant const ConnectedResetPassword = connect(mapStateToProps)(ResetPassword); - -// Export the named constant export default ConnectedResetPassword; diff --git a/frontend/src/pages/Login/ResetPasswordConfirm.tsx b/frontend/src/pages/Login/ResetPasswordConfirm.tsx index 533669bb..80f36a63 100644 --- a/frontend/src/pages/Login/ResetPasswordConfirm.tsx +++ b/frontend/src/pages/Login/ResetPasswordConfirm.tsx @@ -1,5 +1,5 @@ import { useFormik } from "formik"; -import { useNavigate, useParams } from "react-router-dom"; +import { useNavigate, useParams, Link } from "react-router-dom"; import { reset_password_confirm, AppDispatch, @@ -17,7 +17,8 @@ const ResetPasswordConfirm: React.FC = ({ isAuthenticated, }) => { const dispatch = useDispatch(); - const [requestSent, setRequestSent] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(null); const { uid, token } = useParams<{ uid: string; token: string }>(); const navigate = useNavigate(); @@ -33,66 +34,94 @@ const ResetPasswordConfirm: React.FC = ({ new_password: "", re_new_password: "", }, - onSubmit: (values) => { - dispatch( - reset_password_confirm( - uid!, - token!, - values.new_password, - values.re_new_password - ) - ); - setRequestSent(true); + onSubmit: async (values, { setSubmitting }) => { + try { + await dispatch( + reset_password_confirm( + uid!, + token!, + values.new_password, + values.re_new_password + ) + ); + setSuccess(true); + } catch { + setError("This reset link is invalid or has expired. Please request a new one."); + } finally { + setSubmitting(false); + } }, }); - if (requestSent) { - navigate("/"); - } - return ( - <> + if (success) { + return ( -
    -

    - Reset Password -

    -
    -
    - - - -
    -
    - -
    -
    +
    +
    +

    + Password updated +

    +

    + Your password has been reset. You can now log in with your new password. +

    + + Log in now + +
    - + ); + } + + return ( + +
    +
    +

    + Set new password +

    + {error &&

    {error}

    } +
    + + +
    +
    + + +
    + +
    +
    +
    ); }; @@ -100,9 +129,5 @@ const mapStateToProps = (state: RootState) => ({ isAuthenticated: state.auth.isAuthenticated, }); -// Assign the connected component to a named constant -const ConnectedResetPasswordConfirm = - connect(mapStateToProps)(ResetPasswordConfirm); - -// Export the named constant +const ConnectedResetPasswordConfirm = connect(mapStateToProps)(ResetPasswordConfirm); export default ConnectedResetPasswordConfirm; From fca027a30c7eeafa83371c5e2833fe9c2a882e89 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:10:01 -0400 Subject: [PATCH 146/181] add "Log In" button to header for unauthenticated users --- frontend/src/components/Header/Header.tsx | 9 ++++++++- frontend/src/components/Header/MdNavBar.tsx | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index c2fe3cfc..488920d8 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -207,7 +207,14 @@ const Header: React.FC = ({ isAuthenticated, isSuperuser }) => { Balancer - {isAuthenticated && authLinks()} + {isAuthenticated ? authLinks() : ( + + Log In + + )} diff --git a/frontend/src/components/Header/MdNavBar.tsx b/frontend/src/components/Header/MdNavBar.tsx index ccd06fcd..550b74d2 100644 --- a/frontend/src/components/Header/MdNavBar.tsx +++ b/frontend/src/components/Header/MdNavBar.tsx @@ -127,15 +127,25 @@ const MdNavBar = (props: LoginFormProps) => { Support Development - {isAuthenticated && + {isAuthenticated ? (
  • Sign Out + to="/logout" + className="mr-9 text-black hover:border-b-2 hover:border-blue-600 hover:text-black hover:no-underline" + > + Sign Out + +
  • + ) : ( +
  • + + Log In
  • - } + )} From 52c9efb748377d73d2d4de77d7c8b8250d938c29 Mon Sep 17 00:00:00 2001 From: anishamahuli Date: Sun, 12 Apr 2026 13:43:34 -0400 Subject: [PATCH 147/181] add token refresh interceptor to adminApi On 401 responses, attempts a silent token refresh using the refresh token from localStorage. On success, retries the original request. On failure (expired or missing refresh token), clears tokens and redirects to /login. Uses a queue to handle concurrent requests during refresh. --- frontend/src/api/apiClient.ts | 65 +++++++++++++++++++++++++++++++++++ frontend/src/api/endpoints.ts | 1 + 2 files changed, 66 insertions(+) diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 856f78a9..545ce5d4 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -4,6 +4,7 @@ import { Conversation } from "../components/Header/Chat"; import { V1_API_ENDPOINTS, CONVERSATION_ENDPOINTS, + AUTH_ENDPOINTS, endpoints, } from "./endpoints"; @@ -31,6 +32,70 @@ adminApi.interceptors.request.use( (error) => Promise.reject(error), ); +// Response interceptor to handle token refresh on 401 +let isRefreshing = false; +let failedQueue: { resolve: (value: unknown) => void; reject: (reason?: unknown) => void }[] = []; + +const processQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else { + prom.resolve(token); + } + }); + failedQueue = []; +}; + +adminApi.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + if (error.response?.status === 401 && !originalRequest._retry) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `JWT ${token}`; + return adminApi(originalRequest); + }).catch((err) => Promise.reject(err)); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem("refresh"); + + if (!refreshToken) { + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(error); + } + + try { + const response = await axios.post(AUTH_ENDPOINTS.JWT_REFRESH, { refresh: refreshToken }); + const newAccessToken = response.data.access; + localStorage.setItem("access", newAccessToken); + processQueue(null, newAccessToken); + originalRequest.headers.Authorization = `JWT ${newAccessToken}`; + return adminApi(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } + + return Promise.reject(error); + }, +); + const handleSubmitFeedback = async ( feedbackType: FormValues["feedbackType"], name: FormValues["name"], diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index edc044b0..8e43a239 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -22,6 +22,7 @@ export const AUTH_ENDPOINTS = { USERS_CREATE: `${API_BASE}/auth/users/`, USERS_ACTIVATION: `${API_BASE}/auth/users/activation/`, USERS_RESEND_ACTIVATION: `${API_BASE}/auth/users/resend_activation/`, + JWT_REFRESH: `${API_BASE}/auth/jwt/refresh/`, } as const; /** From 88adfb1cac637a1f2913857918e64924f44c23ee Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 14 Apr 2026 14:33:16 -0400 Subject: [PATCH 148/181] Add issue template --- .github/ISSUE_TEMPLATE/issue.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/issue.md diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md new file mode 100644 index 00000000..70fca447 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -0,0 +1,23 @@ +## Title + + +## Background + + +## Existing Behavior + + +## Acceptance Criteria +- [] + +## Approach + + +## References + + +## Risks and Rollback + + +## Screenshots / Recordings + \ No newline at end of file From 55acb2ead6cd75b407501b43742645379e93da97 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Tue, 14 Apr 2026 15:01:34 -0400 Subject: [PATCH 149/181] Update README --- README.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0d7d531a..34947225 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) +You can view the website in a sandbox here: [https://sandbox.balancerproject.org/](https://sandbox.balancerproject.org/) ## Contributing @@ -31,11 +32,9 @@ Get the code using git by either forking or cloning `CodeForPhilly/balancer-main ``` 2. (Optional) Add your API keys to `config/env/dev.env`: - `OpenAI API` - - `Anthropic API` Tools used for development: 1. `Docker`: Install Docker Desktop -2. `Postman`: Ask to get invited to the Balancer Postman team `balancer_dev` 3. `npm`: In the terminal run 1) 'cd frontend' 2) 'npm install' 3) 'cd ..' ### Running Balancer for development @@ -91,6 +90,14 @@ Most endpoints require JWT authentication. To test them in Swagger UI: 3. **Test endpoints**: All subsequent requests will include your token. Use **Try it out** on any protected endpoint. 4. **Token refresh**: Access tokens expire after 60 minutes. Use `POST /auth/jwt/refresh/` with your `refresh` token, or repeat step 1. +### Deployment + +1. Merging your PR into develop automatically triggers a GitHub Release +2. The release triggers a container build workflow that builds and pushes the Docker image +3. [Go to GitHub Packages](https://github.com/CodeForPhilly/balancer-main/pkgs/container/balancer-main%2Fapp) to find the new image tag +4. Update newTag in kustomization.yaml [in the cluster repo](https://github.com/CodeForPhilly/cfp-live-cluster/blob/main/balancer/kustomization.yaml) +5. Open a PR to [cfp-sandbox-cluster](https://github.com/CodeForPhilly/cfp-sandbox-cluster) (or [cfp-live-cluster](https://github.com/CodeForPhilly/cfp-live-cluster)) + ## Architecture The Balancer website is a Postgres, Django REST, and React project. The source code layout is: From cedc58ad2ba8008787ea1a902f63452febf284db Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Tue, 14 Apr 2026 15:04:38 -0400 Subject: [PATCH 150/181] Add sandbox link to README usage section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b0480844..fe765910 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ for patients with bipolar disorder, helping them shorten their journey to stabil ## Usage You can view the current build of the website here: [https://balancerproject.org/](https://balancerproject.org/) + You can view the website in a sandbox here: [https://sandbox.balancerproject.org/](https://sandbox.balancerproject.org/) ## Contributing From 4cbb38070b68f6fdb8625219ba4d36c7ff1b2d24 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:56:21 -0400 Subject: [PATCH 151/181] feat(gateway): implement self-service TLS via ListenerSet This change migrates the Gateway API configuration to use ListenerSets instead of attaching HTTPRoutes directly to the Gateway. This enables the self-service TLS model where the application manages its own listeners and certificates. --- .../balancer/base/gateway-listeners.yaml | 26 +++++++++++++++++++ deploy/manifests/balancer/base/httproute.yaml | 7 +++-- .../balancer/base/kustomization.yaml | 1 + 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 deploy/manifests/balancer/base/gateway-listeners.yaml diff --git a/deploy/manifests/balancer/base/gateway-listeners.yaml b/deploy/manifests/balancer/base/gateway-listeners.yaml new file mode 100644 index 00000000..1572a1dd --- /dev/null +++ b/deploy/manifests/balancer/base/gateway-listeners.yaml @@ -0,0 +1,26 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: ListenerSet +metadata: + name: balancer-listeners + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + parentRef: + name: main-gateway + namespace: envoy-gateway-system + group: gateway.networking.k8s.io + kind: Gateway + listeners: + - name: http + protocol: HTTP + port: 80 + hostname: HOSTNAME_PLACEHOLDER + - name: https + protocol: HTTPS + port: 443 + hostname: HOSTNAME_PLACEHOLDER + tls: + mode: Terminate + certificateRefs: + - name: balancer-tls + kind: Secret diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index f9ca0df7..1a16ce66 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -4,10 +4,9 @@ metadata: name: balancer spec: parentRefs: - - name: main-gateway - namespace: envoy-gateway-system - hostnames: - - HOSTNAME_PLACEHOLDER + - name: balancer-listeners + kind: ListenerSet + group: gateway.networking.k8s.io rules: - matches: - path: diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index ad95f5b1..13658e20 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -5,4 +5,5 @@ resources: - namespace.yaml - deployment.yaml - service.yaml + - gateway-listeners.yaml - httproute.yaml From cc3fbe9820810cb1e3710f13062b81c2cd3844ae Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:56:30 -0400 Subject: [PATCH 152/181] fix(gateway): update overlays to patch ListenerSet hostnames This updates the sandbox and production overlays to correctly patch the hostnames on both the ListenerSet and the HTTPRoute. --- .../overlays/production/kustomization.yaml | 16 +++++++++++++--- .../balancer/overlays/sandbox/kustomization.yaml | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 859020ae..1a7ce9fa 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -12,9 +12,19 @@ images: patches: - target: - kind: HTTPRoute - name: balancer + kind: ListenerSet + name: balancer-listeners patch: |- - op: replace - path: /spec/hostnames/0 + path: /spec/listeners/0/hostname + value: balancerproject.org + - op: replace + path: /spec/listeners/1/hostname value: balancerproject.org + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: add + path: /spec/hostnames + value: ["balancerproject.org"] diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index c609dc8b..bdcf6ae8 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -12,9 +12,19 @@ images: patches: - target: - kind: HTTPRoute - name: balancer + kind: ListenerSet + name: balancer-listeners patch: |- - op: replace - path: /spec/hostnames/0 + path: /spec/listeners/0/hostname + value: sandbox.balancerproject.org + - op: replace + path: /spec/listeners/1/hostname value: sandbox.balancerproject.org + - target: + kind: HTTPRoute + name: balancer + patch: |- + - op: add + path: /spec/hostnames + value: ["sandbox.balancerproject.org"] From b9c95913e656b99296d44af4735e3aba68413c48 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:59:05 -0400 Subject: [PATCH 153/181] feat(gateway): implement HTTP to HTTPS redirect via ListenerSet This adds a redirect HTTPRoute targeting the port 80 listener of the ListenerSet, ensuring all HTTP traffic is upgraded to HTTPS. The main HTTPRoute is now pinned to the port 443 listener. --- deploy/manifests/balancer/base/httproute.yaml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index 1a16ce66..c8fef0fe 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -1,5 +1,24 @@ apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute +metadata: + name: balancer-redirect +spec: + parentRefs: + - name: balancer-listeners + kind: ListenerSet + group: gateway.networking.k8s.io + sectionName: http + hostnames: + - HOSTNAME_PLACEHOLDER + rules: + - filters: + - type: RequestRedirect + requestRedirect: + scheme: https + statusCode: 301 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute metadata: name: balancer spec: @@ -7,6 +26,9 @@ spec: - name: balancer-listeners kind: ListenerSet group: gateway.networking.k8s.io + sectionName: https + hostnames: + - HOSTNAME_PLACEHOLDER rules: - matches: - path: From fb7b270c8fde8fcbf9c5dcf529f5b5ef04a26e4e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 18:59:11 -0400 Subject: [PATCH 154/181] fix(gateway): patch hostname for redirect HTTPRoute in overlays Updates the sandbox and production overlays to ensure the hostname is also patched on the new balancer-redirect HTTPRoute. --- .../balancer/overlays/production/kustomization.yaml | 7 +++++++ .../manifests/balancer/overlays/sandbox/kustomization.yaml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 1a7ce9fa..1386e907 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -28,3 +28,10 @@ patches: - op: add path: /spec/hostnames value: ["balancerproject.org"] + - target: + kind: HTTPRoute + name: balancer-redirect + patch: |- + - op: add + path: /spec/hostnames + value: ["balancerproject.org"] diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index bdcf6ae8..c9e9b4ea 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -28,3 +28,10 @@ patches: - op: add path: /spec/hostnames value: ["sandbox.balancerproject.org"] + - target: + kind: HTTPRoute + name: balancer-redirect + patch: |- + - op: add + path: /spec/hostnames + value: ["sandbox.balancerproject.org"] From b1e4918e88c70fe393fe3ccc3a077b19a6f99b6a Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:09:32 -0400 Subject: [PATCH 155/181] Configure CORS Allowed Origins via ConfigMaps --- deploy/manifests/balancer/base/deployment.yaml | 2 ++ deploy/manifests/balancer/base/kustomization.yaml | 3 +++ .../manifests/balancer/overlays/production/configmap.yaml | 6 ++++++ .../balancer/overlays/production/kustomization.yaml | 8 ++++++++ deploy/manifests/balancer/overlays/sandbox/configmap.yaml | 6 ++++++ .../balancer/overlays/sandbox/kustomization.yaml | 8 ++++++++ server/balancer_backend/settings.py | 5 ++++- 7 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 deploy/manifests/balancer/overlays/production/configmap.yaml create mode 100644 deploy/manifests/balancer/overlays/production/kustomization.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/configmap.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/kustomization.yaml diff --git a/deploy/manifests/balancer/base/deployment.yaml b/deploy/manifests/balancer/base/deployment.yaml index c50012c2..10bcfc93 100644 --- a/deploy/manifests/balancer/base/deployment.yaml +++ b/deploy/manifests/balancer/base/deployment.yaml @@ -21,6 +21,8 @@ spec: envFrom: - secretRef: name: balancer-config + - configMapRef: + name: balancer-config ports: - containerPort: 8000 readinessProbe: diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..38e12e47 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,3 +6,6 @@ resources: - deployment.yaml - service.yaml - ingress.yaml + +configMapGenerator: + - name: balancer-config diff --git a/deploy/manifests/balancer/overlays/production/configmap.yaml b/deploy/manifests/balancer/overlays/production/configmap.yaml new file mode 100644 index 00000000..47253b20 --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-config +data: + CORS_ALLOWED_ORIGINS: "https://balancerproject.org" diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml new file mode 100644 index 00000000..c0cf6eb0 --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + - path: configmap.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml new file mode 100644 index 00000000..ad9528e9 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-config +data: + CORS_ALLOWED_ORIGINS: "https://sandbox.balancertestsite.com" diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..c0cf6eb0 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + - path: configmap.yaml diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index a4ccaaae..c1424fc7 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,7 +67,10 @@ ROOT_URLCONF = "balancer_backend.urls" -CORS_ALLOW_ALL_ORIGINS = True +# CORS configuration +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") +# Ensure no empty strings if input was empty or trailing comma +CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] TEMPLATES = [ { From 00250efac2166452f383b433008e7dda5ca9c022 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:12:57 -0400 Subject: [PATCH 156/181] Update sandbox domain for CORS --- deploy/manifests/balancer/overlays/sandbox/configmap.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml index ad9528e9..ff7ec0a0 100644 --- a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -3,4 +3,4 @@ kind: ConfigMap metadata: name: balancer-config data: - CORS_ALLOWED_ORIGINS: "https://sandbox.balancertestsite.com" + CORS_ALLOWED_ORIGINS: "https://sandbox.balancerproject.org" From 8262de577ab12215f72c712ea0207c5e01ea9dd1 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:13:28 -0400 Subject: [PATCH 157/181] Refine ConfigMap overlays using env files and correct sandbox domain --- deploy/manifests/balancer/base/balancer.env | 0 deploy/manifests/balancer/base/kustomization.yaml | 2 ++ deploy/manifests/balancer/overlays/production/balancer.env | 1 + .../manifests/balancer/overlays/production/configmap.yaml | 6 ------ .../balancer/overlays/production/kustomization.yaml | 7 +++++-- deploy/manifests/balancer/overlays/sandbox/balancer.env | 1 + deploy/manifests/balancer/overlays/sandbox/configmap.yaml | 6 ------ .../manifests/balancer/overlays/sandbox/kustomization.yaml | 7 +++++-- 8 files changed, 14 insertions(+), 16 deletions(-) create mode 100644 deploy/manifests/balancer/base/balancer.env create mode 100644 deploy/manifests/balancer/overlays/production/balancer.env delete mode 100644 deploy/manifests/balancer/overlays/production/configmap.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/balancer.env delete mode 100644 deploy/manifests/balancer/overlays/sandbox/configmap.yaml diff --git a/deploy/manifests/balancer/base/balancer.env b/deploy/manifests/balancer/base/balancer.env new file mode 100644 index 00000000..e69de29b diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 38e12e47..1bc2cc1b 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -9,3 +9,5 @@ resources: configMapGenerator: - name: balancer-config + envs: + - balancer.env diff --git a/deploy/manifests/balancer/overlays/production/balancer.env b/deploy/manifests/balancer/overlays/production/balancer.env new file mode 100644 index 00000000..1fa5802e --- /dev/null +++ b/deploy/manifests/balancer/overlays/production/balancer.env @@ -0,0 +1 @@ +CORS_ALLOWED_ORIGINS=https://balancerproject.org diff --git a/deploy/manifests/balancer/overlays/production/configmap.yaml b/deploy/manifests/balancer/overlays/production/configmap.yaml deleted file mode 100644 index 47253b20..00000000 --- a/deploy/manifests/balancer/overlays/production/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: balancer-config -data: - CORS_ALLOWED_ORIGINS: "https://balancerproject.org" diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index c0cf6eb0..7ecd6659 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -4,5 +4,8 @@ kind: Kustomization resources: - ../../base -patches: - - path: configmap.yaml +configMapGenerator: + - name: balancer-config + behavior: merge + envs: + - balancer.env diff --git a/deploy/manifests/balancer/overlays/sandbox/balancer.env b/deploy/manifests/balancer/overlays/sandbox/balancer.env new file mode 100644 index 00000000..55d82ecc --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/balancer.env @@ -0,0 +1 @@ +CORS_ALLOWED_ORIGINS=https://sandbox.balancerproject.org diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml deleted file mode 100644 index ff7ec0a0..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: balancer-config -data: - CORS_ALLOWED_ORIGINS: "https://sandbox.balancerproject.org" diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index c0cf6eb0..7ecd6659 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -4,5 +4,8 @@ kind: Kustomization resources: - ../../base -patches: - - path: configmap.yaml +configMapGenerator: + - name: balancer-config + behavior: merge + envs: + - balancer.env From d97f0c9b56b6b68e975ee91df9e627a98730381b Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:24:34 -0400 Subject: [PATCH 158/181] feat: add balancer database cluster manifest --- deploy/manifests/balancer/base/db.yaml | 19 +++++++++++++++++++ .../balancer/base/kustomization.yaml | 1 + 2 files changed, 20 insertions(+) create mode 100644 deploy/manifests/balancer/base/db.yaml diff --git a/deploy/manifests/balancer/base/db.yaml b/deploy/manifests/balancer/base/db.yaml new file mode 100644 index 00000000..c1b0226b --- /dev/null +++ b/deploy/manifests/balancer/base/db.yaml @@ -0,0 +1,19 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: balancer-db + namespace: balancer +spec: + instances: 3 + imageName: ghcr.io/cloudnative-pg/postgresql:16.1 + storage: + size: 10Gi + bootstrap: + initdb: + database: balancer + owner: balancer + secret: + name: balancer-db-credentials + postgresql: + parameters: + shared_preload_libraries: 'vector' diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..892e7891 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,3 +6,4 @@ resources: - deployment.yaml - service.yaml - ingress.yaml + - db.yaml From 92fd46dae4a1b7f04469cc272968921dc2f402ac Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:28:48 -0400 Subject: [PATCH 159/181] remove redundant cluster manifest (moved to infra repo) --- deploy/manifests/balancer/base/db.yaml | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 deploy/manifests/balancer/base/db.yaml diff --git a/deploy/manifests/balancer/base/db.yaml b/deploy/manifests/balancer/base/db.yaml deleted file mode 100644 index c1b0226b..00000000 --- a/deploy/manifests/balancer/base/db.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: postgresql.cnpg.io/v1 -kind: Cluster -metadata: - name: balancer-db - namespace: balancer -spec: - instances: 3 - imageName: ghcr.io/cloudnative-pg/postgresql:16.1 - storage: - size: 10Gi - bootstrap: - initdb: - database: balancer - owner: balancer - secret: - name: balancer-db-credentials - postgresql: - parameters: - shared_preload_libraries: 'vector' From cf324fb30d4253b89c8398daa2a87483e429bc89 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:28:57 -0400 Subject: [PATCH 160/181] remove db.yaml from kustomization resources --- deploy/manifests/balancer/base/kustomization.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 892e7891..c7d2dcd1 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,4 +6,3 @@ resources: - deployment.yaml - service.yaml - ingress.yaml - - db.yaml From c6f001db970fadf03f44a32a2d17a159988d5164 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:33:15 -0400 Subject: [PATCH 161/181] feat: add sandbox overlay with shared db connection details --- .../overlays/sandbox/kustomization.yaml | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml new file mode 100644 index 00000000..0490789b --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -0,0 +1,26 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: balancer + +resources: + - ../../base + +patches: + - target: + kind: Deployment + name: balancer + patch: |- + - op: add + path: /spec/template/spec/containers/0/env + value: + - name: SQL_HOST + value: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local + - name: SQL_PORT + value: "5432" + - name: SQL_DATABASE + value: balancer + - name: SQL_USER + value: balancer + - name: SQL_ENGINE + value: django.db.backends.postgresql From 76af69fe82f243a709d72c6dcac64bd23b79bc3e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:35:08 -0400 Subject: [PATCH 162/181] feat: update sandbox overlay to use shared db password from secret --- .../manifests/balancer/overlays/sandbox/kustomization.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 0490789b..82ce3278 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -22,5 +22,10 @@ patches: value: balancer - name: SQL_USER value: balancer + - name: SQL_PASSWORD + valueFrom: + secretKeyRef: + name: balancer-db-credentials + key: password - name: SQL_ENGINE value: django.db.backends.postgresql From e48e52d1561cebade285af3a5bcff5bc6885f676 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:35:52 -0400 Subject: [PATCH 163/181] feat: add configmap for database settings in sandbox overlay --- .../manifests/balancer/overlays/sandbox/configmap.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/configmap.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml new file mode 100644 index 00000000..aa421eac --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: balancer-db-config +data: + SQL_HOST: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local + SQL_PORT: "5432" + SQL_DATABASE: balancer + SQL_USER: balancer + SQL_ENGINE: django.db.backends.postgresql From d725fe0a9c3616991da9805938c5d1b6fd785f35 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Sat, 18 Apr 2026 19:35:57 -0400 Subject: [PATCH 164/181] feat: update kustomization to use configmap and secret for db settings --- .../balancer/overlays/sandbox/kustomization.yaml | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 82ce3278..4ccedd16 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -5,27 +5,23 @@ namespace: balancer resources: - ../../base + - configmap.yaml patches: - target: kind: Deployment name: balancer patch: |- + - op: add + path: /spec/template/spec/containers/0/envFrom/- + value: + configMapRef: + name: balancer-db-config - op: add path: /spec/template/spec/containers/0/env value: - - name: SQL_HOST - value: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local - - name: SQL_PORT - value: "5432" - - name: SQL_DATABASE - value: balancer - - name: SQL_USER - value: balancer - name: SQL_PASSWORD valueFrom: secretKeyRef: name: balancer-db-credentials key: password - - name: SQL_ENGINE - value: django.db.backends.postgresql From 99523c934037bf765c9297057bee1bfe0e406410 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Fri, 1 May 2026 14:32:30 -0400 Subject: [PATCH 165/181] feat: move database manifest to base and update SQL_HOST for shared-cluster --- deploy/manifests/balancer/base/database.yaml | 10 ++++++++++ deploy/manifests/balancer/base/kustomization.yaml | 10 ++++++++++ .../manifests/balancer/overlays/sandbox/configmap.yaml | 2 +- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 deploy/manifests/balancer/base/database.yaml diff --git a/deploy/manifests/balancer/base/database.yaml b/deploy/manifests/balancer/base/database.yaml new file mode 100644 index 00000000..4676c6b8 --- /dev/null +++ b/deploy/manifests/balancer/base/database.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: balancer + namespace: cloudnative-pg +spec: + name: balancer + owner: balancer + cluster: + name: shared-cluster diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index c7d2dcd1..37d8cba2 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -6,3 +6,13 @@ resources: - deployment.yaml - service.yaml - ingress.yaml + - database.yaml + +patches: + - target: + kind: Database + name: balancer + patch: |- + - op: replace + path: /metadata/namespace + value: cloudnative-pg diff --git a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml index aa421eac..8ba92568 100644 --- a/deploy/manifests/balancer/overlays/sandbox/configmap.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/configmap.yaml @@ -3,7 +3,7 @@ kind: ConfigMap metadata: name: balancer-db-config data: - SQL_HOST: codeforphilly-cluster-rw.cloudnative-pg.svc.cluster.local + SQL_HOST: shared-cluster-rw.cloudnative-pg.svc.cluster.local SQL_PORT: "5432" SQL_DATABASE: balancer SQL_USER: balancer From 79a32327875f3f17f6b3aa168459b3ab1b5f8764 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Fri, 1 May 2026 15:22:30 -0400 Subject: [PATCH 166/181] Add YAML front matter to issue template --- .github/ISSUE_TEMPLATE/issue.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/issue.md b/.github/ISSUE_TEMPLATE/issue.md index 70fca447..f2820548 100644 --- a/.github/ISSUE_TEMPLATE/issue.md +++ b/.github/ISSUE_TEMPLATE/issue.md @@ -1,3 +1,12 @@ +--- +name: Issue template +about: Help push the project forward +title: '' +labels: '' +assignees: '' + +--- + ## Title From 06359c566c0ad863d81efa67af74a14391d2669e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 7 May 2026 17:34:55 -0400 Subject: [PATCH 167/181] fix: remove redundant namespace patch on database.yaml database.yaml already has namespace: cloudnative-pg hardcoded. The kustomize patch was a no-op replacing the same value. --- deploy/manifests/balancer/base/kustomization.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 43db6731..44516542 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -13,12 +13,3 @@ configMapGenerator: - name: balancer-config envs: - balancer.env - -patches: - - target: - kind: Database - name: balancer - patch: |- - - op: replace - path: /metadata/namespace - value: cloudnative-pg From 1af2d3002ea0a0a4b9dfceb0535a53a40e65d912 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 7 May 2026 17:51:13 -0400 Subject: [PATCH 168/181] fix: move Database CRD to cfp-sandbox-cluster infra The Database resource must live in the cloudnative-pg namespace alongside the Cluster CRD. Kustomize's namespace field overrides all resources, making it impossible to keep a cross-namespace resource in the app overlay. Moved to infra/cloudnative-pg/balancer-database.yaml in cfp-sandbox-cluster. --- deploy/manifests/balancer/base/database.yaml | 10 ---------- deploy/manifests/balancer/base/kustomization.yaml | 1 - 2 files changed, 11 deletions(-) delete mode 100644 deploy/manifests/balancer/base/database.yaml diff --git a/deploy/manifests/balancer/base/database.yaml b/deploy/manifests/balancer/base/database.yaml deleted file mode 100644 index 4676c6b8..00000000 --- a/deploy/manifests/balancer/base/database.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: postgresql.cnpg.io/v1 -kind: Database -metadata: - name: balancer - namespace: cloudnative-pg -spec: - name: balancer - owner: balancer - cluster: - name: shared-cluster diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index 44516542..f6ed71b4 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -7,7 +7,6 @@ resources: - service.yaml - gateway-listeners.yaml - httproute.yaml - - database.yaml configMapGenerator: - name: balancer-config From e8bfa66034e668ce0850f3135ee95073281cc7e6 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Thu, 7 May 2026 17:58:17 -0400 Subject: [PATCH 169/181] fix: move Database CRD to sandbox-only CNPG kustomization Kustomize's namespace field overrides ALL resources, making it impossible to keep the Database in cloudnative-pg namespace within the main overlay. Created a separate kustomization at overlays/sandbox/cnpg/ without the namespace field, so the Database stays in cloudnative-pg namespace. This keeps sandbox and production databases separate - production overlay has no CNPG resources. --- .../balancer/overlays/sandbox/cnpg/database.yaml | 10 ++++++++++ .../balancer/overlays/sandbox/cnpg/kustomization.yaml | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml create mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml new file mode 100644 index 00000000..4676c6b8 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml @@ -0,0 +1,10 @@ +apiVersion: postgresql.cnpg.io/v1 +kind: Database +metadata: + name: balancer + namespace: cloudnative-pg +spec: + name: balancer + owner: balancer + cluster: + name: shared-cluster diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml new file mode 100644 index 00000000..f971acab --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - database.yaml From 7a0b6adc333d12826a20c3aa6ca22eba4ce3982e Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:32:19 -0400 Subject: [PATCH 170/181] =?UTF-8?q?fix:=20remove=20redundant=20balancer-re?= =?UTF-8?q?direct=20HTTPRoute=20(covered=20by=20global=20HTTP=E2=86=92HTTP?= =?UTF-8?q?S=20redirect)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/manifests/balancer/base/httproute.yaml | 20 +------------------ .../overlays/production/kustomization.yaml | 8 +------- .../overlays/sandbox/kustomization.yaml | 8 +------- 3 files changed, 3 insertions(+), 33 deletions(-) diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index c8fef0fe..e818b73a 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -1,22 +1,4 @@ -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: balancer-redirect -spec: - parentRefs: - - name: balancer-listeners - kind: ListenerSet - group: gateway.networking.k8s.io - sectionName: http - hostnames: - - HOSTNAME_PLACEHOLDER - rules: - - filters: - - type: RequestRedirect - requestRedirect: - scheme: https - statusCode: 301 ---- + apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 0bb0bfd3..34dcd9bb 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -34,10 +34,4 @@ patches: - op: add path: /spec/hostnames value: ["balancerproject.org"] - - target: - kind: HTTPRoute - name: balancer-redirect - patch: |- - - op: add - path: /spec/hostnames - value: ["balancerproject.org"] + diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index e2086d37..894ea0d3 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -35,13 +35,7 @@ patches: - op: add path: /spec/hostnames value: ["sandbox.balancerproject.org"] - - target: - kind: HTTPRoute - name: balancer-redirect - patch: |- - - op: add - path: /spec/hostnames - value: ["sandbox.balancerproject.org"] + - target: kind: Deployment name: balancer From 96c961262d619c43e4e04236416d5e75a7616922 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:51:27 -0400 Subject: [PATCH 171/181] fix: simplify balancer kustomize overlays and add common labels --- .../balancer/base/gateway-listeners.yaml | 1 + deploy/manifests/balancer/base/httproute.yaml | 2 ++ .../balancer/base/kustomization.yaml | 10 +++++++ .../overlays/production/kustomization.yaml | 22 +++++++++++----- .../overlays/sandbox/kustomization.yaml | 26 ++++++++++++------- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/deploy/manifests/balancer/base/gateway-listeners.yaml b/deploy/manifests/balancer/base/gateway-listeners.yaml index 1572a1dd..4085d38a 100644 --- a/deploy/manifests/balancer/base/gateway-listeners.yaml +++ b/deploy/manifests/balancer/base/gateway-listeners.yaml @@ -4,6 +4,7 @@ metadata: name: balancer-listeners annotations: cert-manager.io/cluster-issuer: letsencrypt-prod + hostname: HOSTNAME_PLACEHOLDER spec: parentRef: name: main-gateway diff --git a/deploy/manifests/balancer/base/httproute.yaml b/deploy/manifests/balancer/base/httproute.yaml index e818b73a..8877b798 100644 --- a/deploy/manifests/balancer/base/httproute.yaml +++ b/deploy/manifests/balancer/base/httproute.yaml @@ -3,6 +3,8 @@ apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: balancer + annotations: + hostname: HOSTNAME_PLACEHOLDER spec: parentRefs: - name: balancer-listeners diff --git a/deploy/manifests/balancer/base/kustomization.yaml b/deploy/manifests/balancer/base/kustomization.yaml index f6ed71b4..04d76f2a 100644 --- a/deploy/manifests/balancer/base/kustomization.yaml +++ b/deploy/manifests/balancer/base/kustomization.yaml @@ -8,6 +8,16 @@ resources: - gateway-listeners.yaml - httproute.yaml +labels: + - pairs: + app.kubernetes.io/name: balancer + app.kubernetes.io/part-of: balancer + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/component: web + +images: + - name: ghcr.io/codeforphilly/balancer-main/app + configMapGenerator: - name: balancer-config envs: diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 34dcd9bb..3f6332c0 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -8,19 +8,28 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: latest + newTag: v1.0.2 configMapGenerator: - name: balancer-config behavior: merge + literals: + - HOSTNAME=balancerproject.org envs: - balancer.env +labels: + - includeSelectors: true + pairs: + environment: production + app.kubernetes.io/instance: balancer-production + app.kubernetes.io/version: v1.0.2 + patches: - target: kind: ListenerSet name: balancer-listeners - patch: |- + patch: | - op: replace path: /spec/listeners/0/hostname value: balancerproject.org @@ -30,8 +39,7 @@ patches: - target: kind: HTTPRoute name: balancer - patch: |- - - op: add - path: /spec/hostnames - value: ["balancerproject.org"] - + patch: | + - op: replace + path: /spec/hostnames/0 + value: balancerproject.org \ No newline at end of file diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 894ea0d3..058f5a60 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -9,19 +9,28 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: latest + newTag: v1.1.5 configMapGenerator: - name: balancer-config behavior: merge + literals: + - HOSTNAME=sandbox.balancerproject.org envs: - balancer.env +labels: + - includeSelectors: true + pairs: + environment: sandbox + app.kubernetes.io/instance: balancer-sandbox + app.kubernetes.io/version: v1.1.5 + patches: - target: kind: ListenerSet name: balancer-listeners - patch: |- + patch: | - op: replace path: /spec/listeners/0/hostname value: sandbox.balancerproject.org @@ -31,15 +40,14 @@ patches: - target: kind: HTTPRoute name: balancer - patch: |- - - op: add - path: /spec/hostnames - value: ["sandbox.balancerproject.org"] - + patch: | + - op: replace + path: /spec/hostnames/0 + value: sandbox.balancerproject.org - target: kind: Deployment name: balancer - patch: |- + patch: | - op: add path: /spec/template/spec/containers/0/envFrom/- value: @@ -52,4 +60,4 @@ patches: valueFrom: secretKeyRef: name: balancer-db-credentials - key: password + key: password \ No newline at end of file From f71f943d2aa83e4b6e714a85e61a7c2d308561f2 Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:53:12 -0400 Subject: [PATCH 172/181] fix: update production to v1.1.5 --- .../manifests/balancer/overlays/production/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 3f6332c0..eae9a35c 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -8,7 +8,7 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: v1.0.2 + newTag: v1.1.5 configMapGenerator: - name: balancer-config @@ -23,7 +23,7 @@ labels: pairs: environment: production app.kubernetes.io/instance: balancer-production - app.kubernetes.io/version: v1.0.2 + app.kubernetes.io/version: v1.1.5 patches: - target: From e0ca2df243386b8d951f0ff4ba7d9d19e24d4d4a Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:54:12 -0400 Subject: [PATCH 173/181] fix: use image tags without v prefix (1.1.5) --- .../manifests/balancer/overlays/production/kustomization.yaml | 2 +- deploy/manifests/balancer/overlays/sandbox/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index eae9a35c..b1e940e5 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -8,7 +8,7 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: v1.1.5 + newTag: 1.1.5 configMapGenerator: - name: balancer-config diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 058f5a60..f5721214 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -9,7 +9,7 @@ resources: images: - name: ghcr.io/codeforphilly/balancer-main/app - newTag: v1.1.5 + newTag: 1.1.5 configMapGenerator: - name: balancer-config From 1072a9826f4bf94b39fbdfcdb39578736ab05fce Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:55:51 -0400 Subject: [PATCH 174/181] fix: align version label with image tag format (1.1.5) --- .../manifests/balancer/overlays/production/kustomization.yaml | 2 +- deploy/manifests/balancer/overlays/sandbox/kustomization.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index b1e940e5..7c1c3ced 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -23,7 +23,7 @@ labels: pairs: environment: production app.kubernetes.io/instance: balancer-production - app.kubernetes.io/version: v1.1.5 + app.kubernetes.io/version: "1.1.5" patches: - target: diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index f5721214..4cce6f6e 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -24,7 +24,7 @@ labels: pairs: environment: sandbox app.kubernetes.io/instance: balancer-sandbox - app.kubernetes.io/version: v1.1.5 + app.kubernetes.io/version: "1.1.5" patches: - target: From 524d0229c064f5def6d966d1121370aaf853a2ef Mon Sep 17 00:00:00 2001 From: Christopher Tineo Date: Mon, 18 May 2026 00:57:02 -0400 Subject: [PATCH 175/181] fix: remove version label, use image tag as source of truth --- deploy/manifests/balancer/overlays/production/kustomization.yaml | 1 - deploy/manifests/balancer/overlays/sandbox/kustomization.yaml | 1 - 2 files changed, 2 deletions(-) diff --git a/deploy/manifests/balancer/overlays/production/kustomization.yaml b/deploy/manifests/balancer/overlays/production/kustomization.yaml index 7c1c3ced..c664e118 100644 --- a/deploy/manifests/balancer/overlays/production/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/production/kustomization.yaml @@ -23,7 +23,6 @@ labels: pairs: environment: production app.kubernetes.io/instance: balancer-production - app.kubernetes.io/version: "1.1.5" patches: - target: diff --git a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml index 4cce6f6e..89c27bd0 100644 --- a/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/kustomization.yaml @@ -24,7 +24,6 @@ labels: pairs: environment: sandbox app.kubernetes.io/instance: balancer-sandbox - app.kubernetes.io/version: "1.1.5" patches: - target: From a0332981364f8c05f80871e5265249e4a9f78b95 Mon Sep 17 00:00:00 2001 From: C Tineo Date: Mon, 18 May 2026 13:22:13 -0400 Subject: [PATCH 176/181] feat(sandbox): add balancer-db-credentials SealedSecret for CNPG Adds the balancer-db-credentials SealedSecret to the sandbox CNPG overlay so cnpg can set the balancer role password. The sealed secret uses the cluster's sealed-secrets controller cert and targets the cloudnative-pg namespace. --- .../cnpg/balancer-db-credentials-sealed.yaml | 15 +++++++++++++++ .../overlays/sandbox/cnpg/kustomization.yaml | 1 + 2 files changed, 16 insertions(+) create mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml new file mode 100644 index 00000000..dbcf1290 --- /dev/null +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml @@ -0,0 +1,15 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + name: balancer-db-credentials + namespace: cloudnative-pg +spec: + encryptedData: + password: AgAMJuKE3t/cs/B52t27B7NdH+Qo2O+og3keqQny/rNDNDEU+6K7Ogqg1IBMNfuT+wXXjMoqcdoyOmXw+pIdOz50Seum+9XWwjFhNnKOnrH6/P9wsqWsKtn3hDSwoPXnNa6IqFTGnaWkuO33VHXMN5NPr+iJMedESZapvoUj0Ftypw4m2GLIpG9QDcUNBa7BYz6WkDKU7Bdff6BQSVfFW8NCNWbkJf44/rsNZ/Ab72skNVM1iRH7U2QgM2751ARBsZapER3m3OnKXHTFGoOCmomYbaGKSOiqPWkL72AHVwH15ygCxBSKY40tyzYw16IiWZ+e2VhyILes1DrhXeQQDrC5PAVm3495WCDYLemQtKbnFegSeUC3TEgbFTgFwGFW1Morhiu2pSiyAXaSEw+WgJM3rHh4MRT+i1LHqJWD+Somc54JpGqO7AwKfnY6N3NvtPf2X5DRcyYwbAmbYErJuJtFd2JP3FjB7bnMnR1dAO227V+JOEPQR8StjQk5cEb4VnExq9O2YpAGD4xmte+IjqxYz3mRs8vXbhCfCLrp6QpdDC3IaeY+pUlg+XGRJFVE8kg0ConyvfAPZS//9BlqAyhiryCJmCzmZQjWwQ12SIFmhMnIr+z+nJOmB5qaGTynxhVtZeMRTtW3TdcOwW0P9u3RaCgPJRiQ6cFBQPHmuY1FxXWY+LrIjwoChdsgA7It+A0oai2vo3Zj7fZwS9tOHcEiBdh1x/lDKBMCDINS5ET/Yhp8rDkrpCF3M9C79g== + username: AgB6qtZcbgMbYwQsNL0degfED1t/tkb/SujFzVSAet84XaW5A1/2vWdZW/s+YQ4a02bkzGEHPJ7uw/OKqLtdtlvBr1bGpYICEo+jl36H8wEeWPhS/zAd6kQ4Qrw5EsEvSvZjQcyT5yhCCWUb6iKAYp3K/3BM9B4BjYDc97/rmqZiwRqUn618HWSnThe1zpe54kw+szQTG/ndI323b3AMMRlZoyYz8puVKh6ySRnm6Sd+I//A/19jCJzcEMKImMEu4CXNjoyxO2Z7bvzYcvazqzp2/jD/yzYo+Y5Y7QGL6+c1REvju1F/Nq/AOuanRBEaC8nbBTsyGA2z42CDDoimlesgSU+pChh0ixofHq4gbC85Ps43B54hBIoJQDLEpY7nCuOPr5TnzhyRvaFQ/iB594AG92MgPT1+xykWC9XEw3idccth8gRY/FBamu/uqWhGgtszFUr8dtkdWHVGqEdJ1MRseosWFqqXrey3wKAr41rXNo0f+QR6T1cIhemo44uhwdCeQz4w2f0Xk+eGkAzaUVtAwlgG6pFzmW/+Zqq5nbsuQeCCU5/MRmBLD+LGe3W0+JsZRHNqamzV7TKxK1GrR48Cj9h2EaR0ABbUcs0zcVkFJXcilOzjP1ElTPv7xBt8GhjctY0RoKIq2oGAlwWrlUYbETdeJCVWS0ZSHrpbQJnoMQB5ojMYXRUYhOB6LtLecz4rAXejkhKuGA== + template: + metadata: + name: balancer-db-credentials + namespace: cloudnative-pg + type: kubernetes.io/basic-auth diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml index f971acab..9d8024c4 100644 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml +++ b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml @@ -3,3 +3,4 @@ kind: Kustomization resources: - database.yaml + - balancer-db-credentials-sealed.yaml From 6249ed363e12f5a0ec5ca44dc1bac2dffc4f55b0 Mon Sep 17 00:00:00 2001 From: C Tineo Date: Mon, 18 May 2026 13:30:09 -0400 Subject: [PATCH 177/181] =?UTF-8?q?chore(sandbox):=20remove=20cnpg=20overl?= =?UTF-8?q?ay=20=E2=80=94=20CNPG=20resources=20belong=20in=20cfp-sandbox-c?= =?UTF-8?q?luster?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Database CR and SealedSecret should live in cfp-sandbox-cluster under the cloudnative-pg namespace, not in the app overlay. --- .../cnpg/balancer-db-credentials-sealed.yaml | 15 --------------- .../balancer/overlays/sandbox/cnpg/database.yaml | 10 ---------- .../overlays/sandbox/cnpg/kustomization.yaml | 6 ------ 3 files changed, 31 deletions(-) delete mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml delete mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml delete mode 100644 deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml deleted file mode 100644 index dbcf1290..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/balancer-db-credentials-sealed.yaml +++ /dev/null @@ -1,15 +0,0 @@ ---- -apiVersion: bitnami.com/v1alpha1 -kind: SealedSecret -metadata: - name: balancer-db-credentials - namespace: cloudnative-pg -spec: - encryptedData: - password: AgAMJuKE3t/cs/B52t27B7NdH+Qo2O+og3keqQny/rNDNDEU+6K7Ogqg1IBMNfuT+wXXjMoqcdoyOmXw+pIdOz50Seum+9XWwjFhNnKOnrH6/P9wsqWsKtn3hDSwoPXnNa6IqFTGnaWkuO33VHXMN5NPr+iJMedESZapvoUj0Ftypw4m2GLIpG9QDcUNBa7BYz6WkDKU7Bdff6BQSVfFW8NCNWbkJf44/rsNZ/Ab72skNVM1iRH7U2QgM2751ARBsZapER3m3OnKXHTFGoOCmomYbaGKSOiqPWkL72AHVwH15ygCxBSKY40tyzYw16IiWZ+e2VhyILes1DrhXeQQDrC5PAVm3495WCDYLemQtKbnFegSeUC3TEgbFTgFwGFW1Morhiu2pSiyAXaSEw+WgJM3rHh4MRT+i1LHqJWD+Somc54JpGqO7AwKfnY6N3NvtPf2X5DRcyYwbAmbYErJuJtFd2JP3FjB7bnMnR1dAO227V+JOEPQR8StjQk5cEb4VnExq9O2YpAGD4xmte+IjqxYz3mRs8vXbhCfCLrp6QpdDC3IaeY+pUlg+XGRJFVE8kg0ConyvfAPZS//9BlqAyhiryCJmCzmZQjWwQ12SIFmhMnIr+z+nJOmB5qaGTynxhVtZeMRTtW3TdcOwW0P9u3RaCgPJRiQ6cFBQPHmuY1FxXWY+LrIjwoChdsgA7It+A0oai2vo3Zj7fZwS9tOHcEiBdh1x/lDKBMCDINS5ET/Yhp8rDkrpCF3M9C79g== - username: AgB6qtZcbgMbYwQsNL0degfED1t/tkb/SujFzVSAet84XaW5A1/2vWdZW/s+YQ4a02bkzGEHPJ7uw/OKqLtdtlvBr1bGpYICEo+jl36H8wEeWPhS/zAd6kQ4Qrw5EsEvSvZjQcyT5yhCCWUb6iKAYp3K/3BM9B4BjYDc97/rmqZiwRqUn618HWSnThe1zpe54kw+szQTG/ndI323b3AMMRlZoyYz8puVKh6ySRnm6Sd+I//A/19jCJzcEMKImMEu4CXNjoyxO2Z7bvzYcvazqzp2/jD/yzYo+Y5Y7QGL6+c1REvju1F/Nq/AOuanRBEaC8nbBTsyGA2z42CDDoimlesgSU+pChh0ixofHq4gbC85Ps43B54hBIoJQDLEpY7nCuOPr5TnzhyRvaFQ/iB594AG92MgPT1+xykWC9XEw3idccth8gRY/FBamu/uqWhGgtszFUr8dtkdWHVGqEdJ1MRseosWFqqXrey3wKAr41rXNo0f+QR6T1cIhemo44uhwdCeQz4w2f0Xk+eGkAzaUVtAwlgG6pFzmW/+Zqq5nbsuQeCCU5/MRmBLD+LGe3W0+JsZRHNqamzV7TKxK1GrR48Cj9h2EaR0ABbUcs0zcVkFJXcilOzjP1ElTPv7xBt8GhjctY0RoKIq2oGAlwWrlUYbETdeJCVWS0ZSHrpbQJnoMQB5ojMYXRUYhOB6LtLecz4rAXejkhKuGA== - template: - metadata: - name: balancer-db-credentials - namespace: cloudnative-pg - type: kubernetes.io/basic-auth diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml deleted file mode 100644 index 4676c6b8..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/database.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: postgresql.cnpg.io/v1 -kind: Database -metadata: - name: balancer - namespace: cloudnative-pg -spec: - name: balancer - owner: balancer - cluster: - name: shared-cluster diff --git a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml b/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml deleted file mode 100644 index 9d8024c4..00000000 --- a/deploy/manifests/balancer/overlays/sandbox/cnpg/kustomization.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -resources: - - database.yaml - - balancer-db-credentials-sealed.yaml From 5d740f681ddb2f47ddbbb7d8a88a4bebaf25c3b1 Mon Sep 17 00:00:00 2001 From: Sahil D Shah Date: Sat, 23 May 2026 15:26:46 -0400 Subject: [PATCH 178/181] Delete architectural issue template --- .github/ISSUE_TEMPLATE/architectural-issue.md | 27 ------------------- 1 file changed, 27 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/architectural-issue.md diff --git a/.github/ISSUE_TEMPLATE/architectural-issue.md b/.github/ISSUE_TEMPLATE/architectural-issue.md deleted file mode 100644 index 266f6868..00000000 --- a/.github/ISSUE_TEMPLATE/architectural-issue.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: Architectural issue -about: For issues related to alteration of project architecture -title: '' -labels: '' -assignees: '' - ---- - -# USER STORY HERE - -## Purpose: TLDR of why we need this? -### Description -Is your feature request related to a problem? Please describe. - -## Functionality: TLDR of what will this change? -### Description -A clear and concise description of what you will happen. - -## Affects: Where will this change? - -## Pros/Cons -Describe alternatives you've considered -A clear and concise description of any alternative solutions or features you've considered. - -## Additional context -Add any other context or screenshots about the feature request here. From ab498c0846324e2ca8784c1e9173d01a18da27d6 Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 27 May 2026 12:37:55 -0400 Subject: [PATCH 179/181] Clean up CORS_ALLOWED_ORIGINS configuration Refactor CORS_ALLOWED_ORIGINS to remove empty strings. --- server/balancer_backend/settings.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 40422a99..15dfbfd2 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,14 +67,12 @@ ROOT_URLCONF = "balancer_backend.urls" -<<<<<<< auth-login-ux-token-refresh -CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") -======= + # CORS configuration CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Ensure no empty strings if input was empty or trailing comma CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] ->>>>>>> develop + TEMPLATES = [ { From 8dd740a93cf8d92ddd6621b81243383b96ee2ffe Mon Sep 17 00:00:00 2001 From: Sahil Shah Date: Wed, 27 May 2026 12:38:47 -0400 Subject: [PATCH 180/181] Clean up CORS_ALLOWED_ORIGINS configuration Refactor CORS_ALLOWED_ORIGINS to remove empty strings. --- server/balancer_backend/settings.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/balancer_backend/settings.py b/server/balancer_backend/settings.py index 15dfbfd2..7c2c9e67 100644 --- a/server/balancer_backend/settings.py +++ b/server/balancer_backend/settings.py @@ -67,13 +67,11 @@ ROOT_URLCONF = "balancer_backend.urls" - # CORS configuration CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "http://localhost:3000").split(",") # Ensure no empty strings if input was empty or trailing comma CORS_ALLOWED_ORIGINS = [origin.strip() for origin in CORS_ALLOWED_ORIGINS if origin.strip()] - TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", From 79982b6c5bb4a9163f2552b5fe5126d0b7d3d204 Mon Sep 17 00:00:00 2001 From: Charlie Tonneslan Date: Wed, 27 May 2026 21:36:27 -0400 Subject: [PATCH 181/181] Rename 'Click To Enter New Patient' button to 'Enter New Patient' Closes #509. Drops 'Click To' for accessibility (not every user uses a click-capable device) and to match standard concise button labeling. Signed-off-by: Charlie Tonneslan --- frontend/src/pages/PatientManager/NewPatientForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/PatientManager/NewPatientForm.tsx b/frontend/src/pages/PatientManager/NewPatientForm.tsx index 94c718de..144fe684 100644 --- a/frontend/src/pages/PatientManager/NewPatientForm.tsx +++ b/frontend/src/pages/PatientManager/NewPatientForm.tsx @@ -310,7 +310,7 @@ const NewPatientForm = ({ >

    - Click To Enter New Patient + Enter New Patient