From 19393c986be46b42d815c60d938fbb6d07fa99be Mon Sep 17 00:00:00 2001 From: matia Date: Sun, 3 Aug 2025 17:38:59 -0700 Subject: [PATCH 01/11] t1 --- frontend/src/components/ranking/index.ts | 2 + .../ranking/volunteer-matching-form.tsx | 145 +++++++++++ .../ranking/volunteer-ranking-form.tsx | 239 ++++++++++++++++++ frontend/src/components/ui/checkmark-icon.tsx | 27 ++ frontend/src/components/ui/drag-icon.tsx | 13 + frontend/src/components/ui/index.ts | 4 + frontend/src/components/ui/user-icon.tsx | 29 +++ frontend/src/components/ui/welcome-screen.tsx | 81 ++++++ frontend/src/constants/form.ts | 2 +- frontend/src/pages/participant/ranking.tsx | 171 +++++++++++++ 10 files changed, 712 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ranking/index.ts create mode 100644 frontend/src/components/ranking/volunteer-matching-form.tsx create mode 100644 frontend/src/components/ranking/volunteer-ranking-form.tsx create mode 100644 frontend/src/components/ui/checkmark-icon.tsx create mode 100644 frontend/src/components/ui/drag-icon.tsx create mode 100644 frontend/src/components/ui/index.ts create mode 100644 frontend/src/components/ui/user-icon.tsx create mode 100644 frontend/src/components/ui/welcome-screen.tsx create mode 100644 frontend/src/pages/participant/ranking.tsx diff --git a/frontend/src/components/ranking/index.ts b/frontend/src/components/ranking/index.ts new file mode 100644 index 00000000..21422666 --- /dev/null +++ b/frontend/src/components/ranking/index.ts @@ -0,0 +1,2 @@ +export { VolunteerMatchingForm } from './volunteer-matching-form'; +export { VolunteerRankingForm } from './volunteer-ranking-form'; \ No newline at end of file diff --git a/frontend/src/components/ranking/volunteer-matching-form.tsx b/frontend/src/components/ranking/volunteer-matching-form.tsx new file mode 100644 index 00000000..c03e554e --- /dev/null +++ b/frontend/src/components/ranking/volunteer-matching-form.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { COLORS } from '@/constants/form'; +const MATCHING_QUALITIES = [ + 'the same age as me', + 'the same gender identity as me', + 'the same ethnic or cultural group as me', + 'the same marital status as me', + 'the same parental status as me', + 'the same diagnosis as me', + 'experience with Oral Chemotherapy', + 'experience with Radiation Therapy', + 'experience with PTSD', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with Fertility Issues', +]; + +interface VolunteerMatchingFormProps { + selectedQualities: string[]; + onQualityToggle: (quality: string) => void; + onNext: () => void; +} + +export function VolunteerMatchingForm({ + selectedQualities, + onQualityToggle, + onNext +}: VolunteerMatchingFormProps) { + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + Relevant Qualities in a Volunteer + + + You will be ranking these qualities in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same availability. + + + + + + I would prefer a volunteer with... + + + You can select a maximum of 5. Please select at least one quality. + + + + {MATCHING_QUALITIES.map((quality) => ( + onQualityToggle(quality)} + disabled={!selectedQualities.includes(quality) && selectedQualities.length >= 5} + > + + {quality} + + + ))} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ranking/volunteer-ranking-form.tsx b/frontend/src/components/ranking/volunteer-ranking-form.tsx new file mode 100644 index 00000000..ccfd877c --- /dev/null +++ b/frontend/src/components/ranking/volunteer-ranking-form.tsx @@ -0,0 +1,239 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { DragIcon } from '@/components/ui'; +import { COLORS } from '@/constants/form'; + + + +interface VolunteerRankingFormProps { + rankedPreferences: string[]; + onMoveItem: (fromIndex: number, toIndex: number) => void; + onSubmit: () => void; +} + +export function VolunteerRankingForm({ + rankedPreferences, + onMoveItem, + onSubmit +}: VolunteerRankingFormProps) { + const [draggedIndex, setDraggedIndex] = React.useState(null); + const [dropTargetIndex, setDropTargetIndex] = React.useState(null); + + const renderStatementWithBold = (statement: string) => { + const boldPhrases = [ + 'the same age as me', + 'the same diagnosis as me', + 'the same marital status as me', + 'the same ethnic or cultural group as me', + 'the same parental status as me' + ]; + + const phraseToBold = boldPhrases.find(phrase => statement.includes(phrase)); + + if (!phraseToBold) { + return statement; + } + + const parts = statement.split(phraseToBold); + + return ( + <> + {parts[0]} + + {phraseToBold} + + {parts[1]} + + ); + }; + + const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', index.toString()); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropTargetIndex(index); + }; + + const handleDragLeave = () => { + setDropTargetIndex(null); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== dropIndex) { + onMoveItem(draggedIndex, dropIndex); + } + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDropTargetIndex(null); + }; + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + Ranking Match Preferences + + + This information will be used to match you with a suitable volunteer. + + + Note that your volunteer is guaranteed to speak your language and have the same availability. + + + + + + Rank the following statements in the order that you agree with them: + + + 1 is most agreed, 5 is least agreed. + + + + {rankedPreferences.map((statement, index) => { + const isDragging = draggedIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + + + {index + 1}. + + + handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + _hover={{ + borderColor: COLORS.teal, + boxShadow: `0 0 0 1px ${COLORS.teal}20`, + bg: isDragging ? "#e5e7eb" : "#f3f4f6" + }} + > + + + + + + {renderStatementWithBold(statement)} + + + + ); + })} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/checkmark-icon.tsx b/frontend/src/components/ui/checkmark-icon.tsx new file mode 100644 index 00000000..09381c13 --- /dev/null +++ b/frontend/src/components/ui/checkmark-icon.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Box } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +export const CheckMarkIcon: React.FC = () => ( + + + + + +); \ No newline at end of file diff --git a/frontend/src/components/ui/drag-icon.tsx b/frontend/src/components/ui/drag-icon.tsx new file mode 100644 index 00000000..fa41e806 --- /dev/null +++ b/frontend/src/components/ui/drag-icon.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { COLORS } from '@/constants/form'; + +export const DragIcon: React.FC = () => ( + + + + + + + + +); \ No newline at end of file diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts new file mode 100644 index 00000000..9559f5e4 --- /dev/null +++ b/frontend/src/components/ui/index.ts @@ -0,0 +1,4 @@ +export { UserIcon } from './user-icon'; +export { CheckMarkIcon } from './checkmark-icon'; +export { DragIcon } from './drag-icon'; +export { WelcomeScreen } from './welcome-screen'; \ No newline at end of file diff --git a/frontend/src/components/ui/user-icon.tsx b/frontend/src/components/ui/user-icon.tsx new file mode 100644 index 00000000..2b254d97 --- /dev/null +++ b/frontend/src/components/ui/user-icon.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Box } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +export const UserIcon: React.FC = () => ( + + + + + + +); \ No newline at end of file diff --git a/frontend/src/components/ui/welcome-screen.tsx b/frontend/src/components/ui/welcome-screen.tsx new file mode 100644 index 00000000..b71e6816 --- /dev/null +++ b/frontend/src/components/ui/welcome-screen.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Box, Heading, Text, Button } from '@chakra-ui/react'; +import { COLORS } from '@/constants/form'; + +interface WelcomeScreenProps { + icon: React.ReactNode; + title: string; + description: string; + buttonText?: string; + onContinue: () => void; +} + +export const WelcomeScreen: React.FC = ({ + icon, + title, + description, + buttonText = "Continue", + onContinue +}) => ( + + + {icon} + + + {title} + + + + + + + +); \ No newline at end of file diff --git a/frontend/src/constants/form.ts b/frontend/src/constants/form.ts index 091a5297..ebfdbdb3 100644 --- a/frontend/src/constants/form.ts +++ b/frontend/src/constants/form.ts @@ -2,7 +2,7 @@ export const COLORS = { veniceBlue: '#1d3448', fieldGray: '#6b7280', - teal: '#0d7377', + teal: '#056067', lightTeal: '#e6f7f7', lightGray: '#f3f4f6', progressTeal: '#5eead4', diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx new file mode 100644 index 00000000..a9ea45cc --- /dev/null +++ b/frontend/src/pages/participant/ranking.tsx @@ -0,0 +1,171 @@ +import React, { useState } from 'react'; +import { Box, Flex, Heading, Text, Button, VStack, HStack } from '@chakra-ui/react'; +import { UserIcon, CheckMarkIcon, DragIcon, WelcomeScreen } from '@/components/ui'; +import { VolunteerMatchingForm, VolunteerRankingForm } from '@/components/ranking'; +import { COLORS } from '@/constants/form'; + +const RANKING_STATEMENTS = [ + 'I would prefer a volunteer with the same age as me', + 'I would prefer a volunteer with the same diagnosis as me', + 'I would prefer a volunteer with the same marital status as me', + 'I would prefer a volunteer with the same ethnic or cultural group as me', + 'I would prefer a volunteer with the same parental status as me', +]; + +interface RankingFormData { + selectedQualities: string[]; + rankedPreferences: string[]; +} + +export default function ParticipantRankingPage() { + const [currentStep, setCurrentStep] = useState(1); + const [formData, setFormData] = useState({ + selectedQualities: [], + rankedPreferences: [...RANKING_STATEMENTS], + }); + + const WelcomeScreenStep = () => ( + } + title="Welcome to the Peer Support Program!" + description="Let's begin by selecting
your preferences in a volunteer." + onContinue={() => setCurrentStep(2)} + /> + ); + + const QualitiesScreen = () => { + const toggleQuality = (quality: string) => { + setFormData(prev => ({ + ...prev, + selectedQualities: prev.selectedQualities.includes(quality) + ? prev.selectedQualities.filter(q => q !== quality) + : prev.selectedQualities.length < 5 + ? [...prev.selectedQualities, quality] + : prev.selectedQualities + })); + }; + + return ( + + + setCurrentStep(3)} + /> + + + ); + }; + + const RankingScreen = () => { + const moveItem = (fromIndex: number, toIndex: number) => { + setFormData(prev => { + const newRanked = [...prev.rankedPreferences]; + const [movedItem] = newRanked.splice(fromIndex, 1); + newRanked.splice(toIndex, 0, movedItem); + return { ...prev, rankedPreferences: newRanked }; + }); + }; + + return ( + + + setCurrentStep(4)} + /> + + + ); + }; + + const ThankYouScreen = () => ( + + + + + + + Thank you for sharing your experience and + + + preferences with us. + + + + We are reviewing which volunteers would best fit those preferences. You will receive an email from us in the next 1-2 business days with the next steps. If you would like to connect with a LLSC staff before then, please reach out to{' '} + + FirstConnections@lls.org + + . + + + + + ); + + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + default: + return ; + } +} From 871429b699ccd5bf2ef31089cb900f8ea00e3e93 Mon Sep 17 00:00:00 2001 From: matia Date: Sun, 3 Aug 2025 17:39:51 -0700 Subject: [PATCH 02/11] t2 --- .../ranking/caregiver-matching-form.tsx | 142 +++++++++++ .../ranking/caregiver-qualities-form.tsx | 145 +++++++++++ .../ranking/caregiver-ranking-form.tsx | 241 ++++++++++++++++++ frontend/src/components/ranking/index.ts | 5 +- frontend/src/pages/participant/ranking.tsx | 134 ++++++++-- 5 files changed, 645 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/ranking/caregiver-matching-form.tsx create mode 100644 frontend/src/components/ranking/caregiver-qualities-form.tsx create mode 100644 frontend/src/components/ranking/caregiver-ranking-form.tsx diff --git a/frontend/src/components/ranking/caregiver-matching-form.tsx b/frontend/src/components/ranking/caregiver-matching-form.tsx new file mode 100644 index 00000000..fe30000e --- /dev/null +++ b/frontend/src/components/ranking/caregiver-matching-form.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; + +import { CustomRadio } from '@/components/CustomRadio'; +import { COLORS } from '@/constants/form'; + + + +interface CaregiverMatchingFormProps { + volunteerType: string; + onVolunteerTypeChange: (type: string) => void; + onNext: () => void; +} + +export function CaregiverMatchingForm({ + volunteerType, + onVolunteerTypeChange, + onNext +}: CaregiverMatchingFormProps) { + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Your volunteer + + + This information will be used in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same availability. + + + + + + I would like a volunteer that... + + + + onVolunteerTypeChange(value)} + > + + has a similar diagnosis + + + + onVolunteerTypeChange(value)} + > + + is caring for a loved one with blood cancer + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ranking/caregiver-qualities-form.tsx b/frontend/src/components/ranking/caregiver-qualities-form.tsx new file mode 100644 index 00000000..3eed8113 --- /dev/null +++ b/frontend/src/components/ranking/caregiver-qualities-form.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { COLORS } from '@/constants/form'; + +const CAREGIVER_QUALITIES = [ + 'the same age as my loved one', + 'the same gender identity as my loved one', + 'the same diagnosis as my loved one', + 'experience with returning to school or work during/after treatment', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with PTSD', + 'experience with Fertility Issues', +]; + +interface CaregiverQualitiesFormProps { + selectedQualities: string[]; + onQualityToggle: (quality: string) => void; + onNext: () => void; +} + +export function CaregiverQualitiesForm({ + selectedQualities, + onQualityToggle, + onNext +}: CaregiverQualitiesFormProps) { + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Relevant Qualities in a Volunteer + + + You will be ranking these qualities in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same availability. + + + + + + I would prefer a volunteer with... + + + You can select a maximum of 5. Please select at least one quality. + + + + {CAREGIVER_QUALITIES.map((quality) => ( + onQualityToggle(quality)} + disabled={!selectedQualities.includes(quality) && selectedQualities.length >= 5} + > + + {quality} + + + ))} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ranking/caregiver-ranking-form.tsx b/frontend/src/components/ranking/caregiver-ranking-form.tsx new file mode 100644 index 00000000..aa4435af --- /dev/null +++ b/frontend/src/components/ranking/caregiver-ranking-form.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; +import { DragIcon } from '@/components/ui'; +import { COLORS } from '@/constants/form'; + +interface CaregiverRankingFormProps { + rankedPreferences: string[]; + onMoveItem: (fromIndex: number, toIndex: number) => void; + onSubmit: () => void; +} + +export function CaregiverRankingForm({ + rankedPreferences, + onMoveItem, + onSubmit +}: CaregiverRankingFormProps) { + const [draggedIndex, setDraggedIndex] = React.useState(null); + const [dropTargetIndex, setDropTargetIndex] = React.useState(null); + + const renderStatementWithBold = (statement: string) => { + const boldPhrases = [ + 'the same age as my loved one', + 'the same diagnosis as my loved one', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with returning to school or work during/after treatment' + ]; + + const phraseToBold = boldPhrases.find(phrase => statement.includes(phrase)); + + if (!phraseToBold) { + return statement; + } + + const parts = statement.split(phraseToBold); + + return ( + <> + {parts[0]} + + {phraseToBold} + + {parts[1]} + + ); + }; + + const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', index.toString()); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropTargetIndex(index); + }; + + const handleDragLeave = () => { + setDropTargetIndex(null); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== dropIndex) { + onMoveItem(draggedIndex, dropIndex); + } + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDropTargetIndex(null); + }; + + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Ranking Match Preferences + + + This information will be used to match you with a suitable volunteer. + + + Note that your volunteer is guaranteed to speak your language and have the same availability. + + + + + + Rank the following statements in the order that you agree with them: + + + 1 is most agreed, 5 is least agreed. + + + + {rankedPreferences.map((statement, index) => { + const isDragging = draggedIndex === index; + const isDropTarget = dropTargetIndex === index; + + return ( + + + {index + 1}. + + + handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDragLeave={handleDragLeave} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + _hover={{ + borderColor: COLORS.teal, + boxShadow: `0 0 0 1px ${COLORS.teal}20`, + bg: isDragging ? "#e5e7eb" : "#f3f4f6" + }} + > + + + + + + {renderStatementWithBold(statement)} + + + + ); + })} + + + + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ranking/index.ts b/frontend/src/components/ranking/index.ts index 21422666..b2022a44 100644 --- a/frontend/src/components/ranking/index.ts +++ b/frontend/src/components/ranking/index.ts @@ -1,2 +1,5 @@ export { VolunteerMatchingForm } from './volunteer-matching-form'; -export { VolunteerRankingForm } from './volunteer-ranking-form'; \ No newline at end of file +export { VolunteerRankingForm } from './volunteer-ranking-form'; +export { CaregiverMatchingForm } from './caregiver-matching-form'; +export { CaregiverQualitiesForm } from './caregiver-qualities-form'; +export { CaregiverRankingForm } from './caregiver-ranking-form'; \ No newline at end of file diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx index a9ea45cc..ff68c8b6 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Box, Flex, Heading, Text, Button, VStack, HStack } from '@chakra-ui/react'; import { UserIcon, CheckMarkIcon, DragIcon, WelcomeScreen } from '@/components/ui'; -import { VolunteerMatchingForm, VolunteerRankingForm } from '@/components/ranking'; +import { VolunteerMatchingForm, VolunteerRankingForm, CaregiverMatchingForm, CaregiverQualitiesForm, CaregiverRankingForm } from '@/components/ranking'; import { COLORS } from '@/constants/form'; const RANKING_STATEMENTS = [ @@ -12,16 +12,32 @@ const RANKING_STATEMENTS = [ 'I would prefer a volunteer with the same parental status as me', ]; +const CAREGIVER_RANKING_STATEMENTS = [ + 'I would prefer a volunteer with the same age as my loved one', + 'I would prefer a volunteer with the same diagnosis as my loved one', + 'I would prefer a volunteer with experience with Relapse', + 'I would prefer a volunteer with experience with Anxiety / Depression', + 'I would prefer a volunteer with experience with returning to school or work during/after treatment', +]; + interface RankingFormData { selectedQualities: string[]; rankedPreferences: string[]; + volunteerType?: string; } -export default function ParticipantRankingPage() { +interface ParticipantRankingPageProps { + participantType?: 'cancerPatient' | 'caregiver'; +} + +export default function ParticipantRankingPage({ + participantType = 'cancerPatient' +}: ParticipantRankingPageProps) { const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState({ selectedQualities: [], - rankedPreferences: [...RANKING_STATEMENTS], + rankedPreferences: participantType === 'caregiver' ? [...CAREGIVER_RANKING_STATEMENTS] : [...RANKING_STATEMENTS], + volunteerType: participantType === 'caregiver' ? '' : undefined, }); const WelcomeScreenStep = () => ( @@ -45,6 +61,53 @@ export default function ParticipantRankingPage() { })); }; + const handleVolunteerTypeChange = (type: string) => { + setFormData(prev => ({ + ...prev, + volunteerType: type + })); + }; + + return ( + + + {participantType === 'caregiver' ? ( + setCurrentStep(3)} + /> + ) : ( + setCurrentStep(3)} + /> + )} + + + ); + }; + + const CaregiverQualitiesScreen = () => { + const toggleQuality = (quality: string) => { + setFormData(prev => ({ + ...prev, + selectedQualities: prev.selectedQualities.includes(quality) + ? prev.selectedQualities.filter(q => q !== quality) + : prev.selectedQualities.length < 5 + ? [...prev.selectedQualities, quality] + : prev.selectedQualities + })); + }; + return ( - setCurrentStep(3)} + onNext={() => setCurrentStep(4)} /> @@ -75,6 +138,10 @@ export default function ParticipantRankingPage() { }); }; + const participantType = 'caregiver'; + + const nextStep = participantType === 'caregiver' ? 5 : 4; + return ( - setCurrentStep(4)} - /> + {participantType === 'caregiver' ? ( + setCurrentStep(nextStep)} + /> + ) : ( + setCurrentStep(nextStep)} + /> + )} ); @@ -156,16 +231,33 @@ export default function ParticipantRankingPage() { ); - switch (currentStep) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - default: - return ; + if (participantType === 'caregiver') { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + default: + return ; + } + } else { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + default: + return ; + } } } From d8e19a2ee64d9c4ef8ca8f360b7d98577e63679d Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sun, 10 Aug 2025 21:38:57 -0400 Subject: [PATCH 03/11] ranking form: implement two-column variant for the caregiver flow --- .../ranking/caregiver-matching-form.tsx | 6 +- .../caregiver-two-column-qualities-form.tsx | 207 ++++++++++++++++++ frontend/src/components/ranking/index.ts | 3 +- frontend/src/pages/participant/ranking.tsx | 86 ++++++-- 4 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx diff --git a/frontend/src/components/ranking/caregiver-matching-form.tsx b/frontend/src/components/ranking/caregiver-matching-form.tsx index fe30000e..7f428830 100644 --- a/frontend/src/components/ranking/caregiver-matching-form.tsx +++ b/frontend/src/components/ranking/caregiver-matching-form.tsx @@ -9,7 +9,7 @@ import { COLORS } from '@/constants/form'; interface CaregiverMatchingFormProps { volunteerType: string; onVolunteerTypeChange: (type: string) => void; - onNext: () => void; + onNext: (type: string) => void; } export function CaregiverMatchingForm({ @@ -126,7 +126,7 @@ export function CaregiverMatchingForm({ color="white" _hover={{ bg: COLORS.teal }} _active={{ bg: COLORS.teal }} - onClick={onNext} + onClick={() => onNext(volunteerType)} disabled={!volunteerType} w="auto" h="40px" @@ -139,4 +139,4 @@ export function CaregiverMatchingForm({ ); -} \ No newline at end of file +} diff --git a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx new file mode 100644 index 00000000..27e26987 --- /dev/null +++ b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx @@ -0,0 +1,207 @@ +import React from 'react'; +import { Box, Heading, Button, VStack, HStack, Text, SimpleGrid } from '@chakra-ui/react'; +import { Checkbox } from '@/components/ui/checkbox'; +import { COLORS } from '@/constants/form'; + +interface CaregiverTwoColumnQualitiesFormProps { + selectedQualities: string[]; + onQualityToggle: (quality: string) => void; + onNext: () => void; +} + +// Left column options – The volunteer is/has… ("…as me" phrasing) +const VOLUNTEER_OPTIONS = [ + 'the same age as me', + 'the same gender identity as me', + 'the same ethnic or cultural group as me', + 'the same marital status as me', + 'the same parental status as me', + 'the same diagnosis as me', + 'experience with PTSD', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with Fertility Issues', +]; + +// Right column options – Their loved one is/has… ("…as my loved one" phrasing) +const LOVED_ONE_OPTIONS = [ + 'the same age as my loved one', + 'the same gender identity as my loved one', + 'the same diagnosis as my loved one', + 'experience with Oral Chemotherapy', + 'experience with Radiation Therapy', + 'experience with PTSD', + 'experience with Relapse', + 'experience with Anxiety / Depression', + 'experience with Fertility Issues', +]; + +export function CaregiverTwoColumnQualitiesForm({ + selectedQualities, + onQualityToggle, + onNext, +}: CaregiverTwoColumnQualitiesFormProps) { + const maxSelected = 5; + const reachedMax = selectedQualities.length >= maxSelected; + + return ( + + + Volunteer Matching Preferences + + + + + + + + + + + + + + + + + + + Relevant Qualities in a Volunteer + + + You will be ranking these qualities in the next step. + + + Note that your volunteer is guaranteed to speak your language and have the same availability. + + + + + + I would prefer that... + + + You can select a maximum of 5 across both categories. Please select at least one quality. + + + + + + The volunteer is/has... + + + {VOLUNTEER_OPTIONS.map((quality) => ( + onQualityToggle(quality)} + disabled={!selectedQualities.includes(quality) && reachedMax} + > + + {quality} + + + ))} + + + + + + Their loved one is/has... + + + {LOVED_ONE_OPTIONS.map((quality) => ( + onQualityToggle(quality)} + disabled={!selectedQualities.includes(quality) && reachedMax} + > + + {quality} + + + ))} + + + + + + + + + + + + ); +} + + diff --git a/frontend/src/components/ranking/index.ts b/frontend/src/components/ranking/index.ts index b2022a44..87b4b089 100644 --- a/frontend/src/components/ranking/index.ts +++ b/frontend/src/components/ranking/index.ts @@ -2,4 +2,5 @@ export { VolunteerMatchingForm } from './volunteer-matching-form'; export { VolunteerRankingForm } from './volunteer-ranking-form'; export { CaregiverMatchingForm } from './caregiver-matching-form'; export { CaregiverQualitiesForm } from './caregiver-qualities-form'; -export { CaregiverRankingForm } from './caregiver-ranking-form'; \ No newline at end of file +export { CaregiverRankingForm } from './caregiver-ranking-form'; +export { CaregiverTwoColumnQualitiesForm } from './caregiver-two-column-qualities-form'; diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx index ff68c8b6..01621e72 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; -import { Box, Flex, Heading, Text, Button, VStack, HStack } from '@chakra-ui/react'; -import { UserIcon, CheckMarkIcon, DragIcon, WelcomeScreen } from '@/components/ui'; -import { VolunteerMatchingForm, VolunteerRankingForm, CaregiverMatchingForm, CaregiverQualitiesForm, CaregiverRankingForm } from '@/components/ranking'; +import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; +import { UserIcon, CheckMarkIcon, WelcomeScreen } from '@/components/ui'; +import { VolunteerMatchingForm, VolunteerRankingForm, CaregiverMatchingForm, CaregiverQualitiesForm, CaregiverRankingForm, CaregiverTwoColumnQualitiesForm } from '@/components/ranking'; import { COLORS } from '@/constants/form'; const RANKING_STATEMENTS = [ @@ -24,20 +24,24 @@ interface RankingFormData { selectedQualities: string[]; rankedPreferences: string[]; volunteerType?: string; + isCaregiverVolunteerFlow?: boolean; } interface ParticipantRankingPageProps { participantType?: 'cancerPatient' | 'caregiver'; + caregiverHasCancer?: boolean; } export default function ParticipantRankingPage({ - participantType = 'cancerPatient' + participantType = 'caregiver', + caregiverHasCancer = true, }: ParticipantRankingPageProps) { const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState({ selectedQualities: [], rankedPreferences: participantType === 'caregiver' ? [...CAREGIVER_RANKING_STATEMENTS] : [...RANKING_STATEMENTS], volunteerType: participantType === 'caregiver' ? '' : undefined, + isCaregiverVolunteerFlow: undefined, }); const WelcomeScreenStep = () => ( @@ -64,7 +68,9 @@ export default function ParticipantRankingPage({ const handleVolunteerTypeChange = (type: string) => { setFormData(prev => ({ ...prev, - volunteerType: type + volunteerType: type, + // Derive explicit flow flag to avoid any async state timing issues + isCaregiverVolunteerFlow: type === 'caringForLovedOne', })); }; @@ -82,13 +88,25 @@ export default function ParticipantRankingPage({ setCurrentStep(3)} + onNext={(type) => { + // Ensure state is set before navigating + setFormData(prev => ({ + ...prev, + volunteerType: type, + isCaregiverVolunteerFlow: type === 'caringForLovedOne', + })); + setCurrentStep(3); + }} /> ) : ( setCurrentStep(3)} + onNext={() => { + // Build ranking list from selected qualities + setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setCurrentStep(3); + }} /> )} @@ -118,11 +136,51 @@ export default function ParticipantRankingPage({ boxShadow="0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)" p={10} > - setCurrentStep(4)} - /> + {( + // Prefer explicit flag; otherwise infer from value + (formData.isCaregiverVolunteerFlow ?? false) || + (formData.volunteerType === 'caringForLovedOne') || + // Fallback: any non-similarDiagnosis value implies the loved-one flow + (!!formData.volunteerType && formData.volunteerType !== 'similarDiagnosis') + ) ? ( + { + setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setCurrentStep(4); + }} + /> + ) : caregiverHasCancer ? ( + formData.volunteerType === 'similarDiagnosis' ? ( + { + setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setCurrentStep(4); + }} + /> + ) : ( + { + setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setCurrentStep(4); + }} + /> + ) + ) : ( + { + setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setCurrentStep(4); + }} + /> + )} ); @@ -138,9 +196,7 @@ export default function ParticipantRankingPage({ }); }; - const participantType = 'caregiver'; - - const nextStep = participantType === 'caregiver' ? 5 : 4; + const nextStep = (participantType === 'caregiver') ? 5 : 4; return ( From c01b27ec6bf2e43b1e6e914496787ac941713d95 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sun, 10 Aug 2025 21:47:17 -0400 Subject: [PATCH 04/11] ranking form: format files with Prettier --- .../ranking/caregiver-matching-form.tsx | 11 ++- .../ranking/caregiver-qualities-form.tsx | 15 ++-- .../ranking/caregiver-ranking-form.tsx | 35 ++++---- .../caregiver-two-column-qualities-form.tsx | 8 +- .../ranking/volunteer-matching-form.tsx | 15 ++-- .../ranking/volunteer-ranking-form.tsx | 37 +++++---- frontend/src/pages/participant/ranking.tsx | 79 ++++++++++++------- 7 files changed, 112 insertions(+), 88 deletions(-) diff --git a/frontend/src/components/ranking/caregiver-matching-form.tsx b/frontend/src/components/ranking/caregiver-matching-form.tsx index 7f428830..895a0132 100644 --- a/frontend/src/components/ranking/caregiver-matching-form.tsx +++ b/frontend/src/components/ranking/caregiver-matching-form.tsx @@ -4,18 +4,16 @@ import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; import { CustomRadio } from '@/components/CustomRadio'; import { COLORS } from '@/constants/form'; - - interface CaregiverMatchingFormProps { volunteerType: string; onVolunteerTypeChange: (type: string) => void; onNext: (type: string) => void; } -export function CaregiverMatchingForm({ +export function CaregiverMatchingForm({ volunteerType, onVolunteerTypeChange, - onNext + onNext, }: CaregiverMatchingFormProps) { return ( @@ -70,7 +68,8 @@ export function CaregiverMatchingForm({ fontWeight={600} mb={8} > - Note that your volunteer is guaranteed to speak your language and have the same availability. + Note that your volunteer is guaranteed to speak your language and have the same + availability. @@ -100,7 +99,7 @@ export function CaregiverMatchingForm({ has a similar diagnosis - + void; } -export function CaregiverQualitiesForm({ - selectedQualities, - onQualityToggle, - onNext +export function CaregiverQualitiesForm({ + selectedQualities, + onQualityToggle, + onNext, }: CaregiverQualitiesFormProps) { return ( @@ -78,7 +78,8 @@ export function CaregiverQualitiesForm({ fontWeight={600} mb={8} > - Note that your volunteer is guaranteed to speak your language and have the same availability. + Note that your volunteer is guaranteed to speak your language and have the same + availability. @@ -142,4 +143,4 @@ export function CaregiverQualitiesForm({ ); -} \ No newline at end of file +} diff --git a/frontend/src/components/ranking/caregiver-ranking-form.tsx b/frontend/src/components/ranking/caregiver-ranking-form.tsx index aa4435af..f99e530c 100644 --- a/frontend/src/components/ranking/caregiver-ranking-form.tsx +++ b/frontend/src/components/ranking/caregiver-ranking-form.tsx @@ -9,10 +9,10 @@ interface CaregiverRankingFormProps { onSubmit: () => void; } -export function CaregiverRankingForm({ - rankedPreferences, - onMoveItem, - onSubmit +export function CaregiverRankingForm({ + rankedPreferences, + onMoveItem, + onSubmit, }: CaregiverRankingFormProps) { const [draggedIndex, setDraggedIndex] = React.useState(null); const [dropTargetIndex, setDropTargetIndex] = React.useState(null); @@ -23,17 +23,17 @@ export function CaregiverRankingForm({ 'the same diagnosis as my loved one', 'experience with Relapse', 'experience with Anxiety / Depression', - 'experience with returning to school or work during/after treatment' + 'experience with returning to school or work during/after treatment', ]; - const phraseToBold = boldPhrases.find(phrase => statement.includes(phrase)); - + const phraseToBold = boldPhrases.find((phrase) => statement.includes(phrase)); + if (!phraseToBold) { return statement; } const parts = statement.split(phraseToBold); - + return ( <> {parts[0]} @@ -128,7 +128,8 @@ export function CaregiverRankingForm({ fontWeight={600} mb={8} > - Note that your volunteer is guaranteed to speak your language and have the same availability. + Note that your volunteer is guaranteed to speak your language and have the same + availability. @@ -154,7 +155,7 @@ export function CaregiverRankingForm({ {rankedPreferences.map((statement, index) => { const isDragging = draggedIndex === index; const isDropTarget = dropTargetIndex === index; - + return ( {index + 1}. - + handleDrop(e, index)} onDragEnd={handleDragEnd} - _hover={{ + _hover={{ borderColor: COLORS.teal, boxShadow: `0 0 0 1px ${COLORS.teal}20`, - bg: isDragging ? "#e5e7eb" : "#f3f4f6" + bg: isDragging ? '#e5e7eb' : '#f3f4f6', }} > - ); -} \ No newline at end of file +} diff --git a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx index 27e26987..67724266 100644 --- a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx +++ b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx @@ -97,7 +97,8 @@ export function CaregiverTwoColumnQualitiesForm({ fontWeight={600} mb={8} > - Note that your volunteer is guaranteed to speak your language and have the same availability. + Note that your volunteer is guaranteed to speak your language and have the same + availability. @@ -117,7 +118,8 @@ export function CaregiverTwoColumnQualitiesForm({ fontSize="12px" mb={4} > - You can select a maximum of 5 across both categories. Please select at least one quality. + You can select a maximum of 5 across both categories. Please select at least one + quality. @@ -203,5 +205,3 @@ export function CaregiverTwoColumnQualitiesForm({ ); } - - diff --git a/frontend/src/components/ranking/volunteer-matching-form.tsx b/frontend/src/components/ranking/volunteer-matching-form.tsx index c03e554e..b59030d6 100644 --- a/frontend/src/components/ranking/volunteer-matching-form.tsx +++ b/frontend/src/components/ranking/volunteer-matching-form.tsx @@ -4,7 +4,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { COLORS } from '@/constants/form'; const MATCHING_QUALITIES = [ 'the same age as me', - 'the same gender identity as me', + 'the same gender identity as me', 'the same ethnic or cultural group as me', 'the same marital status as me', 'the same parental status as me', @@ -23,10 +23,10 @@ interface VolunteerMatchingFormProps { onNext: () => void; } -export function VolunteerMatchingForm({ - selectedQualities, - onQualityToggle, - onNext +export function VolunteerMatchingForm({ + selectedQualities, + onQualityToggle, + onNext, }: VolunteerMatchingFormProps) { return ( @@ -78,7 +78,8 @@ export function VolunteerMatchingForm({ fontWeight={600} mb={8} > - Note that your volunteer is guaranteed to speak your language and have the same availability. + Note that your volunteer is guaranteed to speak your language and have the same + availability. @@ -142,4 +143,4 @@ export function VolunteerMatchingForm({ ); -} \ No newline at end of file +} diff --git a/frontend/src/components/ranking/volunteer-ranking-form.tsx b/frontend/src/components/ranking/volunteer-ranking-form.tsx index ccfd877c..83f805d2 100644 --- a/frontend/src/components/ranking/volunteer-ranking-form.tsx +++ b/frontend/src/components/ranking/volunteer-ranking-form.tsx @@ -3,18 +3,16 @@ import { Box, Heading, Button, VStack, HStack, Text } from '@chakra-ui/react'; import { DragIcon } from '@/components/ui'; import { COLORS } from '@/constants/form'; - - interface VolunteerRankingFormProps { rankedPreferences: string[]; onMoveItem: (fromIndex: number, toIndex: number) => void; onSubmit: () => void; } -export function VolunteerRankingForm({ - rankedPreferences, - onMoveItem, - onSubmit +export function VolunteerRankingForm({ + rankedPreferences, + onMoveItem, + onSubmit, }: VolunteerRankingFormProps) { const [draggedIndex, setDraggedIndex] = React.useState(null); const [dropTargetIndex, setDropTargetIndex] = React.useState(null); @@ -25,17 +23,17 @@ export function VolunteerRankingForm({ 'the same diagnosis as me', 'the same marital status as me', 'the same ethnic or cultural group as me', - 'the same parental status as me' + 'the same parental status as me', ]; - const phraseToBold = boldPhrases.find(phrase => statement.includes(phrase)); - + const phraseToBold = boldPhrases.find((phrase) => statement.includes(phrase)); + if (!phraseToBold) { return statement; } const parts = statement.split(phraseToBold); - + return ( <> {parts[0]} @@ -126,7 +124,8 @@ export function VolunteerRankingForm({ fontWeight={600} mb={8} > - Note that your volunteer is guaranteed to speak your language and have the same availability. + Note that your volunteer is guaranteed to speak your language and have the same + availability. @@ -152,7 +151,7 @@ export function VolunteerRankingForm({ {rankedPreferences.map((statement, index) => { const isDragging = draggedIndex === index; const isDropTarget = dropTargetIndex === index; - + return ( {index + 1}. - + handleDrop(e, index)} onDragEnd={handleDragEnd} - _hover={{ + _hover={{ borderColor: COLORS.teal, boxShadow: `0 0 0 1px ${COLORS.teal}20`, - bg: isDragging ? "#e5e7eb" : "#f3f4f6" + bg: isDragging ? '#e5e7eb' : '#f3f4f6', }} > - ); -} \ No newline at end of file +} diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx index 01621e72..42d45144 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking.tsx @@ -1,7 +1,14 @@ import React, { useState } from 'react'; import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; import { UserIcon, CheckMarkIcon, WelcomeScreen } from '@/components/ui'; -import { VolunteerMatchingForm, VolunteerRankingForm, CaregiverMatchingForm, CaregiverQualitiesForm, CaregiverRankingForm, CaregiverTwoColumnQualitiesForm } from '@/components/ranking'; +import { + VolunteerMatchingForm, + VolunteerRankingForm, + CaregiverMatchingForm, + CaregiverQualitiesForm, + CaregiverRankingForm, + CaregiverTwoColumnQualitiesForm, +} from '@/components/ranking'; import { COLORS } from '@/constants/form'; const RANKING_STATEMENTS = [ @@ -32,14 +39,15 @@ interface ParticipantRankingPageProps { caregiverHasCancer?: boolean; } -export default function ParticipantRankingPage({ +export default function ParticipantRankingPage({ participantType = 'caregiver', caregiverHasCancer = true, }: ParticipantRankingPageProps) { const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState({ selectedQualities: [], - rankedPreferences: participantType === 'caregiver' ? [...CAREGIVER_RANKING_STATEMENTS] : [...RANKING_STATEMENTS], + rankedPreferences: + participantType === 'caregiver' ? [...CAREGIVER_RANKING_STATEMENTS] : [...RANKING_STATEMENTS], volunteerType: participantType === 'caregiver' ? '' : undefined, isCaregiverVolunteerFlow: undefined, }); @@ -55,18 +63,18 @@ export default function ParticipantRankingPage({ const QualitiesScreen = () => { const toggleQuality = (quality: string) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, selectedQualities: prev.selectedQualities.includes(quality) - ? prev.selectedQualities.filter(q => q !== quality) - : prev.selectedQualities.length < 5 + ? prev.selectedQualities.filter((q) => q !== quality) + : prev.selectedQualities.length < 5 ? [...prev.selectedQualities, quality] - : prev.selectedQualities + : prev.selectedQualities, })); }; const handleVolunteerTypeChange = (type: string) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, volunteerType: type, // Derive explicit flow flag to avoid any async state timing issues @@ -90,7 +98,7 @@ export default function ParticipantRankingPage({ onVolunteerTypeChange={handleVolunteerTypeChange} onNext={(type) => { // Ensure state is set before navigating - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, volunteerType: type, isCaregiverVolunteerFlow: type === 'caringForLovedOne', @@ -104,7 +112,10 @@ export default function ParticipantRankingPage({ onQualityToggle={toggleQuality} onNext={() => { // Build ranking list from selected qualities - setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...prev.selectedQualities], + })); setCurrentStep(3); }} /> @@ -116,13 +127,13 @@ export default function ParticipantRankingPage({ const CaregiverQualitiesScreen = () => { const toggleQuality = (quality: string) => { - setFormData(prev => ({ + setFormData((prev) => ({ ...prev, selectedQualities: prev.selectedQualities.includes(quality) - ? prev.selectedQualities.filter(q => q !== quality) - : prev.selectedQualities.length < 5 + ? prev.selectedQualities.filter((q) => q !== quality) + : prev.selectedQualities.length < 5 ? [...prev.selectedQualities, quality] - : prev.selectedQualities + : prev.selectedQualities, })); }; @@ -136,18 +147,19 @@ export default function ParticipantRankingPage({ boxShadow="0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)" p={10} > - {( - // Prefer explicit flag; otherwise infer from value - (formData.isCaregiverVolunteerFlow ?? false) || - (formData.volunteerType === 'caringForLovedOne') || - // Fallback: any non-similarDiagnosis value implies the loved-one flow - (!!formData.volunteerType && formData.volunteerType !== 'similarDiagnosis') - ) ? ( + {// Prefer explicit flag; otherwise infer from value + (formData.isCaregiverVolunteerFlow ?? false) || + formData.volunteerType === 'caringForLovedOne' || + // Fallback: any non-similarDiagnosis value implies the loved-one flow + (!!formData.volunteerType && formData.volunteerType !== 'similarDiagnosis') ? ( { - setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...prev.selectedQualities], + })); setCurrentStep(4); }} /> @@ -157,7 +169,10 @@ export default function ParticipantRankingPage({ selectedQualities={formData.selectedQualities} onQualityToggle={toggleQuality} onNext={() => { - setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...prev.selectedQualities], + })); setCurrentStep(4); }} /> @@ -166,7 +181,10 @@ export default function ParticipantRankingPage({ selectedQualities={formData.selectedQualities} onQualityToggle={toggleQuality} onNext={() => { - setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...prev.selectedQualities], + })); setCurrentStep(4); }} /> @@ -176,7 +194,10 @@ export default function ParticipantRankingPage({ selectedQualities={formData.selectedQualities} onQualityToggle={toggleQuality} onNext={() => { - setFormData(prev => ({ ...prev, rankedPreferences: [...prev.selectedQualities] })); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...prev.selectedQualities], + })); setCurrentStep(4); }} /> @@ -188,7 +209,7 @@ export default function ParticipantRankingPage({ const RankingScreen = () => { const moveItem = (fromIndex: number, toIndex: number) => { - setFormData(prev => { + setFormData((prev) => { const newRanked = [...prev.rankedPreferences]; const [movedItem] = newRanked.splice(fromIndex, 1); newRanked.splice(toIndex, 0, movedItem); @@ -196,7 +217,7 @@ export default function ParticipantRankingPage({ }); }; - const nextStep = (participantType === 'caregiver') ? 5 : 4; + const nextStep = participantType === 'caregiver' ? 5 : 4; return ( @@ -276,7 +297,9 @@ export default function ParticipantRankingPage({ maxW="600px" textAlign="center" > - We are reviewing which volunteers would best fit those preferences. You will receive an email from us in the next 1-2 business days with the next steps. If you would like to connect with a LLSC staff before then, please reach out to{' '} + We are reviewing which volunteers would best fit those preferences. You will receive an + email from us in the next 1-2 business days with the next steps. If you would like to + connect with a LLSC staff before then, please reach out to{' '} FirstConnections@lls.org From 06b1f67de2df1bfaa88b0918f020600e3427cd06 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sun, 31 Aug 2025 21:17:34 -0400 Subject: [PATCH 05/11] ranking: merge heads, add unified ranking_preferences model + migration --- backend/app/models/RankingPreference.py | 39 +++++- ...ranking_unified_preferences_table_seed_.py | 122 ++++++++++++++++++ ...8c24174_merge_heads_before_ranking_work.py | 22 ++++ 3 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py create mode 100644 backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py diff --git a/backend/app/models/RankingPreference.py b/backend/app/models/RankingPreference.py index 7fb7c595..701bff31 100644 --- a/backend/app/models/RankingPreference.py +++ b/backend/app/models/RankingPreference.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, ForeignKey, Integer +from sqlalchemy import CheckConstraint, Column, Enum, ForeignKey, Integer from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship @@ -9,9 +9,38 @@ class RankingPreference(Base): __tablename__ = "ranking_preferences" user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), primary_key=True) - quality_id = Column(Integer, ForeignKey("qualities.id"), primary_key=True) - rank = Column(Integer, nullable=False) # 1 = most important + # patient or caregiver (the counterpart the participant is ranking for) + target_role = Column(Enum("patient", "caregiver", name="target_role"), primary_key=True) - # Relationships + # kind of item: quality, treatment, or experience + kind = Column(Enum("quality", "treatment", "experience", name="ranking_kind"), primary_key=True) + + # one of these will be set based on kind + quality_id = Column(Integer, nullable=True, primary_key=True) + treatment_id = Column(Integer, nullable=True, primary_key=True) + experience_id = Column(Integer, nullable=True, primary_key=True) + + # scope: self or loved_one; always required (including qualities) + scope = Column(Enum("self", "loved_one", name="ranking_scope"), nullable=False, primary_key=True) + + # rank: 1 is highest + rank = Column(Integer, nullable=False) + + # relationships user = relationship("User") - quality = relationship("Quality") + + __table_args__ = ( + # enforce exclusive columns by kind + CheckConstraint( + "(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL)", + name="ck_ranking_pref_quality_fields", + ), + CheckConstraint( + "(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL)", + name="ck_ranking_pref_treatment_fields", + ), + CheckConstraint( + "(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL)", + name="ck_ranking_pref_experience_fields", + ), + ) diff --git a/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py new file mode 100644 index 00000000..cedf2909 --- /dev/null +++ b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py @@ -0,0 +1,122 @@ +"""ranking: unified preferences table + seed qualities + +Revision ID: 9d7570569af9 +Revises: fb0638c24174 +Create Date: 2025-08-31 20:49:12.042730 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '9d7570569af9' +down_revision: Union[str, None] = 'fb0638c24174' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Drop legacy ranking_preferences table if it exists + bind = op.get_bind() + inspector = sa.inspect(bind) + if 'ranking_preferences' in inspector.get_table_names(): + op.drop_table('ranking_preferences') + + # Create ENUM types + target_role_enum = postgresql.ENUM('patient', 'caregiver', name='target_role', create_type=False) + kind_enum = postgresql.ENUM('quality', 'treatment', 'experience', name='ranking_kind', create_type=False) + scope_enum = postgresql.ENUM('self', 'loved_one', name='ranking_scope', create_type=False) + target_role_enum.create(bind, checkfirst=True) + kind_enum.create(bind, checkfirst=True) + scope_enum.create(bind, checkfirst=True) + + # Create unified ranking_preferences table + op.create_table( + 'ranking_preferences', + sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False), + sa.Column('target_role', target_role_enum, nullable=False), + sa.Column('kind', kind_enum, nullable=False), + sa.Column('quality_id', sa.Integer(), nullable=True), + sa.Column('treatment_id', sa.Integer(), nullable=True), + sa.Column('experience_id', sa.Integer(), nullable=True), + sa.Column('scope', scope_enum, nullable=False), + sa.Column('rank', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('user_id', 'target_role', 'kind', 'quality_id', 'treatment_id', 'experience_id', 'scope'), + ) + + # Add check constraints to enforce exclusivity by kind + op.create_check_constraint( + 'ck_ranking_pref_quality_fields', + 'ranking_preferences', + "(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL AND scope IS NOT NULL)", + ) + op.create_check_constraint( + 'ck_ranking_pref_treatment_fields', + 'ranking_preferences', + "(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL AND scope IS NOT NULL)", + ) + op.create_check_constraint( + 'ck_ranking_pref_experience_fields', + 'ranking_preferences', + "(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL AND scope IS NOT NULL)", + ) + + # Helpful indexes + op.create_index('ix_ranking_pref_user_target', 'ranking_preferences', ['user_id', 'target_role']) + op.create_index('ix_ranking_pref_user_kind', 'ranking_preferences', ['user_id', 'kind']) + + # Seed qualities (idempotent) using slug/label pairs + # Using plain SQL for ON CONFLICT DO NOTHING + qualities = [ + ('same_age', 'the same age as'), + ('same_gender_identity', 'the same gender identity as'), + ('same_ethnic_or_cultural_group', 'same ethnic or cultural group as'), + ('same_marital_status', 'the same marital status as'), + ('same_parental_status', 'the same parental status as'), + ('same_diagnosis', 'the same diagnosis as'), + ] + conn = op.get_bind() + # Ensure the sequence is aligned to current MAX(id) to avoid PK conflicts + conn.execute( + sa.text( + "SELECT setval(pg_get_serial_sequence('qualities','id'), " + "COALESCE((SELECT MAX(id) FROM qualities), 0))" + ) + ) + # First update labels for any existing slugs + for slug, label in qualities: + conn.execute( + sa.text("UPDATE qualities SET label = :label WHERE slug = :slug"), + {"slug": slug, "label": label}, + ) + # Then insert any missing slugs + for slug, label in qualities: + conn.execute( + sa.text( + "INSERT INTO qualities (slug, label) " + "SELECT :slug, :label WHERE NOT EXISTS (SELECT 1 FROM qualities WHERE slug = :slug)" + ), + {"slug": slug, "label": label}, + ) + + +def downgrade() -> None: + # Drop unified table + op.drop_index('ix_ranking_pref_user_kind', table_name='ranking_preferences') + op.drop_index('ix_ranking_pref_user_target', table_name='ranking_preferences') + op.drop_constraint('ck_ranking_pref_experience_fields', 'ranking_preferences', type_='check') + op.drop_constraint('ck_ranking_pref_treatment_fields', 'ranking_preferences', type_='check') + op.drop_constraint('ck_ranking_pref_quality_fields', 'ranking_preferences', type_='check') + op.drop_table('ranking_preferences') + + # Drop ENUMs if present + bind = op.get_bind() + target_role_enum = postgresql.ENUM('patient', 'caregiver', name='target_role') + kind_enum = postgresql.ENUM('quality', 'treatment', 'experience', name='ranking_kind') + scope_enum = postgresql.ENUM('self', 'loved_one', name='ranking_scope') + scope_enum.drop(bind, checkfirst=True) + kind_enum.drop(bind, checkfirst=True) + target_role_enum.drop(bind, checkfirst=True) diff --git a/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py b/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py new file mode 100644 index 00000000..3835e777 --- /dev/null +++ b/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py @@ -0,0 +1,22 @@ +"""merge heads before ranking work + +Revision ID: fb0638c24174 +Revises: 7b797eccb3aa, 88c4cf2a6bd2 +Create Date: 2025-08-31 20:48:25.460360 + +""" +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = 'fb0638c24174' +down_revision: Union[str, None] = ('7b797eccb3aa', '88c4cf2a6bd2') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass From ef80aa1393dcce48669ee00eb2f56a07f5d7a9cd Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sun, 31 Aug 2025 23:29:53 -0400 Subject: [PATCH 06/11] ranking: add GET /ranking/options + service; wire router; unit tests for options; minor migration formatting --- backend/app/routes/__init__.py | 14 +- backend/app/routes/ranking.py | 50 +++++ backend/app/server.py | 3 +- .../implementations/ranking_service.py | 114 +++++++++++ ...ranking_unified_preferences_table_seed_.py | 90 ++++---- ...8c24174_merge_heads_before_ranking_work.py | 5 +- backend/tests/unit/test_ranking_service.py | 192 ++++++++++++++++++ 7 files changed, 418 insertions(+), 50 deletions(-) create mode 100644 backend/app/routes/ranking.py create mode 100644 backend/app/services/implementations/ranking_service.py create mode 100644 backend/tests/unit/test_ranking_service.py diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py index e737cca9..a928e546 100644 --- a/backend/app/routes/__init__.py +++ b/backend/app/routes/__init__.py @@ -1,3 +1,13 @@ -from . import auth, availability, intake, match, send_email, suggested_times, test, user +from . import auth, availability, intake, match, ranking, send_email, suggested_times, test, user -__all__ = ["auth", "availability", "intake", "match", "send_email", "suggested_times", "test", "user"] +__all__ = [ + "auth", + "availability", + "intake", + "match", + "ranking", + "send_email", + "suggested_times", + "test", + "user", +] diff --git a/backend/app/routes/ranking.py b/backend/app/routes/ranking.py new file mode 100644 index 00000000..84c29f21 --- /dev/null +++ b/backend/app/routes/ranking.py @@ -0,0 +1,50 @@ +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.middleware.auth import has_roles +from app.schemas.user import UserRole +from app.services.implementations.ranking_service import RankingService +from app.utilities.db_utils import get_db + + +class StaticQualityOption(BaseModel): + quality_id: int + slug: str + label: str + allowed_scopes: List[str] | None = Field(default=None, description="Optional whitelisted scopes for this quality") + + +class DynamicOption(BaseModel): + kind: str # 'treatment' | 'experience' + id: int + name: str + scope: str # 'self' | 'loved_one' + + +class RankingOptionsResponse(BaseModel): + static_qualities: List[StaticQualityOption] + dynamic_options: List[DynamicOption] + + +router = APIRouter(prefix="/ranking", tags=["ranking"]) + + +@router.get("/options", response_model=RankingOptionsResponse) +async def get_ranking_options( + request: Request, + target: str = Query(..., pattern="^(patient|caregiver)$"), + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +) -> RankingOptionsResponse: + try: + service = RankingService(db) + user_auth_id = request.state.user_id + options = service.get_options(user_auth_id=user_auth_id, target=target) + return RankingOptionsResponse(**options) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/server.py b/backend/app/server.py index 6be61f43..eb1f695c 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -8,7 +8,7 @@ from . import models from .middleware.auth_middleware import AuthMiddleware -from .routes import auth, availability, intake, match, send_email, suggested_times, test, user +from .routes import auth, availability, intake, match, ranking, send_email, suggested_times, test, user from .utilities.constants import LOGGER_NAME from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -68,6 +68,7 @@ async def lifespan(_: FastAPI): app.include_router(suggested_times.router) app.include_router(match.router) app.include_router(intake.router) +app.include_router(ranking.router) app.include_router(send_email.router) app.include_router(test.router) diff --git a/backend/app/services/implementations/ranking_service.py b/backend/app/services/implementations/ranking_service.py new file mode 100644 index 00000000..14d4c173 --- /dev/null +++ b/backend/app/services/implementations/ranking_service.py @@ -0,0 +1,114 @@ +from typing import Dict, List + +from sqlalchemy.orm import Session + +from app.models import Quality, User, UserData + + +class RankingService: + def __init__(self, db: Session): + self.db = db + + def _load_user_and_data(self, user_auth_id: str) -> UserData | None: + user = self.db.query(User).filter(User.auth_id == user_auth_id).first() + if not user: + return None + return self.db.query(UserData).filter(UserData.user_id == user.id).first() + + def _infer_case(self, data: UserData) -> Dict[str, bool]: + has_cancer = (data.has_blood_cancer or "").lower() == "yes" + caring = (data.caring_for_someone or "").lower() == "yes" + return { + "patient": not caring, + "caregiver_with_cancer": has_cancer and caring, + "caregiver_without_cancer": (not has_cancer) and caring, + } + + def _static_qualities(self, data: UserData, target: str, case: Dict[str, bool]) -> List[Dict]: + qualities = self.db.query(Quality).order_by(Quality.id.asc()).all() + items: List[Dict] = [] + # Determine allowed_scopes for same_diagnosis + allow_self_diag = False + allow_loved_diag = False + if target == "patient": + if case["patient"] and data.diagnosis: + allow_self_diag = True + if (case["caregiver_with_cancer"] or case["caregiver_without_cancer"]) and data.loved_one_diagnosis: + allow_loved_diag = True + else: # target == caregiver (two-column) + if data.loved_one_diagnosis: + allow_loved_diag = True + if case["caregiver_with_cancer"] and data.diagnosis: + allow_self_diag = True + + for q in qualities: + allowed_scopes = ["self", "loved_one"] + if q.slug == "same_diagnosis": + scopes: List[str] = [] + if allow_self_diag: + scopes.append("self") + if allow_loved_diag: + scopes.append("loved_one") + allowed_scopes = scopes if scopes else [] + items.append( + { + "quality_id": q.id, + "slug": q.slug, + "label": q.label, + "allowed_scopes": allowed_scopes, + } + ) + return items + + def _dynamic_options(self, data: UserData, target: str, case: Dict[str, bool]) -> List[Dict]: + options: List[Dict] = [] + + def add_txs(txs, scope: str): + for t in txs: + options.append({"kind": "treatment", "id": t.id, "name": getattr(t, "name", str(t.id)), "scope": scope}) + + def add_exps(exps, scope: str): + for e in exps: + options.append( + {"kind": "experience", "id": e.id, "name": getattr(e, "name", str(e.id)), "scope": scope} + ) + + if target == "patient": + if case["patient"]: + add_txs(data.treatments or [], "self") + add_exps(data.experiences or [], "self") + else: + add_txs(data.loved_one_treatments or [], "loved_one") + add_exps(data.loved_one_experiences or [], "loved_one") + else: # caregiver target + add_txs(data.treatments or [], "self") + add_exps(data.experiences or [], "self") + add_txs(data.loved_one_treatments or [], "loved_one") + add_exps(data.loved_one_experiences or [], "loved_one") + # de-duplicate by (kind,id,scope) + seen = set() + deduped: List[Dict] = [] + for opt in options: + key = (opt["kind"], opt["id"], opt["scope"]) + if key in seen: + continue + seen.add(key) + deduped.append(opt) + # sort by name for stable UI + deduped.sort(key=lambda o: (o["scope"], o["kind"], o["name"].lower())) + return deduped + + def get_options(self, user_auth_id: str, target: str) -> Dict: + data = self._load_user_and_data(user_auth_id) + if not data: + # Return just static qualities if no data + dummy_case = {"patient": False, "caregiver_with_cancer": False, "caregiver_without_cancer": False} + return { + "static_qualities": self._static_qualities(UserData(), target, dummy_case), + "dynamic_options": [], + } + case = self._infer_case(data) + return { + "static_qualities": self._static_qualities(data, target, case), + "dynamic_options": self._dynamic_options(data, target, case), + } diff --git a/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py index cedf2909..8172eca7 100644 --- a/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py +++ b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py @@ -5,15 +5,16 @@ Create Date: 2025-08-31 20:49:12.042730 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. -revision: str = '9d7570569af9' -down_revision: Union[str, None] = 'fb0638c24174' +revision: str = "9d7570569af9" +down_revision: Union[str, None] = "fb0638c24174" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -22,69 +23,68 @@ def upgrade() -> None: # Drop legacy ranking_preferences table if it exists bind = op.get_bind() inspector = sa.inspect(bind) - if 'ranking_preferences' in inspector.get_table_names(): - op.drop_table('ranking_preferences') + if "ranking_preferences" in inspector.get_table_names(): + op.drop_table("ranking_preferences") # Create ENUM types - target_role_enum = postgresql.ENUM('patient', 'caregiver', name='target_role', create_type=False) - kind_enum = postgresql.ENUM('quality', 'treatment', 'experience', name='ranking_kind', create_type=False) - scope_enum = postgresql.ENUM('self', 'loved_one', name='ranking_scope', create_type=False) + target_role_enum = postgresql.ENUM("patient", "caregiver", name="target_role", create_type=False) + kind_enum = postgresql.ENUM("quality", "treatment", "experience", name="ranking_kind", create_type=False) + scope_enum = postgresql.ENUM("self", "loved_one", name="ranking_scope", create_type=False) target_role_enum.create(bind, checkfirst=True) kind_enum.create(bind, checkfirst=True) scope_enum.create(bind, checkfirst=True) # Create unified ranking_preferences table op.create_table( - 'ranking_preferences', - sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False), - sa.Column('target_role', target_role_enum, nullable=False), - sa.Column('kind', kind_enum, nullable=False), - sa.Column('quality_id', sa.Integer(), nullable=True), - sa.Column('treatment_id', sa.Integer(), nullable=True), - sa.Column('experience_id', sa.Integer(), nullable=True), - sa.Column('scope', scope_enum, nullable=False), - sa.Column('rank', sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint('user_id', 'target_role', 'kind', 'quality_id', 'treatment_id', 'experience_id', 'scope'), + "ranking_preferences", + sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False), + sa.Column("target_role", target_role_enum, nullable=False), + sa.Column("kind", kind_enum, nullable=False), + sa.Column("quality_id", sa.Integer(), nullable=True), + sa.Column("treatment_id", sa.Integer(), nullable=True), + sa.Column("experience_id", sa.Integer(), nullable=True), + sa.Column("scope", scope_enum, nullable=False), + sa.Column("rank", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint( + "user_id", "target_role", "kind", "quality_id", "treatment_id", "experience_id", "scope" + ), ) # Add check constraints to enforce exclusivity by kind op.create_check_constraint( - 'ck_ranking_pref_quality_fields', - 'ranking_preferences', + "ck_ranking_pref_quality_fields", + "ranking_preferences", "(kind <> 'quality') OR (quality_id IS NOT NULL AND treatment_id IS NULL AND experience_id IS NULL AND scope IS NOT NULL)", ) op.create_check_constraint( - 'ck_ranking_pref_treatment_fields', - 'ranking_preferences', + "ck_ranking_pref_treatment_fields", + "ranking_preferences", "(kind <> 'treatment') OR (treatment_id IS NOT NULL AND quality_id IS NULL AND experience_id IS NULL AND scope IS NOT NULL)", ) op.create_check_constraint( - 'ck_ranking_pref_experience_fields', - 'ranking_preferences', + "ck_ranking_pref_experience_fields", + "ranking_preferences", "(kind <> 'experience') OR (experience_id IS NOT NULL AND quality_id IS NULL AND treatment_id IS NULL AND scope IS NOT NULL)", ) # Helpful indexes - op.create_index('ix_ranking_pref_user_target', 'ranking_preferences', ['user_id', 'target_role']) - op.create_index('ix_ranking_pref_user_kind', 'ranking_preferences', ['user_id', 'kind']) + op.create_index("ix_ranking_pref_user_target", "ranking_preferences", ["user_id", "target_role"]) + op.create_index("ix_ranking_pref_user_kind", "ranking_preferences", ["user_id", "kind"]) # Seed qualities (idempotent) using slug/label pairs # Using plain SQL for ON CONFLICT DO NOTHING qualities = [ - ('same_age', 'the same age as'), - ('same_gender_identity', 'the same gender identity as'), - ('same_ethnic_or_cultural_group', 'same ethnic or cultural group as'), - ('same_marital_status', 'the same marital status as'), - ('same_parental_status', 'the same parental status as'), - ('same_diagnosis', 'the same diagnosis as'), + ("same_age", "the same age as"), + ("same_gender_identity", "the same gender identity as"), + ("same_ethnic_or_cultural_group", "same ethnic or cultural group as"), + ("same_marital_status", "the same marital status as"), + ("same_parental_status", "the same parental status as"), + ("same_diagnosis", "the same diagnosis as"), ] conn = op.get_bind() # Ensure the sequence is aligned to current MAX(id) to avoid PK conflicts conn.execute( - sa.text( - "SELECT setval(pg_get_serial_sequence('qualities','id'), " - "COALESCE((SELECT MAX(id) FROM qualities), 0))" - ) + sa.text("SELECT setval(pg_get_serial_sequence('qualities','id'), COALESCE((SELECT MAX(id) FROM qualities), 0))") ) # First update labels for any existing slugs for slug, label in qualities: @@ -105,18 +105,18 @@ def upgrade() -> None: def downgrade() -> None: # Drop unified table - op.drop_index('ix_ranking_pref_user_kind', table_name='ranking_preferences') - op.drop_index('ix_ranking_pref_user_target', table_name='ranking_preferences') - op.drop_constraint('ck_ranking_pref_experience_fields', 'ranking_preferences', type_='check') - op.drop_constraint('ck_ranking_pref_treatment_fields', 'ranking_preferences', type_='check') - op.drop_constraint('ck_ranking_pref_quality_fields', 'ranking_preferences', type_='check') - op.drop_table('ranking_preferences') + op.drop_index("ix_ranking_pref_user_kind", table_name="ranking_preferences") + op.drop_index("ix_ranking_pref_user_target", table_name="ranking_preferences") + op.drop_constraint("ck_ranking_pref_experience_fields", "ranking_preferences", type_="check") + op.drop_constraint("ck_ranking_pref_treatment_fields", "ranking_preferences", type_="check") + op.drop_constraint("ck_ranking_pref_quality_fields", "ranking_preferences", type_="check") + op.drop_table("ranking_preferences") # Drop ENUMs if present bind = op.get_bind() - target_role_enum = postgresql.ENUM('patient', 'caregiver', name='target_role') - kind_enum = postgresql.ENUM('quality', 'treatment', 'experience', name='ranking_kind') - scope_enum = postgresql.ENUM('self', 'loved_one', name='ranking_scope') + target_role_enum = postgresql.ENUM("patient", "caregiver", name="target_role") + kind_enum = postgresql.ENUM("quality", "treatment", "experience", name="ranking_kind") + scope_enum = postgresql.ENUM("self", "loved_one", name="ranking_scope") scope_enum.drop(bind, checkfirst=True) kind_enum.drop(bind, checkfirst=True) target_role_enum.drop(bind, checkfirst=True) diff --git a/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py b/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py index 3835e777..88083102 100644 --- a/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py +++ b/backend/migrations/versions/fb0638c24174_merge_heads_before_ranking_work.py @@ -5,11 +5,12 @@ Create Date: 2025-08-31 20:48:25.460360 """ + from typing import Sequence, Union # revision identifiers, used by Alembic. -revision: str = 'fb0638c24174' -down_revision: Union[str, None] = ('7b797eccb3aa', '88c4cf2a6bd2') +revision: str = "fb0638c24174" +down_revision: Union[str, None] = ("7b797eccb3aa", "88c4cf2a6bd2") branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/tests/unit/test_ranking_service.py b/backend/tests/unit/test_ranking_service.py new file mode 100644 index 00000000..4a98fd72 --- /dev/null +++ b/backend/tests/unit/test_ranking_service.py @@ -0,0 +1,192 @@ +import os +from typing import List + +import pytest +from sqlalchemy import create_engine, text +from sqlalchemy.orm import Session, sessionmaker + +from app.models import Experience, Quality, Role, Treatment, User, UserData +from app.schemas.user import UserRole +from app.services.implementations.ranking_service import RankingService + +# Postgres-only configuration (migrations assumed to be applied) +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +if not POSTGRES_DATABASE_URL: + raise RuntimeError( + "POSTGRES_DATABASE_URL is not set. Please export a Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@localhost:5432/llsc" + ) +engine = create_engine(POSTGRES_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +@pytest.fixture(scope="function") +def db_session() -> Session: + session = TestingSessionLocal() + try: + # FK-safe cleanup of related tables used in this test module + session.execute( + text( + "TRUNCATE TABLE ranking_preferences, user_loved_one_experiences, user_loved_one_treatments, " + "user_experiences, user_treatments, user_data, users RESTART IDENTITY CASCADE" + ) + ) + session.commit() + + # Ensure roles exist + existing = {r.id for r in session.query(Role).all()} + roles = [ + Role(id=1, name=UserRole.PARTICIPANT), + Role(id=2, name=UserRole.VOLUNTEER), + Role(id=3, name=UserRole.ADMIN), + ] + for r in roles: + if r.id not in existing: + session.add(r) + session.commit() + + # Qualities should have been seeded by migrations; assert presence + assert session.query(Quality).count() >= 6 + + yield session + finally: + session.rollback() + session.close() + + +def _add_user_data( + session: Session, + *, + auth_id: str, + role_id: int = 1, + has_blood_cancer: str = "no", + caring_for_someone: str = "no", + diagnosis: str | None = None, + loved_one_diagnosis: str | None = None, + self_treatments: List[str] | None = None, + self_experiences: List[str] | None = None, + loved_treatments: List[str] | None = None, + loved_experiences: List[str] | None = None, +) -> User: + user = User(first_name="T", last_name="U", email=f"{auth_id}@ex.com", role_id=role_id, auth_id=auth_id) + session.add(user) + session.commit() + + data = UserData( + user_id=user.id, + has_blood_cancer=has_blood_cancer, + caring_for_someone=caring_for_someone, + diagnosis=diagnosis, + loved_one_diagnosis=loved_one_diagnosis, + ) + session.add(data) + session.flush() + + def get_or_create_treatment(name: str) -> Treatment: + t = session.query(Treatment).filter(Treatment.name == name).first() + if not t: + t = Treatment(name=name) + session.add(t) + session.flush() + return t + + def get_or_create_experience(name: str) -> Experience: + e = session.query(Experience).filter(Experience.name == name).first() + if not e: + e = Experience(name=name) + session.add(e) + session.flush() + return e + + for n in self_treatments or []: + data.treatments.append(get_or_create_treatment(n)) + for n in self_experiences or []: + data.experiences.append(get_or_create_experience(n)) + for n in loved_treatments or []: + data.loved_one_treatments.append(get_or_create_treatment(n)) + for n in loved_experiences or []: + data.loved_one_experiences.append(get_or_create_experience(n)) + + session.commit() + return user + + +@pytest.mark.asyncio +async def test_options_patient_participant_target_patient(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_patient", + has_blood_cancer="yes", + caring_for_someone="no", + diagnosis="AML", + self_treatments=["Chemotherapy"], + self_experiences=["Fatigue"], + ) + + service = RankingService(db_session) + res = service.get_options(user_auth_id=user.auth_id, target="patient") + + # same_diagnosis should allow self only + same_diag = next(q for q in res["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag["allowed_scopes"] == ["self"] + + # dynamic should include self items + scopes = {o["scope"] for o in res["dynamic_options"]} + assert scopes == {"self"} + + +@pytest.mark.asyncio +async def test_options_caregiver_without_cancer(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_cg_no_cancer", + has_blood_cancer="no", + caring_for_someone="yes", + self_treatments=["Chemo Pills"], + self_experiences=["Caregiver Fatigue"], + loved_one_diagnosis="CLL", + loved_treatments=["Immunotherapy"], + loved_experiences=["Anxiety"], + ) + + service = RankingService(db_session) + # target=patient → loved_one options only; same_diagnosis loved_one only + res_p = service.get_options(user_auth_id=user.auth_id, target="patient") + same_diag_p = next(q for q in res_p["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag_p["allowed_scopes"] == ["loved_one"] + assert {o["scope"] for o in res_p["dynamic_options"]} == {"loved_one"} + + # target=caregiver → both scopes; same_diagnosis loved_one only + res_c = service.get_options(user_auth_id=user.auth_id, target="caregiver") + same_diag_c = next(q for q in res_c["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag_c["allowed_scopes"] == ["loved_one"] + assert {o["scope"] for o in res_c["dynamic_options"]} == {"self", "loved_one"} + + +@pytest.mark.asyncio +async def test_options_caregiver_with_cancer(db_session: Session): + user = _add_user_data( + db_session, + auth_id="auth_cg_with_cancer", + has_blood_cancer="yes", + caring_for_someone="yes", + diagnosis="MDS", + loved_one_diagnosis="MM", + self_treatments=["Radiation"], + self_experiences=["PTSD"], + loved_treatments=["Watch and Wait / Active Surveillance"], + loved_experiences=["Communication Challenges"], + ) + + service = RankingService(db_session) + # target=patient → loved_one options only; same_diagnosis loved_one only + res_p = service.get_options(user_auth_id=user.auth_id, target="patient") + same_diag_p = next(q for q in res_p["static_qualities"] if q["slug"] == "same_diagnosis") + assert same_diag_p["allowed_scopes"] == ["loved_one"] + assert {o["scope"] for o in res_p["dynamic_options"]} == {"loved_one"} + + # target=caregiver → both scopes; same_diagnosis includes both scopes + res_c = service.get_options(user_auth_id=user.auth_id, target="caregiver") + same_diag_c = next(q for q in res_c["static_qualities"] if q["slug"] == "same_diagnosis") + assert set(same_diag_c["allowed_scopes"]) == {"self", "loved_one"} + assert {o["scope"] for o in res_c["dynamic_options"]} == {"self", "loved_one"} From 9b323a8849a0ff0b4c2e317f889b69fd60434c9f Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sun, 31 Aug 2025 23:46:34 -0400 Subject: [PATCH 07/11] ranking: add PUT /ranking/preferences with validation (max 5, ranks 1..5 unique); unit tests --- backend/app/routes/ranking.py | 28 +++++++++ .../implementations/ranking_service.py | 59 +++++++++++++++++++ backend/tests/unit/test_ranking_service.py | 46 +++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/backend/app/routes/ranking.py b/backend/app/routes/ranking.py index 84c29f21..ed3e6f1b 100644 --- a/backend/app/routes/ranking.py +++ b/backend/app/routes/ranking.py @@ -48,3 +48,31 @@ async def get_ranking_options( raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +class PreferenceItem(BaseModel): + kind: str # 'quality' | 'treatment' | 'experience' + id: int + scope: str # 'self' | 'loved_one' + rank: int + + +@router.put("/preferences", status_code=204) +async def put_ranking_preferences( + request: Request, + target: str = Query(..., pattern="^(patient|caregiver)$"), + items: List[PreferenceItem] = [], + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +) -> None: + try: + service = RankingService(db) + user_auth_id = request.state.user_id + # Convert Pydantic models to dicts + payload = [i.model_dump() for i in items] + service.save_preferences(user_auth_id=user_auth_id, target=target, items=payload) + return None + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/services/implementations/ranking_service.py b/backend/app/services/implementations/ranking_service.py index 14d4c173..57319a77 100644 --- a/backend/app/services/implementations/ranking_service.py +++ b/backend/app/services/implementations/ranking_service.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.models import Quality, User, UserData +from app.models.RankingPreference import RankingPreference class RankingService: @@ -112,3 +113,61 @@ def get_options(self, user_auth_id: str, target: str) -> Dict: "static_qualities": self._static_qualities(data, target, case), "dynamic_options": self._dynamic_options(data, target, case), } + + # Preferences persistence + def save_preferences(self, user_auth_id: str, target: str, items: List[Dict]) -> None: + user = self.db.query(User).filter(User.auth_id == user_auth_id).first() + if not user: + raise ValueError("User not found") + + # Validate and normalize + normalized: List[RankingPreference] = [] + if len(items) > 5: + raise ValueError("A maximum of 5 ranking items is allowed") + + seen_ranks: set[int] = set() + seen_keys: set[tuple] = set() + for item in items: + kind = item.get("kind") + scope = item.get("scope") + rank = int(item.get("rank")) + item_id = item.get("id") + + if kind not in ("quality", "treatment", "experience"): + raise ValueError(f"Invalid kind: {kind}") + if scope not in ("self", "loved_one"): + raise ValueError(f"Invalid scope: {scope}") + if rank < 1 or rank > 5: + raise ValueError("rank must be between 1 and 5") + if rank in seen_ranks: + raise ValueError("ranks must be unique") + seen_ranks.add(rank) + if not isinstance(item_id, int): + raise ValueError("id must be an integer") + + key = (kind, item_id, scope) + if key in seen_keys: + raise ValueError("duplicate item in payload") + seen_keys.add(key) + + pref = RankingPreference( + user_id=user.id, + target_role=target, + kind=kind, + quality_id=item_id if kind == "quality" else None, + treatment_id=item_id if kind == "treatment" else None, + experience_id=item_id if kind == "experience" else None, + scope=scope, + rank=rank, + ) + normalized.append(pref) + + # Overwrite strategy: delete existing rows for (user, target), then bulk insert + ( + self.db.query(RankingPreference) + .filter(RankingPreference.user_id == user.id, RankingPreference.target_role == target) + .delete(synchronize_session=False) + ) + if normalized: + self.db.bulk_save_objects(normalized) + self.db.commit() diff --git a/backend/tests/unit/test_ranking_service.py b/backend/tests/unit/test_ranking_service.py index 4a98fd72..ad2d9080 100644 --- a/backend/tests/unit/test_ranking_service.py +++ b/backend/tests/unit/test_ranking_service.py @@ -190,3 +190,49 @@ async def test_options_caregiver_with_cancer(db_session: Session): same_diag_c = next(q for q in res_c["static_qualities"] if q["slug"] == "same_diagnosis") assert set(same_diag_c["allowed_scopes"]) == {"self", "loved_one"} assert {o["scope"] for o in res_c["dynamic_options"]} == {"self", "loved_one"} + + +def test_save_preferences_validation(db_session: Session): + # Setup a participant with some options + user = _add_user_data( + db_session, + auth_id="auth_validate", + has_blood_cancer="no", + caring_for_someone="yes", + loved_treatments=["Immunotherapy"], + loved_experiences=["Anxiety"], + ) + service = RankingService(db_session) + + # More than 5 items + too_many = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 2, "scope": "self", "rank": 2}, + {"kind": "quality", "id": 3, "scope": "self", "rank": 3}, + {"kind": "quality", "id": 4, "scope": "self", "rank": 4}, + {"kind": "quality", "id": 5, "scope": "self", "rank": 5}, + {"kind": "quality", "id": 6, "scope": "self", "rank": 5}, + ] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=too_many) + + # Duplicate ranks + dup_ranks = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 2, "scope": "self", "rank": 1}, + ] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=dup_ranks) + + # Rank out of bounds + bad_rank = [{"kind": "quality", "id": 1, "scope": "self", "rank": 6}] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=bad_rank) + + # Duplicate items + dup_items = [ + {"kind": "quality", "id": 1, "scope": "self", "rank": 1}, + {"kind": "quality", "id": 1, "scope": "self", "rank": 2}, + ] + with pytest.raises(ValueError): + service.save_preferences(user_auth_id=user.auth_id, target="patient", items=dup_items) From cf9979483dfd813497b11b2fd936da0676a192ae Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Fri, 5 Sep 2025 23:37:01 -0400 Subject: [PATCH 08/11] wired up rankings frontend to backend - todo: testing for all cases, might need to check to make sure that we are showing the correct options for all cases with pms, we shoudl ahve some status thing that controls when the ranking form should be shown after login vs the current behaviour of always navigating to the intake form (even when its already been submitted) --- backend/app/models/RankingPreference.py | 12 +- backend/app/routes/ranking.py | 23 ++ .../implementations/ranking_service.py | 39 ++- ...ranking_unified_preferences_table_seed_.py | 21 +- .../ranking/caregiver-qualities-form.tsx | 22 +- .../ranking/caregiver-ranking-form.tsx | 37 +-- .../caregiver-two-column-qualities-form.tsx | 36 ++- .../ranking/volunteer-matching-form.tsx | 24 +- .../ranking/volunteer-ranking-form.tsx | 37 +-- frontend/src/pages/participant/ranking.tsx | 281 ++++++++++++++++-- 10 files changed, 415 insertions(+), 117 deletions(-) diff --git a/backend/app/models/RankingPreference.py b/backend/app/models/RankingPreference.py index 701bff31..3c127064 100644 --- a/backend/app/models/RankingPreference.py +++ b/backend/app/models/RankingPreference.py @@ -13,18 +13,18 @@ class RankingPreference(Base): target_role = Column(Enum("patient", "caregiver", name="target_role"), primary_key=True) # kind of item: quality, treatment, or experience - kind = Column(Enum("quality", "treatment", "experience", name="ranking_kind"), primary_key=True) + kind = Column(Enum("quality", "treatment", "experience", name="ranking_kind")) # one of these will be set based on kind - quality_id = Column(Integer, nullable=True, primary_key=True) - treatment_id = Column(Integer, nullable=True, primary_key=True) - experience_id = Column(Integer, nullable=True, primary_key=True) + quality_id = Column(Integer, nullable=True) + treatment_id = Column(Integer, nullable=True) + experience_id = Column(Integer, nullable=True) # scope: self or loved_one; always required (including qualities) - scope = Column(Enum("self", "loved_one", name="ranking_scope"), nullable=False, primary_key=True) + scope = Column(Enum("self", "loved_one", name="ranking_scope"), nullable=False) # rank: 1 is highest - rank = Column(Integer, nullable=False) + rank = Column(Integer, nullable=False, primary_key=True) # relationships user = relationship("User") diff --git a/backend/app/routes/ranking.py b/backend/app/routes/ranking.py index ed3e6f1b..d9e390b8 100644 --- a/backend/app/routes/ranking.py +++ b/backend/app/routes/ranking.py @@ -76,3 +76,26 @@ async def put_ranking_preferences( raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +class CaseResponse(BaseModel): + case: str + has_blood_cancer: str | None = None + caring_for_someone: str | None = None + + +@router.get("/case", response_model=CaseResponse) +async def get_participant_case( + request: Request, + db: Session = Depends(get_db), + authorized: bool = has_roles([UserRole.PARTICIPANT, UserRole.ADMIN]), +) -> CaseResponse: + try: + service = RankingService(db) + user_auth_id = request.state.user_id + result = service.get_case(user_auth_id) + return CaseResponse(**result) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/services/implementations/ranking_service.py b/backend/app/services/implementations/ranking_service.py index 57319a77..ccb076b8 100644 --- a/backend/app/services/implementations/ranking_service.py +++ b/backend/app/services/implementations/ranking_service.py @@ -43,14 +43,28 @@ def _static_qualities(self, data: UserData, target: str, case: Dict[str, bool]) allow_self_diag = True for q in qualities: - allowed_scopes = ["self", "loved_one"] - if q.slug == "same_diagnosis": + # Default allowed scopes by slug + # Only age, gender identity, and diagnosis may include loved_one scope + if q.slug == "same_age": + allowed_scopes = ["self", "loved_one"] + elif q.slug == "same_gender_identity": + allowed_scopes = ["self", "loved_one"] + elif q.slug == "same_ethnic_or_cultural_group": + allowed_scopes = ["self"] + elif q.slug == "same_marital_status": + allowed_scopes = ["self"] + elif q.slug == "same_parental_status": + allowed_scopes = ["self"] + elif q.slug == "same_diagnosis": scopes: List[str] = [] if allow_self_diag: scopes.append("self") if allow_loved_diag: scopes.append("loved_one") allowed_scopes = scopes if scopes else [] + else: + # Any unexpected quality defaults to self only + allowed_scopes = ["self"] items.append( { "quality_id": q.id, @@ -114,6 +128,27 @@ def get_options(self, user_auth_id: str, target: str) -> Dict: "dynamic_options": self._dynamic_options(data, target, case), } + def get_case(self, user_auth_id: str) -> Dict: + """Return inferred participant case and raw flags from UserData.""" + data = self._load_user_and_data(user_auth_id) + if not data: + return { + "case": "caregiver_without_cancer", # safe default if missing + "has_blood_cancer": None, + "caring_for_someone": None, + } + inferred = self._infer_case(data) + case_label = ( + "patient" + if inferred["patient"] + else ("caregiver_with_cancer" if inferred["caregiver_with_cancer"] else "caregiver_without_cancer") + ) + return { + "case": case_label, + "has_blood_cancer": data.has_blood_cancer, + "caring_for_someone": data.caring_for_someone, + } + # Preferences persistence def save_preferences(self, user_auth_id: str, target: str, items: List[Dict]) -> None: user = self.db.query(User).filter(User.auth_id == user_auth_id).first() diff --git a/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py index 8172eca7..e28b27d5 100644 --- a/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py +++ b/backend/migrations/versions/9d7570569af9_ranking_unified_preferences_table_seed_.py @@ -45,9 +45,7 @@ def upgrade() -> None: sa.Column("experience_id", sa.Integer(), nullable=True), sa.Column("scope", scope_enum, nullable=False), sa.Column("rank", sa.Integer(), nullable=False), - sa.PrimaryKeyConstraint( - "user_id", "target_role", "kind", "quality_id", "treatment_id", "experience_id", "scope" - ), + sa.PrimaryKeyConstraint("user_id", "target_role", "rank"), ) # Add check constraints to enforce exclusivity by kind @@ -71,12 +69,27 @@ def upgrade() -> None: op.create_index("ix_ranking_pref_user_target", "ranking_preferences", ["user_id", "target_role"]) op.create_index("ix_ranking_pref_user_kind", "ranking_preferences", ["user_id", "kind"]) + # Remove any legacy/static qualities not in the approved set (idempotent) + op.execute( + """ + DELETE FROM qualities + WHERE slug NOT IN ( + 'same_age', + 'same_gender_identity', + 'same_ethnic_or_cultural_group', + 'same_marital_status', + 'same_parental_status', + 'same_diagnosis' + ); + """ + ) + # Seed qualities (idempotent) using slug/label pairs # Using plain SQL for ON CONFLICT DO NOTHING qualities = [ ("same_age", "the same age as"), ("same_gender_identity", "the same gender identity as"), - ("same_ethnic_or_cultural_group", "same ethnic or cultural group as"), + ("same_ethnic_or_cultural_group", "the same ethnic or cultural group as"), ("same_marital_status", "the same marital status as"), ("same_parental_status", "the same parental status as"), ("same_diagnosis", "the same diagnosis as"), diff --git a/frontend/src/components/ranking/caregiver-qualities-form.tsx b/frontend/src/components/ranking/caregiver-qualities-form.tsx index 867462c2..3a041637 100644 --- a/frontend/src/components/ranking/caregiver-qualities-form.tsx +++ b/frontend/src/components/ranking/caregiver-qualities-form.tsx @@ -14,17 +14,25 @@ const CAREGIVER_QUALITIES = [ 'experience with Fertility Issues', ]; +type DisplayOption = { key: string; label: string }; + interface CaregiverQualitiesFormProps { selectedQualities: string[]; - onQualityToggle: (quality: string) => void; + onQualityToggle: (key: string) => void; onNext: () => void; + options?: DisplayOption[]; } export function CaregiverQualitiesForm({ selectedQualities, onQualityToggle, onNext, + options, }: CaregiverQualitiesFormProps) { + const qualities: DisplayOption[] = + options && options.length > 0 + ? options + : CAREGIVER_QUALITIES.map((label) => ({ key: label, label })); return ( - {CAREGIVER_QUALITIES.map((quality) => ( + {qualities.map((opt) => ( onQualityToggle(quality)} - disabled={!selectedQualities.includes(quality) && selectedQualities.length >= 5} + key={opt.key} + checked={selectedQualities.includes(opt.key)} + onChange={() => onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && selectedQualities.length >= 5} > - {quality} + {opt.label} ))} diff --git a/frontend/src/components/ranking/caregiver-ranking-form.tsx b/frontend/src/components/ranking/caregiver-ranking-form.tsx index f99e530c..087d0d7d 100644 --- a/frontend/src/components/ranking/caregiver-ranking-form.tsx +++ b/frontend/src/components/ranking/caregiver-ranking-form.tsx @@ -7,40 +7,31 @@ interface CaregiverRankingFormProps { rankedPreferences: string[]; onMoveItem: (fromIndex: number, toIndex: number) => void; onSubmit: () => void; + itemScopes?: Array<'self' | 'loved_one'>; + itemKinds?: Array<'quality' | 'treatment' | 'experience'>; } export function CaregiverRankingForm({ rankedPreferences, onMoveItem, onSubmit, + itemScopes, + itemKinds, }: CaregiverRankingFormProps) { const [draggedIndex, setDraggedIndex] = React.useState(null); const [dropTargetIndex, setDropTargetIndex] = React.useState(null); - const renderStatementWithBold = (statement: string) => { - const boldPhrases = [ - 'the same age as my loved one', - 'the same diagnosis as my loved one', - 'experience with Relapse', - 'experience with Anxiety / Depression', - 'experience with returning to school or work during/after treatment', - ]; - - const phraseToBold = boldPhrases.find((phrase) => statement.includes(phrase)); - - if (!phraseToBold) { - return statement; - } - - const parts = statement.split(phraseToBold); - + const renderStatement = (index: number, label: string) => { + const kind = itemKinds?.[index]; + const scope = itemScopes?.[index]; + const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; + const prefix = isLovedOneQuality + ? 'I would prefer a volunteer whose loved one is ' + : 'I would prefer a volunteer with '; return ( <> - {parts[0]} - - {phraseToBold} - - {parts[1]} + {prefix} + {label} ); }; @@ -210,7 +201,7 @@ export function CaregiverRankingForm({ flex="1" userSelect="none" > - {renderStatementWithBold(statement)} + {renderStatement(index, statement)} diff --git a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx index 67724266..c4b89d85 100644 --- a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx +++ b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx @@ -5,8 +5,10 @@ import { COLORS } from '@/constants/form'; interface CaregiverTwoColumnQualitiesFormProps { selectedQualities: string[]; - onQualityToggle: (quality: string) => void; + onQualityToggle: (key: string) => void; onNext: () => void; + leftOptions?: { key: string; label: string }[]; + rightOptions?: { key: string; label: string }[]; } // Left column options – The volunteer is/has… ("…as me" phrasing) @@ -40,9 +42,17 @@ export function CaregiverTwoColumnQualitiesForm({ selectedQualities, onQualityToggle, onNext, + leftOptions, + rightOptions, }: CaregiverTwoColumnQualitiesFormProps) { const maxSelected = 5; const reachedMax = selectedQualities.length >= maxSelected; + const volunteerOptions = (leftOptions && leftOptions.length > 0) + ? leftOptions + : VOLUNTEER_OPTIONS.map((label) => ({ key: label, label })); + const lovedOneOptions = (rightOptions && rightOptions.length > 0) + ? rightOptions + : LOVED_ONE_OPTIONS.map((label) => ({ key: label, label })); return ( @@ -133,19 +143,19 @@ export function CaregiverTwoColumnQualitiesForm({ The volunteer is/has... - {VOLUNTEER_OPTIONS.map((quality) => ( + {volunteerOptions.map((opt) => ( onQualityToggle(quality)} - disabled={!selectedQualities.includes(quality) && reachedMax} + key={`vol-${opt.key}`} + checked={selectedQualities.includes(opt.key)} + onChange={() => onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && reachedMax} > - {quality} + {opt.label} ))} @@ -162,19 +172,19 @@ export function CaregiverTwoColumnQualitiesForm({ Their loved one is/has... - {LOVED_ONE_OPTIONS.map((quality) => ( + {lovedOneOptions.map((opt) => ( onQualityToggle(quality)} - disabled={!selectedQualities.includes(quality) && reachedMax} + key={`loved-${opt.key}`} + checked={selectedQualities.includes(opt.key)} + onChange={() => onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && reachedMax} > - {quality} + {opt.label} ))} diff --git a/frontend/src/components/ranking/volunteer-matching-form.tsx b/frontend/src/components/ranking/volunteer-matching-form.tsx index b59030d6..bf3da999 100644 --- a/frontend/src/components/ranking/volunteer-matching-form.tsx +++ b/frontend/src/components/ranking/volunteer-matching-form.tsx @@ -17,17 +17,25 @@ const MATCHING_QUALITIES = [ 'experience with Fertility Issues', ]; +type DisplayOption = { key: string; label: string }; + interface VolunteerMatchingFormProps { - selectedQualities: string[]; - onQualityToggle: (quality: string) => void; + selectedQualities: string[]; // stores option keys + onQualityToggle: (key: string) => void; onNext: () => void; + options?: DisplayOption[]; } export function VolunteerMatchingForm({ selectedQualities, onQualityToggle, onNext, + options, }: VolunteerMatchingFormProps) { + const qualities: DisplayOption[] = + options && options.length > 0 + ? options + : MATCHING_QUALITIES.map((label) => ({ key: label, label })); return ( - {MATCHING_QUALITIES.map((quality) => ( + {qualities.map((opt) => ( onQualityToggle(quality)} - disabled={!selectedQualities.includes(quality) && selectedQualities.length >= 5} + key={opt.key} + checked={selectedQualities.includes(opt.key)} + onChange={() => onQualityToggle(opt.key)} + disabled={!selectedQualities.includes(opt.key) && selectedQualities.length >= 5} > - {quality} + {opt.label} ))} diff --git a/frontend/src/components/ranking/volunteer-ranking-form.tsx b/frontend/src/components/ranking/volunteer-ranking-form.tsx index 83f805d2..19abc75a 100644 --- a/frontend/src/components/ranking/volunteer-ranking-form.tsx +++ b/frontend/src/components/ranking/volunteer-ranking-form.tsx @@ -7,40 +7,31 @@ interface VolunteerRankingFormProps { rankedPreferences: string[]; onMoveItem: (fromIndex: number, toIndex: number) => void; onSubmit: () => void; + itemScopes?: Array<'self' | 'loved_one'>; + itemKinds?: Array<'quality' | 'treatment' | 'experience'>; } export function VolunteerRankingForm({ rankedPreferences, onMoveItem, onSubmit, + itemScopes, + itemKinds, }: VolunteerRankingFormProps) { const [draggedIndex, setDraggedIndex] = React.useState(null); const [dropTargetIndex, setDropTargetIndex] = React.useState(null); - const renderStatementWithBold = (statement: string) => { - const boldPhrases = [ - 'the same age as me', - 'the same diagnosis as me', - 'the same marital status as me', - 'the same ethnic or cultural group as me', - 'the same parental status as me', - ]; - - const phraseToBold = boldPhrases.find((phrase) => statement.includes(phrase)); - - if (!phraseToBold) { - return statement; - } - - const parts = statement.split(phraseToBold); - + const renderStatement = (index: number, label: string) => { + const kind = itemKinds?.[index]; + const scope = itemScopes?.[index]; + const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; + const prefix = isLovedOneQuality + ? 'I would prefer a volunteer whose loved one is ' + : 'I would prefer a volunteer with '; return ( <> - {parts[0]} - - {phraseToBold} - - {parts[1]} + {prefix} + {label} ); }; @@ -206,7 +197,7 @@ export function VolunteerRankingForm({ flex="1" userSelect="none" > - {renderStatementWithBold(statement)} + {renderStatement(index, statement)} diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx index 42d45144..2a54ab50 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { Box, Flex, Heading, Text, VStack } from '@chakra-ui/react'; import { UserIcon, CheckMarkIcon, WelcomeScreen } from '@/components/ui'; import { @@ -10,6 +10,8 @@ import { CaregiverTwoColumnQualitiesForm, } from '@/components/ranking'; import { COLORS } from '@/constants/form'; +import baseAPIClient from '@/APIClients/baseAPIClient'; +import { auth } from '@/config/firebase'; const RANKING_STATEMENTS = [ 'I would prefer a volunteer with the same age as me', @@ -28,8 +30,9 @@ const CAREGIVER_RANKING_STATEMENTS = [ ]; interface RankingFormData { - selectedQualities: string[]; - rankedPreferences: string[]; + selectedQualities: string[]; // option keys + rankedPreferences: string[]; // labels to display + rankedKeys: string[]; // option keys in rank order volunteerType?: string; isCaregiverVolunteerFlow?: boolean; } @@ -39,15 +42,148 @@ interface ParticipantRankingPageProps { caregiverHasCancer?: boolean; } +type Scope = 'self' | 'loved_one'; + +// NOTE: Responses are camelCased by our Axios interceptor +type StaticQuality = { qualityId: number; slug: string; label: string; allowedScopes?: Scope[] }; + +type DynamicOption = { kind: 'treatment' | 'experience'; id: number; name: string; scope: Scope }; + +type OptionsResponse = { staticQualities: StaticQuality[]; dynamicOptions: DynamicOption[] }; + +type DisplayOption = { + key: string; + label: string; + meta: { kind: 'quality' | 'treatment' | 'experience'; id: number; scope: Scope }; +}; + export default function ParticipantRankingPage({ participantType = 'caregiver', caregiverHasCancer = true, }: ParticipantRankingPageProps) { + const [derivedParticipantType, setDerivedParticipantType] = useState<'cancerPatient' | 'caregiver' | null>(null); + const [derivedCaregiverHasCancer, setDerivedCaregiverHasCancer] = useState(null); + const [isLoadingCase, setIsLoadingCase] = useState(false); + + useEffect(() => { + let unsubscribe: (() => void) | undefined; + const run = async () => { + try { + setIsLoadingCase(true); + const { data } = await baseAPIClient.get('/ranking/case'); + if (data && data.case) { + if (data.case === 'patient') { + setDerivedParticipantType('cancerPatient'); + setDerivedCaregiverHasCancer(null); + } else if (data.case === 'caregiver_with_cancer') { + setDerivedParticipantType('caregiver'); + setDerivedCaregiverHasCancer(true); + } else if (data.case === 'caregiver_without_cancer') { + setDerivedParticipantType('caregiver'); + setDerivedCaregiverHasCancer(false); + } + } + } catch { + // Non-blocking for now + } finally { + setIsLoadingCase(false); + } + }; + if (auth.currentUser) { + run(); + } else { + unsubscribe = auth.onIdTokenChanged((user) => { + if (user) { + run(); + if (unsubscribe) unsubscribe(); + } + }); + } + return () => { + if (unsubscribe) unsubscribe(); + }; + }, []); + + useEffect(() => { + if (derivedParticipantType !== null || derivedCaregiverHasCancer !== null) { + console.log('[RANKING_CASE]', { + participantType: derivedParticipantType, + caregiverHasCancer: derivedCaregiverHasCancer, + isLoadingCase, + }); + } + }, [derivedParticipantType, derivedCaregiverHasCancer, isLoadingCase]); + + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + const [optionsError, setOptionsError] = useState(null); + const [singleColumnOptions, setSingleColumnOptions] = useState([]); + const [leftColumnOptions, setLeftColumnOptions] = useState([]); + const [rightColumnOptions, setRightColumnOptions] = useState([]); + + // Helper to index options by key for quick lookups + const optionsIndex = useMemo(() => { + const index: Record = {}; + [...singleColumnOptions, ...leftColumnOptions, ...rightColumnOptions].forEach((opt) => { + index[opt.key] = opt; + }); + return index; + }, [singleColumnOptions, leftColumnOptions, rightColumnOptions]); + + const fetchOptions = async (target: 'patient' | 'caregiver') => { + try { + setIsLoadingOptions(true); + setOptionsError(null); + const { data } = await baseAPIClient.get('/ranking/options', { params: { target } }); + const staticQualitiesExpanded: DisplayOption[] = (data.staticQualities || []).flatMap((q) => { + const scopes = q.allowedScopes || []; + return scopes.map((s) => ({ + key: `quality:${q.qualityId}:${s}`, + label: `${q.label} ${s === 'self' ? 'me' : 'my loved one'}`, + meta: { kind: 'quality', id: q.qualityId, scope: s }, + })); + }); + const dynamicOptions: DisplayOption[] = (data.dynamicOptions || []).map((o) => ({ + key: `${o.kind}:${o.id}:${o.scope}`, + label: `experience with ${o.name}`, + meta: { kind: o.kind, id: o.id, scope: o.scope }, + })); + + const combinedSingle = [...staticQualitiesExpanded, ...dynamicOptions]; + setSingleColumnOptions(combinedSingle); + + const left: DisplayOption[] = []; + const right: DisplayOption[] = []; + staticQualitiesExpanded.forEach((opt) => { + if (opt.meta.scope === 'self') left.push(opt); + if (opt.meta.scope === 'loved_one') right.push(opt); + }); + dynamicOptions.forEach((opt) => { + if (opt.meta.scope === 'self') left.push(opt); + if (opt.meta.scope === 'loved_one') right.push(opt); + }); + setLeftColumnOptions(left); + setRightColumnOptions(right); + } catch { + setOptionsError('Failed to load options. Please try again.'); + } finally { + setIsLoadingOptions(false); + } + }; + + // Prefer derived case values from backend when available + const effectiveParticipantType: 'cancerPatient' | 'caregiver' = + (derivedParticipantType as 'cancerPatient' | 'caregiver') ?? participantType; + const effectiveCaregiverHasCancer: boolean = + derivedParticipantType === 'caregiver' + ? (derivedCaregiverHasCancer ?? caregiverHasCancer) + : caregiverHasCancer; + const [currentStep, setCurrentStep] = useState(1); const [formData, setFormData] = useState({ selectedQualities: [], rankedPreferences: participantType === 'caregiver' ? [...CAREGIVER_RANKING_STATEMENTS] : [...RANKING_STATEMENTS], + rankedKeys: [], volunteerType: participantType === 'caregiver' ? '' : undefined, isCaregiverVolunteerFlow: undefined, }); @@ -62,13 +198,13 @@ export default function ParticipantRankingPage({ ); const QualitiesScreen = () => { - const toggleQuality = (quality: string) => { + const toggleQuality = (key: string) => { setFormData((prev) => ({ ...prev, - selectedQualities: prev.selectedQualities.includes(quality) - ? prev.selectedQualities.filter((q) => q !== quality) + selectedQualities: prev.selectedQualities.includes(key) + ? prev.selectedQualities.filter((q) => q !== key) : prev.selectedQualities.length < 5 - ? [...prev.selectedQualities, quality] + ? [...prev.selectedQualities, key] : prev.selectedQualities, })); }; @@ -77,11 +213,18 @@ export default function ParticipantRankingPage({ setFormData((prev) => ({ ...prev, volunteerType: type, - // Derive explicit flow flag to avoid any async state timing issues isCaregiverVolunteerFlow: type === 'caringForLovedOne', })); }; + // For patient flow, fetch options once + useEffect(() => { + if (effectiveParticipantType === 'cancerPatient' && singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [effectiveParticipantType]); + return ( - {participantType === 'caregiver' ? ( + {optionsError ? ( + {optionsError} + ) : null} + + {effectiveParticipantType === 'caregiver' ? ( { - // Ensure state is set before navigating + onNext={async (type) => { setFormData((prev) => ({ ...prev, volunteerType: type, isCaregiverVolunteerFlow: type === 'caringForLovedOne', })); + // Prefetch options based on caregiver choice before advancing + const target: 'patient' | 'caregiver' = + type === 'caringForLovedOne' ? 'caregiver' : 'patient'; + try { + await fetchOptions(target); + } catch {} setCurrentStep(3); }} /> @@ -110,11 +262,15 @@ export default function ParticipantRankingPage({ { - // Build ranking list from selected qualities + // Build ranking arrays from selected keys + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); setFormData((prev) => ({ ...prev, - rankedPreferences: [...prev.selectedQualities], + rankedPreferences: [...labels], + rankedKeys: [...keys], })); setCurrentStep(3); }} @@ -126,13 +282,13 @@ export default function ParticipantRankingPage({ }; const CaregiverQualitiesScreen = () => { - const toggleQuality = (quality: string) => { + const toggleQuality = (key: string) => { setFormData((prev) => ({ ...prev, - selectedQualities: prev.selectedQualities.includes(quality) - ? prev.selectedQualities.filter((q) => q !== quality) + selectedQualities: prev.selectedQualities.includes(key) + ? prev.selectedQualities.filter((q) => q !== key) : prev.selectedQualities.length < 5 - ? [...prev.selectedQualities, quality] + ? [...prev.selectedQualities, key] : prev.selectedQualities, })); }; @@ -150,28 +306,42 @@ export default function ParticipantRankingPage({ {// Prefer explicit flag; otherwise infer from value (formData.isCaregiverVolunteerFlow ?? false) || formData.volunteerType === 'caringForLovedOne' || - // Fallback: any non-similarDiagnosis value implies the loved-one flow (!!formData.volunteerType && formData.volunteerType !== 'similarDiagnosis') ? ( { + if (leftColumnOptions.length === 0 && rightColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('caregiver'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); setFormData((prev) => ({ ...prev, - rankedPreferences: [...prev.selectedQualities], + rankedPreferences: [...labels], + rankedKeys: [...keys], })); setCurrentStep(4); }} /> - ) : caregiverHasCancer ? ( + ) : effectiveCaregiverHasCancer ? ( formData.volunteerType === 'similarDiagnosis' ? ( { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); setFormData((prev) => ({ ...prev, - rankedPreferences: [...prev.selectedQualities], + rankedPreferences: [...labels], + rankedKeys: [...keys], })); setCurrentStep(4); }} @@ -180,10 +350,17 @@ export default function ParticipantRankingPage({ { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); setFormData((prev) => ({ ...prev, - rankedPreferences: [...prev.selectedQualities], + rankedPreferences: [...labels], + rankedKeys: [...keys], })); setCurrentStep(4); }} @@ -193,10 +370,17 @@ export default function ParticipantRankingPage({ { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); setFormData((prev) => ({ ...prev, - rankedPreferences: [...prev.selectedQualities], + rankedPreferences: [...labels], + rankedKeys: [...keys], })); setCurrentStep(4); }} @@ -210,14 +394,45 @@ export default function ParticipantRankingPage({ const RankingScreen = () => { const moveItem = (fromIndex: number, toIndex: number) => { setFormData((prev) => { - const newRanked = [...prev.rankedPreferences]; - const [movedItem] = newRanked.splice(fromIndex, 1); - newRanked.splice(toIndex, 0, movedItem); - return { ...prev, rankedPreferences: newRanked }; + const newLabels = [...prev.rankedPreferences]; + const newKeys = [...prev.rankedKeys]; + const [movedLabel] = newLabels.splice(fromIndex, 1); + const [movedKey] = newKeys.splice(fromIndex, 1); + newLabels.splice(toIndex, 0, movedLabel); + newKeys.splice(toIndex, 0, movedKey); + return { ...prev, rankedPreferences: newLabels, rankedKeys: newKeys }; }); }; - const nextStep = participantType === 'caregiver' ? 5 : 4; + const nextStep = effectiveParticipantType === 'caregiver' ? 5 : 4; + + const handleSubmit = async () => { + // Determine target based on flow + let target: 'patient' | 'caregiver' = 'patient'; + if ( + effectiveParticipantType === 'caregiver' && + ((formData.isCaregiverVolunteerFlow ?? false) || formData.volunteerType === 'caringForLovedOne') + ) { + target = 'caregiver'; + } else { + target = 'patient'; + } + const items = formData.rankedKeys.map((key, idx) => { + const opt = optionsIndex[key]; + return { + kind: opt?.meta.kind === 'quality' ? 'quality' : opt?.meta.kind, + id: opt?.meta.id, + scope: opt?.meta.scope, + rank: idx + 1, + }; + }); + try { + await baseAPIClient.put('/ranking/preferences', items, { params: { target } }); + setCurrentStep(nextStep); + } catch (e) { + console.error('Failed to save preferences', e); + } + }; return ( @@ -229,17 +444,21 @@ export default function ParticipantRankingPage({ boxShadow="0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)" p={10} > - {participantType === 'caregiver' ? ( + {effectiveParticipantType === 'caregiver' ? ( setCurrentStep(nextStep)} + onSubmit={handleSubmit} + itemScopes={formData.rankedKeys.map((k) => optionsIndex[k]?.meta.scope || 'self')} + itemKinds={formData.rankedKeys.map((k) => optionsIndex[k]?.meta.kind || 'quality')} /> ) : ( setCurrentStep(nextStep)} + onSubmit={handleSubmit} + itemScopes={formData.rankedKeys.map((k) => optionsIndex[k]?.meta.scope || 'self')} + itemKinds={formData.rankedKeys.map((k) => optionsIndex[k]?.meta.kind || 'quality')} /> )} From e9dd5ebff17fb4da09613552ea17fc46223ec7bb Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sat, 6 Sep 2025 18:22:16 -0400 Subject: [PATCH 09/11] rankings: basically done - some minor fixes and test changes. still need to add logic for having some sort of status taht determines when to show the intake form, when to show the intake thank you form, when to show ranking form, and when to show ranking thank you form after login as well as gating certain pages when not authed or wrong status --- .github/workflows/backend-ci.yml | 10 +-- backend/tests/unit/test_ranking_service.py | 62 ++++++++++++++----- backend/tests/unit/test_user.py | 11 ++-- .../ranking/caregiver-ranking-form.tsx | 5 +- .../ranking/volunteer-ranking-form.tsx | 5 +- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index e142d17e..b183bb37 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -16,7 +16,7 @@ jobs: test: runs-on: ubuntu-latest env: - POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test + POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test strategy: matrix: @@ -65,13 +65,14 @@ jobs: - name: Set up environment variables working-directory: ./backend run: | - echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env echo "SECRET_KEY=test-secret-key-for-ci" >> .env echo "ENVIRONMENT=test" >> .env - name: Run database migrations working-directory: ./backend run: | + export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL" pdm run alembic upgrade heads - name: Run linting @@ -118,7 +119,7 @@ jobs: runs-on: ubuntu-latest needs: test env: - POSTGRES_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test + POSTGRES_TEST_DATABASE_URL: postgresql://testuser:testpassword@localhost:5432/llsc_test services: postgres: @@ -155,7 +156,7 @@ jobs: - name: Set up environment variables working-directory: ./backend run: | - echo "POSTGRES_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env + echo "POSTGRES_TEST_DATABASE_URL=postgresql://testuser:testpassword@localhost:5432/llsc_test" >> .env echo "SECRET_KEY=test-secret-key-for-ci" >> .env echo "ENVIRONMENT=test" >> .env echo "TEST_SCRIPT_BACKEND_URL=http://localhost:8000" >> .env @@ -165,6 +166,7 @@ jobs: - name: Run database migrations working-directory: ./backend run: | + export POSTGRES_DATABASE_URL="$POSTGRES_TEST_DATABASE_URL" pdm run alembic upgrade heads - name: Start backend server diff --git a/backend/tests/unit/test_ranking_service.py b/backend/tests/unit/test_ranking_service.py index ad2d9080..a26ac809 100644 --- a/backend/tests/unit/test_ranking_service.py +++ b/backend/tests/unit/test_ranking_service.py @@ -3,6 +3,7 @@ import pytest from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session, sessionmaker from app.models import Experience, Quality, Role, Treatment, User, UserData @@ -10,11 +11,11 @@ from app.services.implementations.ranking_service import RankingService # Postgres-only configuration (migrations assumed to be applied) -POSTGRES_DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") if not POSTGRES_DATABASE_URL: raise RuntimeError( "POSTGRES_DATABASE_URL is not set. Please export a Postgres URL, e.g. " - "postgresql+psycopg2://postgres:postgres@localhost:5432/llsc" + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" ) engine = create_engine(POSTGRES_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -48,6 +49,19 @@ def db_session() -> Session: # Qualities should have been seeded by migrations; assert presence assert session.query(Quality).count() >= 6 + # Ensure sequences are aligned after seeding (avoid PK collisions when inserting) + session.execute( + text( + "SELECT setval(pg_get_serial_sequence('treatments','id'), COALESCE((SELECT MAX(id) FROM treatments), 0))" + ) + ) + session.execute( + text( + "SELECT setval(pg_get_serial_sequence('experiences','id'), COALESCE((SELECT MAX(id) FROM experiences), 0))" + ) + ) + session.commit() + yield session finally: session.rollback() @@ -84,19 +98,39 @@ def _add_user_data( def get_or_create_treatment(name: str) -> Treatment: t = session.query(Treatment).filter(Treatment.name == name).first() - if not t: - t = Treatment(name=name) - session.add(t) - session.flush() - return t + if t: + return t + for _ in range(2): + try: + t = Treatment(name=name) + session.add(t) + session.flush() + return t + except IntegrityError: + session.rollback() + # Sequence collision consumed an id; retry insert + t = session.query(Treatment).filter(Treatment.name == name).first() + if t: + return t + # Final attempt to read existing + return session.query(Treatment).filter(Treatment.name == name).first() def get_or_create_experience(name: str) -> Experience: e = session.query(Experience).filter(Experience.name == name).first() - if not e: - e = Experience(name=name) - session.add(e) - session.flush() - return e + if e: + return e + for _ in range(2): + try: + e = Experience(name=name) + session.add(e) + session.flush() + return e + except IntegrityError: + session.rollback() + e = session.query(Experience).filter(Experience.name == name).first() + if e: + return e + return session.query(Experience).filter(Experience.name == name).first() for n in self_treatments or []: data.treatments.append(get_or_create_treatment(n)) @@ -142,7 +176,7 @@ async def test_options_caregiver_without_cancer(db_session: Session): auth_id="auth_cg_no_cancer", has_blood_cancer="no", caring_for_someone="yes", - self_treatments=["Chemo Pills"], + self_treatments=["Oral Chemotherapy"], self_experiences=["Caregiver Fatigue"], loved_one_diagnosis="CLL", loved_treatments=["Immunotherapy"], @@ -172,7 +206,7 @@ async def test_options_caregiver_with_cancer(db_session: Session): caring_for_someone="yes", diagnosis="MDS", loved_one_diagnosis="MM", - self_treatments=["Radiation"], + self_treatments=["Radiation Therapy"], self_experiences=["PTSD"], loved_treatments=["Watch and Wait / Active Surveillance"], loved_experiences=["Communication Challenges"], diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 643f22ac..613d7fac 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -19,11 +19,11 @@ from app.services.implementations.user_service import UserService # Test DB Configuration - Always require Postgres for full parity -POSTGRES_DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +POSTGRES_DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") if not POSTGRES_DATABASE_URL: raise RuntimeError( "POSTGRES_DATABASE_URL is not set. Please export a Postgres URL, e.g. " - "postgresql+psycopg2://postgres:postgres@localhost:5432/llsc" + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" ) engine = create_engine(POSTGRES_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -533,11 +533,10 @@ async def test_update_user_by_id(db_session): db_session.rollback() raise - # Error case tests @pytest.mark.asyncio async def test_delete_nonexistent_user_by_email(db_session): - """Test deleting a non-existent user by email""" + """Test deleting a non-existent user""" user_service = UserService(db_session) with pytest.raises(HTTPException) as exc_info: await user_service.delete_user_by_email("nonexistent@example.com") @@ -546,7 +545,7 @@ async def test_delete_nonexistent_user_by_email(db_session): @pytest.mark.asyncio async def test_delete_nonexistent_user_by_id(db_session): - """Test deleting a non-existent user by ID""" + """Test deleting a non-existent user""" user_service = UserService(db_session) with pytest.raises(HTTPException) as exc_info: await user_service.delete_user_by_id("00000000-0000-0000-0000-000000000000") @@ -563,7 +562,7 @@ async def test_get_nonexistent_user_by_id(db_session): def test_get_nonexistent_user_by_email(db_session): - """Test getting a non-existent user by email""" + """Test getting user by email""" user_service = UserService(db_session) with pytest.raises(ValueError) as exc_info: user_service.get_user_by_email("nonexistent@example.com") diff --git a/frontend/src/components/ranking/caregiver-ranking-form.tsx b/frontend/src/components/ranking/caregiver-ranking-form.tsx index 087d0d7d..4210f8bb 100644 --- a/frontend/src/components/ranking/caregiver-ranking-form.tsx +++ b/frontend/src/components/ranking/caregiver-ranking-form.tsx @@ -25,9 +25,12 @@ export function CaregiverRankingForm({ const kind = itemKinds?.[index]; const scope = itemScopes?.[index]; const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; + const isLovedOneDynamic = (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; const prefix = isLovedOneQuality ? 'I would prefer a volunteer whose loved one is ' - : 'I would prefer a volunteer with '; + : isLovedOneDynamic + ? 'I would prefer a volunteer whose loved one has ' + : 'I would prefer a volunteer with '; return ( <> {prefix} diff --git a/frontend/src/components/ranking/volunteer-ranking-form.tsx b/frontend/src/components/ranking/volunteer-ranking-form.tsx index 19abc75a..e2c37f8c 100644 --- a/frontend/src/components/ranking/volunteer-ranking-form.tsx +++ b/frontend/src/components/ranking/volunteer-ranking-form.tsx @@ -25,9 +25,12 @@ export function VolunteerRankingForm({ const kind = itemKinds?.[index]; const scope = itemScopes?.[index]; const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; + const isLovedOneDynamic = (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; const prefix = isLovedOneQuality ? 'I would prefer a volunteer whose loved one is ' - : 'I would prefer a volunteer with '; + : isLovedOneDynamic + ? 'I would prefer a volunteer whose loved one has ' + : 'I would prefer a volunteer with '; return ( <> {prefix} From c5b30afb958406b11a399d5dc128dbfdb45c7e64 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sat, 6 Sep 2025 18:41:44 -0400 Subject: [PATCH 10/11] chore(format): fix CI formatting for backend (ruff) and frontend (prettier) --- backend/tests/unit/test_user.py | 1 + .../ranking/caregiver-ranking-form.tsx | 7 +- .../caregiver-two-column-qualities-form.tsx | 14 +- .../ranking/volunteer-ranking-form.tsx | 7 +- frontend/src/components/ui/checkmark-icon.tsx | 2 +- frontend/src/components/ui/drag-icon.tsx | 2 +- frontend/src/components/ui/index.ts | 2 +- frontend/src/components/ui/user-icon.tsx | 20 +-- frontend/src/components/ui/welcome-screen.tsx | 13 +- frontend/src/pages/participant/ranking.tsx | 127 ++++++++++-------- 10 files changed, 102 insertions(+), 93 deletions(-) diff --git a/backend/tests/unit/test_user.py b/backend/tests/unit/test_user.py index 613d7fac..05a525eb 100644 --- a/backend/tests/unit/test_user.py +++ b/backend/tests/unit/test_user.py @@ -533,6 +533,7 @@ async def test_update_user_by_id(db_session): db_session.rollback() raise + # Error case tests @pytest.mark.asyncio async def test_delete_nonexistent_user_by_email(db_session): diff --git a/frontend/src/components/ranking/caregiver-ranking-form.tsx b/frontend/src/components/ranking/caregiver-ranking-form.tsx index 4210f8bb..37a43358 100644 --- a/frontend/src/components/ranking/caregiver-ranking-form.tsx +++ b/frontend/src/components/ranking/caregiver-ranking-form.tsx @@ -25,7 +25,8 @@ export function CaregiverRankingForm({ const kind = itemKinds?.[index]; const scope = itemScopes?.[index]; const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; - const isLovedOneDynamic = (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; + const isLovedOneDynamic = + (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; const prefix = isLovedOneQuality ? 'I would prefer a volunteer whose loved one is ' : isLovedOneDynamic @@ -34,7 +35,9 @@ export function CaregiverRankingForm({ return ( <> {prefix} - {label} + + {label} + ); }; diff --git a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx index c4b89d85..c8516b8c 100644 --- a/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx +++ b/frontend/src/components/ranking/caregiver-two-column-qualities-form.tsx @@ -47,12 +47,14 @@ export function CaregiverTwoColumnQualitiesForm({ }: CaregiverTwoColumnQualitiesFormProps) { const maxSelected = 5; const reachedMax = selectedQualities.length >= maxSelected; - const volunteerOptions = (leftOptions && leftOptions.length > 0) - ? leftOptions - : VOLUNTEER_OPTIONS.map((label) => ({ key: label, label })); - const lovedOneOptions = (rightOptions && rightOptions.length > 0) - ? rightOptions - : LOVED_ONE_OPTIONS.map((label) => ({ key: label, label })); + const volunteerOptions = + leftOptions && leftOptions.length > 0 + ? leftOptions + : VOLUNTEER_OPTIONS.map((label) => ({ key: label, label })); + const lovedOneOptions = + rightOptions && rightOptions.length > 0 + ? rightOptions + : LOVED_ONE_OPTIONS.map((label) => ({ key: label, label })); return ( diff --git a/frontend/src/components/ranking/volunteer-ranking-form.tsx b/frontend/src/components/ranking/volunteer-ranking-form.tsx index e2c37f8c..a1491918 100644 --- a/frontend/src/components/ranking/volunteer-ranking-form.tsx +++ b/frontend/src/components/ranking/volunteer-ranking-form.tsx @@ -25,7 +25,8 @@ export function VolunteerRankingForm({ const kind = itemKinds?.[index]; const scope = itemScopes?.[index]; const isLovedOneQuality = kind === 'quality' && scope === 'loved_one'; - const isLovedOneDynamic = (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; + const isLovedOneDynamic = + (kind === 'treatment' || kind === 'experience') && scope === 'loved_one'; const prefix = isLovedOneQuality ? 'I would prefer a volunteer whose loved one is ' : isLovedOneDynamic @@ -34,7 +35,9 @@ export function VolunteerRankingForm({ return ( <> {prefix} - {label} + + {label} + ); }; diff --git a/frontend/src/components/ui/checkmark-icon.tsx b/frontend/src/components/ui/checkmark-icon.tsx index 09381c13..0c642cf3 100644 --- a/frontend/src/components/ui/checkmark-icon.tsx +++ b/frontend/src/components/ui/checkmark-icon.tsx @@ -24,4 +24,4 @@ export const CheckMarkIcon: React.FC = () => ( /> -); \ No newline at end of file +); diff --git a/frontend/src/components/ui/drag-icon.tsx b/frontend/src/components/ui/drag-icon.tsx index fa41e806..9c7fef35 100644 --- a/frontend/src/components/ui/drag-icon.tsx +++ b/frontend/src/components/ui/drag-icon.tsx @@ -10,4 +10,4 @@ export const DragIcon: React.FC = () => ( -); \ No newline at end of file +); diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 9559f5e4..a48bb5b9 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,4 +1,4 @@ export { UserIcon } from './user-icon'; export { CheckMarkIcon } from './checkmark-icon'; export { DragIcon } from './drag-icon'; -export { WelcomeScreen } from './welcome-screen'; \ No newline at end of file +export { WelcomeScreen } from './welcome-screen'; diff --git a/frontend/src/components/ui/user-icon.tsx b/frontend/src/components/ui/user-icon.tsx index 2b254d97..8fdc8fa7 100644 --- a/frontend/src/components/ui/user-icon.tsx +++ b/frontend/src/components/ui/user-icon.tsx @@ -3,27 +3,15 @@ import { Box } from '@chakra-ui/react'; import { COLORS } from '@/constants/form'; export const UserIcon: React.FC = () => ( - + - - + -); \ No newline at end of file +); diff --git a/frontend/src/components/ui/welcome-screen.tsx b/frontend/src/components/ui/welcome-screen.tsx index b71e6816..ab1c063b 100644 --- a/frontend/src/components/ui/welcome-screen.tsx +++ b/frontend/src/components/ui/welcome-screen.tsx @@ -14,8 +14,8 @@ export const WelcomeScreen: React.FC = ({ icon, title, description, - buttonText = "Continue", - onContinue + buttonText = 'Continue', + onContinue, }) => ( = ({ justifyContent="center" px={4} > - + {icon} = ({ -); \ No newline at end of file +); diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx index 2a54ab50..0cd3df01 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking.tsx @@ -61,7 +61,9 @@ export default function ParticipantRankingPage({ participantType = 'caregiver', caregiverHasCancer = true, }: ParticipantRankingPageProps) { - const [derivedParticipantType, setDerivedParticipantType] = useState<'cancerPatient' | 'caregiver' | null>(null); + const [derivedParticipantType, setDerivedParticipantType] = useState< + 'cancerPatient' | 'caregiver' | null + >(null); const [derivedCaregiverHasCancer, setDerivedCaregiverHasCancer] = useState(null); const [isLoadingCase, setIsLoadingCase] = useState(false); @@ -133,7 +135,9 @@ export default function ParticipantRankingPage({ try { setIsLoadingOptions(true); setOptionsError(null); - const { data } = await baseAPIClient.get('/ranking/options', { params: { target } }); + const { data } = await baseAPIClient.get('/ranking/options', { + params: { target }, + }); const staticQualitiesExpanded: DisplayOption[] = (data.staticQualities || []).flatMap((q) => { const scopes = q.allowedScopes || []; return scopes.map((s) => ({ @@ -219,7 +223,11 @@ export default function ParticipantRankingPage({ // For patient flow, fetch options once useEffect(() => { - if (effectiveParticipantType === 'cancerPatient' && singleColumnOptions.length === 0 && !isLoadingOptions) { + if ( + effectiveParticipantType === 'cancerPatient' && + singleColumnOptions.length === 0 && + !isLoadingOptions + ) { fetchOptions('patient'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -236,7 +244,9 @@ export default function ParticipantRankingPage({ p={10} > {optionsError ? ( - {optionsError} + + {optionsError} + ) : null} {effectiveParticipantType === 'caregiver' ? ( @@ -303,38 +313,23 @@ export default function ParticipantRankingPage({ boxShadow="0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)" p={10} > - {// Prefer explicit flag; otherwise infer from value - (formData.isCaregiverVolunteerFlow ?? false) || - formData.volunteerType === 'caringForLovedOne' || - (!!formData.volunteerType && formData.volunteerType !== 'similarDiagnosis') ? ( - { - if (leftColumnOptions.length === 0 && rightColumnOptions.length === 0 && !isLoadingOptions) { - fetchOptions('caregiver'); - } - const keys = formData.selectedQualities; - const labels = keys.map((k) => optionsIndex[k]?.label || k); - setFormData((prev) => ({ - ...prev, - rankedPreferences: [...labels], - rankedKeys: [...keys], - })); - setCurrentStep(4); - }} - /> - ) : effectiveCaregiverHasCancer ? ( - formData.volunteerType === 'similarDiagnosis' ? ( - { - if (singleColumnOptions.length === 0 && !isLoadingOptions) { - fetchOptions('patient'); + if ( + leftColumnOptions.length === 0 && + rightColumnOptions.length === 0 && + !isLoadingOptions + ) { + fetchOptions('caregiver'); } const keys = formData.selectedQualities; const labels = keys.map((k) => optionsIndex[k]?.label || k); @@ -346,8 +341,48 @@ export default function ParticipantRankingPage({ setCurrentStep(4); }} /> + ) : effectiveCaregiverHasCancer ? ( + formData.volunteerType === 'similarDiagnosis' ? ( + { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(4); + }} + /> + ) : ( + { + if (singleColumnOptions.length === 0 && !isLoadingOptions) { + fetchOptions('patient'); + } + const keys = formData.selectedQualities; + const labels = keys.map((k) => optionsIndex[k]?.label || k); + setFormData((prev) => ({ + ...prev, + rankedPreferences: [...labels], + rankedKeys: [...keys], + })); + setCurrentStep(4); + }} + /> + ) ) : ( - ) - ) : ( - { - if (singleColumnOptions.length === 0 && !isLoadingOptions) { - fetchOptions('patient'); - } - const keys = formData.selectedQualities; - const labels = keys.map((k) => optionsIndex[k]?.label || k); - setFormData((prev) => ({ - ...prev, - rankedPreferences: [...labels], - rankedKeys: [...keys], - })); - setCurrentStep(4); - }} - /> - )} + } ); @@ -411,7 +427,8 @@ export default function ParticipantRankingPage({ let target: 'patient' | 'caregiver' = 'patient'; if ( effectiveParticipantType === 'caregiver' && - ((formData.isCaregiverVolunteerFlow ?? false) || formData.volunteerType === 'caringForLovedOne') + ((formData.isCaregiverVolunteerFlow ?? false) || + formData.volunteerType === 'caringForLovedOne') ) { target = 'caregiver'; } else { From 73232f7c0756b8accc6b54ce11d71dc2a9eba644 Mon Sep 17 00:00:00 2001 From: YashK2005 Date: Sun, 7 Sep 2025 11:29:45 -0400 Subject: [PATCH 11/11] ranking: a bunhc of frontend improvements. added a me endpoint for fetchikng info about the logged in user. added protectedroutes that allow us to block certain types of viewers from viewing certain pages so particiapnts can't access the volunteer intake form and vice versa. added a basic unauthorized page to go with this (we can improve this styling/look later) as well as a basic loading skeleton (which can also potentially be improved visually later). also updated teh intake form thank you screen to match the figma text. only thing left for forms i think is the logic for showing the user certain pages/forms based on what they've already copmleted and what step the admin has put them in. this i think could go in a separate pr --- backend/app/routes/auth.py | 37 ++++ backend/app/utilities/db_utils.py | 8 +- backend/app/utilities/service_utils.py | 4 +- .../components/auth/AuthLoadingSkeleton.tsx | 18 ++ .../src/components/auth/ProtectedPage.tsx | 28 +++ .../components/intake/thank-you-screen.tsx | 36 +--- frontend/src/hooks/useAuthGuard.ts | 171 ++++++++++++++++++ frontend/src/hooks/useProtectedRoute.ts | 29 +++ frontend/src/pages/admin/dashboard.tsx | 150 +++++++-------- frontend/src/pages/participant/intake.tsx | 70 +++---- frontend/src/pages/participant/ranking.tsx | 66 ++++--- frontend/src/pages/unauthorized.tsx | 48 +++++ frontend/src/pages/volunteer/intake.tsx | 87 ++++----- 13 files changed, 544 insertions(+), 208 deletions(-) create mode 100644 frontend/src/components/auth/AuthLoadingSkeleton.tsx create mode 100644 frontend/src/components/auth/ProtectedPage.tsx create mode 100644 frontend/src/hooks/useAuthGuard.ts create mode 100644 frontend/src/hooks/useProtectedRoute.ts create mode 100644 frontend/src/pages/unauthorized.tsx diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index 0e43f2e9..1fb1d859 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -1,10 +1,13 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session +from ..models.User import User from ..schemas.auth import AuthResponse, LoginRequest, RefreshRequest, Token from ..schemas.user import UserCreateRequest, UserCreateResponse, UserRole from ..services.implementations.auth_service import AuthService from ..services.implementations.user_service import UserService +from ..utilities.db_utils import get_db from ..utilities.service_utils import get_auth_service, get_user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -96,3 +99,37 @@ async def verify_email(email: str, auth_service: AuthService = Depends(get_auth_ # Log unexpected errors print(f"Unexpected error during email verification for {email}: {str(e)}") return Response(status_code=500) + + +@router.get("/me", response_model=UserCreateResponse) +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db), +): + """Get current authenticated user information including role""" + try: + # Get user auth_id from request state (set by auth middleware) + user_auth_id = request.state.user_id + if not user_auth_id: + raise HTTPException(status_code=401, detail="Authentication required") + + # Query user from database + user = db.query(User).filter(User.auth_id == user_auth_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + return UserCreateResponse( + id=user.id, + first_name=user.first_name, + last_name=user.last_name, + email=user.email, + role_id=user.role_id, + auth_id=user.auth_id, + approved=user.approved, + ) + except HTTPException: + raise + except Exception as e: + print(f"Error getting current user: {str(e)}") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/backend/app/utilities/db_utils.py b/backend/app/utilities/db_utils.py index 986bdbcb..6edaa687 100644 --- a/backend/app/utilities/db_utils.py +++ b/backend/app/utilities/db_utils.py @@ -6,7 +6,13 @@ load_dotenv() -DATABASE_URL = os.getenv("POSTGRES_DATABASE_URL") +DATABASE_URL = os.getenv("POSTGRES_TEST_DATABASE_URL") +if not DATABASE_URL: + raise RuntimeError( + "POSTGRES_TEST_DATABASE_URL is not set. " + "Set one of them to a valid Postgres URL, e.g. " + "postgresql+psycopg2://postgres:postgres@db:5432/llsc_test" + ) engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) diff --git a/backend/app/utilities/service_utils.py b/backend/app/utilities/service_utils.py index 46893b6e..a362a99c 100644 --- a/backend/app/utilities/service_utils.py +++ b/backend/app/utilities/service_utils.py @@ -12,6 +12,6 @@ def get_user_service(db: Session = Depends(get_db)): return UserService(db) -def get_auth_service(db: Session = Depends(get_db)): +def get_auth_service(user_service: UserService = Depends(get_user_service)): logger = logging.getLogger(__name__) - return AuthService(logger=logger, user_service=UserService(db)) + return AuthService(logger=logger, user_service=user_service) diff --git a/frontend/src/components/auth/AuthLoadingSkeleton.tsx b/frontend/src/components/auth/AuthLoadingSkeleton.tsx new file mode 100644 index 00000000..814b4213 --- /dev/null +++ b/frontend/src/components/auth/AuthLoadingSkeleton.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Box, Flex, Spinner, Text } from '@chakra-ui/react'; + +/** + * Simple loading component for protected pages + */ +export const AuthLoadingSkeleton: React.FC = () => { + return ( + + + + + Loading... + + + + ); +}; diff --git a/frontend/src/components/auth/ProtectedPage.tsx b/frontend/src/components/auth/ProtectedPage.tsx new file mode 100644 index 00000000..c13010af --- /dev/null +++ b/frontend/src/components/auth/ProtectedPage.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { UserRole } from '@/types/authTypes'; +import { useProtectedRoute } from '@/hooks/useProtectedRoute'; + +interface ProtectedPageProps { + allowedRoles: UserRole[]; + children: React.ReactNode; +} + +/** + * Wrapper component that handles auth protection logic for pages + * Eliminates the need to repeat auth checks in every protected page + */ +export const ProtectedPage: React.FC = ({ allowedRoles, children }) => { + const { authorized, LoadingComponent } = useProtectedRoute(allowedRoles); + + // Show loading skeleton while checking auth + if (LoadingComponent) { + return ; + } + + // This will never be reached due to redirects in the hook, but good for safety + if (!authorized) { + return null; + } + + return <>{children}; +}; diff --git a/frontend/src/components/intake/thank-you-screen.tsx b/frontend/src/components/intake/thank-you-screen.tsx index 9d1d1fab..5168d743 100644 --- a/frontend/src/components/intake/thank-you-screen.tsx +++ b/frontend/src/components/intake/thank-you-screen.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Box, Heading, Text, VStack } from '@chakra-ui/react'; -import { COLORS, IntakeFormData } from '@/constants/form'; +import { COLORS } from '@/constants/form'; // Check mark icon component const CheckMarkIcon: React.FC = () => ( @@ -27,11 +27,7 @@ const CheckMarkIcon: React.FC = () => ( ); -interface ThankYouScreenProps { - formData?: IntakeFormData; -} - -export function ThankYouScreen({ formData }: ThankYouScreenProps) { +export function ThankYouScreen() { return ( - You will receive a confirmation email. A staff member will call you within 4-5 business + You will receive a confirmation email. A staff member will call you within 1-2 business days to better understand your match preferences. For any inquiries, please reach us at{' '} - placeholder@placeholder.com + FirstConnections@lls.org - . + . Please note LLSC's working days are Monday-Thursday. - - {/* Debug: Display form data */} - {formData && ( - - - Collected Form Data (Debug) - - - {JSON.stringify(formData, null, 2)} - - - )} diff --git a/frontend/src/hooks/useAuthGuard.ts b/frontend/src/hooks/useAuthGuard.ts new file mode 100644 index 00000000..f08a5acc --- /dev/null +++ b/frontend/src/hooks/useAuthGuard.ts @@ -0,0 +1,171 @@ +import { useEffect, useState, useMemo } from 'react'; +import { useRouter } from 'next/router'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import { auth } from '@/config/firebase'; +import { UserRole } from '@/types/authTypes'; +import baseAPIClient from '@/APIClients/baseAPIClient'; + +interface AxiosError { + response?: { + status: number; + data: unknown; + }; + request?: unknown; + message?: string; +} + +interface AuthGuardState { + loading: boolean; + authorized: boolean; +} + +// Map role IDs to UserRole enum +const roleIdToUserRole = (roleId: number): UserRole | null => { + switch (roleId) { + case 1: + return UserRole.PARTICIPANT; + case 2: + return UserRole.VOLUNTEER; + case 3: + return UserRole.ADMIN; + default: + return null; + } +}; + +/** + * Hook to protect pages with authentication and role-based access control + * @param allowedRoles - Array of roles that can access this page + * @returns Object with loading and authorized states + */ +export const useAuthGuard = (allowedRoles: UserRole[]): AuthGuardState => { + const router = useRouter(); + const [authState, setAuthState] = useState({ + loading: true, + authorized: false, + }); + + // Memoize allowedRoles to prevent infinite re-renders + const memoizedAllowedRoles = useMemo(() => allowedRoles, [allowedRoles]); + + const getUserRole = async (user: User): Promise => { + const cacheKey = `userRole_${user.uid}`; + + // Check cache first + try { + const cached = sessionStorage.getItem(cacheKey); + if (cached) { + const { role, timestamp } = JSON.parse(cached); + // Cache valid for 1 hour + if (Date.now() - timestamp < 3600000) { + return role; + } + // Remove expired cache + sessionStorage.removeItem(cacheKey); + } + } catch { + // If cache is corrupted, remove it and continue + sessionStorage.removeItem(cacheKey); + } + + try { + // Get the Firebase ID token + const token = await user.getIdToken(); + // Call your backend to get user data with role + const response = await baseAPIClient.get('/auth/me', { + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + // Convert roleId to UserRole enum (API client converts snake_case to camelCase) + const userRole = roleIdToUserRole(response.data.roleId); + + // Cache the result + if (userRole) { + sessionStorage.setItem( + cacheKey, + JSON.stringify({ + role: userRole, + timestamp: Date.now(), + }), + ); + } + + return userRole; + } catch (error: unknown) { + console.error('[useAuthGuard] Error fetching user role:', error); + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as AxiosError; + console.error('[useAuthGuard] API Error status:', axiosError.response?.status); + console.error('[useAuthGuard] API Error data:', axiosError.response?.data); + } else if (error && typeof error === 'object' && 'request' in error) { + console.error('[useAuthGuard] No response received:', (error as AxiosError).request); + } else if (error && typeof error === 'object' && 'message' in error) { + console.error('[useAuthGuard] Request setup error:', (error as AxiosError).message); + } + console.error('[useAuthGuard] Full error object:', error); + return null; + } + }; + + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, async (user) => { + try { + if (!user) { + // No authenticated user - redirect to login + router.push('/'); + return; + } + + // Check if email is verified + if (!user.emailVerified) { + router.push(`/verify?email=${encodeURIComponent(user.email || '')}`); + return; + } + + // Get user role from backend + const userRole = await getUserRole(user); + + if (!userRole) { + // Could not get user role - redirect to login + router.push('/'); + return; + } + + if (!memoizedAllowedRoles.includes(userRole)) { + // User doesn't have required role - redirect to unauthorized + router.push('/unauthorized'); + return; + } + + // User is authorized + setAuthState({ loading: false, authorized: true }); + } catch (error) { + console.error('Auth guard error:', error); + router.push('/'); + } + }); + + return () => unsubscribe(); + }, [router, memoizedAllowedRoles]); + + return authState; +}; + +/** + * Clear all cached user role data from session storage + * Call this function when the user logs out + */ +export const clearAuthCache = (): void => { + try { + Object.keys(sessionStorage).forEach((key) => { + if (key.startsWith('userRole_')) { + sessionStorage.removeItem(key); + } + }); + } catch (error) { + // Session storage might not be available (e.g., in SSR or private browsing) + console.warn('[useAuthGuard] Could not clear auth cache:', error); + } +}; diff --git a/frontend/src/hooks/useProtectedRoute.ts b/frontend/src/hooks/useProtectedRoute.ts new file mode 100644 index 00000000..ff60d6bc --- /dev/null +++ b/frontend/src/hooks/useProtectedRoute.ts @@ -0,0 +1,29 @@ +import React from 'react'; +import { UserRole } from '@/types/authTypes'; +import { AuthLoadingSkeleton } from '@/components/auth/AuthLoadingSkeleton'; +import { useAuthGuard } from './useAuthGuard'; + +interface UseProtectedRouteResult { + loading: boolean; + authorized: boolean; + LoadingComponent: React.ComponentType | null; +} + +/** + * Hook that combines auth guarding with loading skeleton + * Returns a LoadingComponent that you can render while auth is being checked + * + * @param allowedRoles - Array of roles that can access this page + * @returns Object with loading state, authorized state, and LoadingComponent + */ +export const useProtectedRoute = (allowedRoles: UserRole[]): UseProtectedRouteResult => { + const { loading, authorized } = useAuthGuard(allowedRoles); + + const LoadingComponent = loading ? () => React.createElement(AuthLoadingSkeleton) : null; + + return { + loading, + authorized, + LoadingComponent, + }; +}; diff --git a/frontend/src/pages/admin/dashboard.tsx b/frontend/src/pages/admin/dashboard.tsx index 8b78b6e0..3af94c2b 100644 --- a/frontend/src/pages/admin/dashboard.tsx +++ b/frontend/src/pages/admin/dashboard.tsx @@ -1,86 +1,90 @@ import React from 'react'; import Image from 'next/image'; import { Box, Flex, Heading, Text, Link } from '@chakra-ui/react'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; const veniceBlue = '#1d3448'; export default function AdminDashboard() { return ( - - {/* Left: Dashboard Content */} - - - - Admin Portal - First Connection Peer Support Program - - - Welcome! - - - We sent a confirmation link to john.doe@gmail.com - - - Didn't get a link?{' '} - + + {/* Left: Dashboard Content */} + + + - Click here to resend. - - + Admin Portal - First Connection Peer Support Program + + + Welcome! + + + We sent a confirmation link to john.doe@gmail.com + + + Didn't get a link?{' '} + + Click here to resend. + + + + + {/* Right: Image */} + + Admin Portal Visual - {/* Right: Image */} - - Admin Portal Visual - - + ); } diff --git a/frontend/src/pages/participant/intake.tsx b/frontend/src/pages/participant/intake.tsx index 0bff1d50..78f7a897 100644 --- a/frontend/src/pages/participant/intake.tsx +++ b/frontend/src/pages/participant/intake.tsx @@ -15,6 +15,8 @@ import { ExperienceData, PersonalData, } from '@/constants/form'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; // Import the component data types interface DemographicCancerFormData { @@ -194,39 +196,45 @@ export default function ParticipantIntakePage() { // If we're on thank you step, show the screen with form data if (currentStepType === 'thank-you') { - return ; + return ( + + + + ); } return ( - - - {currentStepType === 'experience-personal' && ( - - )} - - {currentStepType === 'demographics-cancer' && ( - - )} - - {currentStepType === 'demographics-caregiver' && ( - - )} - - {currentStepType === 'loved-one' && ( - - )} - - {currentStepType === 'demographics-basic' && ( - - )} - - + + + + {currentStepType === 'experience-personal' && ( + + )} + + {currentStepType === 'demographics-cancer' && ( + + )} + + {currentStepType === 'demographics-caregiver' && ( + + )} + + {currentStepType === 'loved-one' && ( + + )} + + {currentStepType === 'demographics-basic' && ( + + )} + + + ); } diff --git a/frontend/src/pages/participant/ranking.tsx b/frontend/src/pages/participant/ranking.tsx index 0cd3df01..d8893c87 100644 --- a/frontend/src/pages/participant/ranking.tsx +++ b/frontend/src/pages/participant/ranking.tsx @@ -12,6 +12,8 @@ import { import { COLORS } from '@/constants/form'; import baseAPIClient from '@/APIClients/baseAPIClient'; import { auth } from '@/config/firebase'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; const RANKING_STATEMENTS = [ 'I would prefer a volunteer with the same age as me', @@ -546,33 +548,39 @@ export default function ParticipantRankingPage({ ); - if (participantType === 'caregiver') { - switch (currentStep) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - case 5: - return ; - default: - return ; - } - } else { - switch (currentStep) { - case 1: - return ; - case 2: - return ; - case 3: - return ; - case 4: - return ; - default: - return ; - } - } + return ( + + {participantType === 'caregiver' + ? (() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + default: + return ; + } + })() + : (() => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + default: + return ; + } + })()} + + ); } diff --git a/frontend/src/pages/unauthorized.tsx b/frontend/src/pages/unauthorized.tsx new file mode 100644 index 00000000..5143749a --- /dev/null +++ b/frontend/src/pages/unauthorized.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import Link from 'next/link'; +import { Box, Flex, Heading, Text, Button } from '@chakra-ui/react'; + +const veniceBlue = '#1d3448'; +const teal = '#056067'; + +export default function Unauthorized() { + return ( + + + + 403 + + + + Access Denied + + + + You don't have permission to access this page. +
+ Please contact your administrator if you believe this is an error. +
+ + + + +
+
+ ); +} diff --git a/frontend/src/pages/volunteer/intake.tsx b/frontend/src/pages/volunteer/intake.tsx index 5143bfa5..d5750624 100644 --- a/frontend/src/pages/volunteer/intake.tsx +++ b/frontend/src/pages/volunteer/intake.tsx @@ -15,6 +15,8 @@ import { ExperienceData, PersonalData, } from '@/constants/form'; +import { ProtectedPage } from '@/components/auth/ProtectedPage'; +import { UserRole } from '@/types/authTypes'; // Import the component data types interface DemographicCancerFormData { @@ -94,19 +96,14 @@ export default function VolunteerIntakePage() { if (nextType === 'thank-you') { setSubmitting(true); try { - // eslint-disable-next-line no-console - console.log('[INTAKE][SUBMIT] About to submit answers (volunteer)', { - currentStep, - nextType, - answers: updated, - }); await baseAPIClient.post('/intake/submissions', { answers: updated }); - } catch (error: any) { + } catch (error: unknown) { // eslint-disable-next-line no-console - console.error( - '[INTAKE][SUBMIT][ERROR] Volunteer submission failed', - error?.response?.data || error, - ); + const errorData = + error && typeof error === 'object' && 'response' in error + ? (error as { response?: { data?: unknown } })?.response?.data || error + : error; + console.error('[INTAKE][SUBMIT][ERROR] Volunteer submission failed', errorData); return; // Do not advance on failure } finally { setSubmitting(false); @@ -208,39 +205,45 @@ export default function VolunteerIntakePage() { // If we're on thank you step, show the screen with form data if (currentStepType === 'thank-you') { - return ; + return ( + + + + ); } return ( - - - {currentStepType === 'experience-personal' && ( - - )} - - {currentStepType === 'demographics-cancer' && ( - - )} - - {currentStepType === 'demographics-caregiver' && ( - - )} - - {currentStepType === 'loved-one' && ( - - )} - - {currentStepType === 'demographics-basic' && ( - - )} - - + + + + {currentStepType === 'experience-personal' && ( + + )} + + {currentStepType === 'demographics-cancer' && ( + + )} + + {currentStepType === 'demographics-caregiver' && ( + + )} + + {currentStepType === 'loved-one' && ( + + )} + + {currentStepType === 'demographics-basic' && ( + + )} + + + ); }