From 66ca1bcfea91e1238b17d8c46cbd4d9be4e1d97f Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sat, 26 Apr 2025 16:02:49 +0530 Subject: [PATCH 01/21] change: demogrpahic data schema change. --- api-service/api/openapi.yaml | 84 +++++++++++++++----- api-service/service/AuthenticationService.js | 15 +++- api-service/utils/dbSchemas.js | 2 +- 3 files changed, 75 insertions(+), 26 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index c390957..8a4a9e1 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -897,27 +897,6 @@ components: phone: type: string pattern: ^\+?[\d\s-]+$ - gender: - type: string - nullable: true - description: User gender identity (e.g., 'male', 'female', 'non-binary', 'prefer_not_to_say') - example: "female" - incomeBracket: - type: string - nullable: true - description: User income bracket category (e.g., '<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say') - example: "50k-100k" - country: - type: string - nullable: true - description: User country of residence (ISO 3166-1 alpha-2 code) - example: "US" - age: - type: integer - format: int32 - nullable: true - description: User age - example: 35 privacySettings: type: object properties: @@ -937,6 +916,8 @@ components: items: type: string description: List of store IDs user has opted out from + demographicData: + $ref: "#/components/schemas/DemographicData" createdAt: type: string format: date-time @@ -1571,6 +1552,67 @@ components: version: type: string + DemographicData: + type: object + description: User-provided and inferred demographic information + properties: + gender: + type: string + nullable: true + description: User gender identity + enum: [male, female, non-binary, prefer_not_to_say, null] + example: "female" + incomeBracket: + type: string + nullable: true + description: User income bracket category + enum: + [ + "<25k", + "25k-50k", + "50k-100k", + "100k-200k", + ">200k", + "prefer_not_to_say", + null, + ] + example: "50k-100k" + country: + type: string + nullable: true + description: User country of residence (e.g., ISO 3166-1 alpha-2 code) + example: "US" + age: + type: integer + format: int32 + nullable: true + description: User age + example: 35 + inferredHasKids: + type: boolean + nullable: true + description: "Inferred: Does the user likely have children? (null if unknown)" + inferredRelationshipStatus: + type: string + nullable: true + description: "Inferred: User relationship status (null if unknown)" + enum: [single, relationship, married, null] + inferredEmploymentStatus: + type: string + nullable: true + description: "Inferred: User employment status (null if unknown)" + enum: [employed, unemployed, student, null] + inferredEducationLevel: + type: string + nullable: true + description: "Inferred: User education level (null if unknown)" + enum: [high_school, bachelors, masters, doctorate, null] + inferredAgeBracket: + type: string + nullable: true + description: "Inferred: User age bracket if age not provided (null if unknown)" + enum: ["18-24", "25-34", "35-44", "45-54", "55-64", "65+", null] + RecentUserDataEntry: type: object properties: diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index e3daed0..27c7ef7 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -100,10 +100,17 @@ exports.registerUser = async function (req, body) { username: userData.username || userData.nickname || userData.sub, email: userData.email, phone: userData.phone_number || null, - gender: gender || null, // Add new fields, defaulting to null if not provided - incomeBracket: incomeBracket || null, - country: country || null, - age: age || null, + demographicData: { + gender: gender || null, + incomeBracket: incomeBracket || null, + country: country || null, + age: age || null, + inferredHasKids: null, + inferredRelationshipStatus: null, + inferredEmploymentStatus: null, + inferredEducationLevel: null, + inferredAgeBracket: null, + }, preferences: preferences || [], privacySettings: { dataSharingConsent, diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 108d7a2..794573c 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.8'; // Incremented version +const SCHEMA_VERSION = '2.0.9'; // Incremented version const userSchema = { validator: { From 50db45fe5a647cf6768b37ab8026bb82b41f354f Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sat, 26 Apr 2025 16:03:05 +0530 Subject: [PATCH 02/21] feat: Refactor User demographic data handling to use a new DemographicData interface --- web/src/api/types/data-contracts.ts | 77 ++++++++++++++----- web/src/pages/UserDashboard/UserDashboard.tsx | 9 +-- .../UserDashboard/UserPreferencesPage.tsx | 18 ++--- 3 files changed, 68 insertions(+), 36 deletions(-) diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 3f263ea..241bb8b 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -28,27 +28,6 @@ export interface User { username?: string; /** @pattern ^\+?[\d\s-]+$ */ phone?: string; - /** - * User gender identity (e.g., 'male', 'female', 'non-binary', 'prefer_not_to_say') - * @example "female" - */ - gender?: string | null; - /** - * User income bracket category (e.g., '<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say') - * @example "50k-100k" - */ - incomeBracket?: string | null; - /** - * User country of residence (ISO 3166-1 alpha-2 code) - * @example "US" - */ - country?: string | null; - /** - * User age - * @format int32 - * @example 35 - */ - age?: number | null; privacySettings: { /** @default false */ dataSharingConsent?: boolean; @@ -59,6 +38,8 @@ export interface User { /** List of store IDs user has opted out from */ optOutStores?: string[]; }; + /** User-provided and inferred demographic information */ + demographicData?: DemographicData; /** @format date-time */ createdAt?: string; /** @format date-time */ @@ -402,6 +383,60 @@ export interface Taxonomy { version: string; } +/** User-provided and inferred demographic information */ +export interface DemographicData { + /** + * User gender identity + * @example "female" + */ + gender?: "male" | "female" | "non-binary" | "prefer_not_to_say" | null; + /** + * User income bracket category + * @example "50k-100k" + */ + incomeBracket?: + | "<25k" + | "25k-50k" + | "50k-100k" + | "100k-200k" + | ">200k" + | "prefer_not_to_say" + | null; + /** + * User country of residence (e.g., ISO 3166-1 alpha-2 code) + * @example "US" + */ + country?: string | null; + /** + * User age + * @format int32 + * @example 35 + */ + age?: number | null; + /** Inferred: Does the user likely have children? (null if unknown) */ + inferredHasKids?: boolean | null; + /** Inferred: User relationship status (null if unknown) */ + inferredRelationshipStatus?: "single" | "relationship" | "married" | null; + /** Inferred: User employment status (null if unknown) */ + inferredEmploymentStatus?: "employed" | "unemployed" | "student" | null; + /** Inferred: User education level (null if unknown) */ + inferredEducationLevel?: + | "high_school" + | "bachelors" + | "masters" + | "doctorate" + | null; + /** Inferred: User age bracket if age not provided (null if unknown) */ + inferredAgeBracket?: + | "18-24" + | "25-34" + | "35-44" + | "45-54" + | "55-64" + | "65+" + | null; +} + export interface RecentUserDataEntry { /** The unique ID of the userData entry. */ _id?: string; diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index 2a3c7ce..2ecee40 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -146,7 +146,6 @@ const renderCustomizedLabel = ({ outerRadius, percent, }: CustomizedLabelProps) => { - // Use the defined interface const radius = innerRadius + (outerRadius - innerRadius) * 0.5; const x = cx + radius * Math.cos(-midAngle * RADIAN); const y = cy + radius * Math.sin(-midAngle * RADIAN); @@ -765,25 +764,25 @@ export default function UserDashboard() { diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index 000251b..d0b4c8a 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -143,15 +143,13 @@ const UserPreferencesPage: React.FC = () => { defaultValues: { category: "", attributes: {}, score: 50 }, }); - // --- Effects --- - // Reset demographics form when profile loads or editing starts/stops useEffect(() => { if (userProfile && !isEditingDemographics) { resetDemoForm({ - gender: userProfile.gender || "", - age: userProfile.age || undefined, - country: userProfile.country || "", - incomeBracket: userProfile.incomeBracket || "", + gender: userProfile?.demographicData?.gender || "", + age: userProfile?.demographicData?.age || undefined, + country: userProfile?.demographicData?.country || "", + incomeBracket: userProfile?.demographicData?.incomeBracket || "", }); } }, [userProfile, isEditingDemographics, resetDemoForm]); @@ -420,25 +418,25 @@ const UserPreferencesPage: React.FC = () => { From 351171cb3d6a26ee58dc09f60e505b2e0e127210 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sat, 26 Apr 2025 17:30:56 +0530 Subject: [PATCH 03/21] feat: Enhance UserRegistrationForm and UserPreferencesPage with country selection and improved demographic handling --- .../components/auth/UserRegistrationForm.tsx | 58 ++-- web/src/data/countries.json | 248 ++++++++++++++++++ .../UserDashboard/UserPreferencesPage.tsx | 163 +++++++++--- 3 files changed, 414 insertions(+), 55 deletions(-) create mode 100644 web/src/data/countries.json diff --git a/web/src/components/auth/UserRegistrationForm.tsx b/web/src/components/auth/UserRegistrationForm.tsx index ad4951b..32cfa3f 100644 --- a/web/src/components/auth/UserRegistrationForm.tsx +++ b/web/src/components/auth/UserRegistrationForm.tsx @@ -3,23 +3,29 @@ import { Button, Checkbox, Label, - Modal, // Import Modal components + Modal, ModalHeader, ModalBody, ModalFooter, - Popover, // <-- Import Popover - Select, // <-- Import Select - TextInput, // <-- Import TextInput + Popover, + Select, // Already imported + TextInput, } from "flowbite-react"; import { UserCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; -import { HiCheckCircle, HiInformationCircle } from "react-icons/hi"; // Import icons +import { HiCheckCircle, HiInformationCircle } from "react-icons/hi"; +import countryData from "../../data/countries.json"; interface UserRegistrationFormProps { onSubmit: (userData: UserCreate) => void; isLoading: boolean; } +interface CountryOption { + value: string; + label: string; +} + // Define options for selects const genderOptions = [ { value: "", label: "Select Gender (Optional)" }, @@ -39,6 +45,20 @@ const incomeOptions = [ { value: "prefer_not_to_say", label: "Prefer not to say" }, ]; +// --- Transform the imported country data object into an array --- +const typedCountryData: CountryOption[] = Object.entries(countryData).map( + ([code, name]) => ({ value: code, label: name }), +); +// Sort alphabetically by label (optional but good UX) +typedCountryData.sort((a, b) => a.label.localeCompare(b.label)); + +// --- Create the final country options array --- +const countryOptions: CountryOption[] = [ + { value: "", label: "Select Country (Optional)" }, + ...typedCountryData, // Spread the transformed array +]; +// --- End Country Options --- + export function UserRegistrationForm({ onSubmit, isLoading, @@ -67,13 +87,11 @@ export function UserRegistrationForm({ } const userData: UserCreate = { - // Use the state variable linked to the checkbox dataSharingConsent: dataSharingConsent, - preferences: [], // We can leave this empty for now - // Add demographic data, ensuring null if empty string or invalid number + preferences: [], gender: gender || null, incomeBracket: incomeBracket || null, - country: country || null, + country: country || null, // country state is already used here age: age !== null && !isNaN(age) ? Number(age) : null, }; @@ -92,7 +110,6 @@ export function UserRegistrationForm({ setShowConsentModal(false); }; - // Content for the popover const popoverContent = (

@@ -150,20 +167,27 @@ export function UserRegistrationForm({ ))}

