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 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/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 08719bb4..644708f8 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,9 +294,8 @@ const sendAssistantMessage = async ( previousResponseId?: string, ) => { try { - // 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`, { + const response = await api.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/settings.py b/server/balancer_backend/settings.py index 16764f0e..9f917a94 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 -DATABASES = { - "default": { +# 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": os.environ.get("SQL_HOST", "localhost"), + "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": db_config, } EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 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")), ]