- {/* Country Input */} + + {/* --- Country Select (Replaces TextInput) --- */}
- - Country +
+ {/* --- End Country Select --- */} + {/* Age Input */}
diff --git a/web/src/data/countries.json b/web/src/data/countries.json new file mode 100644 index 0000000..6f5df5c --- /dev/null +++ b/web/src/data/countries.json @@ -0,0 +1,248 @@ +{ + "AF": "Afghanistan", + "AX": "Aland Islands", + "AL": "Albania", + "DZ": "Algeria", + "AS": "American Samoa", + "AD": "Andorra", + "AO": "Angola", + "AI": "Anguilla", + "AQ": "Antarctica", + "AG": "Antigua And Barbuda", + "AR": "Argentina", + "AM": "Armenia", + "AW": "Aruba", + "AU": "Australia", + "AT": "Austria", + "AZ": "Azerbaijan", + "BS": "Bahamas", + "BH": "Bahrain", + "BD": "Bangladesh", + "BB": "Barbados", + "BY": "Belarus", + "BE": "Belgium", + "BZ": "Belize", + "BJ": "Benin", + "BM": "Bermuda", + "BT": "Bhutan", + "BO": "Bolivia", + "BA": "Bosnia And Herzegovina", + "BW": "Botswana", + "BV": "Bouvet Island", + "BR": "Brazil", + "IO": "British Indian Ocean Territory", + "BN": "Brunei Darussalam", + "BG": "Bulgaria", + "BF": "Burkina Faso", + "BI": "Burundi", + "KH": "Cambodia", + "CM": "Cameroon", + "CA": "Canada", + "CV": "Cape Verde", + "KY": "Cayman Islands", + "CF": "Central African Republic", + "TD": "Chad", + "CL": "Chile", + "CN": "China", + "CX": "Christmas Island", + "CC": "Cocos (Keeling) Islands", + "CO": "Colombia", + "KM": "Comoros", + "CG": "Congo", + "CD": "Congo, Democratic Republic", + "CK": "Cook Islands", + "CR": "Costa Rica", + "CI": "Cote D\"Ivoire", + "HR": "Croatia", + "CU": "Cuba", + "CY": "Cyprus", + "CZ": "Czech Republic", + "DK": "Denmark", + "DJ": "Djibouti", + "DM": "Dominica", + "DO": "Dominican Republic", + "EC": "Ecuador", + "EG": "Egypt", + "SV": "El Salvador", + "GQ": "Equatorial Guinea", + "ER": "Eritrea", + "EE": "Estonia", + "ET": "Ethiopia", + "FK": "Falkland Islands (Malvinas)", + "FO": "Faroe Islands", + "FJ": "Fiji", + "FI": "Finland", + "FR": "France", + "GF": "French Guiana", + "PF": "French Polynesia", + "TF": "French Southern Territories", + "GA": "Gabon", + "GM": "Gambia", + "GE": "Georgia", + "DE": "Germany", + "GH": "Ghana", + "GI": "Gibraltar", + "GR": "Greece", + "GL": "Greenland", + "GD": "Grenada", + "GP": "Guadeloupe", + "GU": "Guam", + "GT": "Guatemala", + "GG": "Guernsey", + "GN": "Guinea", + "GW": "Guinea-Bissau", + "GY": "Guyana", + "HT": "Haiti", + "HM": "Heard Island & Mcdonald Islands", + "VA": "Holy See (Vatican City State)", + "HN": "Honduras", + "HK": "Hong Kong", + "HU": "Hungary", + "IS": "Iceland", + "IN": "India", + "ID": "Indonesia", + "IR": "Iran, Islamic Republic Of", + "IQ": "Iraq", + "IE": "Ireland", + "IM": "Isle Of Man", + "IL": "Israel", + "IT": "Italy", + "JM": "Jamaica", + "JP": "Japan", + "JE": "Jersey", + "JO": "Jordan", + "KZ": "Kazakhstan", + "KE": "Kenya", + "KI": "Kiribati", + "KR": "Korea", + "KP": "North Korea", + "KW": "Kuwait", + "KG": "Kyrgyzstan", + "LA": "Lao People\"s Democratic Republic", + "LV": "Latvia", + "LB": "Lebanon", + "LS": "Lesotho", + "LR": "Liberia", + "LY": "Libyan Arab Jamahiriya", + "LI": "Liechtenstein", + "LT": "Lithuania", + "LU": "Luxembourg", + "MO": "Macao", + "MK": "Macedonia", + "MG": "Madagascar", + "MW": "Malawi", + "MY": "Malaysia", + "MV": "Maldives", + "ML": "Mali", + "MT": "Malta", + "MH": "Marshall Islands", + "MQ": "Martinique", + "MR": "Mauritania", + "MU": "Mauritius", + "YT": "Mayotte", + "MX": "Mexico", + "FM": "Micronesia, Federated States Of", + "MD": "Moldova", + "MC": "Monaco", + "MN": "Mongolia", + "ME": "Montenegro", + "MS": "Montserrat", + "MA": "Morocco", + "MZ": "Mozambique", + "MM": "Myanmar", + "NA": "Namibia", + "NR": "Nauru", + "NP": "Nepal", + "NL": "Netherlands", + "AN": "Netherlands Antilles", + "NC": "New Caledonia", + "NZ": "New Zealand", + "NI": "Nicaragua", + "NE": "Niger", + "NG": "Nigeria", + "NU": "Niue", + "NF": "Norfolk Island", + "MP": "Northern Mariana Islands", + "NO": "Norway", + "OM": "Oman", + "PK": "Pakistan", + "PW": "Palau", + "PS": "Palestinian Territory, Occupied", + "PA": "Panama", + "PG": "Papua New Guinea", + "PY": "Paraguay", + "PE": "Peru", + "PH": "Philippines", + "PN": "Pitcairn", + "PL": "Poland", + "PT": "Portugal", + "PR": "Puerto Rico", + "QA": "Qatar", + "RE": "Reunion", + "RO": "Romania", + "RU": "Russian Federation", + "RW": "Rwanda", + "BL": "Saint Barthelemy", + "SH": "Saint Helena", + "KN": "Saint Kitts And Nevis", + "LC": "Saint Lucia", + "MF": "Saint Martin", + "PM": "Saint Pierre And Miquelon", + "VC": "Saint Vincent And Grenadines", + "WS": "Samoa", + "SM": "San Marino", + "ST": "Sao Tome And Principe", + "SA": "Saudi Arabia", + "SN": "Senegal", + "RS": "Serbia", + "SC": "Seychelles", + "SL": "Sierra Leone", + "SG": "Singapore", + "SK": "Slovakia", + "SI": "Slovenia", + "SB": "Solomon Islands", + "SO": "Somalia", + "ZA": "South Africa", + "GS": "South Georgia And Sandwich Isl.", + "ES": "Spain", + "LK": "Sri Lanka", + "SD": "Sudan", + "SR": "Suriname", + "SJ": "Svalbard And Jan Mayen", + "SZ": "Swaziland", + "SE": "Sweden", + "CH": "Switzerland", + "SY": "Syrian Arab Republic", + "TW": "Taiwan", + "TJ": "Tajikistan", + "TZ": "Tanzania", + "TH": "Thailand", + "TL": "Timor-Leste", + "TG": "Togo", + "TK": "Tokelau", + "TO": "Tonga", + "TT": "Trinidad And Tobago", + "TN": "Tunisia", + "TR": "Turkey", + "TM": "Turkmenistan", + "TC": "Turks And Caicos Islands", + "TV": "Tuvalu", + "UG": "Uganda", + "UA": "Ukraine", + "AE": "United Arab Emirates", + "GB": "United Kingdom", + "US": "United States", + "UM": "United States Outlying Islands", + "UY": "Uruguay", + "UZ": "Uzbekistan", + "VU": "Vanuatu", + "VE": "Venezuela", + "VN": "Vietnam", + "VG": "Virgin Islands, British", + "VI": "Virgin Islands, U.S.", + "WF": "Wallis And Futuna", + "EH": "Western Sahara", + "YE": "Yemen", + "ZM": "Zambia", + "ZW": "Zimbabwe" +} diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index d0b4c8a..32f17dd 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -10,10 +10,10 @@ import { ModalFooter, Label, TextInput, - Select, + Select, // Already imported RangeSlider, - List, // Import List - ListItem, // Import ListItem + List, + ListItem, } from "flowbite-react"; import { HiInformationCircle, @@ -26,23 +26,23 @@ import { HiOutlineCake, HiOutlineGlobeAlt, HiOutlineCash, - // --- End Add icons --- } from "react-icons/hi"; -import { useForm, Controller, SubmitHandler } from "react-hook-form"; import { useUserProfile, - useUpdateUserProfile, useUserPreferences, + useUpdateUserProfile, useUpdateUserPreferences, } from "../../api/hooks/useUserHooks"; import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; -import LoadingSpinner from "../../components/common/LoadingSpinner"; -import ErrorDisplay from "../../components/common/ErrorDisplay"; import { UserUpdate, PreferenceItem, TaxonomyCategory, } from "../../api/types/data-contracts"; +import LoadingSpinner from "../../components/common/LoadingSpinner"; +import ErrorDisplay from "../../components/common/ErrorDisplay"; +import { Controller, SubmitHandler, useForm } from "react-hook-form"; +import countryData from "../../data/countries.json"; // <-- Import country data // --- Form Types --- type DemographicsFormData = Pick< @@ -55,12 +55,46 @@ type PreferenceFormData = { attributes?: Record; }; -// --- Mini Demographic Card Component (Add this) --- +// --- Define options for selects (copied from UserRegistrationForm) --- +const genderOptions = [ + { value: "", label: "Select Gender" }, + { value: "male", label: "Male" }, + { value: "female", label: "Female" }, + { value: "non-binary", label: "Non-binary" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +const incomeOptions = [ + { value: "", label: "Select Income Bracket" }, // Make placeholder less optional here + { value: "<25k", label: "< $25,000" }, + { value: "25k-50k", label: "$25,000 - $49,999" }, + { value: "50k-100k", label: "$50,000 - $99,999" }, + { value: "100k-200k", label: "$100,000 - $199,999" }, + { value: ">200k", label: "> $200,000" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +// --- Country Options (copied from UserRegistrationForm) --- +interface CountryOption { + value: string; + label: string; +} +const typedCountryData: CountryOption[] = Object.entries(countryData).map( + ([code, name]) => ({ value: code, label: name }), +); +typedCountryData.sort((a, b) => a.label.localeCompare(b.label)); +const countryOptions: CountryOption[] = [ + { value: "", label: "Select Country" }, // Make placeholder less optional here + ...typedCountryData, +]; +// --- End Country Options --- + +// --- Mini Demographic Card Component (Keep existing) --- interface DemoInfoCardProps { icon: React.ElementType; label: string; value: string | number | null | undefined; - isLoading?: boolean; // Optional loading state if needed later + isLoading?: boolean; } const DemoInfoCard: React.FC = ({ @@ -88,7 +122,7 @@ const DemoInfoCard: React.FC = ({ // --- End Mini Demographic Card Component --- const UserPreferencesPage: React.FC = () => { - // --- Data Fetching --- + // --- Data Fetching (Keep existing) --- const { data: userProfile, isLoading: profileLoading, @@ -105,7 +139,7 @@ const UserPreferencesPage: React.FC = () => { error: taxonomyError, } = useTaxonomy(); - // --- Mutations --- + // --- Mutations (Keep existing) --- const { mutate: updateProfile, isPending: isUpdatingProfile, @@ -117,7 +151,7 @@ const UserPreferencesPage: React.FC = () => { error: updatePreferencesError, } = useUpdateUserPreferences(); - // --- State --- + // --- State (Keep existing) --- const [isEditingDemographics, setIsEditingDemographics] = useState(false); const [showPreferenceModal, setShowPreferenceModal] = useState(false); const [editingPreferenceIndex, setEditingPreferenceIndex] = useState< @@ -234,17 +268,31 @@ const UserPreferencesPage: React.FC = () => { // --- Handlers --- const onDemoSubmit: SubmitHandler = (data) => { - // Filter out empty strings before sending - const payload: Partial = {}; - if (data.gender) payload.gender = data.gender; - if (data.age) payload.age = data.age; - if (data.country) payload.country = data.country; - if (data.incomeBracket) payload.incomeBracket = data.incomeBracket; - - updateProfile(payload as UserUpdate, { - // Cast as UserUpdate - onSuccess: () => setIsEditingDemographics(false), - }); + // Filter out empty strings or nulls before sending + const payload: Partial = {}; // Use UserUpdate for payload type + if (data.gender && data.gender !== "") payload.gender = data.gender; + else payload.gender = null; // Explicitly set to null if empty + + if (data.age !== undefined && data.age !== null && !isNaN(data.age)) + payload.age = Number(data.age); + else payload.age = null; // Explicitly set to null if empty/invalid + + if (data.country && data.country !== "") payload.country = data.country; + else payload.country = null; // Explicitly set to null if empty + + if (data.incomeBracket && data.incomeBracket !== "") + payload.incomeBracket = data.incomeBracket; + else payload.incomeBracket = null; // Explicitly set to null if empty + + // Only submit if there are actual changes + if (Object.keys(payload).length > 0) { + updateProfile(payload as UserUpdate, { + onSuccess: () => setIsEditingDemographics(false), + }); + } else { + // No changes detected, just exit edit mode + setIsEditingDemographics(false); + } }; const onPrefSubmit: SubmitHandler = (data) => { @@ -361,13 +409,21 @@ const UserPreferencesPage: React.FC = () => { onSubmit={handleDemoSubmit(onDemoSubmit)} className="mt-4 space-y-4" // Use space-y for consistent spacing > + {/* --- Gender Select --- */}
- + {...registerDemo("gender")} // Register the select + className="mt-1" + defaultValue={userProfile?.demographicData?.gender || ""} // Set default value for reset + > + {genderOptions.map((option) => ( + + ))} +
@@ -376,28 +432,57 @@ const UserPreferencesPage: React.FC = () => { type="number" {...registerDemo("age", { valueAsNumber: true })} placeholder="e.g., 30" + min="0" // Add min value + className="mt-1" />
- + {...registerDemo("country")} // Register the select + className="mt-1" + defaultValue={userProfile?.demographicData?.country || ""} // Set default value + > + {countryOptions.map((option) => ( + + ))} +
+ {/* --- Income Bracket Select --- */}
- + {...registerDemo("incomeBracket")} // Register the select + className="mt-1" + defaultValue={ + userProfile?.demographicData?.incomeBracket || "" + } // Set default value for reset + > + {incomeOptions.map((option) => ( + + ))} +
) : ( -
+ // --- DISPLAY VIEW --- +
{ Select a category... {Array.from(categoryMap.values()) + .filter((cat) => cat.id) // Ensure category has an ID .sort((a, b) => a.name.localeCompare(b.name)) .map((cat) => (
); From 6930423d36e71718b2e2f9ed0cb000e526c0a20e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sat, 26 Apr 2025 20:41:30 +0530 Subject: [PATCH 08/21] fix: Align demographic cards to the top in UserPreferencesPage --- web/src/pages/UserDashboard/UserPreferencesPage.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index 8a8456c..4b499a6 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -33,7 +33,7 @@ import { HiQuestionMarkCircle, // Icon for inferred/unverified HiTrash, HiPlus, - HiOutlineHeart, // Icon for correcting + HiOutlineHeart, } from "react-icons/hi"; import { useUserProfile, @@ -591,7 +591,8 @@ const UserPreferencesPage: React.FC = () => { Manage Your Profile & Interests -
+ {/* Add items-start to align cards to the top */} +
{/* --- Demographics Section (UPDATED) --- */}
From 1c265687879168f65b5a1df3ae5124a0a6858cbf Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 02:33:47 +0530 Subject: [PATCH 09/21] Revert "fix: Align demographic cards to the top in UserPreferencesPage" This reverts commit 6930423d36e71718b2e2f9ed0cb000e526c0a20e. --- web/src/pages/UserDashboard/UserPreferencesPage.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index 4b499a6..8a8456c 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -33,7 +33,7 @@ import { HiQuestionMarkCircle, // Icon for inferred/unverified HiTrash, HiPlus, - HiOutlineHeart, + HiOutlineHeart, // Icon for correcting } from "react-icons/hi"; import { useUserProfile, @@ -591,8 +591,7 @@ const UserPreferencesPage: React.FC = () => { Manage Your Profile & Interests - {/* Add items-start to align cards to the top */} -
+
{/* --- Demographics Section (UPDATED) --- */}
From 2af5fbb7a4574bf593707d4c08cbde25f4a4e57b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 02:34:20 +0530 Subject: [PATCH 10/21] Revert "Refactor code structure for improved readability and maintainability" This reverts commit 6d89ade278f69af381730a319fc0f666c2a27b2d. --- web/src/api/hooks/useSystemHooks.ts | 6 +- web/src/api/hooks/useUserHooks.ts | 82 +- .../UserDashboard/UserPreferencesPage.tsx | 1131 +++++------------ 3 files changed, 372 insertions(+), 847 deletions(-) diff --git a/web/src/api/hooks/useSystemHooks.ts b/web/src/api/hooks/useSystemHooks.ts index 322d41f..4a2257b 100644 --- a/web/src/api/hooks/useSystemHooks.ts +++ b/web/src/api/hooks/useSystemHooks.ts @@ -5,7 +5,7 @@ import { HealthStatus, PingStatus, Error } from "../types/data-contracts"; export function useHealthCheck() { // Destructure apiClients first, then get health from it - const { apiClients, clientsReady } = useApiClients(); // Get clientsReady + const { apiClients } = useApiClients(); const { health } = apiClients; // Now get health client // Adjust the useQuery generic to expect HealthStatus as the data type @@ -13,14 +13,13 @@ export function useHealthCheck() { queryKey: cacheKeys.system.health(), // Ensure health client exists before calling queryFn: () => health.healthCheck().then((res) => res.data), - enabled: clientsReady, // Only run when client is ready ...cacheSettings.system, }); } export function usePing() { // Destructure apiClients first, then get ping from it - const { apiClients, clientsReady } = useApiClients(); // Get clientsReady + const { apiClients } = useApiClients(); const { ping } = apiClients; // Now get ping client // Adjust the useQuery generic to expect PingStatus as the data type @@ -28,7 +27,6 @@ export function usePing() { queryKey: cacheKeys.system.ping(), // Ensure ping client exists before calling queryFn: () => ping.ping().then((res) => res.data), // queryFn returns PingStatus - enabled: clientsReady, // Only run when client is ready ...cacheSettings.system, // Ping doesn't require auth, so no enabled check needed here }); diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 38dbadb..17e81ae 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -1,9 +1,4 @@ -import { - useMutation, - useQuery, - useQueryClient, - QueryKey, -} from "@tanstack/react-query"; // Import QueryKey +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; import { cacheKeys, cacheSettings, optimisticUpdates } from "../utils/cache"; import { @@ -15,25 +10,15 @@ import { MonthlySpendingAnalytics, GetSpendingAnalyticsParams, GetRecentUserDataParams, // <-- Import params type for recent data - Error, // <-- Import Error type } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth -// Define a type for the context returned by onMutate -interface MutationContext { - previousData?: User | undefined; - queryKey?: QueryKey; -} - export function useUserProfile() { const { apiClients, clientsReady } = useApiClients(); - const { isAuthenticated, isLoading: authLoading } = useAuth(); // Get auth state - return useQuery({ - // Specify types + return useQuery({ queryKey: cacheKeys.users.profile(), queryFn: () => apiClients.users.getUserProfile().then((res) => res.data), - // Update enabled check - enabled: isAuthenticated && !authLoading && clientsReady, + enabled: clientsReady, ...cacheSettings.user, }); } @@ -42,16 +27,11 @@ export function useUpdateUserProfile() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); - return useMutation({ - // Specify types + return useMutation({ mutationFn: (userData: UserUpdate) => apiClients.users.updateUserProfile(userData).then((res) => res.data), - onSuccess: (updatedUser) => { - // Can use updatedUser if needed - // Invalidate or directly update the cache - queryClient.setQueryData(cacheKeys.users.profile(), updatedUser); - // Optionally invalidate if optimistic updates aren't enough or for related data - // queryClient.invalidateQueries({ queryKey: cacheKeys.users.profile() }); + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: cacheKeys.users.profile() }); }, }); } @@ -62,7 +42,6 @@ export function useUserPreferences() { const { isAuthenticated, isLoading: authLoading } = useAuth(); // Get auth state return useQuery({ - // Add types if UserPreferences type exists queryKey: cacheKeys.users.preferences(), queryFn: () => apiClients.users.getUserOwnPreferences().then((res) => res.data), @@ -77,22 +56,14 @@ export function useUpdateUserPreferences() { const queryClient = useQueryClient(); return useMutation({ - // Add types if UserPreferences type exists mutationFn: (preferences: UserPreferencesUpdate) => apiClients.users .updateUserPreferences(preferences) .then((res) => res.data), - onSuccess: (updatedPreferences) => { - // Can use updatedPreferences - // Invalidate or directly update the cache - queryClient.setQueryData( - cacheKeys.users.preferences(), - updatedPreferences, - ); - // Optionally invalidate - // queryClient.invalidateQueries({ - // queryKey: cacheKeys.users.preferences(), - // }); + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: cacheKeys.users.preferences(), + }); }, }); } @@ -101,9 +72,7 @@ export function useOptInToStore() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); - return useMutation({ - // Add MutationContext type - // Specify types + return useMutation({ mutationFn: (storeId: string) => apiClients.users.optInToStore(storeId).then((res) => res.data), onMutate: async (storeId) => { @@ -113,24 +82,18 @@ export function useOptInToStore() { const previousData = queryClient.getQueryData(queryKey); // <--- Use User type // Apply optimistic update (which now targets the profile cache) - if (previousData) { - optimisticUpdates.optInStore(storeId); // Apply optimistic update locally - } + optimisticUpdates.optInStore(storeId); return { previousData, queryKey }; // Pass queryKey for rollback/settled }, onError: (_err, _variables, context) => { - // Context is now typed // Rollback on error using the correct key and data - // Check if context and its properties exist before using them - if (context?.queryKey && context.previousData !== undefined) { + if (context?.previousData) { queryClient.setQueryData(context.queryKey, context.previousData); } }, onSettled: (_data, _error, _variables, context) => { - // Context is now typed // Invalidate the USER PROFILE cache on settled - // Check if context and queryKey exist if (context?.queryKey) { queryClient.invalidateQueries({ queryKey: context.queryKey }); } @@ -146,9 +109,7 @@ export function useOptOutFromStore() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); - return useMutation({ - // Add MutationContext type - // Specify types + return useMutation({ mutationFn: (storeId: string) => apiClients.users.optOutFromStore(storeId).then((res) => res.data), onMutate: async (storeId) => { @@ -158,24 +119,18 @@ export function useOptOutFromStore() { const previousData = queryClient.getQueryData(queryKey); // <--- Use User type // Apply optimistic update (which now targets the profile cache) - if (previousData) { - optimisticUpdates.optOutStore(storeId); // Apply optimistic update locally - } + optimisticUpdates.optOutStore(storeId); return { previousData, queryKey }; // Pass queryKey for rollback/settled }, onError: (_err, _variables, context) => { - // Context is now typed // Rollback on error using the correct key and data - // Check if context and its properties exist before using them - if (context?.queryKey && context.previousData !== undefined) { + if (context?.previousData) { queryClient.setQueryData(context.queryKey, context.previousData); } }, onSettled: (_data, _error, _variables, context) => { - // Context is now typed // Invalidate the USER PROFILE cache on settled - // Check if context and queryKey exist if (context?.queryKey) { queryClient.invalidateQueries({ queryKey: context.queryKey }); } @@ -191,14 +146,13 @@ export function useDeleteUserProfile() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); - return useMutation({ - // Specify types + return useMutation({ mutationFn: () => apiClients.users.deleteUserProfile().then((res) => res.data), onSuccess: () => { // After successful deletion, clear user-related cache queryClient.invalidateQueries({ queryKey: ["auth", "metadata"] }); - queryClient.invalidateQueries({ queryKey: cacheKeys.users.all }); // Invalidate all user queries + queryClient.invalidateQueries({ queryKey: ["users"] }); }, }); } diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index 8a8456c..32f17dd 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; // Add React import +import React, { useState, useEffect, useMemo } from "react"; import { Card, Button, @@ -10,71 +10,52 @@ import { ModalFooter, Label, TextInput, - Select, + Select, // Already imported RangeSlider, List, ListItem, - Badge, // Import Badge - Tooltip, // Import Tooltip } from "flowbite-react"; import { - HiUser, - HiPencil, HiInformationCircle, - HiOutlineCalendar, // Example icon for Age - HiOutlineGlobeAlt, // Example icon for Country - HiOutlineCurrencyDollar, // Example icon for Income - HiOutlineIdentification, // Example icon for Gender - HiOutlineUsers, // Example icon for Relationship - HiOutlineBriefcase, // Example icon for Employment - HiOutlineAcademicCap, // Example icon for Education - HiOutlineUserGroup, // Example icon for Kids - HiCheckCircle, // Icon for verified - HiQuestionMarkCircle, // Icon for inferred/unverified + HiPencil, HiTrash, HiPlus, - HiOutlineHeart, // Icon for correcting + HiUser, + HiSparkles, + HiOutlineUserCircle, + HiOutlineCake, + HiOutlineGlobeAlt, + HiOutlineCash, } from "react-icons/hi"; import { useUserProfile, useUserPreferences, - useUpdateUserProfile, // Import the hook + useUpdateUserProfile, useUpdateUserPreferences, -} from "../../api/hooks/useUserHooks"; // Adjust path as needed -import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; // Import useTaxonomy separately +} from "../../api/hooks/useUserHooks"; +import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; import { UserUpdate, PreferenceItem, TaxonomyCategory, - DemographicData, // Import DemographicData type - TaxonomyAttribute, // Import TaxonomyAttribute type -} from "../../api/types/data-contracts"; // Adjust path as needed +} from "../../api/types/data-contracts"; import LoadingSpinner from "../../components/common/LoadingSpinner"; import ErrorDisplay from "../../components/common/ErrorDisplay"; import { Controller, SubmitHandler, useForm } from "react-hook-form"; -import countryData from "../../data/countries.json"; +import countryData from "../../data/countries.json"; // <-- Import country data // --- Form Types --- -// This form now handles ALL demographic fields the user can provide -type DemographicsFormData = { - gender?: DemographicData["gender"]; - age?: DemographicData["age"]; - country?: DemographicData["country"]; - incomeBracket?: DemographicData["incomeBracket"]; - // Add fields that were previously only inferred - hasKids?: boolean | null; // Use boolean for hasKids - relationshipStatus?: DemographicData["inferredRelationshipStatus"]; - employmentStatus?: DemographicData["inferredEmploymentStatus"]; - educationLevel?: DemographicData["inferredEducationLevel"]; -}; -// ... (PreferenceFormData remains the same) ... +type DemographicsFormData = Pick< + UserUpdate, + "gender" | "age" | "country" | "incomeBracket" +>; type PreferenceFormData = { category: string; score: number; attributes?: Record; }; -// --- Define options for selects (Keep existing) --- +// --- Define options for selects (copied from UserRegistrationForm) --- const genderOptions = [ { value: "", label: "Select Gender" }, { value: "male", label: "Male" }, @@ -84,7 +65,7 @@ const genderOptions = [ ]; const incomeOptions = [ - { value: "", label: "Select Income Bracket" }, + { value: "", label: "Select Income Bracket" }, // Make placeholder less optional here { value: "<25k", label: "< $25,000" }, { value: "25k-50k", label: "$25,000 - $49,999" }, { value: "50k-100k", label: "$50,000 - $99,999" }, @@ -93,7 +74,7 @@ const incomeOptions = [ { value: "prefer_not_to_say", label: "Prefer not to say" }, ]; -// --- Country Options (Keep existing) --- +// --- Country Options (copied from UserRegistrationForm) --- interface CountryOption { value: string; label: string; @@ -103,195 +84,50 @@ const typedCountryData: CountryOption[] = Object.entries(countryData).map( ); typedCountryData.sort((a, b) => a.label.localeCompare(b.label)); const countryOptions: CountryOption[] = [ - { value: "", label: "Select Country" }, + { value: "", label: "Select Country" }, // Make placeholder less optional here ...typedCountryData, ]; +// --- End Country Options --- -// --- NEW Options for Inferred Fields --- -const hasKidsOptions = [ - { value: "", label: "Select Option" }, // Represents null - { value: "true", label: "Yes" }, - { value: "false", label: "No" }, -]; - -const relationshipStatusOptions = [ - { value: "", label: "Select Status" }, // Represents null - { value: "single", label: "Single" }, - { value: "relationship", label: "In a Relationship" }, - { value: "married", label: "Married" }, - // Add other relevant options if needed, matching the enum in DemographicData -]; - -const employmentStatusOptions = [ - { value: "", label: "Select Status" }, // Represents null - { value: "employed", label: "Employed" }, - { value: "unemployed", label: "Unemployed" }, - { value: "student", label: "Student" }, - // Add other relevant options if needed -]; - -const educationLevelOptions = [ - { value: "", label: "Select Level" }, // Represents null - { value: "high_school", label: "High School" }, - { value: "bachelors", label: "Bachelor's Degree" }, - { value: "masters", label: "Master's Degree" }, - { value: "doctorate", label: "Doctorate" }, - // Add other relevant options if needed -]; -// --- End NEW Options --- - -// --- NEW: Helper to format demographic values --- -const formatValue = ( - value: string | number | boolean | null | undefined, -): string => { - if (value === null || value === undefined || value === "") return "Not set"; - if (typeof value === "boolean") return value ? "Yes" : "No"; // Format boolean for 'hasKids' - // Add specific formatting if needed (e.g., capitalize) - if (typeof value === "string") { - return value.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); // Capitalize words - } - return String(value); -}; - -// --- UPDATED: Demographic Info Display Component --- -interface DemoInfoDisplayProps { +// --- Mini Demographic Card Component (Keep existing) --- +interface DemoInfoCardProps { icon: React.ElementType; label: string; - // Keep userProvidedValue for original fields - userProvidedValue: string | number | boolean | null | undefined; - inferredValue?: string | number | boolean | null | undefined; // Can be boolean for hasKids - isVerified?: boolean; - verificationFieldName?: keyof DemographicData; // e.g., 'genderIsVerified' - onVerify?: (fieldName: keyof DemographicData) => void; - onEdit?: () => void; // Function to open the edit form + value: string | number | null | undefined; isLoading?: boolean; - isMutating?: boolean; // To disable buttons during mutation - isEditingDemographics?: boolean; } -const DemoInfoDisplay: React.FC = ({ +const DemoInfoCard: React.FC = ({ icon: Icon, label, - userProvidedValue, // This now represents the USER's input for ALL fields - inferredValue, - isVerified, - verificationFieldName, - onVerify, - onEdit, + value, isLoading, - isMutating, - isEditingDemographics, -}) => { - // Determine the primary value to display: User's input takes precedence - const primaryValue = - userProvidedValue !== null && - userProvidedValue !== undefined && - userProvidedValue !== "" - ? userProvidedValue - : inferredValue; // Fall back to inferred if user hasn't provided - - const displayValue = formatValue(primaryValue); - - // Determine if the displayed value is inferred and unverified - const isDisplayingInferredUnverified = - (userProvidedValue === null || - userProvidedValue === undefined || - userProvidedValue === "") && // User hasn't provided - inferredValue !== null && - inferredValue !== undefined && // Inferred value exists - !isVerified; // And it's not verified - - return ( -
- -
-

- {label} -

-

- {isLoading ? ( - - ) : ( - <> - {displayValue} - {/* Show badge only if displaying an inferred value */} - {(userProvidedValue === null || - userProvidedValue === undefined || - userProvidedValue === "") && - inferredValue !== null && - inferredValue !== undefined && ( - - - {isVerified ? "Confirmed" : "Inferred"} - - - )} - - )} +}) => ( +

+ +
+

+ {label} +

+ {isLoading ? ( + + ) : ( +

+ {value || "Not set"}

-
- {/* Action Buttons */} - {!isLoading && !isEditingDemographics && ( -
- {/* Show Verify button ONLY if displaying an inferred, unverified value */} - {isDisplayingInferredUnverified && - verificationFieldName && - onVerify && ( - - - - )} - {/* Edit button should always be available to add/change user-provided value */} - {onEdit && ( - - - - )} -
)}
- ); -}; -// --- End Demographic Info Display Component --- +
+); +// --- End Mini Demographic Card Component --- const UserPreferencesPage: React.FC = () => { // --- Data Fetching (Keep existing) --- const { - data: userProfile, // This should now contain the full User object including demographicData + data: userProfile, isLoading: profileLoading, error: profileError, - refetch: refetchUserProfile, // Add refetch } = useUserProfile(); - // ... (rest of data fetching) ... const { data: preferencesData, isLoading: preferencesLoading, @@ -303,13 +139,12 @@ const UserPreferencesPage: React.FC = () => { error: taxonomyError, } = useTaxonomy(); - // --- Mutations (Keep existing, ensure useUpdateUserProfile handles nested demographicData) --- + // --- Mutations (Keep existing) --- const { mutate: updateProfile, isPending: isUpdatingProfile, error: updateProfileError, } = useUpdateUserProfile(); - // ... (rest of mutations) ... const { mutate: updatePreferences, isPending: isUpdatingPreferences, @@ -318,22 +153,21 @@ const UserPreferencesPage: React.FC = () => { // --- State (Keep existing) --- const [isEditingDemographics, setIsEditingDemographics] = useState(false); - // ... (rest of state) ... const [showPreferenceModal, setShowPreferenceModal] = useState(false); const [editingPreferenceIndex, setEditingPreferenceIndex] = useState< number | null >(null); - // --- Forms (Keep existing, Demographics form only targets user-provided fields) --- + // --- Forms --- const { + register: registerDemo, handleSubmit: handleDemoSubmit, reset: resetDemoForm, - control: demoControl, // Use control for Controller - formState: { isDirty: isDemoDirty, errors: demoErrors }, // Add errors - } = useForm(); // Form data is only for user-provided fields + formState: { isDirty: isDemoDirty }, + } = useForm(); - // ... (Preference form remains the same) ... const { + register: registerPref, handleSubmit: handlePrefSubmit, reset: resetPrefForm, control: prefControl, @@ -343,39 +177,18 @@ const UserPreferencesPage: React.FC = () => { defaultValues: { category: "", attributes: {}, score: 50 }, }); - // --- Effects (UPDATED demo form reset) --- useEffect(() => { - // Reset the demographics form when the profile data loads or editing stops - if (userProfile?.demographicData && !isEditingDemographics) { + if (userProfile && !isEditingDemographics) { resetDemoForm({ - // Fields stored directly as user-provided in backend - gender: userProfile.demographicData.gender || null, - age: userProfile.demographicData.age || null, - country: userProfile.demographicData.country || null, - incomeBracket: userProfile.demographicData.incomeBracket || null, - - // Fields currently ONLY inferred/verified in backend - initialize form to null - hasKids: null, // Or potentially pre-fill from inferred if desired, but form submits only the above 4 - relationshipStatus: null, - employmentStatus: null, - educationLevel: null, - }); - } else if (!isEditingDemographics) { - // If no profile data, reset all to null/defaults - resetDemoForm({ - gender: null, - age: null, - country: null, - incomeBracket: null, - hasKids: null, - relationshipStatus: null, - employmentStatus: null, - educationLevel: null, + gender: userProfile?.demographicData?.gender || "", + age: userProfile?.demographicData?.age || undefined, + country: userProfile?.demographicData?.country || "", + incomeBracket: userProfile?.demographicData?.incomeBracket || "", }); } }, [userProfile, isEditingDemographics, resetDemoForm]); - // ... (Preference form reset effect remains the same) ... + // Reset preference form when modal opens/closes or editing target changes useEffect(() => { if (showPreferenceModal) { if ( @@ -386,13 +199,14 @@ const UserPreferencesPage: React.FC = () => { const pref = preferencesData.preferences[editingPreferenceIndex]; const formAttributes: Record = {}; if (pref.attributes) { - // Assuming pref.attributes is Record> Object.entries(pref.attributes).forEach(([key, valueObj]) => { let displayValue: string | undefined = undefined; if (typeof valueObj === "object" && valueObj !== null) { + // Attempt to get the first key if it's an object like { "Blue": 1.0 } const firstKey = Object.keys(valueObj)[0]; if (firstKey) displayValue = firstKey; } else if (typeof valueObj === "string") { + // Handle simple string attributes if your API supports them displayValue = valueObj; } formAttributes[key] = displayValue; @@ -403,8 +217,8 @@ const UserPreferencesPage: React.FC = () => { attributes: formAttributes, score: pref.score !== null && pref.score !== undefined - ? Math.round(pref.score * 100) - : 50, + ? Math.round(pref.score * 100) // Convert 0-1 score to 0-100 for slider + : 50, // Default slider value }); } else { // Adding new preference @@ -418,24 +232,23 @@ const UserPreferencesPage: React.FC = () => { resetPrefForm, ]); - // --- Memos (Keep existing) --- + // --- Memos --- const { categoryMap, attributeMap } = useMemo(() => { - // ... (implementation remains the same) ... const catMap = new Map(); const attrMap = new Map>(); // categoryId -> Map if (taxonomyData?.categories) { - taxonomyData.categories.forEach((cat: TaxonomyCategory) => { + taxonomyData.categories.forEach((cat) => { catMap.set(cat.id, cat); const catAttrs = new Map(); - cat.attributes?.forEach((attr: TaxonomyAttribute) => { + cat.attributes?.forEach((attr) => { catAttrs.set(attr.name, attr.description || attr.name); }); // Include parent attributes (simple one-level for now) if (cat.parent_id) { const parentCat = taxonomyData.categories.find( - (p: TaxonomyCategory) => p.id === cat.parent_id, + (p) => p.id === cat.parent_id, ); - parentCat?.attributes?.forEach((attr: TaxonomyAttribute) => { + parentCat?.attributes?.forEach((attr) => { if (!catAttrs.has(attr.name)) { // Avoid overwriting child attributes catAttrs.set(attr.name, attr.description || attr.name); @@ -454,63 +267,45 @@ const UserPreferencesPage: React.FC = () => { }, [selectedCategoryId, attributeMap]); // --- Handlers --- - - // UPDATED: Demo Submit sends all user-provided fields const onDemoSubmit: SubmitHandler = (data) => { - const payload: UserUpdate = { - demographicData: {}, - }; + // Filter out empty strings or nulls before sending + const payload: Partial = {}; // Use UserUpdate for payload type + if (data.gender && data.gender !== "") payload.gender = data.gender; + else payload.gender = null; // Explicitly set to null if empty + + if (data.age !== undefined && data.age !== null && !isNaN(data.age)) + payload.age = Number(data.age); + else payload.age = null; // Explicitly set to null if empty/invalid - // Only include fields the backend schema accepts as user-provided - payload.demographicData!.gender = data.gender || null; - payload.demographicData!.age = data.age ? Number(data.age) : null; - payload.demographicData!.country = data.country || null; - payload.demographicData!.incomeBracket = data.incomeBracket || null; + if (data.country && data.country !== "") payload.country = data.country; + else payload.country = null; // Explicitly set to null if empty - // DO NOT send hasKids, relationshipStatus etc. directly here - // unless the backend schema (dbSchemas.js) and update logic (UserProfileService.js) - // are modified to accept and store them as user-provided values. - // Verification flags are handled by handleVerifyDemographic. + if (data.incomeBracket && data.incomeBracket !== "") + payload.incomeBracket = data.incomeBracket; + else payload.incomeBracket = null; // Explicitly set to null if empty // Only submit if there are actual changes - if (isDemoDirty) { - updateProfile(payload, { - onSuccess: () => { - setIsEditingDemographics(false); // Close form on success - refetchUserProfile(); // Refetch to show updated data - }, + if (Object.keys(payload).length > 0) { + updateProfile(payload as UserUpdate, { + onSuccess: () => setIsEditingDemographics(false), }); } else { - setIsEditingDemographics(false); // Close form if no changes were made + // No changes detected, just exit edit mode + setIsEditingDemographics(false); } }; - // NEW: Handler for verifying inferred data - const handleVerifyDemographic = (fieldName: keyof DemographicData) => { - const payload: UserUpdate = { - demographicData: { - [fieldName]: true, // Set the specific verification flag to true - }, - }; - updateProfile(payload, { - onSuccess: () => { - refetchUserProfile(); // Refetch profile to show updated status - }, - // Optional: Add onError handling - }); - }; - - // --- Preference Handlers (Keep existing) --- const onPrefSubmit: SubmitHandler = (data) => { - // ... (implementation remains the same) ... const currentPreferences = preferencesData?.preferences || []; let updatedPreferences: PreferenceItem[]; + // Format attributes for the API: { "brand": { "Apple": 1.0 }, "color": { "Blue": 1.0 } } const apiAttributes: Record = {}; if (data.attributes) { Object.entries(data.attributes).forEach(([key, value]) => { if (value && value.trim() !== "") { - apiAttributes[key] = { [value.trim()]: 1.0 }; + // Only include non-empty attributes + apiAttributes[key] = { [value.trim()]: 1.0 }; // Assign score 1.0 } }); } @@ -518,15 +313,16 @@ const UserPreferencesPage: React.FC = () => { const newPrefItem: PreferenceItem = { category: data.category, attributes: apiAttributes, - score: data.score / 100, + score: data.score / 100, // Convert 0-100 slider value to 0-1 score }; if (editingPreferenceIndex !== null) { - updatedPreferences = currentPreferences.map( - (pref, index) => - index === editingPreferenceIndex ? newPrefItem : pref, // pref type is PreferenceItem + // Update existing preference + updatedPreferences = currentPreferences.map((pref, index) => + index === editingPreferenceIndex ? newPrefItem : pref, ); } else { + // Add new preference updatedPreferences = [...currentPreferences, newPrefItem]; } @@ -542,22 +338,19 @@ const UserPreferencesPage: React.FC = () => { }; const handleRemovePreference = (indexToRemove: number) => { - // ... (implementation remains the same) ... const currentPreferences = preferencesData?.preferences || []; const updatedPreferences = currentPreferences.filter( - (_: PreferenceItem, index: number) => index !== indexToRemove, + (_, index) => index !== indexToRemove, ); updatePreferences({ preferences: updatedPreferences }); }; const openAddModal = () => { - // ... (implementation remains the same) ... setEditingPreferenceIndex(null); setShowPreferenceModal(true); }; const openEditModal = (index: number) => { - // ... (implementation remains the same) ... setEditingPreferenceIndex(index); setShowPreferenceModal(true); }; @@ -565,10 +358,9 @@ const UserPreferencesPage: React.FC = () => { // --- Render Logic --- const isLoading = profileLoading || preferencesLoading || taxonomyLoading; const error = profileError || preferencesError || taxonomyError; - const isMutating = isUpdatingProfile || isUpdatingPreferences; // Combined mutation state + const isMutating = isUpdatingProfile || isUpdatingPreferences; - if (isLoading && !userProfile) { - // Show loading only if profile isn't loaded yet + if (isLoading) { return ; } @@ -582,9 +374,6 @@ const UserPreferencesPage: React.FC = () => { ); } - // Get demographic data safely - const demographics = userProfile?.demographicData; - return (

@@ -592,14 +381,23 @@ const UserPreferencesPage: React.FC = () => {

- {/* --- Demographics Section (UPDATED) --- */} + {/* --- Demographics Section (Left Column on Large Screens) --- */}

About You

- {/* Edit button is now handled within DemoInfoDisplay */} + {!isEditingDemographics && ( + + )}
{updateProfileError && ( @@ -607,238 +405,92 @@ const UserPreferencesPage: React.FC = () => { )} {isEditingDemographics ? ( - // --- EDIT FORM (Only User-Provided Fields) ---
- {/* Gender */} + {/* --- Gender Select --- */}
- ( - - )} - /> +
- {/* Age */}
- ( - - field.onChange( - e.target.value === "" - ? null - : parseInt(e.target.value, 10), - ) - } // Convert back to number or null - className="mt-1" - /> - )} + - {demoErrors.age && ( -

- {demoErrors.age.message} -

- )}
- {/* Country */}
- ( - - )} - /> +
- {/* Income Bracket */} + {/* --- Income Bracket Select --- */}
- ( - - )} - /> -
- - {/* --- NEW Form Fields for Previously Inferred Data --- */} - - {/* Has Kids */} -
- - ( - - )} - /> -
- - {/* Relationship Status */} -
- - ( - - )} - /> -
- - {/* Employment Status */} -
- - ( - - )} - /> -
- - {/* Education Level */} -
- - ( - - )} - /> +
- {/* --- End NEW Form Fields --- */} - - {/* Form Actions */} -
+
-
) : ( - // --- DISPLAY VIEW (User-Provided + Inferred) --- -
- {/* Gender */} - + setIsEditingDemographics(true)} - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} + value={userProfile?.demographicData?.gender} + isLoading={profileLoading} /> - {/* Age */} - setIsEditingDemographics(true)} - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} + value={userProfile?.demographicData?.age} + isLoading={profileLoading} /> - {/* Country */} - setIsEditingDemographics(true)} - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} - /> - {/* Income */} - setIsEditingDemographics(true)} - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} - /> - {/* Has Kids */} - setIsEditingDemographics(true)} // Allow editing (form collects input, but submit might ignore it) - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} - /> - {/* Relationship Status */} - setIsEditingDemographics(true)} // Allow editing - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} + value={userProfile?.demographicData?.country} + isLoading={profileLoading} /> - {/* Employment Status */} - setIsEditingDemographics(true)} // Allow editing - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} - /> - {/* Education Level */} - setIsEditingDemographics(true)} // Allow editing - isLoading={profileLoading && !demographics} - isMutating={isUpdatingProfile} - isEditingDemographics={isEditingDemographics} +
)} - {/* --- Preferences Section (Right Columns on Large Screens) --- */} + {/* --- Preferences Section (Right Column on Large Screens) --- */} - {/* ... (Preference list and modal rendering remains the same) ... */}

- {/* Example Icon */} + Your Interests

- - - -
-
- - ))} - - ) : ( -

- You haven't added any specific interests yet. Add some to help - personalize your experience! -

- )} +
+ + ); + })} + + ) : ( +

+ You haven't added any specific interests yet. Click "Add + Interest" to get started. +

+ )} +
- {/* --- Preference Edit/Add Modal --- */} + {/* --- Preference Add/Edit Modal --- */} setShowPreferenceModal(false)} + onClose={() => !isMutating && setShowPreferenceModal(false)} + size="lg" // Slightly larger modal > - {editingPreferenceIndex !== null ? "Edit Interest" : "Add Interest"} + {editingPreferenceIndex !== null + ? "Edit Interest" + : "Add New Interest"} - -
+ + {/* Category Select */}
- - ( - - )} - /> + + {prefErrors.category && ( -

+

{prefErrors.category.message}

)}
- {/* Dynamic Attributes */} + {/* Attributes */} {selectedCategoryId && availableAttributes.size > 0 && ( - - {" "} - {/* Wrap attributes in a card for better visual grouping */} -

- Refine by Attributes (Optional) -

-
+
+ + Refine Interest (Optional) + +
{Array.from(availableAttributes.entries()).map( ([attrName, attrDesc]) => (
-
), )}
- +
)} {/* Score Slider */}
- + ( - + render={({ field: { onChange, value } }) => ( +
+ onChange(parseInt(e.target.value, 10))} + className="flex-grow" + /> + + {value ?? 50} + +
)} /> +

+ How interested are you in this category? (0 = Not at all, 100 = + Very interested) +

- - {updatePreferencesError && ( - - Failed to save interest: {updatePreferencesError.message} - - )} - - - - - + + + + + +
); From 0b6e0a441f40e64e7da5cab3c8729c60d2491a60 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 03:21:54 +0530 Subject: [PATCH 11/21] feat: Enhance demographic data handling by adding user-provided fields and removing verification flags --- api-service/api/openapi.yaml | 150 ++++++++------- api-service/service/AuthenticationService.js | 22 ++- api-service/service/UserProfileService.js | 67 +++---- api-service/utils/dbSchemas.js | 80 ++++---- .../app/services/demographicInference.py | 173 +++++++----------- web/src/api/types/data-contracts.ts | 128 +++++++------ 6 files changed, 288 insertions(+), 332 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 8311ba6..d1e9b61 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -1038,6 +1038,7 @@ components: phone: type: string description: User's phone number (E.164 format recommended) + pattern: '^\+?[\d\s-]+$' preferences: type: array description: User interest preferences with taxonomy categorization @@ -1050,21 +1051,11 @@ components: type: boolean anonymizeData: type: boolean - optInStores: - type: array - items: - type: string - description: List of store IDs the user has opted into sharing data with - optOutStores: - type: array - items: - type: string - description: List of store IDs the user has opted out of sharing data with + # optInStores/optOutStores are handled by separate endpoints demographicData: type: object - description: Updatable demographic information (user-provided and verification flags). + description: Updatable user-provided demographic information. Setting a value here implies verification and may clear inferred values. properties: - # User-provided fields gender: type: string nullable: true @@ -1092,26 +1083,36 @@ components: type: integer format: int32 nullable: true - description: "User-provided age. Setting this clears inferredAgeBracket." - # Verification flags for inferred fields - hasKidsIsVerified: - type: boolean - description: "Set to true by the user to confirm the inferred 'hasKids' status." - relationshipStatusIsVerified: - type: boolean - description: "Set to true by the user to confirm the inferred 'relationshipStatus'." - employmentStatusIsVerified: - type: boolean - description: "Set to true by the user to confirm the inferred 'employmentStatus'." - educationLevelIsVerified: - type: boolean - description: "Set to true by the user to confirm the inferred 'educationLevel'." - ageBracketIsVerified: - type: boolean - description: "Set to true by the user to confirm the inferred 'ageBracket'. Only applicable if 'age' is not set." - genderIsVerified: + description: "User-provided age. Setting this clears the inferred age bracket." + minimum: 0 + hasKids: type: boolean - description: "Set to true by the user to confirm the inferred 'gender'. Only applicable if 'gender' is not set." + nullable: true + description: "User-provided: Does the user have children?" + relationshipStatus: + type: string + nullable: true + description: "User-provided: User relationship status" + enum: [single, relationship, married, prefer_not_to_say, null] + employmentStatus: + type: string + nullable: true + description: "User-provided: User employment status" + enum: [employed, unemployed, student, prefer_not_to_say, null] + educationLevel: + type: string + nullable: true + description: "User-provided: User education level" + enum: + [ + high_school, + bachelors, + masters, + doctorate, + prefer_not_to_say, + null, + ] + # REMOVED verification flags from update payload ApiKey: type: object @@ -1595,7 +1596,7 @@ components: gender: type: string nullable: true - description: "User-provided gender identity" # Keep as user-provided + description: "User-provided gender identity" enum: [male, female, non-binary, prefer_not_to_say, null] example: "female" incomeBracket: @@ -1622,62 +1623,71 @@ components: type: integer format: int32 nullable: true - description: "User-provided age" # Keep as user-provided + description: "User-provided age" example: 35 - # --- Inferred fields --- - inferredHasKids: + minimum: 0 + hasKids: # NEW User-provided type: boolean nullable: true - description: "Inferred: Does the user likely have children? (null if unknown)" - hasKidsIsVerified: # NEW + description: "User-provided: Does the user have children?" + example: true + relationshipStatus: # NEW User-provided + type: string + nullable: true + description: "User-provided: User relationship status" + enum: [single, relationship, married, prefer_not_to_say, null] + example: "married" + employmentStatus: # NEW User-provided + type: string + nullable: true + description: "User-provided: User employment status" + enum: [employed, unemployed, student, prefer_not_to_say, null] + example: "employed" + educationLevel: # NEW User-provided + type: string + nullable: true + description: "User-provided: User education level" + enum: + [ + high_school, + bachelors, + masters, + doctorate, + prefer_not_to_say, + null, + ] + example: "bachelors" + # --- Inferred (Read-Only in responses, not updatable directly) --- + inferredHasKids: type: boolean - description: "Flag indicating if inferredHasKids has been verified by the user" - default: false + nullable: true + readOnly: true + description: "Inferred: Does the user likely have children? (null if unknown or user provided)" inferredRelationshipStatus: type: string nullable: true - description: "Inferred: User relationship status (null if unknown)" + readOnly: true + description: "Inferred: User relationship status (null if unknown or user provided)" enum: [single, relationship, married, null] - relationshipStatusIsVerified: # NEW - type: boolean - description: "Flag indicating if inferredRelationshipStatus has been verified by the user" - default: false inferredEmploymentStatus: type: string nullable: true - description: "Inferred: User employment status (null if unknown)" + readOnly: true + description: "Inferred: User employment status (null if unknown or user provided)" enum: [employed, unemployed, student, null] - employmentStatusIsVerified: # NEW - type: boolean - description: "Flag indicating if inferredEmploymentStatus has been verified by the user" - default: false inferredEducationLevel: type: string nullable: true - description: "Inferred: User education level (null if unknown)" + readOnly: true + description: "Inferred: User education level (null if unknown or user provided)" enum: [high_school, bachelors, masters, doctorate, null] - educationLevelIsVerified: # NEW - type: boolean - description: "Flag indicating if inferredEducationLevel has been verified by the user" - default: false - inferredAgeBracket: # NEW (separate from user-provided 'age') + inferredGender: type: string nullable: true - description: "Inferred: User age bracket (null if unknown or age provided)" - enum: ["18-24", "25-34", "35-44", "45-54", "55-64", "65+", null] - ageBracketIsVerified: # NEW - type: boolean - description: "Flag indicating if inferredAgeBracket has been verified by the user" - default: false - inferredGender: # NEW (separate from user-provided 'gender') - type: string - nullable: true - description: "Inferred: User gender identity (null if unknown or gender provided)" - enum: [male, female, non-binary, null] # Note: 'prefer_not_to_say' is user-only - genderIsVerified: # NEW - type: boolean - description: "Flag indicating if inferredGender has been verified by the user" - default: false + readOnly: true + description: "Inferred: User gender identity (null if unknown or user provided)" + enum: [male, female, non-binary, null] + RecentUserDataEntry: type: object properties: diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index e271f6e..e13680f 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -101,24 +101,28 @@ exports.registerUser = async function (req, body) { email: userData.email, phone: userData.phone_number || null, demographicData: { - // User provided + // User provided (initialize as null unless provided in registration body) gender: gender || null, incomeBracket: incomeBracket || null, country: country || null, age: age || null, - // Inferred (initialize as null, verified as false) + hasKids: null, // NEW user-provided, init null + relationshipStatus: null, // NEW user-provided, init null + employmentStatus: null, // NEW user-provided, init null + educationLevel: null, // NEW user-provided, init null + // Inferred (initialize as null) inferredHasKids: null, - hasKidsIsVerified: false, // Initialize + // REMOVED hasKidsIsVerified inferredRelationshipStatus: null, - relationshipStatusIsVerified: false, // Initialize + // REMOVED relationshipStatusIsVerified inferredEmploymentStatus: null, - employmentStatusIsVerified: false, // Initialize + // REMOVED employmentStatusIsVerified inferredEducationLevel: null, - educationLevelIsVerified: false, // Initialize - inferredAgeBracket: null, - ageBracketIsVerified: false, // Initialize + // REMOVED educationLevelIsVerified + // REMOVED inferredAgeBracket + // REMOVED ageBracketIsVerified inferredGender: null, - genderIsVerified: false, // Initialize + // REMOVED genderIsVerified }, preferences: preferences || [], privacySettings: { diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index d376a22..6906c65 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -111,13 +111,11 @@ exports.updateUserProfile = async function (req, body) { // --- Update Demographic Data --- // Use dot notation to set fields within the demographicData object - // User-provided fields (existing logic) - if (body.demographicData?.gender !== undefined) { // Check within demographicData object + // User-provided fields + if (body.demographicData?.gender !== undefined) { updateData['demographicData.gender'] = body.demographicData.gender; + updateData['demographicData.inferredGender'] = null; // Clear inferred on user update demographicsChanged = true; - // If gender is being set by user, clear the inferred gender and its verification - updateData['demographicData.inferredGender'] = null; - updateData['demographicData.genderIsVerified'] = false; // Reset verification if user changes main field } if (body.demographicData?.incomeBracket !== undefined) { updateData['demographicData.incomeBracket'] = body.demographicData.incomeBracket; @@ -129,58 +127,41 @@ exports.updateUserProfile = async function (req, body) { } if (body.demographicData?.age !== undefined) { const ageValue = body.demographicData.age === null ? null : parseInt(body.demographicData.age); - if (ageValue === null || !isNaN(ageValue)) { + if (ageValue === null || (!isNaN(ageValue) && ageValue >= 0)) { // Added age >= 0 check updateData['demographicData.age'] = ageValue; + // No inferred age bracket to clear anymore demographicsChanged = true; - // If age is being set by user, clear the inferred age bracket and its verification - updateData['demographicData.inferredAgeBracket'] = null; - updateData['demographicData.ageBracketIsVerified'] = false; // Reset verification } else { console.warn(`Invalid age value provided for user ${auth0UserId}: ${body.demographicData.age}`); // Optionally return a 400 error here + // return respondWithCode(400, { code: 400, message: 'Invalid age provided.' }); } } - - // --- NEW: Handle Verification Flags --- - // Only allow setting verification flags via this endpoint - if (body.demographicData?.hasKidsIsVerified !== undefined && typeof body.demographicData.hasKidsIsVerified === 'boolean') { - updateData['demographicData.hasKidsIsVerified'] = body.demographicData.hasKidsIsVerified; - demographicsChanged = true; // Consider verification change as demographic change for cache invalidation - } - if (body.demographicData?.relationshipStatusIsVerified !== undefined && typeof body.demographicData.relationshipStatusIsVerified === 'boolean') { - updateData['demographicData.relationshipStatusIsVerified'] = body.demographicData.relationshipStatusIsVerified; + // --- NEW User-Provided Fields --- + if (body.demographicData?.hasKids !== undefined) { + updateData['demographicData.hasKids'] = body.demographicData.hasKids; + updateData['demographicData.inferredHasKids'] = null; // Clear inferred on user update demographicsChanged = true; } - if (body.demographicData?.employmentStatusIsVerified !== undefined && typeof body.demographicData.employmentStatusIsVerified === 'boolean') { - updateData['demographicData.employmentStatusIsVerified'] = body.demographicData.employmentStatusIsVerified; + if (body.demographicData?.relationshipStatus !== undefined) { + updateData['demographicData.relationshipStatus'] = body.demographicData.relationshipStatus; + updateData['demographicData.inferredRelationshipStatus'] = null; // Clear inferred on user update demographicsChanged = true; } - if (body.demographicData?.educationLevelIsVerified !== undefined && typeof body.demographicData.educationLevelIsVerified === 'boolean') { - updateData['demographicData.educationLevelIsVerified'] = body.demographicData.educationLevelIsVerified; + if (body.demographicData?.employmentStatus !== undefined) { + updateData['demographicData.employmentStatus'] = body.demographicData.employmentStatus; + updateData['demographicData.inferredEmploymentStatus'] = null; // Clear inferred on user update demographicsChanged = true; } - if (body.demographicData?.ageBracketIsVerified !== undefined && typeof body.demographicData.ageBracketIsVerified === 'boolean') { - // Only allow verifying inferred age bracket if user hasn't provided their specific age - const currentUserDoc = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { 'demographicData.age': 1 } }); - if (currentUserDoc?.demographicData?.age === null) { - updateData['demographicData.ageBracketIsVerified'] = body.demographicData.ageBracketIsVerified; - demographicsChanged = true; - } else { - console.warn(`User ${auth0UserId} attempted to verify age bracket when specific age is set.`); - // Do not update the flag if specific age is provided - } - } - if (body.demographicData?.genderIsVerified !== undefined && typeof body.demographicData.genderIsVerified === 'boolean') { - // Only allow verifying inferred gender if user hasn't provided their specific gender - const currentUserDoc = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { 'demographicData.gender': 1 } }); - if (currentUserDoc?.demographicData?.gender === null) { - updateData['demographicData.genderIsVerified'] = body.demographicData.genderIsVerified; - demographicsChanged = true; - } else { - console.warn(`User ${auth0UserId} attempted to verify inferred gender when specific gender is set.`); - // Do not update the flag if specific gender is provided - } + if (body.demographicData?.educationLevel !== undefined) { + updateData['demographicData.educationLevel'] = body.demographicData.educationLevel; + updateData['demographicData.inferredEducationLevel'] = null; // Clear inferred on user update + demographicsChanged = true; } + + // --- REMOVED Verification Flag Handling --- + // The logic for hasKidsIsVerified, relationshipStatusIsVerified, etc. is removed. + // --- End Update Demographic Data --- diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index c34feb5..3598b27 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -57,66 +57,58 @@ const userSchema = { description: 'User-provided age', // Clarified description minimum: 0, }, - // --- Inferred fields --- - inferredHasKids: { + // --- NEW User-Provided fields (mirroring inferred ones) --- + hasKids: { bsonType: ['bool', 'null'], - description: 'Inferred: Does the user likely have children? (null if unknown)', + description: 'User-provided: Does the user have children?', }, - hasKidsIsVerified: { // NEW verification flag - bsonType: 'bool', - description: 'Flag indicating if inferredHasKids has been verified by the user', - default: false // Default to false + relationshipStatus: { + bsonType: ['string', 'null'], + description: 'User-provided: User relationship status', + enum: ['single', 'relationship', 'married', 'prefer_not_to_say', null], // Added prefer_not_to_say }, - inferredRelationshipStatus: { + employmentStatus: { + bsonType: ['string', 'null'], + description: 'User-provided: User employment status', + enum: ['employed', 'unemployed', 'student', 'prefer_not_to_say', null], // Added prefer_not_to_say + }, + educationLevel: { bsonType: ['string', 'null'], - description: 'Inferred: User relationship status (null if unknown)', - enum: ['single', 'relationship', 'married', null], + description: 'User-provided: User education level', + enum: ['high_school', 'bachelors', 'masters', 'doctorate', 'prefer_not_to_say', null], // Added prefer_not_to_say + }, + // --- Inferred fields (kept separate, no verification flags) --- + inferredHasKids: { + bsonType: ['bool', 'null'], + description: 'Inferred: Does the user likely have children? (null if unknown or user provided)', }, - relationshipStatusIsVerified: { // NEW verification flag - bsonType: 'bool', - description: 'Flag indicating if inferredRelationshipStatus has been verified by the user', - default: false + // REMOVED hasKidsIsVerified + inferredRelationshipStatus: { + bsonType: ['string', 'null'], + description: 'Inferred: User relationship status (null if unknown or user provided)', + enum: ['single', 'relationship', 'married', null], // Inferred won't be 'prefer_not_to_say' }, + // REMOVED relationshipStatusIsVerified inferredEmploymentStatus: { bsonType: ['string', 'null'], - description: 'Inferred: User employment status (null if unknown)', + description: 'Inferred: User employment status (null if unknown or user provided)', enum: ['employed', 'unemployed', 'student', null], }, - employmentStatusIsVerified: { // NEW verification flag - bsonType: 'bool', - description: 'Flag indicating if inferredEmploymentStatus has been verified by the user', - default: false - }, + // REMOVED employmentStatusIsVerified inferredEducationLevel: { bsonType: ['string', 'null'], - description: 'Inferred: User education level (null if unknown)', + description: 'Inferred: User education level (null if unknown or user provided)', enum: ['high_school', 'bachelors', 'masters', 'doctorate', null], }, - educationLevelIsVerified: { // NEW verification flag - bsonType: 'bool', - description: 'Flag indicating if inferredEducationLevel has been verified by the user', - default: false - }, - inferredAgeBracket: { // Kept separate from user-provided 'age' + // REMOVED educationLevelIsVerified + // REMOVED inferredAgeBracket + // REMOVED ageBracketIsVerified + inferredGender: { // Kept inferred gender bsonType: ['string', 'null'], - description: 'Inferred: User age bracket (null if unknown or age provided)', // Clarified description - enum: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+', null], - }, - ageBracketIsVerified: { // NEW verification flag - bsonType: 'bool', - description: 'Flag indicating if inferredAgeBracket has been verified by the user', - default: false - }, - inferredGender: { // NEW inferred field - bsonType: ['string', 'null'], - description: 'Inferred: User gender identity (null if unknown or gender provided)', - enum: ['male', 'female', 'non-binary', null], // Note: 'prefer_not_to_say' is user-only - }, - genderIsVerified: { // NEW verification flag - bsonType: 'bool', - description: 'Flag indicating if inferredGender has been verified by the user', - default: false + description: 'Inferred: User gender identity (null if unknown or user provided)', + enum: ['male', 'female', 'non-binary', null], }, + // REMOVED genderIsVerified } }, // --- End: Demographic Data Object --- diff --git a/ml-service/app/services/demographicInference.py b/ml-service/app/services/demographicInference.py index 9d30368..8bed9eb 100644 --- a/ml-service/app/services/demographicInference.py +++ b/ml-service/app/services/demographicInference.py @@ -25,33 +25,27 @@ "relationship_status": { "married": {"wedding", "anniversary", "spouse", "husband", "wife"}, "relationship": {"boyfriend", "girlfriend", "dating", "partner gift", "couples"} - # 'single' is hard to determine via keywords reliably }, "employment_status": { "student": {"student loan", "internship", "university", "college", "textbook", "dorm"}, - "unemployed": {"resume help", "job search"} # Still weak signals + "unemployed": {"resume help", "job search"} }, "education_level": { "doctorate": {"phd", "dissertation", "postdoc"}, "masters": {"master's degree", "thesis"}, "bachelors": {"bachelor's degree", "undergrad"}, - # 'high_school' is too ambiguous }, - "gender": { # Use with extreme caution - high potential for bias + "gender": { # Use with extreme caution "male": {"men's", "for him", "grooming kit men"}, "female": {"women's", "for her", "makeup set", "feminine hygiene"} } - # No reliable keywords for age_bracket } # --- NEW: Evidence Weights --- RULE_MATCH_WEIGHT = 1.5 SEMANTIC_MATCH_WEIGHT = 1.0 -# --- End NEW Configuration --- - -# --- Semantic Target Descriptions (Keep as is) --- -# Keys should match the field names in DemographicData (e.g., inferredHasKids -> has_kids) +# --- Semantic Target Descriptions --- SEMANTIC_TARGETS = { "has_kids": { True: [ @@ -62,26 +56,25 @@ "maternity wear or products", "family activities or vacations", ], - # False is hard to infer semantically, rely on lack of True evidence }, "relationship_status": { "married": [ "wedding gifts or planning items", "anniversary presents", "items for spouse or partner", - "joint home purchases", # Requires more context than just text + "joint home purchases", "husband or wife related items", ], "relationship": [ "gifts for partner or significant other", "couples items or activities", "romantic presents", - "dating related items", # Can overlap with single, use threshold + "dating related items", "items for boyfriend or girlfriend", ], "single": [ "items for one person", - "dating app subscriptions", # Very specific if found + "dating app subscriptions", "solo travel or activities", "self-care items focused on independence", ] @@ -102,10 +95,9 @@ "internship-related items", "study aids", ], - "unemployed": [ # Very hard to infer reliably from purchases/searches + "unemployed": [ "job searching resources", "resume building services", - # "unemployment benefit applications" # Unlikely purchase/search ] }, "education_level": { @@ -126,72 +118,28 @@ "college supplies", "university merchandise", ], - "high_school": [ # Very hard to infer post-facto - "high school supplies", # Overlaps heavily - # "ged preparation" - ] - }, - "age_bracket": { # Extremely unreliable, keep targets broad - "18-24": [ - "college student items", - "first apartment essentials", - "entry-level job search", - "youth fashion trends", - "music festivals or concerts", - ], - "25-34": [ - "young professional items", - "starting a family supplies", # Overlaps kids - "new homeowner items", - "advanced career development", - "travel and experiences", - ], - "35-44": [ - "mid-career professional items", - "family-oriented products", # Overlaps kids - "home renovation supplies", - "investment or retirement planning", - ], - "45-54": [ - "senior management or executive items", - "planning for children's college", - "luxury travel or hobbies", - "health and wellness focus", - ], - "55-64": [ - "pre-retirement planning", - "downsizing home items", - "travel for seniors", - "health monitoring devices", - "grandparent gifts", - ], - "65+": [ - "retirement living items", - "senior health care products", - "hobby supplies for retirees", - "accessible travel", - "gifts for grandchildren", + "high_school": [ + "high school supplies", ] }, "gender": { # Also potentially unreliable/sensitive "male": [ "men's clothing and accessories", "grooming products typically for men", - "hobbies stereotypically associated with men", # Be very careful with stereotypes + "hobbies stereotypically associated with men", "gifts for him", ], "female": [ "women's clothing and accessories", "makeup and cosmetics", "skincare products typically for women", - "hobbies stereotypically associated with women", # Be very careful + "hobbies stereotypically associated with women", "gifts for her", "feminine hygiene products", ], - "non-binary": [ # Extremely difficult to infer semantically from general purchases + "non-binary": [ "gender-neutral clothing", "unisex products", - # "lgbtq+ related merchandise" # Can be indicative but not definitive ] } } @@ -370,11 +318,11 @@ async def _run_hybrid_inference_for_attribute( # Renamed for clarity # --- End Determine inferred value --- -# --- Main Inference Runner (Updated to call hybrid helper) --- +# --- Main Inference Runner (Updated) --- async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) -> bool: """ Runs HYBRID demographic inference based on recent user data and updates - the user document if changes are found and the field is not verified by the user. + the user document if changes are found AND the user has not provided their own value. Returns True if the user document was updated, False otherwise. """ logger.info(f"Running HYBRID demographic inference for user {user_id} ({email})") @@ -399,58 +347,65 @@ async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) # Get Taxonomy Service (needed for embeddings) taxonomy_service = await get_taxonomy_service(db) - # No need to check embedding model availability here, helper function handles it - # --- Run hybrid inference functions --- - # Call the renamed helper function - inferred_kids = await _run_hybrid_inference_for_attribute("has_kids", recent_data, taxonomy_service) - inferred_status = await _run_hybrid_inference_for_attribute("relationship_status", recent_data, taxonomy_service) - inferred_employment = await _run_hybrid_inference_for_attribute("employment_status", recent_data, taxonomy_service) - inferred_education = await _run_hybrid_inference_for_attribute("education_level", recent_data, taxonomy_service) - - # Conditional inference based on user-provided data current_demographics = user.get("demographicData", {}) - inferred_age_bracket = None - if current_demographics.get("age") is None: - logger.info(f"Inference: User {email} has no age set, attempting age bracket inference.") - inferred_age_bracket = await _run_hybrid_inference_for_attribute("age_bracket", recent_data, taxonomy_service) + + # --- Run hybrid inference functions (conditionally) --- + inferred_kids = None + if current_demographics.get("hasKids") is None: + inferred_kids = await _run_hybrid_inference_for_attribute("has_kids", recent_data, taxonomy_service) + else: + logger.info(f"Inference (has_kids): Skipped, user value exists ('{current_demographics.get('hasKids')}')") + + inferred_status = None + if current_demographics.get("relationshipStatus") is None: + inferred_status = await _run_hybrid_inference_for_attribute("relationship_status", recent_data, taxonomy_service) + else: + logger.info(f"Inference (relationship_status): Skipped, user value exists ('{current_demographics.get('relationshipStatus')}')") + + inferred_employment = None + if current_demographics.get("employmentStatus") is None: + inferred_employment = await _run_hybrid_inference_for_attribute("employment_status", recent_data, taxonomy_service) + else: + logger.info(f"Inference (employment_status): Skipped, user value exists ('{current_demographics.get('employmentStatus')}')") + + inferred_education = None + if current_demographics.get("educationLevel") is None: + inferred_education = await _run_hybrid_inference_for_attribute("education_level", recent_data, taxonomy_service) else: - logger.info(f"Inference: User {email} has age set, skipping age bracket inference.") + logger.info(f"Inference (education_level): Skipped, user value exists ('{current_demographics.get('educationLevel')}')") inferred_gender = None if current_demographics.get("gender") is None: - logger.info(f"Inference: User {email} has no gender set, attempting gender inference.") inferred_gender = await _run_hybrid_inference_for_attribute("gender", recent_data, taxonomy_service) else: - logger.info(f"Inference: User {email} has gender set, skipping gender inference.") + logger.info(f"Inference (gender): Skipped, user value exists ('{current_demographics.get('gender')}')") - # --- Prepare update payload, respecting verification flags (No changes needed here) --- + # --- Prepare update payload (Simplified check_and_set) --- update_payload = {} now = datetime.now() - def check_and_set(field_name: str, inferred_value: Any, is_verified_flag: str): - current_value = current_demographics.get(field_name) - is_verified = current_demographics.get(is_verified_flag, False) - - if inferred_value is not None and inferred_value != current_value: - if not is_verified: - # Use dot notation for nested update - db_field_name = f"demographicData.{field_name}" - update_payload[db_field_name] = inferred_value - logger.info(f"Inference update for {email}: {db_field_name} -> {inferred_value} (was {current_value}, verified: {is_verified})") - else: - logger.info(f"Inference skipped for {email}: {field_name} is verified by user (Value: {current_value}). Would have inferred: {inferred_value}") - - # Map inferred fields to their verification flags - check_and_set("inferredHasKids", inferred_kids, "hasKidsIsVerified") - check_and_set("inferredRelationshipStatus", inferred_status, "relationshipStatusIsVerified") - check_and_set("inferredEmploymentStatus", inferred_employment, "employmentStatusIsVerified") - check_and_set("inferredEducationLevel", inferred_education, "educationLevelIsVerified") - check_and_set("inferredAgeBracket", inferred_age_bracket, "ageBracketIsVerified") - check_and_set("inferredGender", inferred_gender, "genderIsVerified") + # Simplified: Only updates the inferred field if the new inference differs from the current inferred value + def check_and_set(field_name: str, inferred_value: Any): + # Note: field_name here is the *inferred* field name (e.g., "inferredHasKids") + current_inferred_value = current_demographics.get(field_name) + + if inferred_value is not None and inferred_value != current_inferred_value: + # Use dot notation for nested update + db_field_name = f"demographicData.{field_name}" + update_payload[db_field_name] = inferred_value + logger.info(f"Inference update for {email}: {db_field_name} -> {inferred_value} (was {current_inferred_value})") + # No need to log skipping based on verification anymore + + # Map inferred values to their DB field names + check_and_set("inferredHasKids", inferred_kids) + check_and_set("inferredRelationshipStatus", inferred_status) + check_and_set("inferredEmploymentStatus", inferred_employment) + check_and_set("inferredEducationLevel", inferred_education) + check_and_set("inferredGender", inferred_gender) # --- End Prepare update payload --- - # --- Update user document in DB if there are changes (No changes needed here) --- + # --- Update user document in DB if there are changes --- if update_payload: update_payload["updatedAt"] = now # Update timestamp result = await db.users.update_one( @@ -459,26 +414,28 @@ def check_and_set(field_name: str, inferred_value: Any, is_verified_flag: str): ) if result.modified_count > 0: updated = True - logger.info(f"Inference: Successfully updated demographic data for user {user_id}") + logger.info(f"Inference: Successfully updated inferred demographic data for user {user_id}") # --- Invalidate Caches --- auth0_id = user.get("auth0Id") if auth0_id: await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") - await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") + await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") # Invalidate prefs as demographics changed # Invalidate store-specific caches if opt-in stores exist if user.get("privacySettings", {}).get("optInStores"): for store_id in user["privacySettings"]["optInStores"]: - # CRITICAL FIX: Use user_object_id (ObjectId string) for store cache key consistency + # Use user_id (ObjectId string) for store cache key consistency await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_id}:{store_id}") logger.info(f"Inference: Invalidated relevant caches for user {auth0_id}") # --- End Cache Invalidation --- else: logger.warning(f"Inference: Update attempted for {user_id} but no documents were modified.") else: - logger.info(f"Inference: No demographic updates found for {user_id}") + logger.info(f"Inference: No inferred demographic updates needed for {user_id}") except Exception as e: logger.error(f"Error during demographic inference for user {user_id}: {str(e)}", exc_info=True) + # Do not return True here, as the update didn't necessarily succeed + updated = False # Ensure updated is False on error return updated # --- End Main Inference Runner --- diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 56ed96c..3974817 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -95,17 +95,18 @@ export interface StoreCreate { export interface UserUpdate { /** User's unique username */ username?: string; - /** User's phone number (E.164 format recommended) */ + /** + * User's phone number (E.164 format recommended) + * @pattern ^\+?[\d\s-]+$ + */ phone?: string; /** User interest preferences with taxonomy categorization */ preferences?: PreferenceItem[]; privacySettings?: { dataSharingConsent?: boolean; anonymizeData?: boolean; - optInStores?: string[]; - optOutStores?: string[]; }; - /** Updatable demographic information (user-provided and verification flags). */ + /** Updatable user-provided demographic information. Setting a value here implies verification and may clear inferred values. */ demographicData?: { /** User-provided gender identity */ gender?: "male" | "female" | "non-binary" | "prefer_not_to_say" | null; @@ -121,22 +122,35 @@ export interface UserUpdate { /** User-provided country of residence (e.g., ISO 3166-1 alpha-2 code) */ country?: string | null; /** - * User-provided age. Setting this clears inferredAgeBracket. + * User-provided age. Setting this clears the inferred age bracket. * @format int32 + * @min 0 */ age?: number | null; - /** Set to true by the user to confirm the inferred 'hasKids' status. */ - hasKidsIsVerified?: boolean; - /** Set to true by the user to confirm the inferred 'relationshipStatus'. */ - relationshipStatusIsVerified?: boolean; - /** Set to true by the user to confirm the inferred 'employmentStatus'. */ - employmentStatusIsVerified?: boolean; - /** Set to true by the user to confirm the inferred 'educationLevel'. */ - educationLevelIsVerified?: boolean; - /** Set to true by the user to confirm the inferred 'ageBracket'. Only applicable if 'age' is not set. */ - ageBracketIsVerified?: boolean; - /** Set to true by the user to confirm the inferred 'gender'. Only applicable if 'gender' is not set. */ - genderIsVerified?: boolean; + /** User-provided: Does the user have children? */ + hasKids?: boolean | null; + /** User-provided: User relationship status */ + relationshipStatus?: + | "single" + | "relationship" + | "married" + | "prefer_not_to_say" + | null; + /** User-provided: User employment status */ + employmentStatus?: + | "employed" + | "unemployed" + | "student" + | "prefer_not_to_say" + | null; + /** User-provided: User education level */ + educationLevel?: + | "high_school" + | "bachelors" + | "masters" + | "doctorate" + | "prefer_not_to_say" + | null; }; } @@ -432,63 +446,61 @@ export interface DemographicData { /** * User-provided age * @format int32 + * @min 0 * @example 35 */ age?: number | null; - /** Inferred: Does the user likely have children? (null if unknown) */ - inferredHasKids?: boolean | null; /** - * Flag indicating if inferredHasKids has been verified by the user - * @default false + * User-provided: Does the user have children? + * @example true */ - hasKidsIsVerified?: boolean; - /** Inferred: User relationship status (null if unknown) */ - inferredRelationshipStatus?: "single" | "relationship" | "married" | null; + hasKids?: boolean | null; /** - * Flag indicating if inferredRelationshipStatus has been verified by the user - * @default false + * User-provided: User relationship status + * @example "married" */ - relationshipStatusIsVerified?: boolean; - /** Inferred: User employment status (null if unknown) */ - inferredEmploymentStatus?: "employed" | "unemployed" | "student" | null; + relationshipStatus?: + | "single" + | "relationship" + | "married" + | "prefer_not_to_say" + | null; /** - * Flag indicating if inferredEmploymentStatus has been verified by the user - * @default false + * User-provided: User employment status + * @example "employed" */ - employmentStatusIsVerified?: boolean; - /** Inferred: User education level (null if unknown) */ - inferredEducationLevel?: + employmentStatus?: + | "employed" + | "unemployed" + | "student" + | "prefer_not_to_say" + | null; + /** + * User-provided: User education level + * @example "bachelors" + */ + educationLevel?: | "high_school" | "bachelors" | "masters" | "doctorate" + | "prefer_not_to_say" | null; - /** - * Flag indicating if inferredEducationLevel has been verified by the user - * @default false - */ - educationLevelIsVerified?: boolean; - /** Inferred: User age bracket (null if unknown or age provided) */ - inferredAgeBracket?: - | "18-24" - | "25-34" - | "35-44" - | "45-54" - | "55-64" - | "65+" + /** Inferred: Does the user likely have children? (null if unknown or user provided) */ + inferredHasKids?: boolean | null; + /** Inferred: User relationship status (null if unknown or user provided) */ + inferredRelationshipStatus?: "single" | "relationship" | "married" | null; + /** Inferred: User employment status (null if unknown or user provided) */ + inferredEmploymentStatus?: "employed" | "unemployed" | "student" | null; + /** Inferred: User education level (null if unknown or user provided) */ + inferredEducationLevel?: + | "high_school" + | "bachelors" + | "masters" + | "doctorate" | null; - /** - * Flag indicating if inferredAgeBracket has been verified by the user - * @default false - */ - ageBracketIsVerified?: boolean; - /** Inferred: User gender identity (null if unknown or gender provided) */ + /** Inferred: User gender identity (null if unknown or user provided) */ inferredGender?: "male" | "female" | "non-binary" | null; - /** - * Flag indicating if inferredGender has been verified by the user - * @default false - */ - genderIsVerified?: boolean; } export interface RecentUserDataEntry { From e69b9a09086b1058fdbd325dee22a7215f3f03cd Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 03:35:12 +0530 Subject: [PATCH 12/21] feat: Expand demographic form to include additional fields and improve data handling --- .../UserDashboard/UserPreferencesPage.tsx | 528 +++++++++++++----- 1 file changed, 377 insertions(+), 151 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index 32f17dd..a1a9601 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -1,4 +1,3 @@ -import React, { useState, useEffect, useMemo } from "react"; import { Card, Button, @@ -10,44 +9,61 @@ import { ModalFooter, Label, TextInput, - Select, // Already imported + Select, + ToggleSwitch, // <-- Add ToggleSwitch for boolean fields RangeSlider, List, ListItem, } from "flowbite-react"; import { - HiInformationCircle, - HiPencil, - HiTrash, - HiPlus, HiUser, - HiSparkles, HiOutlineUserCircle, HiOutlineCake, HiOutlineGlobeAlt, HiOutlineCash, -} from "react-icons/hi"; + HiOutlineAcademicCap, // <-- Icon for education + HiOutlineBriefcase, // <-- Icon for employment + HiOutlineUsers, // <-- Icon for relationship + HiOutlineHeart, // <-- Icon for hasKids + HiInformationCircle, + HiSparkles, + HiPlus, + HiPencil, + HiTrash, + HiCheck, + HiX, +} from "react-icons/hi"; // <-- Add new icons +import { useForm, SubmitHandler, Controller } from "react-hook-form"; +import { useEffect, useState, useMemo } from "react"; import { useUserProfile, useUserPreferences, useUpdateUserProfile, useUpdateUserPreferences, -} from "../../api/hooks/useUserHooks"; -import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; +} from "../../api/hooks/useUserHooks"; // <-- Corrected import path +import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; // <-- Corrected import path import { UserUpdate, PreferenceItem, TaxonomyCategory, + DemographicData, // <-- Import DemographicData type } from "../../api/types/data-contracts"; import LoadingSpinner from "../../components/common/LoadingSpinner"; import ErrorDisplay from "../../components/common/ErrorDisplay"; -import { Controller, SubmitHandler, useForm } from "react-hook-form"; -import countryData from "../../data/countries.json"; // <-- Import country data +import countryData from "../../data/countries.json"; // --- Form Types --- +// Update to include all user-editable demographic fields type DemographicsFormData = Pick< - UserUpdate, - "gender" | "age" | "country" | "incomeBracket" + DemographicData, // Use DemographicData type directly + | "gender" + | "age" + | "country" + | "incomeBracket" + | "hasKids" + | "relationshipStatus" + | "employmentStatus" + | "educationLevel" >; type PreferenceFormData = { category: string; @@ -55,7 +71,7 @@ type PreferenceFormData = { attributes?: Record; }; -// --- Define options for selects (copied from UserRegistrationForm) --- +// --- Define options for selects --- const genderOptions = [ { value: "", label: "Select Gender" }, { value: "male", label: "Male" }, @@ -74,6 +90,33 @@ const incomeOptions = [ { value: "prefer_not_to_say", label: "Prefer not to say" }, ]; +// --- NEW Options --- +const relationshipOptions = [ + { value: "", label: "Select Relationship Status" }, + { value: "single", label: "Single" }, + { value: "relationship", label: "In a relationship" }, + { value: "married", label: "Married" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +const employmentOptions = [ + { value: "", label: "Select Employment Status" }, + { value: "employed", label: "Employed" }, + { value: "unemployed", label: "Unemployed" }, + { value: "student", label: "Student" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +const educationOptions = [ + { value: "", label: "Select Education Level" }, + { value: "high_school", label: "High School" }, + { value: "bachelors", label: "Bachelor's Degree" }, + { value: "masters", label: "Master's Degree" }, + { value: "doctorate", label: "Doctorate" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; +// --- End NEW Options --- + // --- Country Options (copied from UserRegistrationForm) --- interface CountryOption { value: string; @@ -163,7 +206,8 @@ const UserPreferencesPage: React.FC = () => { register: registerDemo, handleSubmit: handleDemoSubmit, reset: resetDemoForm, - formState: { isDirty: isDemoDirty }, + control: demoControl, // <-- Add control for ToggleSwitch + formState: { isDirty: isDemoDirty, errors: demoErrors }, // <-- Add errors } = useForm(); const { @@ -177,13 +221,19 @@ const UserPreferencesPage: React.FC = () => { defaultValues: { category: "", attributes: {}, score: 50 }, }); + // Update useEffect to reset ALL demographic fields useEffect(() => { - if (userProfile && !isEditingDemographics) { + if (userProfile?.demographicData && !isEditingDemographics) { resetDemoForm({ - gender: userProfile?.demographicData?.gender || "", - age: userProfile?.demographicData?.age || undefined, - country: userProfile?.demographicData?.country || "", - incomeBracket: userProfile?.demographicData?.incomeBracket || "", + gender: userProfile.demographicData.gender ?? null, // Use ?? null + age: userProfile.demographicData.age ?? undefined, // Use ?? for null/undefined + country: userProfile.demographicData.country ?? null, // Use ?? null + incomeBracket: userProfile.demographicData.incomeBracket ?? null, // Use ?? null + hasKids: userProfile.demographicData.hasKids ?? null, // Default to null + relationshipStatus: + userProfile.demographicData.relationshipStatus ?? null, // Use ?? null + employmentStatus: userProfile.demographicData.employmentStatus ?? null, // Use ?? null + educationLevel: userProfile.demographicData.educationLevel ?? null, // Use ?? null }); } }, [userProfile, isEditingDemographics, resetDemoForm]); @@ -267,27 +317,45 @@ const UserPreferencesPage: React.FC = () => { }, [selectedCategoryId, attributeMap]); // --- Handlers --- + // Update onDemoSubmit to handle all fields and nest payload const onDemoSubmit: SubmitHandler = (data) => { - // Filter out empty strings or nulls before sending - const payload: Partial = {}; // Use UserUpdate for payload type - if (data.gender && data.gender !== "") payload.gender = data.gender; - else payload.gender = null; // Explicitly set to null if empty - - if (data.age !== undefined && data.age !== null && !isNaN(data.age)) - payload.age = Number(data.age); - else payload.age = null; // Explicitly set to null if empty/invalid - - if (data.country && data.country !== "") payload.country = data.country; - else payload.country = null; // Explicitly set to null if empty + // Construct the nested demographicData payload + const demoPayload: Partial = {}; + + // Handle each field, setting to null if empty/default + demoPayload.gender = data.gender ? data.gender : null; + demoPayload.age = + data.age !== undefined && data.age !== null && !isNaN(data.age) + ? Number(data.age) + : null; + demoPayload.country = data.country ? data.country : null; + demoPayload.incomeBracket = data.incomeBracket ? data.incomeBracket : null; + // Handle boolean (null is allowed) + demoPayload.hasKids = data.hasKids === undefined ? null : data.hasKids; + demoPayload.relationshipStatus = data.relationshipStatus + ? data.relationshipStatus + : null; + demoPayload.employmentStatus = data.employmentStatus + ? data.employmentStatus + : null; + demoPayload.educationLevel = data.educationLevel + ? data.educationLevel + : null; + + // Construct the final UserUpdate payload + const finalPayload: UserUpdate = { + demographicData: demoPayload, + }; - if (data.incomeBracket && data.incomeBracket !== "") - payload.incomeBracket = data.incomeBracket; - else payload.incomeBracket = null; // Explicitly set to null if empty + console.log("Submitting demographic update:", finalPayload); // Debug log - // Only submit if there are actual changes - if (Object.keys(payload).length > 0) { - updateProfile(payload as UserUpdate, { + // Only submit if the form is dirty (React Hook Form tracks this) + if (isDemoDirty) { + updateProfile(finalPayload, { onSuccess: () => setIsEditingDemographics(false), + onError: (err) => { + console.error("Profile update failed:", err); // Log error + }, }); } else { // No changes detected, just exit edit mode @@ -374,6 +442,22 @@ const UserPreferencesPage: React.FC = () => { ); } + // Helper to format boolean/null + const formatBoolean = (value: boolean | null | undefined): string => { + if (value === true) return "Yes"; + if (value === false) return "No"; + return "Not set"; + }; + + // Helper to format enum values + const formatEnum = ( + value: string | null | undefined, + options: { value: string; label: string }[], + ): string => { + const found = options.find((opt) => opt.value === value); + return found?.label || value || "Not set"; + }; + return (

@@ -390,7 +474,7 @@ const UserPreferencesPage: React.FC = () => {

{!isEditingDemographics && ( -
@@ -501,10 +642,17 @@ const UserPreferencesPage: React.FC = () => { ) : ( // --- DISPLAY VIEW ---
+ {/* User Provided */} +

+ Your Information +

{ + + + + + + {/* Inferred (Read-only) */} +

+ Inferred Information{" "} + (Read-only) +

+ + + + +
@@ -558,77 +795,61 @@ const UserPreferencesPage: React.FC = () => {
{preferencesData?.preferences && preferencesData.preferences.length > 0 ? ( - + {preferencesData.preferences.map((pref, index) => { - // Extract attribute display logic - const attributesDisplay = - pref.attributes && - Object.entries(pref.attributes).map(([key, valueObj]) => { - let displayValue = "[Complex Value]"; - if (typeof valueObj === "object" && valueObj !== null) { - const firstKey = Object.keys(valueObj)[0]; - if (firstKey) displayValue = firstKey; - } else if (typeof valueObj === "string") { - displayValue = valueObj; - } - return { key, displayValue }; - }); + const categoryName = + categoryMap.get(pref.category)?.name || pref.category; + const attributeEntries = Object.entries( + pref.attributes || {}, + ); return ( -
- - {categoryMap.get(pref.category || "")?.name || - "Unknown Category"} - - - Score:{" "} - {pref.score !== null && pref.score !== undefined - ? Math.round(pref.score * 100) - : "N/A"} - - {attributesDisplay && attributesDisplay.length > 0 && ( -
- {attributesDisplay.map(({ key, displayValue }) => ( - - - {attributeMap - .get(pref.category || "") - ?.get(key) || key} - : - {" "} - {displayValue} - - ))} -
- )} -
-
- - +
+
+

+ {categoryName} +

+

+ Score:{" "} + {pref.score !== null && pref.score !== undefined + ? `${Math.round(pref.score * 100)}%` + : "N/A"} +

+ {attributeEntries.length > 0 && ( +
+ Attributes:{" "} + {attributeEntries + .map(([attrKey, valueObj]) => { + // Get the first key (value) from the inner object + const attrValue = Object.keys(valueObj)[0]; + return `${attrKey}: ${attrValue}`; + }) + .join(", ")} +
+ )} +
+
+ + +
); @@ -636,8 +857,7 @@ const UserPreferencesPage: React.FC = () => { ) : (

- You haven't added any specific interests yet. Click "Add - Interest" to get started. + You haven't added any interests yet.

)}
@@ -707,7 +927,13 @@ const UserPreferencesPage: React.FC = () => {
From c033855c73d577e440d1fe738d280f4e7335f18f Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 03:46:27 +0530 Subject: [PATCH 13/21] feat: Add allowInference field to privacy settings and update related logic for demographic inference --- api-service/api/openapi.yaml | 51 ++++++++++++------- api-service/service/AuthenticationService.js | 4 +- api-service/service/UserProfileService.js | 6 ++- api-service/utils/dbSchemas.js | 8 ++- .../app/services/preferenceProcessor.py | 29 +++++++---- 5 files changed, 65 insertions(+), 33 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index d1e9b61..045a393 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -898,24 +898,7 @@ components: type: string pattern: ^\+?[\d\s-]+$ privacySettings: - type: object - properties: - dataSharingConsent: - type: boolean - default: false - anonymizeData: - type: boolean - default: false - optInStores: - type: array - items: - type: string - description: List of store IDs user has opted into - optOutStores: - type: array - items: - type: string - description: List of store IDs user has opted out from + $ref: "#/components/schemas/PrivacySettings" demographicData: $ref: "#/components/schemas/DemographicData" createdAt: @@ -927,6 +910,35 @@ components: format: date-time readOnly: true + PrivacySettings: + type: object + properties: + dataSharingConsent: + type: boolean + description: User consent to share aggregated/anonymized data. + anonymizeData: + type: boolean + description: User preference to anonymize data where possible (future use). + default: false + allowInference: # <-- Add allowInference here + type: boolean + description: Allow Tapiro to infer demographic data based on user activity. + default: true + optInStores: + type: array + items: + type: string + format: objectId # Assuming store IDs are ObjectIds represented as strings + description: List of store IDs the user explicitly allows data sharing with. + optOutStores: + type: array + items: + type: string + format: objectId # Assuming store IDs are ObjectIds represented as strings + description: List of store IDs the user explicitly blocks data sharing with. + required: + - dataSharingConsent # Only this is strictly required on creation + Store: type: object required: @@ -987,6 +999,9 @@ components: dataSharingConsent: type: boolean description: User's consent for data sharing + allowInference: + type: boolean + description: Allow Tapiro to infer demographic data (defaults to true if omitted). gender: type: string nullable: true diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index e13680f..9e9ebf8 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -13,10 +13,11 @@ const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); exports.registerUser = async function (req, body) { try { const db = getDB(); - // Destructure new demographic fields + // Destructure new demographic fields AND allowInference const { preferences, dataSharingConsent, + allowInference, // <-- Add allowInference gender, incomeBracket, country, @@ -128,6 +129,7 @@ exports.registerUser = async function (req, body) { privacySettings: { dataSharingConsent, anonymizeData: false, + allowInference: allowInference !== undefined ? allowInference : true, // <-- Set allowInference, default true optInStores: [], optOutStores: [], }, diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 6906c65..1959e8c 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -177,6 +177,10 @@ exports.updateUserProfile = async function (req, body) { updateData['privacySettings.anonymizeData'] = body.privacySettings.anonymizeData; privacySettingsChanged = true; } + if (body.privacySettings.allowInference !== undefined) { // <-- Add check for allowInference + updateData['privacySettings.allowInference'] = body.privacySettings.allowInference; + privacySettingsChanged = true; + } // DO NOT update optInStores or optOutStores here } @@ -217,7 +221,7 @@ exports.updateUserProfile = async function (req, body) { } // Invalidate store-specific preferences if demographics or relevant privacy settings changed - // Also invalidate if the optInStores list exists (safer to clear on any profile update) + // (Keep existing logic, as privacySettingsChanged flag now includes allowInference) const updatedUserDoc = result; // Use the returned document from findOneAndUpdate if ((demographicsChanged || privacySettingsChanged) && updatedUserDoc.privacySettings?.optInStores) { const userObjectId = updatedUserDoc._id; // Use the _id from the updated result diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 3598b27..780291e 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -147,8 +147,12 @@ const userSchema = { properties: { dataSharingConsent: { bsonType: 'bool' }, anonymizeData: { bsonType: 'bool' }, - optInStores: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type - optOutStores: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type + allowInference: { // <-- Add new field + bsonType: 'bool', + description: 'Allow Tapiro to infer demographic data based on user activity (default: true)', + }, + optInStores: { bsonType: 'array', items: { bsonType: 'string' } }, + optOutStores: { bsonType: 'array', items: { bsonType: 'string' } }, }, }, createdAt: { bsonType: 'date' }, diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index 7ae867e..8a022b2 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -47,6 +47,10 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: user_id = str(user["_id"]) # Use the confirmed user ID from DB logger.info(f"Found user {email} with DB ID {user_id}") + # --- Check Inference Permission --- + privacy_settings = user.get("privacySettings", {}) + allow_inference = privacy_settings.get("allowInference", True) # Default to True if missing + # Extract demographics from the nested 'demographicData' field user_demographics_nested = user.get("demographicData", {}) # Flatten the dictionary to pass to processing functions @@ -155,18 +159,21 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: except Exception as e: logger.error(f"Failed to update userData status for {email}: {str(e)}") - # --- Run Demographic Inference (After main processing) --- + # --- Run Demographic Inference (Conditionally) --- inference_updated_user = False - try: - logger.info(f"Starting demographic inference for user {email} ({user_id})") - inference_updated_user = await run_inference_for_user(user_id, email, db) - if inference_updated_user: - logger.info(f"Demographic inference updated user document for {email}") - # Cache invalidation is handled within run_inference_for_user - else: - logger.info(f"Demographic inference did not result in updates for user {email}") - except Exception as inference_error: - logger.error(f"Demographic inference failed for user {email}: {inference_error}", exc_info=True) + if allow_inference: # <-- Check the flag + try: + logger.info(f"Starting demographic inference for user {email} ({user_id}) as allowInference is True.") + inference_updated_user = await run_inference_for_user(user_id, email, db) + if inference_updated_user: + logger.info(f"Demographic inference updated user document for {email}") + # Cache invalidation is handled within run_inference_for_user + else: + logger.info(f"Demographic inference did not result in updates for user {email}") + except Exception as inference_error: + logger.error(f"Demographic inference failed for user {email}: {inference_error}", exc_info=True) + else: + logger.info(f"Skipping demographic inference for user {email} ({user_id}) as allowInference is False.") # --- End Demographic Inference --- From b23c71b875b774fbffb25dd66b1d82f1408e661e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 04:09:20 +0530 Subject: [PATCH 14/21] feat: Update UserPreferencesPage to enhance demographic data handling with new options and verification logic --- .../UserDashboard/UserPreferencesPage.tsx | 342 ++++++++++++++---- 1 file changed, 265 insertions(+), 77 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index a1a9601..e365e18 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -10,7 +10,6 @@ import { Label, TextInput, Select, - ToggleSwitch, // <-- Add ToggleSwitch for boolean fields RangeSlider, List, ListItem, @@ -117,6 +116,15 @@ const educationOptions = [ ]; // --- End NEW Options --- +// --- Has Kids Options --- +const hasKidsOptions = [ + { value: "", label: "Select an option" }, // Default/unset option + { value: "true", label: "Yes" }, + { value: "false", label: "No" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, // Represented by null in the data +]; +// --- End Has Kids Options --- + // --- Country Options (copied from UserRegistrationForm) --- interface CountryOption { value: string; @@ -132,12 +140,20 @@ const countryOptions: CountryOption[] = [ ]; // --- End Country Options --- -// --- Mini Demographic Card Component (Keep existing) --- +// --- Mini Demographic Card Component --- +// filepath: /Users/cdevmina/Projects/Tapiro/web/src/pages/UserDashboard/UserPreferencesPage.tsx interface DemoInfoCardProps { icon: React.ElementType; label: string; value: string | number | null | undefined; isLoading?: boolean; + isInferred?: boolean; // Added: Flag for inferred data + fieldName?: keyof DemographicsFormData; // Added: Field name for verification + onVerify?: ( + fieldName: keyof DemographicsFormData, + // --- CHANGE HERE --- + valueToVerify: string | number | boolean | null | undefined, + ) => void; // Added: Handler for verify button } const DemoInfoCard: React.FC = ({ @@ -145,6 +161,9 @@ const DemoInfoCard: React.FC = ({ label, value, isLoading, + isInferred, // Destructure + fieldName, // Destructure + onVerify, // Destructure }) => (
@@ -155,9 +174,30 @@ const DemoInfoCard: React.FC = ({ {isLoading ? ( ) : ( -

- {value || "Not set"} -

+
+ {" "} + {/* Wrap value and button */} +

+ {value || "Not set"} + {isInferred && + value && ( // Show "(inferred)" text + + (inferred) + + )} +

+ {/* Show Verify button if inferred, has value, not loading, and handler provided */} + {isInferred && value && !isLoading && fieldName && onVerify && ( + + )} +
)}
@@ -206,7 +246,7 @@ const UserPreferencesPage: React.FC = () => { register: registerDemo, handleSubmit: handleDemoSubmit, reset: resetDemoForm, - control: demoControl, // <-- Add control for ToggleSwitch + setValue: setValueDemo, // <-- Get setValue for verification formState: { isDirty: isDemoDirty, errors: demoErrors }, // <-- Add errors } = useForm(); @@ -221,7 +261,7 @@ const UserPreferencesPage: React.FC = () => { defaultValues: { category: "", attributes: {}, score: 50 }, }); - // Update useEffect to reset ALL demographic fields + // Update useEffect to reset ALL demographic fields when NOT editing useEffect(() => { if (userProfile?.demographicData && !isEditingDemographics) { resetDemoForm({ @@ -250,9 +290,9 @@ const UserPreferencesPage: React.FC = () => { const formAttributes: Record = {}; if (pref.attributes) { Object.entries(pref.attributes).forEach(([key, valueObj]) => { + // Attempt to get the first key if it's an object like { "Blue": 1.0 } let displayValue: string | undefined = undefined; if (typeof valueObj === "object" && valueObj !== null) { - // Attempt to get the first key if it's an object like { "Blue": 1.0 } const firstKey = Object.keys(valueObj)[0]; if (firstKey) displayValue = firstKey; } else if (typeof valueObj === "string") { @@ -331,6 +371,7 @@ const UserPreferencesPage: React.FC = () => { demoPayload.country = data.country ? data.country : null; demoPayload.incomeBracket = data.incomeBracket ? data.incomeBracket : null; // Handle boolean (null is allowed) + // Ensure undefined from form becomes null for API demoPayload.hasKids = data.hasKids === undefined ? null : data.hasKids; demoPayload.relationshipStatus = data.relationshipStatus ? data.relationshipStatus @@ -422,7 +463,66 @@ const UserPreferencesPage: React.FC = () => { setEditingPreferenceIndex(index); setShowPreferenceModal(true); }; + const handleVerify = ( + fieldName: keyof DemographicsFormData, + // --- CHANGE HERE --- + valueToVerify: string | number | boolean | null | undefined, + ) => { + setIsEditingDemographics(true); + // Use timeout to ensure state update completes before setting value + setTimeout(() => { + // Convert boolean "Yes"/"No" back to boolean for ToggleSwitch + // --- FIX: Handle potential undefined from valueToVerify --- + let formValue: string | number | boolean | null = valueToVerify ?? null; + + // Find the corresponding value for enum fields OR hasKids + if (fieldName === "hasKids") { + // Find the option matching the display label + const option = hasKidsOptions.find((o) => o.label === valueToVerify); + // Convert the option's string value back to boolean/null + formValue = + option?.value === "true" + ? true + : option?.value === "false" + ? false + : null; + } else if (fieldName === "gender") + formValue = + genderOptions.find((o) => o.label === valueToVerify)?.value ?? null; + else if (fieldName === "country") + formValue = + countryOptions.find((o) => o.label === valueToVerify)?.value ?? null; + else if (fieldName === "incomeBracket") + formValue = + incomeOptions.find((o) => o.label === valueToVerify)?.value ?? null; + else if (fieldName === "relationshipStatus") + formValue = + relationshipOptions.find((o) => o.label === valueToVerify)?.value ?? + null; + else if (fieldName === "employmentStatus") + formValue = + employmentOptions.find((o) => o.label === valueToVerify)?.value ?? + null; + else if (fieldName === "educationLevel") + formValue = + educationOptions.find((o) => o.label === valueToVerify)?.value ?? + null; + // Ensure age is treated as a number or null for the form + else if (fieldName === "age") { + // Use valueToVerify here as formValue might already be null + formValue = typeof valueToVerify === "number" ? valueToVerify : null; + } + + // Use setValueDemo with the potentially transformed formValue + setValueDemo(fieldName, formValue, { shouldDirty: true }); + // Optional: Focus the element after setting value + const element = document.getElementById(fieldName); + element?.focus(); + element?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 0); + console.log(`Verifying ${fieldName} with value:`, valueToVerify); + }; // --- Render Logic --- const isLoading = profileLoading || preferencesLoading || taxonomyLoading; const error = profileError || preferencesError || taxonomyError; @@ -515,8 +615,13 @@ const UserPreferencesPage: React.FC = () => { type="number" placeholder="Enter your age" {...registerDemo("age", { - valueAsNumber: true, + valueAsNumber: true, // Keep this if API expects number min: { value: 0, message: "Age cannot be negative" }, + validate: (value) => + value === null || + value === undefined || + !isNaN(value) || + "Invalid age", // Allow null/undefined })} /> {demoErrors.age && ( @@ -551,19 +656,27 @@ const UserPreferencesPage: React.FC = () => { ))}
- {/* Has Kids */} - ( - onChange(checked)} // Directly pass boolean - name={name} - /> - )} - /> + {/* Has Kids - Changed to Select */} +
+ + +
{/* Relationship Status */}
@@ -640,12 +753,9 @@ const UserPreferencesPage: React.FC = () => {
) : ( - // --- DISPLAY VIEW --- + // --- DISPLAY VIEW (Combined User-Provided and Inferred) ---
- {/* User Provided */} -

- Your Information -

+ {/* User Provided or Verified */} { genderOptions, )} isLoading={profileLoading} + // If user provided is null, show inferred with verify button + isInferred={ + !userProfile?.demographicData?.gender && + !!userProfile?.demographicData?.inferredGender + } + fieldName="gender" + onVerify={handleVerify} /> { countryOptions, )} isLoading={profileLoading} + // No inferred country currently /> { incomeOptions, )} isLoading={profileLoading} + // No inferred income currently /> { relationshipOptions, )} isLoading={profileLoading} + // Show inferred if user provided is null + isInferred={ + !userProfile?.demographicData?.relationshipStatus && + !!userProfile?.demographicData?.inferredRelationshipStatus + } + fieldName="relationshipStatus" + onVerify={handleVerify} /> { employmentOptions, )} isLoading={profileLoading} + // Show inferred if user provided is null + isInferred={ + !userProfile?.demographicData?.employmentStatus && + !!userProfile?.demographicData?.inferredEmploymentStatus + } + fieldName="employmentStatus" + onVerify={handleVerify} /> { educationOptions, )} isLoading={profileLoading} + // Show inferred if user provided is null + isInferred={ + !userProfile?.demographicData?.educationLevel && + !!userProfile?.demographicData?.inferredEducationLevel + } + fieldName="educationLevel" + onVerify={handleVerify} /> - {/* Inferred (Read-only) */} -

- Inferred Information{" "} - (Read-only) -

- - + )} + {!userProfile?.demographicData?.hasKids && + userProfile?.demographicData?.inferredHasKids !== null && ( + + )} + {!userProfile?.demographicData?.relationshipStatus && + userProfile?.demographicData?.inferredRelationshipStatus && ( + + )} + {!userProfile?.demographicData?.employmentStatus && + userProfile?.demographicData?.inferredEmploymentStatus && ( + + )} + {!userProfile?.demographicData?.educationLevel && + userProfile?.demographicData?.inferredEducationLevel && ( + )} - isLoading={profileLoading} - /> - - -
)} From d01ef83b0fdcb1c188715be9dee5a7200c4249bb Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 04:18:12 +0530 Subject: [PATCH 15/21] style: Improve layout structure by wrapping cards in a grid and updating section labels for clarity --- web/src/pages/UserDashboard/UserPreferencesPage.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index e365e18..8412c0f 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -564,8 +564,9 @@ const UserPreferencesPage: React.FC = () => { Manage Your Profile & Interests -
- {/* --- Demographics Section (Left Column on Large Screens) --- */} + {/* wrap both cards in a grid */} +
+ {/* --- Demographics Section (Left) */}

@@ -954,7 +955,7 @@ const UserPreferencesPage: React.FC = () => { )} - {/* --- Preferences Section (Right Column on Large Screens) --- */} + {/* --- Your Interests Card (Right) */}

From bac3c5bc331d83efbac9c7e81acb60894b89586d Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 04:20:54 +0530 Subject: [PATCH 16/21] feat: Refactor privacy settings in User interface and add UserProfilePage route --- web/src/api/types/data-contracts.ts | 32 ++++++++++++++++++++--------- web/src/main.tsx | 5 +++++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 3974817..b88db6e 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -28,16 +28,7 @@ export interface User { username?: string; /** @pattern ^\+?[\d\s-]+$ */ phone?: string; - privacySettings: { - /** @default false */ - dataSharingConsent?: boolean; - /** @default false */ - anonymizeData?: boolean; - /** List of store IDs user has opted into */ - optInStores?: string[]; - /** List of store IDs user has opted out from */ - optOutStores?: string[]; - }; + privacySettings: PrivacySettings; /** User-provided and inferred demographic information */ demographicData?: DemographicData; /** @format date-time */ @@ -46,6 +37,25 @@ export interface User { updatedAt?: string; } +export interface PrivacySettings { + /** User consent to share aggregated/anonymized data. */ + dataSharingConsent: boolean; + /** + * User preference to anonymize data where possible (future use). + * @default false + */ + anonymizeData?: boolean; + /** + * Allow Tapiro to infer demographic data based on user activity. + * @default true + */ + allowInference?: boolean; + /** List of store IDs the user explicitly allows data sharing with. */ + optInStores?: string[]; + /** List of store IDs the user explicitly blocks data sharing with. */ + optOutStores?: string[]; +} + export interface Store { storeId?: string; /** Auth0 organization ID */ @@ -69,6 +79,8 @@ export interface UserCreate { preferences?: PreferenceItem[]; /** User's consent for data sharing */ dataSharingConsent: boolean; + /** Allow Tapiro to infer demographic data (defaults to true if omitted). */ + allowInference?: boolean; /** User gender identity */ gender?: string | null; /** User income bracket category */ diff --git a/web/src/main.tsx b/web/src/main.tsx index ebfc7f7..3483564 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -15,6 +15,7 @@ import { AuthProviderWrapper } from "./context/AuthContext"; import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; import StoreProfilePage from "./pages/StoreDashboard/StoreProfilePage"; +import UserProfilePage from "./pages/UserDashboard/UserProfilePage"; const router = createBrowserRouter([ { @@ -45,6 +46,10 @@ const router = createBrowserRouter([ path: "dashboard/user", element: , }, + { + path: "profile/user", + element: , + }, ], }, // --- Protected Store Routes --- From 19a81cdb4bbbc45be8c89fd0d0df560352324417 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 04:40:57 +0530 Subject: [PATCH 17/21] feat: Integrate allowInference field into privacy settings and update UserProfilePage and UserRegistrationForm --- api-service/api/openapi.yaml | 8 +- web/src/api/types/data-contracts.ts | 5 +- .../components/auth/UserRegistrationForm.tsx | 30 ++++- .../pages/UserDashboard/UserProfilePage.tsx | 109 +++++++++++++----- 4 files changed, 113 insertions(+), 39 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 045a393..669daa4 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -1060,13 +1060,7 @@ components: items: $ref: "#/components/schemas/PreferenceItem" privacySettings: - type: object - properties: - dataSharingConsent: - type: boolean - anonymizeData: - type: boolean - # optInStores/optOutStores are handled by separate endpoints + $ref: "#/components/schemas/PrivacySettings" demographicData: type: object description: Updatable user-provided demographic information. Setting a value here implies verification and may clear inferred values. diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index b88db6e..7438d13 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -114,10 +114,7 @@ export interface UserUpdate { phone?: string; /** User interest preferences with taxonomy categorization */ preferences?: PreferenceItem[]; - privacySettings?: { - dataSharingConsent?: boolean; - anonymizeData?: boolean; - }; + privacySettings?: PrivacySettings; /** Updatable user-provided demographic information. Setting a value here implies verification and may clear inferred values. */ demographicData?: { /** User-provided gender identity */ diff --git a/web/src/components/auth/UserRegistrationForm.tsx b/web/src/components/auth/UserRegistrationForm.tsx index 32cfa3f..41f1872 100644 --- a/web/src/components/auth/UserRegistrationForm.tsx +++ b/web/src/components/auth/UserRegistrationForm.tsx @@ -10,6 +10,7 @@ import { Popover, Select, // Already imported TextInput, + Tooltip, // <-- Import ToggleSwitch if you prefer it over Checkbox } from "flowbite-react"; import { UserCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; @@ -69,6 +70,8 @@ export function UserRegistrationForm({ const [showConsentModal, setShowConsentModal] = useState(false); // State to track if consent has been explicitly accepted via the modal const [consentAccepted, setConsentAccepted] = useState(false); + // --- Add state for allowInference --- + const [allowInference, setAllowInference] = useState(true); // Default to true // Add state for demographic fields const [gender, setGender] = useState(null); @@ -88,6 +91,7 @@ export function UserRegistrationForm({ const userData: UserCreate = { dataSharingConsent: dataSharingConsent, + allowInference: allowInference, // <-- Include allowInference preferences: [], gender: gender || null, incomeBracket: incomeBracket || null, @@ -207,7 +211,8 @@ export function UserRegistrationForm({

{/* Consent Section */} -
+
+ {/* --- Data Sharing Consent (Existing) --- */} {/* Conditionally wrap Checkbox/Label in Popover */} {!consentAccepted ? ( @@ -248,6 +253,26 @@ export function UserRegistrationForm({
)} + {/* --- Allow Inference Toggle --- */} +
+ {/* Using Checkbox for consistency, but ToggleSwitch is also an option */} + setAllowInference(e.target.checked)} + /> + +
+ {/* --- End Allow Inference Toggle --- */} + {/* Button to open the modal remains the same */}
diff --git a/web/src/pages/UserDashboard/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx index c3a9be3..57aea1c 100644 --- a/web/src/pages/UserDashboard/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -14,6 +14,7 @@ import { Modal, // Import Modal ModalBody, ModalHeader, + Tooltip, // <-- Import Tooltip } from "flowbite-react"; // Import necessary icons import { @@ -24,6 +25,7 @@ import { HiX, HiTrash, // Import Trash icon for Delete HiExclamation, // Import Exclamation icon for Modal + HiInformationCircle, // <-- Import for Tooltip } from "react-icons/hi"; import { useUserProfile, @@ -41,6 +43,7 @@ type UserProfileFormData = { phone?: string; privacySettings_dataSharingConsent?: boolean; privacySettings_anonymizeData?: boolean; + privacySettings_allowInference?: boolean; // <-- Add allowInference }; export default function UserProfilePage() { @@ -80,6 +83,7 @@ export default function UserProfilePage() { phone: "", privacySettings_dataSharingConsent: false, privacySettings_anonymizeData: false, + privacySettings_allowInference: true, // <-- Default to true }, }); @@ -93,6 +97,9 @@ export default function UserProfilePage() { userProfile.privacySettings?.dataSharingConsent ?? false, privacySettings_anonymizeData: userProfile.privacySettings?.anonymizeData ?? false, + // <-- Reset allowInference + privacySettings_allowInference: + userProfile.privacySettings?.allowInference ?? true, // Default true if undefined }); } }, [userProfile, reset]); @@ -156,8 +163,9 @@ export default function UserProfilePage() { username: data.username, phone: data.phone, privacySettings: { - dataSharingConsent: data.privacySettings_dataSharingConsent, - anonymizeData: data.privacySettings_anonymizeData, + dataSharingConsent: data.privacySettings_dataSharingConsent ?? false, + anonymizeData: data.privacySettings_anonymizeData ?? false, + allowInference: data.privacySettings_allowInference ?? false, }, }; updateUser(updatePayload); @@ -303,29 +311,78 @@ export default function UserProfilePage() { Privacy Settings

{/* Data Sharing Consent */} - ( - - )} - /> +
+ {" "} + {/* Wrap ToggleSwitch and HelperText */} + ( + + )} + /> + {/* Add HelperText or simple div below */} +
+ Controls sharing preferences with specific stores.{" "} + + + +
+
+ {/* Anonymize Data */} - ( - - )} - /> +
+ {" "} + {/* Wrap ToggleSwitch and HelperText */} + ( + + )} + /> + {/* Add HelperText or simple div below */} +
+ This feature is not yet active. +
+
+ + {/* --- Allow Inference Toggle --- */} +
+ {" "} + {/* Wrap ToggleSwitch and HelperText */} + ( + + )} + /> + {/* Add HelperText or simple div below */} +
+ Allow Tapiro to estimate demographic insights.{" "} + + + +
+
+ {/* Save Button - Common for all tabs within the form */} - {/* Security Tab */} + {/* Security Tab (Existing) */}
{" "} From 189af4e2cb1c61961d203fdf101114482c4a581c Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 04:41:35 +0530 Subject: [PATCH 18/21] Revert "feat: Add UserProfilePage route for user profile management" This reverts commit 46421dd89ba7159c98f23af366e987753b36441c. --- web/src/main.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/web/src/main.tsx b/web/src/main.tsx index 3483564..ebfc7f7 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -15,7 +15,6 @@ import { AuthProviderWrapper } from "./context/AuthContext"; import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; import StoreProfilePage from "./pages/StoreDashboard/StoreProfilePage"; -import UserProfilePage from "./pages/UserDashboard/UserProfilePage"; const router = createBrowserRouter([ { @@ -46,10 +45,6 @@ const router = createBrowserRouter([ path: "dashboard/user", element: , }, - { - path: "profile/user", - element: , - }, ], }, // --- Protected Store Routes --- From b177bc9325d279e07295913a439e30c0e43bc3b9 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 27 Apr 2025 04:47:57 +0530 Subject: [PATCH 19/21] feat: Add allowInference field to privacy settings and update related components --- api-service/api/openapi.yaml | 4 -- api-service/service/UserProfileService.js | 4 -- api-service/utils/dbSchemas.js | 1 - web/src/api/types/data-contracts.ts | 5 -- .../pages/UserDashboard/UserProfilePage.tsx | 49 +++++-------------- 5 files changed, 11 insertions(+), 52 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 669daa4..2c3988b 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -916,10 +916,6 @@ components: dataSharingConsent: type: boolean description: User consent to share aggregated/anonymized data. - anonymizeData: - type: boolean - description: User preference to anonymize data where possible (future use). - default: false allowInference: # <-- Add allowInference here type: boolean description: Allow Tapiro to infer demographic data based on user activity. diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 1959e8c..940d3cb 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -173,10 +173,6 @@ exports.updateUserProfile = async function (req, body) { updateData['privacySettings.dataSharingConsent'] = body.privacySettings.dataSharingConsent; privacySettingsChanged = true; } - if (body.privacySettings.anonymizeData !== undefined) { - updateData['privacySettings.anonymizeData'] = body.privacySettings.anonymizeData; - privacySettingsChanged = true; - } if (body.privacySettings.allowInference !== undefined) { // <-- Add check for allowInference updateData['privacySettings.allowInference'] = body.privacySettings.allowInference; privacySettingsChanged = true; diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 780291e..b000021 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -146,7 +146,6 @@ const userSchema = { required: ['dataSharingConsent'], properties: { dataSharingConsent: { bsonType: 'bool' }, - anonymizeData: { bsonType: 'bool' }, allowInference: { // <-- Add new field bsonType: 'bool', description: 'Allow Tapiro to infer demographic data based on user activity (default: true)', diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 7438d13..9c68958 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -40,11 +40,6 @@ export interface User { export interface PrivacySettings { /** User consent to share aggregated/anonymized data. */ dataSharingConsent: boolean; - /** - * User preference to anonymize data where possible (future use). - * @default false - */ - anonymizeData?: boolean; /** * Allow Tapiro to infer demographic data based on user activity. * @default true diff --git a/web/src/pages/UserDashboard/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx index 57aea1c..1eca662 100644 --- a/web/src/pages/UserDashboard/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -42,7 +42,6 @@ type UserProfileFormData = { username?: string; phone?: string; privacySettings_dataSharingConsent?: boolean; - privacySettings_anonymizeData?: boolean; privacySettings_allowInference?: boolean; // <-- Add allowInference }; @@ -82,7 +81,6 @@ export default function UserProfilePage() { username: "", phone: "", privacySettings_dataSharingConsent: false, - privacySettings_anonymizeData: false, privacySettings_allowInference: true, // <-- Default to true }, }); @@ -95,11 +93,8 @@ export default function UserProfilePage() { phone: userProfile.phone || "", privacySettings_dataSharingConsent: userProfile.privacySettings?.dataSharingConsent ?? false, - privacySettings_anonymizeData: - userProfile.privacySettings?.anonymizeData ?? false, - // <-- Reset allowInference privacySettings_allowInference: - userProfile.privacySettings?.allowInference ?? true, // Default true if undefined + userProfile.privacySettings?.allowInference ?? true, }); } }, [userProfile, reset]); @@ -164,8 +159,7 @@ export default function UserProfilePage() { phone: data.phone, privacySettings: { dataSharingConsent: data.privacySettings_dataSharingConsent ?? false, - anonymizeData: data.privacySettings_anonymizeData ?? false, - allowInference: data.privacySettings_allowInference ?? false, + allowInference: data.privacySettings_allowInference ?? true, // Default to true if undefined }, }; updateUser(updatePayload); @@ -329,35 +323,12 @@ export default function UserProfilePage() { {/* Add HelperText or simple div below */}
Controls sharing preferences with specific stores.{" "} - - + +
- {/* Anonymize Data */} -
- {" "} - {/* Wrap ToggleSwitch and HelperText */} - ( - - )} - /> - {/* Add HelperText or simple div below */} -
- This feature is not yet active. -
-
- {/* --- Allow Inference Toggle --- */}
{" "} @@ -368,7 +339,7 @@ export default function UserProfilePage() { render={({ field }) => ( @@ -376,12 +347,14 @@ export default function UserProfilePage() { /> {/* Add HelperText or simple div below */}
- Allow Tapiro to estimate demographic insights.{" "} - - + Allow Tapiro to infer demographic insights based on your + activity.{" "} + +
+ {/* --- End Allow Inference Toggle --- */} {/* Save Button - Common for all tabs within the form */}