From 09766b624f23702a1d2147900804c07b8f7a24bf Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 22:49:16 +0530 Subject: [PATCH 01/10] feat: Enhance UserDashboard with Pie Chart for preferences and demographic cards --- web/src/pages/UserDashboard.tsx | 383 +++++++++++++++++++++++++------- 1 file changed, 299 insertions(+), 84 deletions(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index b5e8415..a95dada 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -13,6 +13,7 @@ import { ListItem, Datepicker, Button, + Spinner, // <-- Import Spinner for inline loading } from "flowbite-react"; import { HiArrowRight, @@ -23,6 +24,10 @@ import { HiOutlineShare, HiOutlineAdjustments, HiCalendar, + HiOutlineGlobeAlt, + HiOutlineCake, + HiOutlineCash, + HiOutlineUserCircle, } from "react-icons/hi"; import { ResponsiveContainer, @@ -33,9 +38,9 @@ import { CartesianGrid, Tooltip, Legend, - BarChart, - Bar, Cell, + PieChart, + Pie, } from "recharts"; import { @@ -46,6 +51,7 @@ import { useUserPreferences, } from "../api/hooks/useUserHooks"; import { useLookupStores } from "../api/hooks/useStoreHooks"; +import { useTaxonomy } from "../api/hooks/useTaxonomyHooks"; import LoadingSpinner from "../components/common/LoadingSpinner"; import ErrorDisplay from "../components/common/ErrorDisplay"; import { @@ -67,7 +73,7 @@ const formatDate = (dateString: string | Date | undefined) => { }); }; -// Define colors for the lines (can reuse or define new ones) +// Define colors for the lines/pie slices (keep existing) const LINE_COLORS = [ "#0088FE", "#00C49F", @@ -91,7 +97,7 @@ const formatCurrency = (value: number) => { }).format(value); }; -// Helper to format YYYY-MM date string for display +// Helper to format YYYY-MM date string for display (keep existing) const formatMonth = (monthString: string) => { try { const [year, month] = monthString.split("-"); @@ -105,12 +111,86 @@ const formatMonth = (monthString: string) => { } }; -// Helper to format Date object to YYYY-MM-DD string +// Helper to format Date object to YYYY-MM-DD string (keep existing) const formatDateToISO = (date: Date | null | undefined): string | undefined => { if (!date) return undefined; return date.toISOString().split("T")[0]; }; +// Helper for Pie Chart Label Rendering (Optional, for better labels) +const RADIAN = Math.PI / 180; + +// Define an interface for the label props +interface CustomizedLabelProps { + cx: number; + cy: number; + midAngle: number; + innerRadius: number; + outerRadius: number; + percent: number; +} + +const renderCustomizedLabel = ({ + cx, + cy, + midAngle, + innerRadius, + 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); + + if (percent < 0.05) return null; // Don't render label for small slices + + return ( + cx ? "start" : "end"} + dominantBaseline="central" + fontSize={12} + > + {`${(percent * 100).toFixed(0)}%`} + + ); +}; + +// --- Mini Demographic Card Component --- +interface DemoInfoCardProps { + icon: React.ElementType; + label: string; + value: string | number | null | undefined; + isLoading?: boolean; +} + +const DemoInfoCard: React.FC = ({ + icon: Icon, + label, + value, + isLoading, +}) => ( +
+ +
+

+ {label} +

+ {isLoading ? ( + + ) : ( +

+ {value ?? "--"} {/* Display '--' if value is null/undefined */} +

+ )} +
+
+); +// --- End Mini Demographic Card Component --- + export default function UserDashboard() { // --- State for Date Range --- const [startDate, setStartDate] = useState(null); @@ -126,7 +206,7 @@ export default function UserDashboard() { data: recentActivity, isLoading: activityLoading, error: activityError, - } = useRecentUserData(3); // <-- Changed limit from 5 to 3 + } = useRecentUserData(3); const { data: spendingData, isLoading: spendingLoading, @@ -145,6 +225,11 @@ export default function UserDashboard() { isLoading: preferencesLoading, error: preferencesError, } = useUserPreferences(); + const { + data: taxonomyData, + isLoading: taxonomyLoading, + error: taxonomyError, + } = useTaxonomy(); // <-- Fetch taxonomy // --- Prepare Derived Data (useMemo hooks called unconditionally) --- const optInStoreIds = useMemo( @@ -200,17 +285,84 @@ export default function UserDashboard() { }; }, [spendingData]); - // Data for Preferences Bar Chart - const topPreferencesChartData = useMemo(() => { - if (!preferencesData?.preferences) return []; - return [...preferencesData.preferences] - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - .slice(0, 5) - .map((pref) => ({ - name: pref.category, - score: pref.score != null ? pref.score * 100 : 0, + // --- Data Transformation for Preferences Pie Chart --- + const preferencesPieChartData = useMemo(() => { + if ( + !preferencesData?.preferences || + !taxonomyData?.categories || + preferencesData.preferences.length === 0 || + taxonomyData.categories.length === 0 + ) { + return []; + } + + // Build helper maps from taxonomy + const categoryNameMap = new Map(); + const parentMap = new Map(); + taxonomyData.categories.forEach((cat) => { + categoryNameMap.set(cat.id, cat.name); + parentMap.set(cat.id, cat.parent_id || null); + }); + + // Function to find the top-level parent + const getTopLevelCategory = ( + categoryId: string, + ): { id: string; name: string } | null => { + let currentId: string | null | undefined = categoryId; // Allow undefined + let topLevelId: string = categoryId; + let safety = 0; // Prevent infinite loops + + while (currentId != null && safety < 10) { + // Check for null or undefined + const parentId = parentMap.get(currentId); + if (parentId == null) { + // Check for null or undefined + topLevelId = currentId; // Found the root + break; + } + currentId = parentId; + safety++; + } + const topLevelName = categoryNameMap.get(topLevelId); + return topLevelName ? { id: topLevelId, name: topLevelName } : null; + }; + + // Aggregate scores by top-level category + const aggregatedScores = new Map(); + preferencesData.preferences.forEach((pref) => { + if (pref.category && pref.score != null) { + // Use pref.category + const topLevelCat = getTopLevelCategory(pref.category); // Use pref.category + if (topLevelCat) { + const current = aggregatedScores.get(topLevelCat.id) || { + name: topLevelCat.name, + score: 0, + }; + current.score += pref.score; + aggregatedScores.set(topLevelCat.id, current); + } + } + }); + + // Convert map to array suitable for PieChart, calculate total score + let totalScore = 0; + const chartData = Array.from(aggregatedScores.values()).map((item) => { + totalScore += item.score; + return { name: item.name, value: item.score }; // Use 'value' for PieChart + }); + + // Normalize scores to percentages (optional, but good for display) + // If totalScore is 0, avoid division by zero + if (totalScore > 0) { + return chartData.map((item) => ({ + ...item, + value: (item.value / totalScore) * 100, // Normalize to percentage })); - }, [preferencesData]); + } else { + // Handle case where all scores are 0 or negative (unlikely but possible) + return chartData.map((item) => ({ ...item, value: 0 })); + } + }, [preferencesData, taxonomyData]); // --- Loading and Error States (Checked AFTER hooks) --- const isLoading = @@ -219,7 +371,8 @@ export default function UserDashboard() { spendingLoading || consentLoading || preferencesLoading || - storesLoading; + storesLoading || + taxonomyLoading; // <-- Add taxonomy loading const combinedError = profileError || @@ -227,7 +380,8 @@ export default function UserDashboard() { spendingError || consentError || preferencesError || - storesError; + storesError || + taxonomyError; // <-- Add taxonomy error const [showInterestForm, setShowInterestForm] = useState(false); @@ -242,11 +396,13 @@ export default function UserDashboard() { } }, [preferencesData, preferencesLoading]); - if (isLoading && !spendingData) { + // Adjust initial loading state check if needed + if (isLoading && !profile && !spendingData && !preferencesData) { return ; } - if (combinedError && !spendingData) { + // Adjust combined error check if needed + if (combinedError && !profile && !spendingData && !preferencesData) { return ( {/* --- Spending Overview Card --- */} - + + {" "} + {/* Make spending full width on large screens */}

@@ -461,80 +619,137 @@ export default function UserDashboard() { - {/* --- Preferences Summary Card --- */} + {/* --- Preferences & Demographics Card --- */} + {/* Make this card span 2 columns on medium screens and up */}
+ {/* Main Title */}

- Top Preferences + Preference & Profile Overview

- {preferencesError ? ( - - Could not load preferences data. - - ) : preferencesLoading ? ( -
- + + {/* Grid for Pie Chart and Demographics */} +
+ {/* Pie Chart Section */} +
+

+ Top Interests +

+ {preferencesError || taxonomyError ? ( + + Could not load preference data. + + ) : preferencesLoading || taxonomyLoading ? ( +
+ +
+ ) : !preferencesPieChartData || + preferencesPieChartData.length === 0 ? ( +

+ No preference data available yet. Add interests to see + insights. +

+ ) : ( +
+ + + + {preferencesPieChartData.map((_entry, index) => ( + + ))} + + + `${value.toFixed(1)}%` + } + contentStyle={{ + backgroundColor: "rgba(31, 41, 55, 0.9)", + borderColor: "rgba(75, 85, 99, 0.5)", + borderRadius: "0.375rem", + }} + itemStyle={{ color: "#e5e7eb" }} + labelStyle={{ + color: "#f9fafb", + fontWeight: "bold", + }} + /> + + + +
+ )} + + Manage All Preferences{" "} + +
- ) : !topPreferencesChartData || - topPreferencesChartData.length === 0 ? ( -

- No preference data available yet. -

- ) : ( -
- - - - `${value}%`} - fontSize={12} - tick={{ fill: "currentColor" }} + + {/* Demographics Section */} +
+

+ About You +

+ {profileError ? ( + + Could not load profile information. + + ) : ( +
+ - - `${value.toFixed(1)}%`} - cursor={{ fill: "rgba(156, 163, 175, 0.2)" }} - contentStyle={{ - backgroundColor: "rgba(31, 41, 55, 0.9)", - borderColor: "rgba(75, 85, 99, 0.5)", - borderRadius: "0.375rem", - }} - itemStyle={{ color: "#e5e7eb" }} - labelStyle={{ color: "#f9fafb", fontWeight: "bold" }} + - - - {topPreferencesChartData.map((_entry, index) => ( - - ))} - - - + +
+ )}
- )} +
- - Manage All Preferences - + {/* Removed the single link at the bottom, added links within sections */}
From 8adb6c3256b916da828680e9d2e09079c617510b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 22:50:35 +0530 Subject: [PATCH 02/10] refactor: Remove redundant link from UserDashboard for improved layout --- web/src/pages/UserDashboard.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index a95dada..f3f50f9 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -749,7 +749,6 @@ export default function UserDashboard() {

- {/* Removed the single link at the bottom, added links within sections */}
From 0952808000166863f2f98296523aa3ec5f36f802 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 00:45:28 +0530 Subject: [PATCH 03/10] feat: Improve Data Sharing Card layout and loading states in UserDashboard --- web/src/pages/UserDashboard.tsx | 47 +++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index f3f50f9..1a9e6c1 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -574,46 +574,71 @@ export default function UserDashboard() { {/* --- Data Sharing Card --- */} + {" "} + {/* Ensure flex-col */}
+ {" "} + {/* Content takes available space */}

Data Sharing

- {consentError || storesError ? ( + {/* Specific Loading State for this Card */} + {consentLoading || storesLoading ? ( +
+ {" "} + {/* Center spinner, add min-height */} + +
+ ) : consentError || storesError ? ( Could not load sharing status. ) : (consentLists?.optInStores?.length ?? 0) === 0 ? ( -

- You are not currently sharing data with any stores. -

+
+ {" "} + {/* Center empty state, add min-height */} +

+ You are not currently sharing data with any stores. +

+
) : ( <> -

+

You are sharing data with{" "} - {consentLists?.optInStores?.length} store(s): + + {consentLists?.optInStores?.length} + {" "} + store(s):

- + {/* Use a slightly more styled list */} + + {" "} + {/* Add max-height and scroll */} {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( {storeNameMap.get(storeId) || `Store ID: ${storeId}`} ))} {(consentLists?.optInStores?.length ?? 0) > 5 && ( - - ... and more + + ... and {consentLists!.optInStores!.length - 5} more )} )}
+ {/* Link stays at the bottom */} Manage Sharing Settings From bb7831d41af4118ce17fbf4d638db499eacd5b30 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 02:36:30 +0530 Subject: [PATCH 04/10] feat(api): Enhance user activity log endpoint with pagination and filtering options - Updated `/users/data/recent` endpoint to return a paginated list of user activity, including data submissions and usage. - Added query parameters for filtering by date range, data type, store ID, and search term. - Modified response structure to include pagination details. - Introduced new `/stores` endpoint for listing and searching stores with pagination. - Updated related service and controller methods to handle new parameters and logic. - Adjusted TypeScript types to reflect changes in API responses and request parameters. --- api-service/api/openapi.yaml | 265 +++++++++++++----- api-service/controllers/StoreProfile.js | 10 + api-service/controllers/UserProfile.js | 5 +- .../service/PreferenceManagementService.js | 160 ++++++----- api-service/service/StoreProfileService.js | 66 +++++ api-service/service/UserProfileService.js | 171 ++++++++--- web/src/api/types/Stores.ts | 44 +++ web/src/api/types/Users.ts | 44 ++- web/src/api/types/data-contracts.ts | 154 ++++++---- 9 files changed, 673 insertions(+), 246 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index eb0eaf2..e2a71cb 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -612,33 +612,83 @@ paths: /users/data/recent: get: tags: [User Management] - summary: Get Recent User Data Submissions - description: Retrieves a list of recent data submissions made about the authenticated user. + summary: Get User Activity Log + description: Retrieves a paginated list of user activity (data submissions, usage), filterable and searchable. operationId: getRecentUserData parameters: - name: limit in: query - description: Maximum number of records to return + description: Maximum number of entries per page. required: false schema: type: integer - default: 10 + format: int32 + default: 15 - name: page in: query - description: Page number for pagination + description: Page number for pagination. required: false schema: type: integer + format: int32 default: 1 + - name: startDate + in: query + description: Filter activity from this date onwards (YYYY-MM-DD). + required: false + schema: + type: string + format: date + - name: endDate + in: query + description: Filter activity up to this date (YYYY-MM-DD). + required: false + schema: + type: string + format: date + - name: dataType + in: query + description: Filter by the type of data submission. + required: false + schema: + type: string + enum: [purchase, search] + - name: storeId + in: query + description: Filter activity related to a specific store ID. + required: false + schema: + type: string + format: objectId + - name: search + in: query + description: Search term for activity details (e.g., item name, search query). + required: false + schema: + type: string responses: "200": - description: Recent data submissions retrieved successfully + description: User activity log retrieved successfully. content: application/json: schema: - type: array - items: - $ref: "#/components/schemas/RecentUserDataEntry" + type: object + properties: + activity: + type: array + items: + $ref: "#/components/schemas/RecentUserDataEntry" + pagination: + type: object + properties: + currentPage: + type: integer + totalPages: + type: integer + totalEntries: + type: integer + "400": + $ref: "#/components/responses/BadRequestError" "401": $ref: "#/components/responses/UnauthorizedError" "500": @@ -717,6 +767,64 @@ paths: - oauth2: [user:read] x-swagger-router-controller: StoreProfile + /stores: + get: + tags: [Store Management] + summary: List or Search Stores + description: Retrieves a paginated list of registered stores, optionally filtered by a search term. Intended for user discovery (e.g., finding stores to opt-in/out). + operationId: listStoresForUserDiscovery + parameters: + - name: search + in: query + description: Optional search term to filter stores by name. + required: false + schema: + type: string + - name: limit + in: query + description: Maximum number of stores to return per page. + required: false + schema: + type: integer + format: int32 + default: 20 + - name: page + in: query + description: Page number for pagination. + required: false + schema: + type: integer + format: int32 + default: 1 + responses: + "200": + description: A paginated list of stores. + content: + application/json: + schema: + type: object + properties: + stores: + type: array + items: + $ref: "#/components/schemas/StoreBasicInfo" + pagination: + type: object + properties: + currentPage: + type: integer + totalPages: + type: integer + totalStores: + type: integer + "401": + $ref: "#/components/responses/UnauthorizedError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] + x-swagger-router-controller: StoreProfile + components: schemas: AttributeDistribution: @@ -978,7 +1086,7 @@ components: type: object required: - email - - dataType # Make dataType required + - dataType - entries properties: email: @@ -995,11 +1103,11 @@ components: List of data entries. Each entry must conform to either the PurchaseEntry or SearchEntry schema, matching the top-level 'dataType'. items: - oneOf: # Use oneOf to specify possible entry types + oneOf: - $ref: "#/components/schemas/PurchaseEntry" - $ref: "#/components/schemas/SearchEntry" description: "An entry representing either a purchase event or a search event." - minItems: 1 # Require at least one entry + minItems: 1 metadata: type: object description: Additional metadata about the collection event (e.g., source, device). @@ -1017,7 +1125,7 @@ components: source: "web" deviceType: "desktop" sessionId: "abc-123-xyz-789" - example: # Example for a purchase submission + example: email: "user@example.com" dataType: "purchase" entries: @@ -1049,10 +1157,10 @@ components: example: timestamp: "2024-05-15T14:30:00Z" items: - - $ref: "#/components/schemas/PurchaseItem/example" # Reference the example above + - $ref: "#/components/schemas/PurchaseItem/example" - sku: "ABC-789" name: "Running Shorts" - category: "201" # Clothing + category: "201" price: 39.95 quantity: 1 attributes: @@ -1065,7 +1173,7 @@ components: type: object required: - name - - category # Making category required for better processing + - category properties: sku: type: string @@ -1091,7 +1199,7 @@ components: example: sku: "XYZ-123" name: "Men's Cotton T-Shirt" - category: "201" # Example: Clothing ID + category: "201" price: 25.99 quantity: 2 attributes: @@ -1141,7 +1249,7 @@ components: example: timestamp: "2024-05-15T10:15:00Z" query: "noise cancelling headphones" - category: "105" # Example: Audio ID + category: "105" results: 25 clicked: ["Bose-QC45", "Sony-WH1000XM5"] @@ -1167,59 +1275,33 @@ components: items: $ref: "#/components/schemas/PreferenceItem" description: User interest preferences with taxonomy categorization + required: + - preferences PreferenceItem: type: object - required: - - category - - score properties: category: type: string - description: Category ID or name (e.g., "101" or "smartphones") + description: The ID of the taxonomy category. + example: "102" score: type: number format: float - minimum: 0.0 - maximum: 1.0 - description: Preference score (0.0-1.0) + description: The user's interest score for this category (e.g., 0-1). + example: 0.85 attributes: type: object - description: Category-specific attribute preferences - properties: - price_range: - type: object - properties: - budget: - type: number - format: float - mid_range: - type: number - format: float - premium: - type: number - format: float - luxury: - type: number - format: float - color: - $ref: "#/components/schemas/AttributeDistribution" - brand: - $ref: "#/components/schemas/AttributeDistribution" - material: - $ref: "#/components/schemas/AttributeDistribution" - style: - $ref: "#/components/schemas/AttributeDistribution" - room: - $ref: "#/components/schemas/AttributeDistribution" - size: - $ref: "#/components/schemas/AttributeDistribution" - feature: - $ref: "#/components/schemas/AttributeDistribution" - season: - $ref: "#/components/schemas/AttributeDistribution" - gender: - $ref: "#/components/schemas/AttributeDistribution" + description: Specific attribute preferences within this category (key-value pairs). Values should align with taxonomy definitions. + additionalProperties: + type: string + example: + brand: "Apple" + screen_size: "13-inch" + usage_type: "Work" + required: + - category + - score StoreUpdate: type: object @@ -1432,25 +1514,66 @@ components: properties: _id: type: string - description: The unique ID of the userData entry. - storeId: - type: string - description: The ID of the store that submitted the data. + format: objectId + description: Unique identifier for the activity entry. dataType: type: string enum: [purchase, search] - description: The type of data submitted. + description: The type of data activity. timestamp: type: string format: date-time - description: When the data was submitted to Tapiro. - entryTimestamp: + description: Timestamp of the original event or submission. + storeId: type: string - format: date-time - description: The timestamp of the original event (e.g., purchase time). + format: objectId + description: ID of the store associated with the activity. + storeName: + type: string + description: Name of the store (looked up). + example: "Awesome Gadgets Inc." details: type: object - description: Simplified details (e.g., item count for purchase, query string for search) + description: Specific details based on the dataType. + properties: + items: + type: array + items: + type: object + properties: + name: + type: string + example: "Wireless Mouse" + category: + type: string + description: Taxonomy category ID. + example: "100" + price: + type: number + format: float + example: 25.99 + quantity: + type: integer + example: 1 + totalAmount: + type: number + format: float + description: Total amount for the purchase event. + example: 25.99 + searchTerm: + type: string + example: "best gaming laptop" + categorySearched: + type: string + description: Taxonomy category ID searched within, if applicable. + example: "102" + required: + - _id + - dataType + - timestamp + - storeId + - storeName + - details SpendingAnalytics: type: object @@ -1459,7 +1582,7 @@ components: The structure might vary based on implementation (e.g., object keyed by month/year, or an array of objects each representing a time point). additionalProperties: - type: object # Example: { "YYYY-MM": { "Category1": 100, "Category2": 50 } } + type: object additionalProperties: type: number format: float diff --git a/api-service/controllers/StoreProfile.js b/api-service/controllers/StoreProfile.js index ef368da..d0458af 100644 --- a/api-service/controllers/StoreProfile.js +++ b/api-service/controllers/StoreProfile.js @@ -39,4 +39,14 @@ module.exports.lookupStores = function lookupStores(req, res, next, ids) { .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.listStoresForUserDiscovery = function listStoresForUserDiscovery (req, res, next, search, limit, page) { + StoreProfile.listStoresForUserDiscovery(req, search, limit, page) // Pass params to service + .then(function (response) { + utils.writeJson(res, response); + }) + .catch(function (response) { + utils.writeJson(res, response); + }); }; \ No newline at end of file diff --git a/api-service/controllers/UserProfile.js b/api-service/controllers/UserProfile.js index 0364e2e..8fadd96 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -31,9 +31,8 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { }); }; -module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page) { - // Pass query parameters to the service function - UserProfile.getRecentUserData(req, limit, page) +module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page, startDate, endDate, dataType, storeId, search) { + UserProfile.getRecentUserData(req, limit, page, startDate, endDate, dataType, storeId, search) .then((response) => { utils.writeJson(res, response); }) diff --git a/api-service/service/PreferenceManagementService.js b/api-service/service/PreferenceManagementService.js index ed4998d..aa082e2 100644 --- a/api-service/service/PreferenceManagementService.js +++ b/api-service/service/PreferenceManagementService.js @@ -10,41 +10,35 @@ const TaxonomyService = require('../service/TaxonomyService'); // Import Taxonom exports.getUserOwnPreferences = async function (req) { try { - // Get user data from middleware const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - const db = getDB(); - - // Check cache first - standardized cache key const cacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; + + // Cache check remains the same conceptually, but ensure cached data includes attributes if needed by frontend const cachedPreferences = await getCache(cacheKey); if (cachedPreferences) { - // Ensure privacySettings are not included in the cached response being returned - const prefs = JSON.parse(cachedPreferences); - delete prefs.privacySettings; - return respondWithCode(200, prefs); + // Assuming cache stores the full preference structure including attributes + return respondWithCode(200, JSON.parse(cachedPreferences)); } - // Find user in database, only selecting necessary fields + // Fetch user, including the full preferences array (with attributes) const user = await db.collection('users').findOne( { auth0Id: userData.sub }, - { projection: { _id: 1, preferences: 1, updatedAt: 1 } } // Select only needed fields + // Ensure 'preferences' field is projected correctly, including nested 'attributes' + { projection: { _id: 1, preferences: 1, updatedAt: 1 } } ); if (!user) { return respondWithCode(404, { code: 404, message: 'User not found' }); } - // Prepare the response object without privacySettings + // Prepare response - includes attributes now const preferencesResponse = { userId: user._id.toString(), - preferences: user.preferences || [], - // REMOVED privacySettings + preferences: user.preferences || [], // This now includes attributes if stored updatedAt: user.updatedAt || new Date(), }; - // Cache the preferences result (without privacySettings) with specific TTL - // Note: Caching the minimal response. If optimistic updates need privacySettings, - // they might need to fetch the full user profile or adjust logic. + // Cache the full response (including attributes) await setCache(cacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); return respondWithCode(200, preferencesResponse); @@ -128,94 +122,114 @@ exports.updateUserPreferences = async function (req, body) { } let validatedPreferences = []; - if (body.preferences) { - // --- Validation --- - if (!Array.isArray(body.preferences)) { - return respondWithCode(400, { code: 400, message: 'Preferences must be an array.' }); + if (!body.preferences || !Array.isArray(body.preferences)) { + return respondWithCode(400, { code: 400, message: 'Preferences array is required and must be an array.' }); + } + + // --- Validation against Taxonomy --- + let taxonomyDoc; + try { + // Use the TaxonomyService to get the current taxonomy data + taxonomyDoc = await TaxonomyService.getLatestTaxonomy(); // Assuming this function exists and returns the structure + if (!taxonomyDoc || !taxonomyDoc.data || !taxonomyDoc.data.categories) { + throw new Error('Taxonomy data is unavailable for validation.'); } + } catch (taxError) { + console.error("Failed to load taxonomy for validation:", taxError); + return respondWithCode(500, { code: 500, message: 'Internal error: Could not load taxonomy for validation.' }); + } - // Fetch taxonomy for validation - let taxonomyDoc; - try { - // Use the service function to get taxonomy (handles caching) - const taxonomyResponse = await TaxonomyService.getTaxonomyCategories(); - if (taxonomyResponse.code !== 200) { - throw new Error('Failed to fetch taxonomy for validation'); - } - taxonomyDoc = taxonomyResponse.payload; // Assuming payload contains the taxonomy doc - } catch (taxError) { - console.error("Taxonomy fetch error during preference update:", taxError); - return respondWithCode(500, { code: 500, message: 'Could not load taxonomy for validation.' }); + const categoryMap = new Map(taxonomyDoc.data.categories.map(cat => [cat.id, cat])); + const validationErrors = []; + + for (const pref of body.preferences) { + if (!pref || typeof pref !== 'object') { + validationErrors.push(`Invalid preference item format: ${JSON.stringify(pref)}`); + continue; + } + if (!pref.category || typeof pref.category !== 'string') { + validationErrors.push(`Preference item missing or invalid category ID: ${JSON.stringify(pref)}`); + continue; + } + if (pref.score == null || typeof pref.score !== 'number' || pref.score < 0 || pref.score > 1) { + validationErrors.push(`Preference item for category ${pref.category} has invalid score: ${pref.score}`); + continue; } - const validCategoryIds = new Set(taxonomyDoc?.data?.categories?.map(cat => cat.id) || []); + const categoryDefinition = categoryMap.get(pref.category); + if (!categoryDefinition) { + validationErrors.push(`Invalid category ID used in preference: ${pref.category}`); + continue; + } - for (const pref of body.preferences) { - // Basic structure validation - if (typeof pref.category !== 'string' || typeof pref.score !== 'number' || pref.score < 0 || pref.score > 1) { - return respondWithCode(400, { code: 400, message: `Invalid preference item format or score range: ${JSON.stringify(pref)}` }); - } - // Attributes validation (if present, must be a non-null object) - if (pref.attributes !== undefined && (typeof pref.attributes !== 'object' || pref.attributes === null || Array.isArray(pref.attributes))) { - return respondWithCode(400, { code: 400, message: `Invalid 'attributes' format for category ${pref.category}. Must be an object.` }); + const validAttributes = new Set(categoryDefinition.attributes?.map(attr => attr.name) || []); + const validatedAttributes = {}; + + if (pref.attributes && typeof pref.attributes === 'object') { + for (const attrKey in pref.attributes) { + if (!validAttributes.has(attrKey)) { + validationErrors.push(`Invalid attribute '${attrKey}' for category ${pref.category} (${categoryDefinition.name}). Valid attributes are: ${Array.from(validAttributes).join(', ')}`); + } else { + // Basic validation: ensure value is a string (can be enhanced) + if (typeof pref.attributes[attrKey] !== 'string') { + validationErrors.push(`Attribute '${attrKey}' for category ${pref.category} must have a string value.`); + } else { + validatedAttributes[attrKey] = pref.attributes[attrKey]; + } + } } - // Taxonomy validation - if (!validCategoryIds.has(pref.category)) { - return respondWithCode(400, { code: 400, message: `Invalid category ID in preferences: ${pref.category}` }); - } - validatedPreferences.push(pref); // Add valid preference } - // --- End Validation --- - - } else { - // If body.preferences is explicitly null or undefined, maybe clear preferences? - // Or return an error if preferences are required for update. - // Current behavior: If body.preferences is missing/null, validatedPreferences remains [] - // which will effectively clear preferences in the $set below. - // If you require preferences, add: - // return respondWithCode(400, { code: 400, message: 'Preferences array is required for update.' }); + // Only add if no validation errors for this specific preference item occurred during attribute check + if (!validationErrors.some(err => err.includes(`category ${pref.category}`))) { + validatedPreferences.push({ + category: pref.category, + score: pref.score, + // Only include attributes if they exist and passed validation + ...(Object.keys(validatedAttributes).length > 0 && { attributes: validatedAttributes }), + }); + } + } + + if (validationErrors.length > 0) { + console.warn('Preference validation failed:', validationErrors); + return respondWithCode(400, { code: 400, message: 'Invalid preference data provided.', details: validationErrors }); } + // --- End Validation --- - // Log the data being sent to the database for debugging - console.log('Attempting to update preferences with:', JSON.stringify(validatedPreferences, null, 2)); // Update preferences in the database using the validated list const updateResult = await db.collection('users').updateOne( { _id: user._id }, { $set: { - preferences: validatedPreferences, // Use the validated array + preferences: validatedPreferences, // Use the validated array (includes attributes) updatedAt: new Date(), }, }, ); - // Fetch the updated user data to get the latest timestamp and preferences - // No need to fetch again if we trust the update, but it confirms the write - const updatedUser = await db.collection('users').findOne( - { _id: user._id }, - { projection: { preferences: 1, updatedAt: 1 } } - ); + // Fetch updated user data to return and cache + const updatedUser = await db.collection('users').findOne( + { _id: user._id }, + { projection: { preferences: 1, updatedAt: 1 } } + ); - // Clear related caches + // Clear relevant caches const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; await invalidateCache(userCacheKey); - - // Clear store-specific preference caches as preferences changed + // Invalidate store-specific caches if needed (logic remains similar) if (user.privacySettings?.optInStores) { for (const storeId of user.privacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); } } - // Return updated preferences object + // Prepare and cache the response (now includes attributes) const preferencesResponse = { userId: user._id.toString(), - preferences: updatedUser.preferences || [], // Use actual updated preferences - updatedAt: updatedUser.updatedAt, // Use the actual updated timestamp + preferences: updatedUser.preferences || [], + updatedAt: updatedUser.updatedAt, }; - - // Update the cache with the new minimal response await setCache(userCacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); return respondWithCode(200, preferencesResponse); diff --git a/api-service/service/StoreProfileService.js b/api-service/service/StoreProfileService.js index ce5677b..f2d4f19 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -166,3 +166,69 @@ exports.lookupStores = async function (req, ids) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * List or Search Stores (for User Discovery) + * Retrieves a paginated list of stores, optionally filtered by name. + */ +exports.listStoresForUserDiscovery = async function (req, search, limit = 20, page = 1) { + try { + const db = getDB(); + const skip = (page - 1) * limit; + + const query = {}; + if (search) { + // Use a case-insensitive regex search on the store name + query.name = { $regex: search, $options: 'i' }; + } + + // Ensure limit is a number + const numericLimit = parseInt(limit, 10); + if (isNaN(numericLimit) || numericLimit <= 0) { + numericLimit = 20; // Default if invalid + } + + + // Find stores matching the query, projecting only necessary fields + const storesCursor = db.collection('stores').find(query, { + projection: { _id: 1, name: 1 }, // Only need ID and name + }); + + // Get total count for pagination before applying skip/limit + const totalStores = await storesCursor.count(); // Use count() on the cursor before skip/limit + + // Apply sorting, skip, and limit + const stores = await storesCursor + .sort({ name: 1 }) // Sort alphabetically by name + .skip(skip) + .limit(numericLimit) + .toArray(); + + // Format the response + const formattedStores = stores.map(store => ({ + storeId: store._id.toString(), + name: store.name, + })); + + const totalPages = Math.ceil(totalStores / numericLimit); + + const responsePayload = { + stores: formattedStores, + pagination: { + currentPage: page, + totalPages: totalPages, + totalStores: totalStores, + }, + }; + + // Caching could be added here if needed, consider search/page/limit in key + // const cacheKey = `${CACHE_KEYS.STORES_LIST}:${search || 'all'}:${page}:${limit}`; + // await setCache(cacheKey, JSON.stringify(responsePayload), { EX: CACHE_TTL.SHORT }); + + return respondWithCode(200, responsePayload); + + } catch (error) { + console.error('List stores for user discovery failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error' }); + } +}; diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 60189d9..893872c 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -272,7 +272,7 @@ exports.deleteUserProfile = async function (req) { * Get Recent User Data Submissions * Retrieves a list of recent data submissions made about the authenticated user. */ -exports.getRecentUserData = async function (req, limit = 10, page = 1) { +exports.getRecentUserData = async function (req, limit = 15, page = 1, startDate, endDate, dataType, storeId, search) { // <-- Add new params try { const db = getDB(); const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); @@ -283,43 +283,144 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1) { return respondWithCode(404, { code: 404, message: 'User not found' }); } - const skip = (page - 1) * limit; - - // Query userData collection - const recentData = await db.collection('userData') - .find({ userId: user._id }) // Filter by the user's ObjectId - .sort({ timestamp: -1 }) // Sort by submission time descending - .skip(skip) - .limit(limit) - .project({ // Project only necessary fields for RecentUserDataEntry schema - _id: 1, - storeId: 1, - dataType: 1, - timestamp: 1, // Submission timestamp - entryTimestamp: '$entries.timestamp', // Assuming timestamp is within entries array - // Add simplified details if needed, e.g., item count or query string - // details: { $cond: { if: { $eq: ['$dataType', 'purchase'] }, then: { itemCount: { $size: '$entries.items' } }, else: '$entries.query' } } - }) - .toArray(); - - // Simple transformation if needed (e.g., flatten entryTimestamp if it's an array) - const formattedData = recentData.map(entry => ({ - ...entry, - // If entryTimestamp is an array due to projection, take the first element - entryTimestamp: Array.isArray(entry.entryTimestamp) ? entry.entryTimestamp[0] : entry.entryTimestamp, - // Add placeholder for details - details: {} - })); - - - // Caching could be added here if this data is frequently accessed - // const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}`; - // await setCache(cacheKey, JSON.stringify(formattedData), { EX: CACHE_TTL.SHORT }); // Example TTL - - return respondWithCode(200, formattedData); + const numericLimit = parseInt(limit, 10) || 15; + const numericPage = parseInt(page, 10) || 1; + const skip = (numericPage - 1) * numericLimit; + + // --- Build Match Query --- + const matchQuery = { userId: user._id }; + + // Date Range Filter + const dateMatch = {}; + if (startDate) { + try { + dateMatch['$gte'] = new Date(startDate); + } catch (e) { console.warn('Invalid startDate:', startDate); } + } + if (endDate) { + try { + const end = new Date(endDate); + end.setDate(end.getDate() + 1); // Include the whole end day + dateMatch['$lt'] = end; + } catch (e) { console.warn('Invalid endDate:', endDate); } + } + if (Object.keys(dateMatch).length > 0) { + // Apply date filter to the 'timestamp' field of the main document + // or potentially 'entries.timestamp' if querying nested entries directly + matchQuery.timestamp = dateMatch; // Adjust if timestamp is nested + } + + // Data Type Filter + if (dataType && ['purchase', 'search'].includes(dataType)) { + matchQuery.dataType = dataType; + } + + // Store ID Filter + if (storeId) { + try { + matchQuery.storeId = new ObjectId(storeId); + } catch (e) { console.warn('Invalid storeId format:', storeId); } + } + + // Search Filter (Basic Example - requires text index for efficiency) + // Assumes text index exists on fields like 'entries.items.name', 'entries.searchTerm' + // db.collection('userData').createIndex({ "entries.items.name": "text", "entries.searchTerm": "text" }) + if (search) { + matchQuery.$text = { $search: search }; + } + // --- End Build Match Query --- + + // --- Aggregation Pipeline --- + // We need aggregation to potentially lookup store names and format details + const pipeline = [ + { $match: matchQuery }, + { $sort: { timestamp: -1 } }, // Sort before skip/limit for correct pagination + { $skip: skip }, + { $limit: numericLimit }, + // Lookup Store Name + { + $lookup: { + from: 'stores', + localField: 'storeId', + foreignField: '_id', + as: 'storeInfo' + } + }, + { + $unwind: { // Unwind the storeInfo array (should only be one match) + path: '$storeInfo', + preserveNullAndEmptyArrays: true // Keep entries even if store is deleted/not found + } + }, + // Project and Format the Output + { + $project: { + _id: 1, + dataType: 1, + timestamp: 1, + storeId: { $toString: '$storeId' }, // Convert ObjectId to string + storeName: { $ifNull: ['$storeInfo.name', 'Unknown Store'] }, // Use looked-up name or default + details: { // Structure the details field + $switch: { + branches: [ + { + case: { $eq: ['$dataType', 'purchase'] }, + then: { + // Assuming 'entries' is an array, take the first one for simplicity + // Adjust if multiple entries per document is possible and needs handling + items: { $ifNull: [{ $arrayElemAt: ['$entries.items', 0] }, []] }, // Example: take first entry's items + totalAmount: { $ifNull: [{ $sum: '$entries.items.price' }, 0] } // Example: sum prices (needs refinement based on actual schema) + } + }, + { + case: { $eq: ['$dataType', 'search'] }, + then: { + searchTerm: { $ifNull: [{ $arrayElemAt: ['$entries.searchTerm', 0] }, null] }, // Example + categorySearched: { $ifNull: [{ $arrayElemAt: ['$entries.category', 0] }, null] } // Example + } + } + ], + default: {} // Default empty object for unknown types + } + } + } + } + ]; + + // Execute pipeline + const activityEntries = await db.collection('userData').aggregate(pipeline).toArray(); + + // Get total count matching the filter (without skip/limit) for pagination + // Need to run a separate count aggregation or query + const countPipeline = [ + { $match: matchQuery }, + { $count: "totalEntries" } + ]; + const countResult = await db.collection('userData').aggregate(countPipeline).toArray(); + const totalEntries = countResult.length > 0 ? countResult[0].totalEntries : 0; + const totalPages = Math.ceil(totalEntries / numericLimit); + + const responsePayload = { + activity: activityEntries, + pagination: { + currentPage: numericPage, + totalPages: totalPages, + totalEntries: totalEntries, + }, + }; + + // Caching could be added here, complex key needed due to filters/search + // const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${numericPage}:${numericLimit}:${startDate}:${endDate}:${dataType}:${storeId}:${search}`; + // await setCache(cacheKey, JSON.stringify(responsePayload), { EX: CACHE_TTL.SHORT }); + + return respondWithCode(200, responsePayload); } catch (error) { console.error('Get recent user data failed:', error); + // Handle potential invalid ObjectId errors during filtering + if (error.name === 'BSONTypeError') { + return respondWithCode(400, { code: 400, message: 'Invalid ID format provided for filtering.' }); + } return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; diff --git a/web/src/api/types/Stores.ts b/web/src/api/types/Stores.ts index ce70675..3d5e4e3 100644 --- a/web/src/api/types/Stores.ts +++ b/web/src/api/types/Stores.ts @@ -17,6 +17,7 @@ import { ApiKeyUsage, Error, GetApiKeyUsagePayload, + ListStoresForUserDiscoveryParams, LookupStoresParams, Store, StoreBasicInfo, @@ -229,4 +230,47 @@ export class Stores< format: "json", ...params, }); + /** + * @description Retrieves a paginated list of registered stores, optionally filtered by a search term. Intended for user discovery (e.g., finding stores to opt-in/out). + * + * @tags Store Management + * @name ListStoresForUserDiscovery + * @summary List or Search Stores + * @request GET:/stores + * @secure + * @response `200` `{ + stores?: (StoreBasicInfo)[], + pagination?: { + currentPage?: number, + totalPages?: number, + totalStores?: number, + +}, + +}` A paginated list of stores. + * @response `401` `Error` + * @response `500` `Error` + */ + listStoresForUserDiscovery = ( + query: ListStoresForUserDiscoveryParams, + params: RequestParams = {}, + ) => + this.request< + { + stores?: StoreBasicInfo[]; + pagination?: { + currentPage?: number; + totalPages?: number; + totalStores?: number; + }; + }, + Error + >({ + path: `/stores`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); } diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index df15d2a..883c752 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -291,22 +291,42 @@ export class Users< ...params, }); /** - * @description Retrieves a list of recent data submissions made about the authenticated user. - * - * @tags User Management - * @name GetRecentUserData - * @summary Get Recent User Data Submissions - * @request GET:/users/data/recent - * @secure - * @response `200` `(RecentUserDataEntry)[]` Recent data submissions retrieved successfully - * @response `401` `Error` - * @response `500` `Error` - */ + * @description Retrieves a paginated list of user activity (data submissions, usage), filterable and searchable. + * + * @tags User Management + * @name GetRecentUserData + * @summary Get User Activity Log + * @request GET:/users/data/recent + * @secure + * @response `200` `{ + activity?: (RecentUserDataEntry)[], + pagination?: { + currentPage?: number, + totalPages?: number, + totalEntries?: number, + +}, + +}` User activity log retrieved successfully. + * @response `400` `Error` + * @response `401` `Error` + * @response `500` `Error` + */ getRecentUserData = ( query: GetRecentUserDataParams, params: RequestParams = {}, ) => - this.request({ + this.request< + { + activity?: RecentUserDataEntry[]; + pagination?: { + currentPage?: number; + totalPages?: number; + totalEntries?: number; + }; + }, + Error + >({ path: `/users/data/recent`, method: "GET", query: query, diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 4e1d9af..312bc45 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -247,50 +247,26 @@ export interface UserPreferences { export interface UserPreferencesUpdate { /** User interest preferences with taxonomy categorization */ - preferences?: PreferenceItem[]; + preferences: PreferenceItem[]; } export interface PreferenceItem { - /** Category ID or name (e.g., "101" or "smartphones") */ + /** + * The ID of the taxonomy category. + * @example "102" + */ category: string; /** - * Preference score (0.0-1.0) + * The user's interest score for this category (e.g., 0-1). * @format float - * @min 0 - * @max 1 + * @example 0.85 */ score: number; - /** Category-specific attribute preferences */ - attributes?: { - price_range?: { - /** @format float */ - budget?: number; - /** @format float */ - mid_range?: number; - /** @format float */ - premium?: number; - /** @format float */ - luxury?: number; - }; - /** Distribution of attribute values (0.0-1.0) */ - color?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - brand?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - material?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - style?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - room?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - size?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - feature?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - season?: AttributeDistribution; - /** Distribution of attribute values (0.0-1.0) */ - gender?: AttributeDistribution; - }; + /** + * Specific attribute preferences within this category (key-value pairs). Values should align with taxonomy definitions. + * @example {"brand":"Apple","screen_size":"13-inch","usage_type":"Work"} + */ + attributes?: Record; } export interface StoreUpdate { @@ -403,24 +379,60 @@ export interface Taxonomy { } export interface RecentUserDataEntry { - /** The unique ID of the userData entry. */ - _id?: string; - /** The ID of the store that submitted the data. */ - storeId?: string; - /** The type of data submitted. */ - dataType?: "purchase" | "search"; /** - * When the data was submitted to Tapiro. - * @format date-time + * Unique identifier for the activity entry. + * @format objectId */ - timestamp?: string; + _id: string; + /** The type of data activity. */ + dataType: "purchase" | "search"; /** - * The timestamp of the original event (e.g., purchase time). + * Timestamp of the original event or submission. * @format date-time */ - entryTimestamp?: string; - /** Simplified details (e.g., item count for purchase, query string for search) */ - details?: object; + timestamp: string; + /** + * ID of the store associated with the activity. + * @format objectId + */ + storeId: string; + /** + * Name of the store (looked up). + * @example "Awesome Gadgets Inc." + */ + storeName: string; + /** Specific details based on the dataType. */ + details: { + items?: { + /** @example "Wireless Mouse" */ + name?: string; + /** + * Taxonomy category ID. + * @example "100" + */ + category?: string; + /** + * @format float + * @example 25.99 + */ + price?: number; + /** @example 1 */ + quantity?: number; + }[]; + /** + * Total amount for the purchase event. + * @format float + * @example 25.99 + */ + totalAmount?: number; + /** @example "best gaming laptop" */ + searchTerm?: string; + /** + * Taxonomy category ID searched within, if applicable. + * @example "102" + */ + categorySearched?: string; + }; } /** @@ -465,15 +477,36 @@ export interface GetApiKeyUsagePayload { export interface GetRecentUserDataParams { /** - * Maximum number of records to return - * @default 10 + * Maximum number of entries per page. + * @format int32 + * @default 15 */ limit?: number; /** - * Page number for pagination + * Page number for pagination. + * @format int32 * @default 1 */ page?: number; + /** + * Filter activity from this date onwards (YYYY-MM-DD). + * @format date + */ + startDate?: string; + /** + * Filter activity up to this date (YYYY-MM-DD). + * @format date + */ + endDate?: string; + /** Filter by the type of data submission. */ + dataType?: "purchase" | "search"; + /** + * Filter activity related to a specific store ID. + * @format objectId + */ + storeId?: string; + /** Search term for activity details (e.g., item name, search query). */ + search?: string; } export interface GetSpendingAnalyticsParams { @@ -493,3 +526,20 @@ export interface LookupStoresParams { /** Comma-separated list of store IDs to lookup. */ ids: string; } + +export interface ListStoresForUserDiscoveryParams { + /** Optional search term to filter stores by name. */ + search?: string; + /** + * Maximum number of stores to return per page. + * @format int32 + * @default 20 + */ + limit?: number; + /** + * Page number for pagination. + * @format int32 + * @default 1 + */ + page?: number; +} From 1970293f0a51966be74211762acf0be3bcb24f42 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 03:38:31 +0530 Subject: [PATCH 05/10] Revert "feat(api): Enhance user activity log endpoint with pagination and filtering options" This reverts commit bb7831d41af4118ce17fbf4d638db499eacd5b30. --- api-service/api/openapi.yaml | 265 +++++------------- api-service/controllers/StoreProfile.js | 10 - api-service/controllers/UserProfile.js | 5 +- .../service/PreferenceManagementService.js | 160 +++++------ api-service/service/StoreProfileService.js | 66 ----- api-service/service/UserProfileService.js | 171 +++-------- web/src/api/types/Stores.ts | 44 --- web/src/api/types/Users.ts | 44 +-- web/src/api/types/data-contracts.ts | 154 ++++------ 9 files changed, 246 insertions(+), 673 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index e2a71cb..eb0eaf2 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -612,83 +612,33 @@ paths: /users/data/recent: get: tags: [User Management] - summary: Get User Activity Log - description: Retrieves a paginated list of user activity (data submissions, usage), filterable and searchable. + summary: Get Recent User Data Submissions + description: Retrieves a list of recent data submissions made about the authenticated user. operationId: getRecentUserData parameters: - name: limit in: query - description: Maximum number of entries per page. + description: Maximum number of records to return required: false schema: type: integer - format: int32 - default: 15 + default: 10 - name: page in: query - description: Page number for pagination. + description: Page number for pagination required: false schema: type: integer - format: int32 default: 1 - - name: startDate - in: query - description: Filter activity from this date onwards (YYYY-MM-DD). - required: false - schema: - type: string - format: date - - name: endDate - in: query - description: Filter activity up to this date (YYYY-MM-DD). - required: false - schema: - type: string - format: date - - name: dataType - in: query - description: Filter by the type of data submission. - required: false - schema: - type: string - enum: [purchase, search] - - name: storeId - in: query - description: Filter activity related to a specific store ID. - required: false - schema: - type: string - format: objectId - - name: search - in: query - description: Search term for activity details (e.g., item name, search query). - required: false - schema: - type: string responses: "200": - description: User activity log retrieved successfully. + description: Recent data submissions retrieved successfully content: application/json: schema: - type: object - properties: - activity: - type: array - items: - $ref: "#/components/schemas/RecentUserDataEntry" - pagination: - type: object - properties: - currentPage: - type: integer - totalPages: - type: integer - totalEntries: - type: integer - "400": - $ref: "#/components/responses/BadRequestError" + type: array + items: + $ref: "#/components/schemas/RecentUserDataEntry" "401": $ref: "#/components/responses/UnauthorizedError" "500": @@ -767,64 +717,6 @@ paths: - oauth2: [user:read] x-swagger-router-controller: StoreProfile - /stores: - get: - tags: [Store Management] - summary: List or Search Stores - description: Retrieves a paginated list of registered stores, optionally filtered by a search term. Intended for user discovery (e.g., finding stores to opt-in/out). - operationId: listStoresForUserDiscovery - parameters: - - name: search - in: query - description: Optional search term to filter stores by name. - required: false - schema: - type: string - - name: limit - in: query - description: Maximum number of stores to return per page. - required: false - schema: - type: integer - format: int32 - default: 20 - - name: page - in: query - description: Page number for pagination. - required: false - schema: - type: integer - format: int32 - default: 1 - responses: - "200": - description: A paginated list of stores. - content: - application/json: - schema: - type: object - properties: - stores: - type: array - items: - $ref: "#/components/schemas/StoreBasicInfo" - pagination: - type: object - properties: - currentPage: - type: integer - totalPages: - type: integer - totalStores: - type: integer - "401": - $ref: "#/components/responses/UnauthorizedError" - "500": - $ref: "#/components/responses/InternalServerError" - security: - - oauth2: [user:read] - x-swagger-router-controller: StoreProfile - components: schemas: AttributeDistribution: @@ -1086,7 +978,7 @@ components: type: object required: - email - - dataType + - dataType # Make dataType required - entries properties: email: @@ -1103,11 +995,11 @@ components: List of data entries. Each entry must conform to either the PurchaseEntry or SearchEntry schema, matching the top-level 'dataType'. items: - oneOf: + oneOf: # Use oneOf to specify possible entry types - $ref: "#/components/schemas/PurchaseEntry" - $ref: "#/components/schemas/SearchEntry" description: "An entry representing either a purchase event or a search event." - minItems: 1 + minItems: 1 # Require at least one entry metadata: type: object description: Additional metadata about the collection event (e.g., source, device). @@ -1125,7 +1017,7 @@ components: source: "web" deviceType: "desktop" sessionId: "abc-123-xyz-789" - example: + example: # Example for a purchase submission email: "user@example.com" dataType: "purchase" entries: @@ -1157,10 +1049,10 @@ components: example: timestamp: "2024-05-15T14:30:00Z" items: - - $ref: "#/components/schemas/PurchaseItem/example" + - $ref: "#/components/schemas/PurchaseItem/example" # Reference the example above - sku: "ABC-789" name: "Running Shorts" - category: "201" + category: "201" # Clothing price: 39.95 quantity: 1 attributes: @@ -1173,7 +1065,7 @@ components: type: object required: - name - - category + - category # Making category required for better processing properties: sku: type: string @@ -1199,7 +1091,7 @@ components: example: sku: "XYZ-123" name: "Men's Cotton T-Shirt" - category: "201" + category: "201" # Example: Clothing ID price: 25.99 quantity: 2 attributes: @@ -1249,7 +1141,7 @@ components: example: timestamp: "2024-05-15T10:15:00Z" query: "noise cancelling headphones" - category: "105" + category: "105" # Example: Audio ID results: 25 clicked: ["Bose-QC45", "Sony-WH1000XM5"] @@ -1275,33 +1167,59 @@ components: items: $ref: "#/components/schemas/PreferenceItem" description: User interest preferences with taxonomy categorization - required: - - preferences PreferenceItem: type: object + required: + - category + - score properties: category: type: string - description: The ID of the taxonomy category. - example: "102" + description: Category ID or name (e.g., "101" or "smartphones") score: type: number format: float - description: The user's interest score for this category (e.g., 0-1). - example: 0.85 + minimum: 0.0 + maximum: 1.0 + description: Preference score (0.0-1.0) attributes: type: object - description: Specific attribute preferences within this category (key-value pairs). Values should align with taxonomy definitions. - additionalProperties: - type: string - example: - brand: "Apple" - screen_size: "13-inch" - usage_type: "Work" - required: - - category - - score + description: Category-specific attribute preferences + properties: + price_range: + type: object + properties: + budget: + type: number + format: float + mid_range: + type: number + format: float + premium: + type: number + format: float + luxury: + type: number + format: float + color: + $ref: "#/components/schemas/AttributeDistribution" + brand: + $ref: "#/components/schemas/AttributeDistribution" + material: + $ref: "#/components/schemas/AttributeDistribution" + style: + $ref: "#/components/schemas/AttributeDistribution" + room: + $ref: "#/components/schemas/AttributeDistribution" + size: + $ref: "#/components/schemas/AttributeDistribution" + feature: + $ref: "#/components/schemas/AttributeDistribution" + season: + $ref: "#/components/schemas/AttributeDistribution" + gender: + $ref: "#/components/schemas/AttributeDistribution" StoreUpdate: type: object @@ -1514,66 +1432,25 @@ components: properties: _id: type: string - format: objectId - description: Unique identifier for the activity entry. + description: The unique ID of the userData entry. + storeId: + type: string + description: The ID of the store that submitted the data. dataType: type: string enum: [purchase, search] - description: The type of data activity. + description: The type of data submitted. timestamp: type: string format: date-time - description: Timestamp of the original event or submission. - storeId: - type: string - format: objectId - description: ID of the store associated with the activity. - storeName: + description: When the data was submitted to Tapiro. + entryTimestamp: type: string - description: Name of the store (looked up). - example: "Awesome Gadgets Inc." + format: date-time + description: The timestamp of the original event (e.g., purchase time). details: type: object - description: Specific details based on the dataType. - properties: - items: - type: array - items: - type: object - properties: - name: - type: string - example: "Wireless Mouse" - category: - type: string - description: Taxonomy category ID. - example: "100" - price: - type: number - format: float - example: 25.99 - quantity: - type: integer - example: 1 - totalAmount: - type: number - format: float - description: Total amount for the purchase event. - example: 25.99 - searchTerm: - type: string - example: "best gaming laptop" - categorySearched: - type: string - description: Taxonomy category ID searched within, if applicable. - example: "102" - required: - - _id - - dataType - - timestamp - - storeId - - storeName - - details + description: Simplified details (e.g., item count for purchase, query string for search) SpendingAnalytics: type: object @@ -1582,7 +1459,7 @@ components: The structure might vary based on implementation (e.g., object keyed by month/year, or an array of objects each representing a time point). additionalProperties: - type: object + type: object # Example: { "YYYY-MM": { "Category1": 100, "Category2": 50 } } additionalProperties: type: number format: float diff --git a/api-service/controllers/StoreProfile.js b/api-service/controllers/StoreProfile.js index d0458af..ef368da 100644 --- a/api-service/controllers/StoreProfile.js +++ b/api-service/controllers/StoreProfile.js @@ -39,14 +39,4 @@ module.exports.lookupStores = function lookupStores(req, res, next, ids) { .catch((response) => { utils.writeJson(res, response); }); -}; - -module.exports.listStoresForUserDiscovery = function listStoresForUserDiscovery (req, res, next, search, limit, page) { - StoreProfile.listStoresForUserDiscovery(req, search, limit, page) // Pass params to service - .then(function (response) { - utils.writeJson(res, response); - }) - .catch(function (response) { - utils.writeJson(res, response); - }); }; \ No newline at end of file diff --git a/api-service/controllers/UserProfile.js b/api-service/controllers/UserProfile.js index 8fadd96..0364e2e 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -31,8 +31,9 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { }); }; -module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page, startDate, endDate, dataType, storeId, search) { - UserProfile.getRecentUserData(req, limit, page, startDate, endDate, dataType, storeId, search) +module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page) { + // Pass query parameters to the service function + UserProfile.getRecentUserData(req, limit, page) .then((response) => { utils.writeJson(res, response); }) diff --git a/api-service/service/PreferenceManagementService.js b/api-service/service/PreferenceManagementService.js index aa082e2..ed4998d 100644 --- a/api-service/service/PreferenceManagementService.js +++ b/api-service/service/PreferenceManagementService.js @@ -10,35 +10,41 @@ const TaxonomyService = require('../service/TaxonomyService'); // Import Taxonom exports.getUserOwnPreferences = async function (req) { try { + // Get user data from middleware const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const db = getDB(); - const cacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; - // Cache check remains the same conceptually, but ensure cached data includes attributes if needed by frontend + // Check cache first - standardized cache key + const cacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; const cachedPreferences = await getCache(cacheKey); if (cachedPreferences) { - // Assuming cache stores the full preference structure including attributes - return respondWithCode(200, JSON.parse(cachedPreferences)); + // Ensure privacySettings are not included in the cached response being returned + const prefs = JSON.parse(cachedPreferences); + delete prefs.privacySettings; + return respondWithCode(200, prefs); } - // Fetch user, including the full preferences array (with attributes) + // Find user in database, only selecting necessary fields const user = await db.collection('users').findOne( { auth0Id: userData.sub }, - // Ensure 'preferences' field is projected correctly, including nested 'attributes' - { projection: { _id: 1, preferences: 1, updatedAt: 1 } } + { projection: { _id: 1, preferences: 1, updatedAt: 1 } } // Select only needed fields ); if (!user) { return respondWithCode(404, { code: 404, message: 'User not found' }); } - // Prepare response - includes attributes now + // Prepare the response object without privacySettings const preferencesResponse = { userId: user._id.toString(), - preferences: user.preferences || [], // This now includes attributes if stored + preferences: user.preferences || [], + // REMOVED privacySettings updatedAt: user.updatedAt || new Date(), }; - // Cache the full response (including attributes) + // Cache the preferences result (without privacySettings) with specific TTL + // Note: Caching the minimal response. If optimistic updates need privacySettings, + // they might need to fetch the full user profile or adjust logic. await setCache(cacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); return respondWithCode(200, preferencesResponse); @@ -122,114 +128,94 @@ exports.updateUserPreferences = async function (req, body) { } let validatedPreferences = []; - if (!body.preferences || !Array.isArray(body.preferences)) { - return respondWithCode(400, { code: 400, message: 'Preferences array is required and must be an array.' }); - } - - // --- Validation against Taxonomy --- - let taxonomyDoc; - try { - // Use the TaxonomyService to get the current taxonomy data - taxonomyDoc = await TaxonomyService.getLatestTaxonomy(); // Assuming this function exists and returns the structure - if (!taxonomyDoc || !taxonomyDoc.data || !taxonomyDoc.data.categories) { - throw new Error('Taxonomy data is unavailable for validation.'); + if (body.preferences) { + // --- Validation --- + if (!Array.isArray(body.preferences)) { + return respondWithCode(400, { code: 400, message: 'Preferences must be an array.' }); } - } catch (taxError) { - console.error("Failed to load taxonomy for validation:", taxError); - return respondWithCode(500, { code: 500, message: 'Internal error: Could not load taxonomy for validation.' }); - } - - const categoryMap = new Map(taxonomyDoc.data.categories.map(cat => [cat.id, cat])); - const validationErrors = []; - for (const pref of body.preferences) { - if (!pref || typeof pref !== 'object') { - validationErrors.push(`Invalid preference item format: ${JSON.stringify(pref)}`); - continue; - } - if (!pref.category || typeof pref.category !== 'string') { - validationErrors.push(`Preference item missing or invalid category ID: ${JSON.stringify(pref)}`); - continue; - } - if (pref.score == null || typeof pref.score !== 'number' || pref.score < 0 || pref.score > 1) { - validationErrors.push(`Preference item for category ${pref.category} has invalid score: ${pref.score}`); - continue; + // Fetch taxonomy for validation + let taxonomyDoc; + try { + // Use the service function to get taxonomy (handles caching) + const taxonomyResponse = await TaxonomyService.getTaxonomyCategories(); + if (taxonomyResponse.code !== 200) { + throw new Error('Failed to fetch taxonomy for validation'); + } + taxonomyDoc = taxonomyResponse.payload; // Assuming payload contains the taxonomy doc + } catch (taxError) { + console.error("Taxonomy fetch error during preference update:", taxError); + return respondWithCode(500, { code: 500, message: 'Could not load taxonomy for validation.' }); } - const categoryDefinition = categoryMap.get(pref.category); - if (!categoryDefinition) { - validationErrors.push(`Invalid category ID used in preference: ${pref.category}`); - continue; - } + const validCategoryIds = new Set(taxonomyDoc?.data?.categories?.map(cat => cat.id) || []); - const validAttributes = new Set(categoryDefinition.attributes?.map(attr => attr.name) || []); - const validatedAttributes = {}; - - if (pref.attributes && typeof pref.attributes === 'object') { - for (const attrKey in pref.attributes) { - if (!validAttributes.has(attrKey)) { - validationErrors.push(`Invalid attribute '${attrKey}' for category ${pref.category} (${categoryDefinition.name}). Valid attributes are: ${Array.from(validAttributes).join(', ')}`); - } else { - // Basic validation: ensure value is a string (can be enhanced) - if (typeof pref.attributes[attrKey] !== 'string') { - validationErrors.push(`Attribute '${attrKey}' for category ${pref.category} must have a string value.`); - } else { - validatedAttributes[attrKey] = pref.attributes[attrKey]; - } - } + for (const pref of body.preferences) { + // Basic structure validation + if (typeof pref.category !== 'string' || typeof pref.score !== 'number' || pref.score < 0 || pref.score > 1) { + return respondWithCode(400, { code: 400, message: `Invalid preference item format or score range: ${JSON.stringify(pref)}` }); + } + // Attributes validation (if present, must be a non-null object) + if (pref.attributes !== undefined && (typeof pref.attributes !== 'object' || pref.attributes === null || Array.isArray(pref.attributes))) { + return respondWithCode(400, { code: 400, message: `Invalid 'attributes' format for category ${pref.category}. Must be an object.` }); } + // Taxonomy validation + if (!validCategoryIds.has(pref.category)) { + return respondWithCode(400, { code: 400, message: `Invalid category ID in preferences: ${pref.category}` }); + } + validatedPreferences.push(pref); // Add valid preference } - // Only add if no validation errors for this specific preference item occurred during attribute check - if (!validationErrors.some(err => err.includes(`category ${pref.category}`))) { - validatedPreferences.push({ - category: pref.category, - score: pref.score, - // Only include attributes if they exist and passed validation - ...(Object.keys(validatedAttributes).length > 0 && { attributes: validatedAttributes }), - }); - } - } - - if (validationErrors.length > 0) { - console.warn('Preference validation failed:', validationErrors); - return respondWithCode(400, { code: 400, message: 'Invalid preference data provided.', details: validationErrors }); + // --- End Validation --- + + } else { + // If body.preferences is explicitly null or undefined, maybe clear preferences? + // Or return an error if preferences are required for update. + // Current behavior: If body.preferences is missing/null, validatedPreferences remains [] + // which will effectively clear preferences in the $set below. + // If you require preferences, add: + // return respondWithCode(400, { code: 400, message: 'Preferences array is required for update.' }); } - // --- End Validation --- + // Log the data being sent to the database for debugging + console.log('Attempting to update preferences with:', JSON.stringify(validatedPreferences, null, 2)); // Update preferences in the database using the validated list const updateResult = await db.collection('users').updateOne( { _id: user._id }, { $set: { - preferences: validatedPreferences, // Use the validated array (includes attributes) + preferences: validatedPreferences, // Use the validated array updatedAt: new Date(), }, }, ); - // Fetch updated user data to return and cache - const updatedUser = await db.collection('users').findOne( - { _id: user._id }, - { projection: { preferences: 1, updatedAt: 1 } } - ); + // Fetch the updated user data to get the latest timestamp and preferences + // No need to fetch again if we trust the update, but it confirms the write + const updatedUser = await db.collection('users').findOne( + { _id: user._id }, + { projection: { preferences: 1, updatedAt: 1 } } + ); - // Clear relevant caches + // Clear related caches const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; await invalidateCache(userCacheKey); - // Invalidate store-specific caches if needed (logic remains similar) + + // Clear store-specific preference caches as preferences changed if (user.privacySettings?.optInStores) { for (const storeId of user.privacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); } } - // Prepare and cache the response (now includes attributes) + // Return updated preferences object const preferencesResponse = { userId: user._id.toString(), - preferences: updatedUser.preferences || [], - updatedAt: updatedUser.updatedAt, + preferences: updatedUser.preferences || [], // Use actual updated preferences + updatedAt: updatedUser.updatedAt, // Use the actual updated timestamp }; + + // Update the cache with the new minimal response await setCache(userCacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); return respondWithCode(200, preferencesResponse); diff --git a/api-service/service/StoreProfileService.js b/api-service/service/StoreProfileService.js index f2d4f19..ce5677b 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -166,69 +166,3 @@ exports.lookupStores = async function (req, ids) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; - -/** - * List or Search Stores (for User Discovery) - * Retrieves a paginated list of stores, optionally filtered by name. - */ -exports.listStoresForUserDiscovery = async function (req, search, limit = 20, page = 1) { - try { - const db = getDB(); - const skip = (page - 1) * limit; - - const query = {}; - if (search) { - // Use a case-insensitive regex search on the store name - query.name = { $regex: search, $options: 'i' }; - } - - // Ensure limit is a number - const numericLimit = parseInt(limit, 10); - if (isNaN(numericLimit) || numericLimit <= 0) { - numericLimit = 20; // Default if invalid - } - - - // Find stores matching the query, projecting only necessary fields - const storesCursor = db.collection('stores').find(query, { - projection: { _id: 1, name: 1 }, // Only need ID and name - }); - - // Get total count for pagination before applying skip/limit - const totalStores = await storesCursor.count(); // Use count() on the cursor before skip/limit - - // Apply sorting, skip, and limit - const stores = await storesCursor - .sort({ name: 1 }) // Sort alphabetically by name - .skip(skip) - .limit(numericLimit) - .toArray(); - - // Format the response - const formattedStores = stores.map(store => ({ - storeId: store._id.toString(), - name: store.name, - })); - - const totalPages = Math.ceil(totalStores / numericLimit); - - const responsePayload = { - stores: formattedStores, - pagination: { - currentPage: page, - totalPages: totalPages, - totalStores: totalStores, - }, - }; - - // Caching could be added here if needed, consider search/page/limit in key - // const cacheKey = `${CACHE_KEYS.STORES_LIST}:${search || 'all'}:${page}:${limit}`; - // await setCache(cacheKey, JSON.stringify(responsePayload), { EX: CACHE_TTL.SHORT }); - - return respondWithCode(200, responsePayload); - - } catch (error) { - console.error('List stores for user discovery failed:', error); - return respondWithCode(500, { code: 500, message: 'Internal server error' }); - } -}; diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 893872c..60189d9 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -272,7 +272,7 @@ exports.deleteUserProfile = async function (req) { * Get Recent User Data Submissions * Retrieves a list of recent data submissions made about the authenticated user. */ -exports.getRecentUserData = async function (req, limit = 15, page = 1, startDate, endDate, dataType, storeId, search) { // <-- Add new params +exports.getRecentUserData = async function (req, limit = 10, page = 1) { try { const db = getDB(); const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); @@ -283,144 +283,43 @@ exports.getRecentUserData = async function (req, limit = 15, page = 1, startDate return respondWithCode(404, { code: 404, message: 'User not found' }); } - const numericLimit = parseInt(limit, 10) || 15; - const numericPage = parseInt(page, 10) || 1; - const skip = (numericPage - 1) * numericLimit; - - // --- Build Match Query --- - const matchQuery = { userId: user._id }; - - // Date Range Filter - const dateMatch = {}; - if (startDate) { - try { - dateMatch['$gte'] = new Date(startDate); - } catch (e) { console.warn('Invalid startDate:', startDate); } - } - if (endDate) { - try { - const end = new Date(endDate); - end.setDate(end.getDate() + 1); // Include the whole end day - dateMatch['$lt'] = end; - } catch (e) { console.warn('Invalid endDate:', endDate); } - } - if (Object.keys(dateMatch).length > 0) { - // Apply date filter to the 'timestamp' field of the main document - // or potentially 'entries.timestamp' if querying nested entries directly - matchQuery.timestamp = dateMatch; // Adjust if timestamp is nested - } - - // Data Type Filter - if (dataType && ['purchase', 'search'].includes(dataType)) { - matchQuery.dataType = dataType; - } - - // Store ID Filter - if (storeId) { - try { - matchQuery.storeId = new ObjectId(storeId); - } catch (e) { console.warn('Invalid storeId format:', storeId); } - } - - // Search Filter (Basic Example - requires text index for efficiency) - // Assumes text index exists on fields like 'entries.items.name', 'entries.searchTerm' - // db.collection('userData').createIndex({ "entries.items.name": "text", "entries.searchTerm": "text" }) - if (search) { - matchQuery.$text = { $search: search }; - } - // --- End Build Match Query --- - - // --- Aggregation Pipeline --- - // We need aggregation to potentially lookup store names and format details - const pipeline = [ - { $match: matchQuery }, - { $sort: { timestamp: -1 } }, // Sort before skip/limit for correct pagination - { $skip: skip }, - { $limit: numericLimit }, - // Lookup Store Name - { - $lookup: { - from: 'stores', - localField: 'storeId', - foreignField: '_id', - as: 'storeInfo' - } - }, - { - $unwind: { // Unwind the storeInfo array (should only be one match) - path: '$storeInfo', - preserveNullAndEmptyArrays: true // Keep entries even if store is deleted/not found - } - }, - // Project and Format the Output - { - $project: { - _id: 1, - dataType: 1, - timestamp: 1, - storeId: { $toString: '$storeId' }, // Convert ObjectId to string - storeName: { $ifNull: ['$storeInfo.name', 'Unknown Store'] }, // Use looked-up name or default - details: { // Structure the details field - $switch: { - branches: [ - { - case: { $eq: ['$dataType', 'purchase'] }, - then: { - // Assuming 'entries' is an array, take the first one for simplicity - // Adjust if multiple entries per document is possible and needs handling - items: { $ifNull: [{ $arrayElemAt: ['$entries.items', 0] }, []] }, // Example: take first entry's items - totalAmount: { $ifNull: [{ $sum: '$entries.items.price' }, 0] } // Example: sum prices (needs refinement based on actual schema) - } - }, - { - case: { $eq: ['$dataType', 'search'] }, - then: { - searchTerm: { $ifNull: [{ $arrayElemAt: ['$entries.searchTerm', 0] }, null] }, // Example - categorySearched: { $ifNull: [{ $arrayElemAt: ['$entries.category', 0] }, null] } // Example - } - } - ], - default: {} // Default empty object for unknown types - } - } - } - } - ]; - - // Execute pipeline - const activityEntries = await db.collection('userData').aggregate(pipeline).toArray(); - - // Get total count matching the filter (without skip/limit) for pagination - // Need to run a separate count aggregation or query - const countPipeline = [ - { $match: matchQuery }, - { $count: "totalEntries" } - ]; - const countResult = await db.collection('userData').aggregate(countPipeline).toArray(); - const totalEntries = countResult.length > 0 ? countResult[0].totalEntries : 0; - const totalPages = Math.ceil(totalEntries / numericLimit); - - const responsePayload = { - activity: activityEntries, - pagination: { - currentPage: numericPage, - totalPages: totalPages, - totalEntries: totalEntries, - }, - }; - - // Caching could be added here, complex key needed due to filters/search - // const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${numericPage}:${numericLimit}:${startDate}:${endDate}:${dataType}:${storeId}:${search}`; - // await setCache(cacheKey, JSON.stringify(responsePayload), { EX: CACHE_TTL.SHORT }); - - return respondWithCode(200, responsePayload); + const skip = (page - 1) * limit; + + // Query userData collection + const recentData = await db.collection('userData') + .find({ userId: user._id }) // Filter by the user's ObjectId + .sort({ timestamp: -1 }) // Sort by submission time descending + .skip(skip) + .limit(limit) + .project({ // Project only necessary fields for RecentUserDataEntry schema + _id: 1, + storeId: 1, + dataType: 1, + timestamp: 1, // Submission timestamp + entryTimestamp: '$entries.timestamp', // Assuming timestamp is within entries array + // Add simplified details if needed, e.g., item count or query string + // details: { $cond: { if: { $eq: ['$dataType', 'purchase'] }, then: { itemCount: { $size: '$entries.items' } }, else: '$entries.query' } } + }) + .toArray(); + + // Simple transformation if needed (e.g., flatten entryTimestamp if it's an array) + const formattedData = recentData.map(entry => ({ + ...entry, + // If entryTimestamp is an array due to projection, take the first element + entryTimestamp: Array.isArray(entry.entryTimestamp) ? entry.entryTimestamp[0] : entry.entryTimestamp, + // Add placeholder for details + details: {} + })); + + + // Caching could be added here if this data is frequently accessed + // const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}`; + // await setCache(cacheKey, JSON.stringify(formattedData), { EX: CACHE_TTL.SHORT }); // Example TTL + + return respondWithCode(200, formattedData); } catch (error) { console.error('Get recent user data failed:', error); - // Handle potential invalid ObjectId errors during filtering - if (error.name === 'BSONTypeError') { - return respondWithCode(400, { code: 400, message: 'Invalid ID format provided for filtering.' }); - } return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; diff --git a/web/src/api/types/Stores.ts b/web/src/api/types/Stores.ts index 3d5e4e3..ce70675 100644 --- a/web/src/api/types/Stores.ts +++ b/web/src/api/types/Stores.ts @@ -17,7 +17,6 @@ import { ApiKeyUsage, Error, GetApiKeyUsagePayload, - ListStoresForUserDiscoveryParams, LookupStoresParams, Store, StoreBasicInfo, @@ -230,47 +229,4 @@ export class Stores< format: "json", ...params, }); - /** - * @description Retrieves a paginated list of registered stores, optionally filtered by a search term. Intended for user discovery (e.g., finding stores to opt-in/out). - * - * @tags Store Management - * @name ListStoresForUserDiscovery - * @summary List or Search Stores - * @request GET:/stores - * @secure - * @response `200` `{ - stores?: (StoreBasicInfo)[], - pagination?: { - currentPage?: number, - totalPages?: number, - totalStores?: number, - -}, - -}` A paginated list of stores. - * @response `401` `Error` - * @response `500` `Error` - */ - listStoresForUserDiscovery = ( - query: ListStoresForUserDiscoveryParams, - params: RequestParams = {}, - ) => - this.request< - { - stores?: StoreBasicInfo[]; - pagination?: { - currentPage?: number; - totalPages?: number; - totalStores?: number; - }; - }, - Error - >({ - path: `/stores`, - method: "GET", - query: query, - secure: true, - format: "json", - ...params, - }); } diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index 883c752..df15d2a 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -291,42 +291,22 @@ export class Users< ...params, }); /** - * @description Retrieves a paginated list of user activity (data submissions, usage), filterable and searchable. - * - * @tags User Management - * @name GetRecentUserData - * @summary Get User Activity Log - * @request GET:/users/data/recent - * @secure - * @response `200` `{ - activity?: (RecentUserDataEntry)[], - pagination?: { - currentPage?: number, - totalPages?: number, - totalEntries?: number, - -}, - -}` User activity log retrieved successfully. - * @response `400` `Error` - * @response `401` `Error` - * @response `500` `Error` - */ + * @description Retrieves a list of recent data submissions made about the authenticated user. + * + * @tags User Management + * @name GetRecentUserData + * @summary Get Recent User Data Submissions + * @request GET:/users/data/recent + * @secure + * @response `200` `(RecentUserDataEntry)[]` Recent data submissions retrieved successfully + * @response `401` `Error` + * @response `500` `Error` + */ getRecentUserData = ( query: GetRecentUserDataParams, params: RequestParams = {}, ) => - this.request< - { - activity?: RecentUserDataEntry[]; - pagination?: { - currentPage?: number; - totalPages?: number; - totalEntries?: number; - }; - }, - Error - >({ + this.request({ path: `/users/data/recent`, method: "GET", query: query, diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 312bc45..4e1d9af 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -247,26 +247,50 @@ export interface UserPreferences { export interface UserPreferencesUpdate { /** User interest preferences with taxonomy categorization */ - preferences: PreferenceItem[]; + preferences?: PreferenceItem[]; } export interface PreferenceItem { - /** - * The ID of the taxonomy category. - * @example "102" - */ + /** Category ID or name (e.g., "101" or "smartphones") */ category: string; /** - * The user's interest score for this category (e.g., 0-1). + * Preference score (0.0-1.0) * @format float - * @example 0.85 + * @min 0 + * @max 1 */ score: number; - /** - * Specific attribute preferences within this category (key-value pairs). Values should align with taxonomy definitions. - * @example {"brand":"Apple","screen_size":"13-inch","usage_type":"Work"} - */ - attributes?: Record; + /** Category-specific attribute preferences */ + attributes?: { + price_range?: { + /** @format float */ + budget?: number; + /** @format float */ + mid_range?: number; + /** @format float */ + premium?: number; + /** @format float */ + luxury?: number; + }; + /** Distribution of attribute values (0.0-1.0) */ + color?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + brand?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + material?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + style?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + room?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + size?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + feature?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + season?: AttributeDistribution; + /** Distribution of attribute values (0.0-1.0) */ + gender?: AttributeDistribution; + }; } export interface StoreUpdate { @@ -379,60 +403,24 @@ export interface Taxonomy { } export interface RecentUserDataEntry { + /** The unique ID of the userData entry. */ + _id?: string; + /** The ID of the store that submitted the data. */ + storeId?: string; + /** The type of data submitted. */ + dataType?: "purchase" | "search"; /** - * Unique identifier for the activity entry. - * @format objectId - */ - _id: string; - /** The type of data activity. */ - dataType: "purchase" | "search"; - /** - * Timestamp of the original event or submission. + * When the data was submitted to Tapiro. * @format date-time */ - timestamp: string; - /** - * ID of the store associated with the activity. - * @format objectId - */ - storeId: string; + timestamp?: string; /** - * Name of the store (looked up). - * @example "Awesome Gadgets Inc." + * The timestamp of the original event (e.g., purchase time). + * @format date-time */ - storeName: string; - /** Specific details based on the dataType. */ - details: { - items?: { - /** @example "Wireless Mouse" */ - name?: string; - /** - * Taxonomy category ID. - * @example "100" - */ - category?: string; - /** - * @format float - * @example 25.99 - */ - price?: number; - /** @example 1 */ - quantity?: number; - }[]; - /** - * Total amount for the purchase event. - * @format float - * @example 25.99 - */ - totalAmount?: number; - /** @example "best gaming laptop" */ - searchTerm?: string; - /** - * Taxonomy category ID searched within, if applicable. - * @example "102" - */ - categorySearched?: string; - }; + entryTimestamp?: string; + /** Simplified details (e.g., item count for purchase, query string for search) */ + details?: object; } /** @@ -477,36 +465,15 @@ export interface GetApiKeyUsagePayload { export interface GetRecentUserDataParams { /** - * Maximum number of entries per page. - * @format int32 - * @default 15 + * Maximum number of records to return + * @default 10 */ limit?: number; /** - * Page number for pagination. - * @format int32 + * Page number for pagination * @default 1 */ page?: number; - /** - * Filter activity from this date onwards (YYYY-MM-DD). - * @format date - */ - startDate?: string; - /** - * Filter activity up to this date (YYYY-MM-DD). - * @format date - */ - endDate?: string; - /** Filter by the type of data submission. */ - dataType?: "purchase" | "search"; - /** - * Filter activity related to a specific store ID. - * @format objectId - */ - storeId?: string; - /** Search term for activity details (e.g., item name, search query). */ - search?: string; } export interface GetSpendingAnalyticsParams { @@ -526,20 +493,3 @@ export interface LookupStoresParams { /** Comma-separated list of store IDs to lookup. */ ids: string; } - -export interface ListStoresForUserDiscoveryParams { - /** Optional search term to filter stores by name. */ - search?: string; - /** - * Maximum number of stores to return per page. - * @format int32 - * @default 20 - */ - limit?: number; - /** - * Page number for pagination. - * @format int32 - * @default 1 - */ - page?: number; -} From e518a00644128708639bd1ebb047d4f7d7aa51a0 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 03:52:43 +0530 Subject: [PATCH 06/10] feat(api): Enhance getRecentUserData with filtering and searching options --- api-service/api/openapi.yaml | 39 +++++++++- api-service/controllers/UserProfile.js | 4 +- api-service/service/UserProfileService.js | 93 ++++++++++++++++++----- web/src/api/hooks/useUserHooks.ts | 21 ++--- web/src/api/types/Users.ts | 5 +- web/src/api/types/data-contracts.ts | 16 ++++ web/src/api/utils/cache.ts | 18 ++++- 7 files changed, 160 insertions(+), 36 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index eb0eaf2..f8f7dcf 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -613,7 +613,7 @@ paths: get: tags: [User Management] summary: Get Recent User Data Submissions - description: Retrieves a list of recent data submissions made about the authenticated user. + description: Retrieves a list of recent data submissions made about the authenticated user, with optional filtering and searching. operationId: getRecentUserData parameters: - name: limit @@ -630,9 +630,42 @@ paths: schema: type: integer default: 1 + - name: dataType + in: query + description: Filter by data type (purchase or search) + required: false + schema: + type: string + enum: [purchase, search] + - name: storeId + in: query + description: Filter by store ID + required: false + schema: + type: string + - name: startDate + in: query + description: Filter by start date (ISO 8601 format YYYY-MM-DD) + required: false + schema: + type: string + format: date + - name: endDate + in: query + description: Filter by end date (ISO 8601 format YYYY-MM-DD) + required: false + schema: + type: string + format: date + - name: searchTerm + in: query + description: Search term for entries (e.g., item name, search query) + required: false + schema: + type: string responses: "200": - description: Recent data submissions retrieved successfully + description: Recent user data retrieved successfully. content: application/json: schema: @@ -641,6 +674,8 @@ paths: $ref: "#/components/schemas/RecentUserDataEntry" "401": $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalServerError" security: diff --git a/api-service/controllers/UserProfile.js b/api-service/controllers/UserProfile.js index 0364e2e..fd4dc52 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -32,8 +32,8 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { }; module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page) { - // Pass query parameters to the service function - UserProfile.getRecentUserData(req, limit, page) + const { dataType, storeId, startDate, endDate, searchTerm } = req.query; + UserProfile.getRecentUserData(req, limit, page, dataType, storeId, startDate, endDate, searchTerm) .then((response) => { utils.writeJson(res, response); }) diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 60189d9..53ba8fa 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -272,7 +272,7 @@ exports.deleteUserProfile = async function (req) { * Get Recent User Data Submissions * Retrieves a list of recent data submissions made about the authenticated user. */ -exports.getRecentUserData = async function (req, limit = 10, page = 1) { +exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, storeId, startDate, endDate, searchTerm) { // Add new params try { const db = getDB(); const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); @@ -285,36 +285,95 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1) { const skip = (page - 1) * limit; - // Query userData collection + // --- Build the MongoDB query dynamically --- + const matchQuery = { userId: user._id }; + + if (dataType) { + matchQuery.dataType = dataType; + } + if (storeId) { + // Validate storeId format if necessary before querying + try { + matchQuery.storeId = new ObjectId(storeId); + } catch (e) { + console.warn(`Invalid storeId format provided: ${storeId}`); + // Decide how to handle: return empty, error, or ignore filter + return respondWithCode(400, { code: 400, message: 'Invalid store ID format provided.' }); + } + } + + // Date range filtering on the main document timestamp + const dateFilter = {}; + if (startDate) { + try { + dateFilter.$gte = new Date(startDate); + } catch (e) { console.warn('Invalid startDate format:', startDate); } + } + if (endDate) { + try { + const end = new Date(endDate); + end.setDate(end.getDate() + 1); // Include the whole end day + dateFilter.$lt = end; + } catch (e) { console.warn('Invalid endDate format:', endDate); } + } + if (Object.keys(dateFilter).length > 0) { + matchQuery.timestamp = dateFilter; + } + + // Search term filtering within entries (simple regex example) + // NOTE: For better performance on large datasets, consider a text index + // on 'entries.items.name', 'entries.items.category', 'entries.query', etc. + if (searchTerm) { + const regex = new RegExp(searchTerm, 'i'); // Case-insensitive regex + matchQuery.$or = [ + { 'entries.items.name': regex }, + { 'entries.items.category': regex }, + { 'entries.query': regex }, + // Add other fields within entries to search if needed + ]; + } + // --- End Query Building --- + + // Query userData collection with the built query const recentData = await db.collection('userData') - .find({ userId: user._id }) // Filter by the user's ObjectId + .find(matchQuery) // Use the dynamic query .sort({ timestamp: -1 }) // Sort by submission time descending .skip(skip) .limit(limit) - .project({ // Project only necessary fields for RecentUserDataEntry schema + .project({ // Expand projection to include details needed for display _id: 1, storeId: 1, dataType: 1, timestamp: 1, // Submission timestamp - entryTimestamp: '$entries.timestamp', // Assuming timestamp is within entries array - // Add simplified details if needed, e.g., item count or query string - // details: { $cond: { if: { $eq: ['$dataType', 'purchase'] }, then: { itemCount: { $size: '$entries.items' } }, else: '$entries.query' } } + entries: 1, // Include the full entries array for now + // Alternatively, project specific fields from entries if known: + // 'entries.timestamp': 1, + // 'entries.query': 1, + // 'entries.items.name': 1, + // 'entries.items.price': 1, + // 'entries.items.quantity': 1, + // 'entries.items.category': 1, }) .toArray(); - // Simple transformation if needed (e.g., flatten entryTimestamp if it's an array) + // Simple transformation (can be enhanced on frontend) const formattedData = recentData.map(entry => ({ - ...entry, - // If entryTimestamp is an array due to projection, take the first element - entryTimestamp: Array.isArray(entry.entryTimestamp) ? entry.entryTimestamp[0] : entry.entryTimestamp, - // Add placeholder for details - details: {} + _id: entry._id.toString(), // Convert ObjectId to string + storeId: entry.storeId.toString(), // Convert ObjectId to string + dataType: entry.dataType, + timestamp: entry.timestamp, + // Process entries for simpler display structure if needed here, + // or handle it on the frontend. Example: + details: entry.entries.map(e => ({ + timestamp: e.timestamp, + ...(entry.dataType === 'purchase' && { items: e.items }), + ...(entry.dataType === 'search' && { query: e.query, results: e.results }), + })), })); - - // Caching could be added here if this data is frequently accessed - // const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}`; - // await setCache(cacheKey, JSON.stringify(formattedData), { EX: CACHE_TTL.SHORT }); // Example TTL + // Caching: Consider if caching is appropriate with dynamic filters. + // If cached, the cache key MUST include all filter parameters. + // Example: const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}:${dataType || 'all'}:${storeId || 'all'}:${startDate || 'all'}:${endDate || 'all'}:${searchTerm || ''}`; return respondWithCode(200, formattedData); diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 512f32d..17e81ae 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -6,10 +6,10 @@ import { UserUpdate, User, RecentUserDataEntry, - // SpendingAnalytics, // <-- Remove old type if not used elsewhere StoreConsentList, - MonthlySpendingAnalytics, // <-- Import new type - GetSpendingAnalyticsParams, // <-- Import params type + MonthlySpendingAnalytics, + GetSpendingAnalyticsParams, + GetRecentUserDataParams, // <-- Import params type for recent data } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -159,21 +159,22 @@ export function useDeleteUserProfile() { // --- New Hooks --- -export function useRecentUserData(limit: number = 10, page: number = 1) { +// Update useRecentUserData to accept GetRecentUserDataParams +export function useRecentUserData(params?: GetRecentUserDataParams) { const { apiClients, clientsReady } = useApiClients(); const { isAuthenticated, isLoading: authLoading } = useAuth(); + // Use the params object directly for the queryKey + const queryParams = params || {}; // Ensure params is an object + return useQuery({ - // Expect an array - queryKey: cacheKeys.users.recentData(limit, page), + queryKey: cacheKeys.users.recentData(queryParams), // Pass the params object queryFn: () => + // Pass the params object to the API call apiClients.users - .getRecentUserData({ limit, page }) + .getRecentUserData(queryParams) // Pass the whole object .then((res) => res.data), enabled: isAuthenticated && !authLoading && clientsReady, - // Add specific cache settings if needed, otherwise defaults apply - // ...cacheSettings.recentData, // Example - // Replace keepPreviousData with placeholderData for TanStack Query v5+ placeholderData: (previousData) => previousData, }); } diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index df15d2a..5656970 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -291,15 +291,16 @@ export class Users< ...params, }); /** - * @description Retrieves a list of recent data submissions made about the authenticated user. + * @description Retrieves a list of recent data submissions made about the authenticated user, with optional filtering and searching. * * @tags User Management * @name GetRecentUserData * @summary Get Recent User Data Submissions * @request GET:/users/data/recent * @secure - * @response `200` `(RecentUserDataEntry)[]` Recent data submissions retrieved successfully + * @response `200` `(RecentUserDataEntry)[]` Recent user data retrieved successfully. * @response `401` `Error` + * @response `404` `Error` * @response `500` `Error` */ getRecentUserData = ( diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 4e1d9af..e91d14f 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -474,6 +474,22 @@ export interface GetRecentUserDataParams { * @default 1 */ page?: number; + /** Filter by data type (purchase or search) */ + dataType?: "purchase" | "search"; + /** Filter by store ID */ + storeId?: string; + /** + * Filter by start date (ISO 8601 format YYYY-MM-DD) + * @format date + */ + startDate?: string; + /** + * Filter by end date (ISO 8601 format YYYY-MM-DD) + * @format date + */ + endDate?: string; + /** Search term for entries (e.g., item name, search query) */ + searchTerm?: string; } export interface GetSpendingAnalyticsParams { diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index 7772a20..4e8ee8a 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -1,5 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; -import { User } from "../types/data-contracts"; +// Import GetRecentUserDataParams if not already imported +import { User, GetRecentUserDataParams } from "../types/data-contracts"; // Cache time configurations (in milliseconds) export const CACHE_TIMES = { @@ -26,10 +27,21 @@ export const cacheKeys = { all: ["users"], profile: () => [...cacheKeys.users.all, "profile"], preferences: () => [...cacheKeys.users.all, "preferences"], - recentData: (limit: number, page: number) => [ + // Update recentData to accept GetRecentUserDataParams + recentData: (params: GetRecentUserDataParams = {}) => [ + // Default to empty object ...cacheKeys.users.all, "recentData", - { limit, page }, + // Create a stable object key based on params + { + limit: params.limit ?? 10, // Default limit + page: params.page ?? 1, // Default page + dataType: params.dataType ?? "all", + storeId: params.storeId ?? "all", + startDate: params.startDate ?? "all", + endDate: params.endDate ?? "all", + searchTerm: params.searchTerm ?? "", + }, ], // Update spendingAnalytics to accept optional dates spendingAnalytics: (startDate?: string, endDate?: string) => [ From 07d8049b418e4eb0114637127f9b30a50bf7e88a Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 04:25:09 +0530 Subject: [PATCH 07/10] feat(api): Update useRecentUserData to accept an options object for improved flexibility --- web/src/pages/UserDashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 1a9e6c1..ac5f255 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -206,7 +206,7 @@ export default function UserDashboard() { data: recentActivity, isLoading: activityLoading, error: activityError, - } = useRecentUserData(3); + } = useRecentUserData({ limit: 3 }); const { data: spendingData, isLoading: spendingLoading, From e9376c1a813f3b4bd0fe4e837da87a5dfd2eacb9 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 05:03:02 +0530 Subject: [PATCH 08/10] feat: Enhance User Data Sharing and Preferences Pages - Implemented search functionality for stores in UserDataSharingPage. - Added loading and error handling for store search results. - Improved UI for displaying opt-in and opt-out stores with conditional rendering. - Refactored UserPreferencesPage to include a modal for adding/editing interests. - Enhanced user experience with loading spinners and error alerts in both pages. - Updated form handling for user demographics and preferences with better state management. --- api-service/api/openapi.yaml | 40 ++ api-service/controllers/StoreProfile.js | 10 + api-service/service/StoreProfileService.js | 37 ++ web/src/api/hooks/useStoreHooks.ts | 52 +- web/src/api/types/Stores.ts | 23 + web/src/api/types/data-contracts.ts | 13 + web/src/pages/UserAnalyticsPage.tsx | 524 +++++++++++++++++- web/src/pages/UserDataSharingPage.tsx | 287 +++++++++- web/src/pages/UserPreferencesPage.tsx | 611 ++++++++++++++++++++- 9 files changed, 1566 insertions(+), 31 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index f8f7dcf..4073ba4 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -752,6 +752,46 @@ paths: - oauth2: [user:read] x-swagger-router-controller: StoreProfile + /stores/search: + get: + tags: [Store Management] + summary: Search Stores + description: Searches for stores by name. Requires authentication. + operationId: searchStores + parameters: + - name: query + in: query + description: The search term to look for in store names. + required: true + schema: + type: string + minLength: 2 + - name: limit + in: query + description: Maximum number of results to return. + required: false + schema: + type: integer + default: 10 + responses: + "200": + description: Stores matching the query retrieved successfully. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/StoreBasicInfo" + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] + x-swagger-router-controller: StoreProfile + components: schemas: AttributeDistribution: diff --git a/api-service/controllers/StoreProfile.js b/api-service/controllers/StoreProfile.js index ef368da..83c2ad0 100644 --- a/api-service/controllers/StoreProfile.js +++ b/api-service/controllers/StoreProfile.js @@ -39,4 +39,14 @@ module.exports.lookupStores = function lookupStores(req, res, next, ids) { .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.getStoreUsers = function getStoreUsers(req, res, next) { + StoreProfile.getStoreUsers(req) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); }; \ No newline at end of file diff --git a/api-service/service/StoreProfileService.js b/api-service/service/StoreProfileService.js index ce5677b..1cbdf3f 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -166,3 +166,40 @@ exports.lookupStores = async function (req, ids) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Search Stores + * Searches for stores by name. + */ +exports.searchStores = async function (req, query, limit = 10) { + try { + if (!query || query.length < 2) { // Basic validation + return respondWithCode(400, { code: 400, message: 'Search query must be at least 2 characters long.' }); + } + + const db = getDB(); + const regex = new RegExp(query, 'i'); // Case-insensitive search + + // Consider adding a text index on the 'name' field in the 'stores' collection for performance + // db.collection('stores').createIndex({ name: "text" }); + // Then use: { $text: { $search: query } } instead of regex for better performance + + const stores = await db.collection('stores') + .find({ name: regex }) // Using regex for simplicity here + .limit(parseInt(limit)) // Ensure limit is an integer + .project({ _id: 1, name: 1 }) // Project only ID and name + .toArray(); + + // Format the response to match StoreBasicInfo schema + const formattedStores = stores.map(store => ({ + storeId: store._id.toString(), // Convert ObjectId back to string + name: store.name + })); + + return respondWithCode(200, formattedStores); + + } catch (error) { + console.error('Search stores failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error during store search' }); + } +}; diff --git a/web/src/api/hooks/useStoreHooks.ts b/web/src/api/hooks/useStoreHooks.ts index a92f26a..297137b 100644 --- a/web/src/api/hooks/useStoreHooks.ts +++ b/web/src/api/hooks/useStoreHooks.ts @@ -1,12 +1,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; -import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache"; // <-- Import CACHE_TIMES +import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache"; import { ApiKeyCreate, StoreUpdate, - StoreBasicInfo, // <-- Import StoreBasicInfo + StoreBasicInfo, + Error, // <-- Import Error type + SearchStoresParams, // <-- Import SearchStoresParams if generated } from "../types/data-contracts"; -import { useAuth } from "../../hooks/useAuth"; // Import useAuth +import { useAuth } from "../../hooks/useAuth"; +import { useState, useEffect } from "react"; // <-- Import useState and useEffect for debounce export function useStoreProfile() { // Get clientsReady state @@ -90,6 +93,49 @@ export function useApiKeyUsage(keyId: string) { }); } +// --- New Hook for Searching Stores --- +export function useSearchStores(searchTerm: string, debounceMs = 300) { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm); + + // Debounce effect + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, debounceMs); + + // Cleanup function to cancel the timeout if searchTerm changes again quickly + return () => { + clearTimeout(handler); + }; + }, [searchTerm, debounceMs]); + + // Define query parameters type if not auto-generated + // type SearchStoresParams = { query: string; limit?: number }; + + return useQuery({ + // Query key includes the debounced term + queryKey: [...cacheKeys.stores.all, "search", debouncedSearchTerm], + queryFn: () => { + // Prepare parameters for the API call + const params: SearchStoresParams = { + query: debouncedSearchTerm, + limit: 15, + }; // Adjust limit as needed + return apiClients.stores.searchStores(params).then((res) => res.data); + }, + // Only run query if client is ready, user is authenticated, + // and the debounced search term is long enough + enabled: + clientsReady && + isAuthenticated && + !authLoading && + debouncedSearchTerm.length >= 2, // Match backend validation + staleTime: CACHE_TIMES.MEDIUM, // Cache results for a bit + }); +} + // --- New Hook --- // Hook to lookup multiple stores by their IDs diff --git a/web/src/api/types/Stores.ts b/web/src/api/types/Stores.ts index ce70675..19fc1c2 100644 --- a/web/src/api/types/Stores.ts +++ b/web/src/api/types/Stores.ts @@ -18,6 +18,7 @@ import { Error, GetApiKeyUsagePayload, LookupStoresParams, + SearchStoresParams, Store, StoreBasicInfo, StoreCreate, @@ -229,4 +230,26 @@ export class Stores< format: "json", ...params, }); + /** + * @description Searches for stores by name. Requires authentication. + * + * @tags Store Management + * @name SearchStores + * @summary Search Stores + * @request GET:/stores/search + * @secure + * @response `200` `(StoreBasicInfo)[]` Stores matching the query retrieved successfully. + * @response `400` `Error` + * @response `401` `Error` + * @response `500` `Error` + */ + searchStores = (query: SearchStoresParams, params: RequestParams = {}) => + this.request({ + path: `/stores/search`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); } diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index e91d14f..5e50b7b 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -509,3 +509,16 @@ export interface LookupStoresParams { /** Comma-separated list of store IDs to lookup. */ ids: string; } + +export interface SearchStoresParams { + /** + * The search term to look for in store names. + * @minLength 2 + */ + query: string; + /** + * Maximum number of results to return. + * @default 10 + */ + limit?: number; +} diff --git a/web/src/pages/UserAnalyticsPage.tsx b/web/src/pages/UserAnalyticsPage.tsx index 5d1265e..9775b5b 100644 --- a/web/src/pages/UserAnalyticsPage.tsx +++ b/web/src/pages/UserAnalyticsPage.tsx @@ -1,18 +1,524 @@ -import React from "react"; -import { Card } from "flowbite-react"; +import React, { useState, useMemo } from "react"; // Added useEffect +import { + Card, + Datepicker, + Button, + Spinner, + Table, + TableHead, + TableBody, + TableRow, + TableCell, + TableHeadCell, + TextInput, + Select, + Label, +} from "flowbite-react"; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, +} from "recharts"; +import { + HiCalendar, + HiOutlineSearch, + HiChevronLeft, + HiChevronRight, +} from "react-icons/hi"; +import { + useRecentUserData, + useSpendingAnalytics, +} from "../api/hooks/useUserHooks"; +import { useLookupStores } from "../api/hooks/useStoreHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { + RecentUserDataEntry, // Keep this import now + StoreBasicInfo, + MonthlySpendingItem, + GetRecentUserDataParams, + PurchaseItem, // Assuming PurchaseItem is the type for purchase details items + PurchaseEntry, // <-- Import PurchaseEntry + SearchEntry, // Assuming SearchEntry is the type for search details +} from "../api/types/data-contracts"; + +// --- Helper Functions (Keep existing) --- +const formatDate = (dateString: string | Date | undefined) => { + if (!dateString) return "N/A"; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +}; + +const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value); +}; + +const formatMonth = (monthString: string) => { + try { + const [year, month] = monthString.split("-"); + const date = new Date(parseInt(year), parseInt(month) - 1); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + }); + } catch { + return monthString; + } +}; + +const formatDateToISO = (date: Date | null | undefined): string | undefined => { + if (!date) return undefined; + return date.toISOString().split("T")[0]; +}; + +const LINE_COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FF5733", + "#C70039", + "#900C3F", + "#581845", +]; +// --- End Helper Functions --- const UserAnalyticsPage: React.FC = () => { + // --- State for Filters --- + const [spendingStartDate, setSpendingStartDate] = useState(null); + const [spendingEndDate, setSpendingEndDate] = useState(null); + const [activityStartDate, setActivityStartDate] = useState(null); + const [activityEndDate, setActivityEndDate] = useState(null); + const [activityDataType, setActivityDataType] = + useState(undefined); // Initialize with undefined + const [activitySearchTerm, setActivitySearchTerm] = useState(""); + const [activityPage, setActivityPage] = useState(1); + const activityLimit = 15; // Items per page + + // --- Data Fetching --- + const { + data: spendingData, + isLoading: spendingLoading, + error: spendingError, + } = useSpendingAnalytics({ + startDate: formatDateToISO(spendingStartDate), + endDate: formatDateToISO(spendingEndDate), + }); + + const activityParams: GetRecentUserDataParams = useMemo( + () => ({ + limit: activityLimit, + page: activityPage, + startDate: formatDateToISO(activityStartDate), + endDate: formatDateToISO(activityEndDate), + dataType: activityDataType || undefined, + searchTerm: activitySearchTerm || undefined, + }), + [ + activityPage, + activityStartDate, + activityEndDate, + activityDataType, + activitySearchTerm, + ], + ); + + const { + data: activityData, + isLoading: activityLoading, + error: activityError, + isPlaceholderData, // Check if data is placeholder (useful for disabling next) + } = useRecentUserData(activityParams); + + const activityStoreIds = useMemo(() => { + const ids = new Set(); + activityData?.forEach((entry) => { + if (entry.storeId) ids.add(entry.storeId); + }); + return Array.from(ids); + }, [activityData]); + + const { + data: storeDetails, + isLoading: storesLoading, + error: storesError, + } = useLookupStores(activityStoreIds); + + // --- Memos (Keep existing) --- + const storeNameMap = useMemo(() => { + const map = new Map(); + storeDetails?.forEach((store: StoreBasicInfo) => { + map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); + }); + return map; + }, [storeDetails]); + + const { lineChartData, categories } = useMemo(() => { + if (!spendingData) return { lineChartData: [], categories: [] }; + const allCategories = new Set(); + const dataMap = new Map>(); + spendingData.forEach((monthlyItem: MonthlySpendingItem) => { + const monthData: Record = { + month: monthlyItem.month, + }; + Object.entries(monthlyItem.spending).forEach(([category, amount]) => { + allCategories.add(category); + monthData[category] = amount; + }); + dataMap.set(monthlyItem.month, monthData); + }); + const processedData = spendingData.map((item: MonthlySpendingItem) => { + const monthEntry = dataMap.get(item.month) || { month: item.month }; + allCategories.forEach((cat) => { + if (!(cat in monthEntry)) monthEntry[cat] = 0; + }); + return monthEntry; + }); + return { + lineChartData: processedData, + categories: Array.from(allCategories).sort(), + }; + }, [spendingData]); + + // --- Handlers (Keep existing) --- + const clearSpendingDates = () => { + setSpendingStartDate(null); + setSpendingEndDate(null); + }; + + const clearActivityFilters = () => { + setActivityStartDate(null); + setActivityEndDate(null); + setActivityDataType(undefined); // Reset to undefined + setActivitySearchTerm(""); + setActivityPage(1); + }; + + // --- Pagination Logic --- + // Determine if there might be a next page + // We infer this if the current page loaded the maximum number of items + const hasMoreData = useMemo(() => { + return activityData && activityData.length === activityLimit; + }, [activityData, activityLimit]); + + const handlePreviousPage = () => { + if (activityPage > 1) { + setActivityPage((prevPage) => prevPage - 1); + } + }; + + const handleNextPage = () => { + if (hasMoreData) { + setActivityPage((prevPage) => prevPage + 1); + } + }; + // --- End Pagination Logic --- + + // --- Render Logic --- + const isLoading = spendingLoading || activityLoading || storesLoading; + // Remove combinedError + // const combinedError = spendingError || activityError || storesError; + + if (isLoading && !spendingData && !activityData) { + return ; + } + return ( -
-

+
+

Your Data Insights

+ + {/* --- Spending Overview Section (Keep existing) --- */} -

- View insights derived from the data you've shared, such as spending - habits and recent activity. (Implementation coming soon!) -

- {/* Analytics charts and recent activity list will go here */} +

+ Spending Overview +

+ {/* Date Filters */} +
+ setSpendingStartDate(date)} + maxDate={spendingEndDate || undefined} + placeholder="Start Date" + /> + setSpendingEndDate(date)} + minDate={spendingStartDate || undefined} + placeholder="End Date" + /> + {(spendingStartDate || spendingEndDate) && ( + + )} +
+ + {/* Chart */} + {spendingError ? ( + + ) : spendingLoading ? ( +
+ +
+ ) : !lineChartData || lineChartData.length === 0 ? ( +

+ No spending data available + {spendingStartDate || spendingEndDate ? " for this period" : " yet"} + . +

+ ) : ( +
+ + + + + + formatCurrency(value)} + labelFormatter={formatMonth} + /> + + {categories.map((category, index) => ( + + ))} + + +
+ )} +
+ + {/* --- Recent Activity Section --- */} + +

+ Recent Activity Log +

+ {/* Activity Filters (Keep existing) */} +
+ { + setActivityStartDate(date); + setActivityPage(1); + }} + maxDate={activityEndDate || undefined} + placeholder="Start Date" + /> + { + setActivityEndDate(date); + setActivityPage(1); + }} + minDate={activityStartDate || undefined} + placeholder="End Date" + /> +
+ + +
+
+ + { + setActivitySearchTerm(e.target.value); + setActivityPage(1); + }} + /> +
+
+ +
+
+ + {/* Activity Table */} + {activityError || storesError ? ( // <-- Check both activityError and storesError + + ) : activityLoading && isPlaceholderData ? ( // Show spinner only if loading AND data is placeholder +
+ +
+ ) : !activityData || activityData.length === 0 ? ( +

+ No activity found matching your filters. +

+ ) : ( + <> +
+ + + Date + Type + Store + Details + + + {activityData.map((entry: RecentUserDataEntry) => { + // Safely access details[0] + const firstDetail = + Array.isArray(entry.details) && entry.details.length > 0 + ? entry.details[0] + : undefined; + + // Cast details based on dataType for better type safety (optional but recommended) + const purchaseDetail = + entry.dataType === "purchase" + ? (firstDetail as PurchaseEntry | undefined) + : undefined; + const searchDetail = + entry.dataType === "search" + ? (firstDetail as SearchEntry | undefined) + : undefined; + + return ( + + + {formatDate(entry.timestamp)} + + + {entry.dataType} + + + {/* Check storeId before using map */} + {entry.storeId + ? (storeNameMap.get(entry.storeId) ?? entry.storeId) + : "N/A"} + + + {/* Display relevant details based on type */} + {entry.dataType === "purchase" && + purchaseDetail?.items && // Use casted detail and optional chaining + purchaseDetail.items.length > 0 && ( + + {purchaseDetail.items + .map((item: PurchaseItem) => item.name) // Add type to item + .join(", ")} + + )} + {entry.dataType === "search" && + searchDetail?.query && ( // Use casted detail and optional chaining + Query: "{searchDetail.query}" + )} + {/* Add more detail rendering as needed */} + + + ); + })} + +
+
+ + {/* --- Simple Previous/Next Pagination --- */} +
+ + + Page {activityPage} + + +
+ {/* --- End Simple Pagination --- */} + + )}
); diff --git a/web/src/pages/UserDataSharingPage.tsx b/web/src/pages/UserDataSharingPage.tsx index abab8c4..9d58e32 100644 --- a/web/src/pages/UserDataSharingPage.tsx +++ b/web/src/pages/UserDataSharingPage.tsx @@ -1,19 +1,286 @@ -import React from "react"; -import { Card } from "flowbite-react"; +import React, { useState, useMemo } from "react"; +import { + Card, + List, + ListItem, + Button, + Spinner, + Alert, + TextInput, // For search +} from "flowbite-react"; +import { HiInformationCircle, HiOutlineSearch } from "react-icons/hi"; +import { + useStoreConsentLists, + useOptInToStore, + useOptOutFromStore, +} from "../api/hooks/useUserHooks"; +// Import the new search hook +import { useLookupStores, useSearchStores } from "../api/hooks/useStoreHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { StoreBasicInfo } from "../api/types/data-contracts"; const UserDataSharingPage: React.FC = () => { + const { + data: consentLists, + isLoading: consentLoading, + error: consentError, + } = useStoreConsentLists(); + const { + mutate: optIn, + isPending: isOptingIn, + variables: optInVariables, // Get variables for optIn + } = useOptInToStore(); + const { + mutate: optOut, + isPending: isOptingOut, + variables: optOutVariables, // Get variables for optOut + } = useOptOutFromStore(); + + // Combine IDs from both lists for lookup + const storeIdsToLookup = useMemo(() => { + const ids = new Set(); + (consentLists?.optInStores || []).forEach((id) => ids.add(id)); + (consentLists?.optOutStores || []).forEach((id) => ids.add(id)); + return Array.from(ids); + }, [consentLists]); + + const { + data: storeDetails, + isLoading: storesLoading, + error: storesError, + } = useLookupStores(storeIdsToLookup); + + // Map store IDs to names for easy display + const storeNameMap = useMemo(() => { + const map = new Map(); + storeDetails?.forEach((store: StoreBasicInfo) => { + map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); + }); + return map; + }, [storeDetails]); + + // --- State and Hook for Search --- + const [searchTerm, setSearchTerm] = useState(""); + const { + data: searchResults, + isLoading: searchLoading, + error: searchError, // Add error handling for search + } = useSearchStores(searchTerm); // Use the search hook + + // --- Move useMemo hook here, before early returns --- + const filteredSearchResults = useMemo(() => { + // Ensure searchResults and consentLists are defined before accessing them + const existingIds = new Set([ + ...(consentLists?.optInStores || []), + ...(consentLists?.optOutStores || []), + ]); + // Only filter if searchResults is available + return searchResults?.filter((store) => !existingIds.has(store.storeId)); + }, [searchResults, consentLists]); // Dependencies remain the same + + const handleOptIn = (storeId: string) => { + optIn(storeId); + }; + + const handleOptOut = (storeId: string) => { + optOut(storeId); + }; + + // --- Loading and Error Checks (Now after the useMemo) --- + const isLoadingInitial = consentLoading || storesLoading; + const initialError = consentError || storesError; + + if (isLoadingInitial) { + return ; + } + + if (initialError) { + return ( + + ); + } + + const isMutating = isOptingIn || isOptingOut; + return (
-

+

Control Data Sharing

- -

- Manage which stores you allow to access your preference data. You can - opt-in or opt-out from individual stores at any time. (Implementation - coming soon!) -

- {/* Opt-in/Opt-out UI will go here */} + +
+ {/* Opt-In List (Existing) */} + +

+ Stores You Share Data With (Opt-In) +

+

+ These stores can access your anonymized preference data based on + your profile settings. +

+ {consentLists?.optInStores && consentLists.optInStores.length > 0 ? ( + + {consentLists.optInStores.map((storeId) => ( + + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + + + ))} + + ) : ( +

+ You are not currently sharing data with any stores. +

+ )} +
+ + {/* Opt-Out List (Existing) */} + +

+ Stores You Don't Share Data With (Opt-Out) +

+

+ These stores cannot access your preference data. +

+ {consentLists?.optOutStores && + consentLists.optOutStores.length > 0 ? ( + + {consentLists.optOutStores.map((storeId) => ( + + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + + + ))} + + ) : ( +

+ You haven't opted out of any specific stores yet. +

+ )} +
+
+ + {/* --- Search Stores Section (Uncommented and Implemented) --- */} + +

+ Find Other Stores +

+
+ setSearchTerm(e.target.value)} // Update search term state + /> +
+ + {/* Search Loading State */} + {searchLoading && ( +
+ +
+ )} + + {/* Search Error State */} + {searchError && ( + + Error searching stores: {searchError.message} + + )} + + {/* Search Results */} + {!searchLoading && + !searchError && + searchTerm.length >= 2 && // Only show results/message if search term is long enough + filteredSearchResults && + filteredSearchResults.length > 0 && ( + + {" "} + {/* Added max-height and scroll */} + {filteredSearchResults.map((store) => ( + + + {store.name} + + {/* Only show Opt-In button for search results */} + + + ))} + + )} + + {/* No Results Message */} + {!searchLoading && + !searchError && + searchTerm.length >= 2 && // Only show message if search term is long enough + (!filteredSearchResults || filteredSearchResults.length === 0) && ( +

+ No new stores found matching "{searchTerm}". +

+ )}
); diff --git a/web/src/pages/UserPreferencesPage.tsx b/web/src/pages/UserPreferencesPage.tsx index 97e80d0..0d71e72 100644 --- a/web/src/pages/UserPreferencesPage.tsx +++ b/web/src/pages/UserPreferencesPage.tsx @@ -1,19 +1,612 @@ -import React from "react"; -import { Card } from "flowbite-react"; +import React, { useState, useEffect, useMemo } from "react"; // Ensure React is imported +import { + Card, + Button, + Spinner, + Alert, + Modal, + ModalHeader, + ModalBody, + ModalFooter, + Label, + TextInput, + Select, + RangeSlider, +} from "flowbite-react"; +import { HiInformationCircle, HiPencil, HiTrash, HiPlus } from "react-icons/hi"; +import { useForm, Controller, SubmitHandler } from "react-hook-form"; +import { + useUserProfile, + useUpdateUserProfile, + useUserPreferences, + 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, + // Removed AttributeDistribution as it's not directly used in the form/display logic now +} from "../api/types/data-contracts"; + +// --- Form Types --- +// ... (Keep existing form types: DemographicsFormData, PreferenceFormData) ... +type DemographicsFormData = Pick< + UserUpdate, + "gender" | "age" | "country" | "incomeBracket" +>; +type PreferenceFormData = { + category: string; + score: number; + attributes?: Record; +}; const UserPreferencesPage: React.FC = () => { + // --- Data Fetching --- + const { + data: userProfile, + isLoading: profileLoading, + error: profileError, + } = useUserProfile(); + const { + data: preferencesData, + isLoading: preferencesLoading, + error: preferencesError, + } = useUserPreferences(); + const { + data: taxonomyData, + isLoading: taxonomyLoading, + error: taxonomyError, + } = useTaxonomy(); + + // --- Mutations --- + const { + mutate: updateProfile, + isPending: isUpdatingProfile, + error: updateProfileError, + } = useUpdateUserProfile(); + const { + mutate: updatePreferences, + isPending: isUpdatingPreferences, + error: updatePreferencesError, + } = useUpdateUserPreferences(); + + // --- State --- + const [isEditingDemographics, setIsEditingDemographics] = useState(false); + const [showPreferenceModal, setShowPreferenceModal] = useState(false); + const [editingPreferenceIndex, setEditingPreferenceIndex] = useState< + number | null + >(null); + + // --- Forms --- + const { + register: registerDemo, + handleSubmit: handleDemoSubmit, + reset: resetDemoForm, + formState: { isDirty: isDemoDirty }, + } = useForm(); + + const { + register: registerPref, + handleSubmit: handlePrefSubmit, + reset: resetPrefForm, + control: prefControl, + watch: watchPref, + formState: { errors: prefErrors }, + } = useForm({ + defaultValues: { category: "", attributes: {}, score: 50 }, + }); + + // --- Effects --- + // ... (Keep existing useEffect hooks for resetting forms) ... + // 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 || "", + }); + } + }, [userProfile, isEditingDemographics, resetDemoForm]); + + // Reset preference form when modal opens/closes or editing target changes + useEffect(() => { + if (showPreferenceModal) { + if ( + editingPreferenceIndex !== null && + preferencesData?.preferences?.[editingPreferenceIndex] + ) { + // Editing existing preference + const pref = preferencesData.preferences[editingPreferenceIndex]; + const formAttributes: Record = {}; + if (pref.attributes) { + Object.entries(pref.attributes).forEach(([key, valueObj]) => { + let displayValue: string | undefined = undefined; + if (typeof valueObj === "object" && valueObj !== null) { + const firstKey = Object.keys(valueObj)[0]; + if (firstKey) displayValue = firstKey; + } else if (typeof valueObj === "string") { + displayValue = valueObj; + } + formAttributes[key] = displayValue; + }); + } + resetPrefForm({ + category: pref.category || "", + attributes: formAttributes, + score: + pref.score !== null && pref.score !== undefined + ? Math.round(pref.score * 100) + : 50, + }); + } else { + // Adding new preference + resetPrefForm({ category: "", attributes: {}, score: 50 }); + } + } + }, [ + showPreferenceModal, + editingPreferenceIndex, + preferencesData, + resetPrefForm, + ]); + + // --- Memos --- + // ... (Keep existing useMemo hooks for categoryMap, attributeMap, availableAttributes) ... + const { categoryMap, attributeMap } = useMemo(() => { + const catMap = new Map(); + const attrMap = new Map>(); // categoryId -> Map + if (taxonomyData?.categories) { + taxonomyData.categories.forEach((cat) => { + catMap.set(cat.id, cat); + const catAttrs = new Map(); + 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) => p.id === cat.parent_id, + ); + parentCat?.attributes?.forEach((attr) => { + if (!catAttrs.has(attr.name)) { + // Avoid overwriting child attributes + catAttrs.set(attr.name, attr.description || attr.name); + } + }); + } + attrMap.set(cat.id, catAttrs); + }); + } + return { categoryMap: catMap, attributeMap: attrMap }; + }, [taxonomyData]); + + const selectedCategoryId = watchPref("category"); + const availableAttributes = useMemo(() => { + return attributeMap.get(selectedCategoryId) || new Map(); + }, [selectedCategoryId, attributeMap]); + + // --- Handlers --- + // ... (Keep existing onDemoSubmit, onPrefSubmit, handleRemovePreference) ... + const onDemoSubmit: SubmitHandler = (data) => { + updateProfile(data, { + onSuccess: () => setIsEditingDemographics(false), + }); + }; + + const onPrefSubmit: SubmitHandler = (data) => { + const currentPreferences = preferencesData?.preferences || []; + let updatedPreferences: PreferenceItem[]; + + const cleanedAttributes: Record = {}; + if (data.attributes) { + Object.entries(data.attributes).forEach(([key, value]) => { + if (value) { + cleanedAttributes[key] = value; + } + }); + } + + // Using Option A: Send empty object for attributes for now + const apiAttributes = {}; + // If you implement Option B (formatting attributes): + /* + const apiAttributes: Record = {}; + Object.entries(cleanedAttributes).forEach(([key, value]) => { + apiAttributes[key] = { [value]: 1.0 }; // Example structure + }); + */ + + const newPrefItem: PreferenceItem = { + category: data.category, + attributes: apiAttributes, + score: data.score / 100, // Convert 0-100 to 0-1 + }; + + if (editingPreferenceIndex !== null) { + updatedPreferences = currentPreferences.map((pref, index) => + index === editingPreferenceIndex ? newPrefItem : pref, + ); + } else { + updatedPreferences = [...currentPreferences, newPrefItem]; + } + + updatePreferences( + { preferences: updatedPreferences }, + { + onSuccess: () => { + setShowPreferenceModal(false); + setEditingPreferenceIndex(null); + }, + }, + ); + }; + + const handleRemovePreference = (indexToRemove: number) => { + const currentPreferences = preferencesData?.preferences || []; + const updatedPreferences = currentPreferences.filter( + (_, index) => index !== indexToRemove, + ); + updatePreferences({ preferences: updatedPreferences }); + }; + + // --- Define Modal Handlers --- + const openAddModal = () => { + setEditingPreferenceIndex(null); // Ensure we are adding, not editing + setShowPreferenceModal(true); + }; + + const openEditModal = (index: number) => { + setEditingPreferenceIndex(index); // Set the index of the item to edit + setShowPreferenceModal(true); + }; + // --- End Define Modal Handlers --- + + // --- Render Logic --- + const isLoading = profileLoading || preferencesLoading || taxonomyLoading; + const error = profileError || preferencesError || taxonomyError; + const isMutating = isUpdatingProfile || isUpdatingPreferences; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + return (
-

- Manage Your Preferences +

+ Manage Your Preferences & Profile

+ + {/* --- Demographics Section --- */} + + {/* ... (Keep existing Demographics JSX, including form and display logic) ... */} +
+

+ About You +

+ {!isEditingDemographics && ( + + )} +
+ {updateProfileError && ( + + Failed to update profile: {updateProfileError.message} + + )} + {isEditingDemographics ? ( +
+ {/* Form Fields: Gender, Age, Country, Income */} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : ( +
+

+ + Gender: + {" "} + {userProfile?.gender || "Not set"} +

+

+ + Age: + {" "} + {userProfile?.age || "Not set"} +

+

+ + Country: + {" "} + {userProfile?.country || "Not set"} +

+

+ + Income: + {" "} + {userProfile?.incomeBracket || "Not set"} +

+
+ )} +
+ + {/* --- Preferences Section --- */} -

- Here you can view and update your interest preferences. This helps us - show you more relevant content and ads. (Implementation coming soon!) -

- {/* Preference selection UI will go here */} +
+

+ Your Interests +

+ {/* Use the defined handler */} + +
+ {updatePreferencesError && ( + + Failed to update preferences: {updatePreferencesError.message} + + )} + {isUpdatingPreferences && + !showPreferenceModal && ( // Only show global spinner if modal isn't open +
+ Saving... +
+ )} +
+ {preferencesData?.preferences && + preferencesData.preferences.length > 0 ? ( + preferencesData.preferences.map((pref, index) => ( +
+
+ + {categoryMap.get(pref.category || "")?.name || + "Unknown Category"} + +
+ {/* Use the defined handler */} + + +
+
+
+ Score:{" "} + {pref.score !== null && pref.score !== undefined + ? Math.round(pref.score * 100) + : "N/A"} +
+ {pref.attributes && Object.keys(pref.attributes).length > 0 && ( +
+ {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 ( + + + {/* Look up attribute description/name */} + {attributeMap.get(pref.category || "")?.get(key) || + key} + : + {" "} + {displayValue} + + ); + })} +
+ )} +
+ )) + ) : ( +

+ You haven't added any specific interests yet. +

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

+ {prefErrors.category.message} +

+ )} +
+ + {/* Attributes */} + {selectedCategoryId && availableAttributes.size > 0 && ( +
+ + Refine Interest (Optional) + +
+ {Array.from(availableAttributes.entries()).map( + ([attrName, attrDesc]) => ( +
+ + +
+ ), + )} +
+
+ )} + + {/* Score Slider */} +
+ + ( +
+ onChange(parseInt(e.target.value, 10))} + className="flex-grow" + /> + + {value ?? 50} + +
+ )} + /> +
+
+ + + + +
+
); }; From bf813a6bb6617b2f16088f50960dbd2704804ad4 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 24 Apr 2025 05:10:06 +0530 Subject: [PATCH 09/10] feat: Update button styles on User Data Sharing Page for better visibility --- web/src/pages/UserDataSharingPage.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/web/src/pages/UserDataSharingPage.tsx b/web/src/pages/UserDataSharingPage.tsx index 9d58e32..3053439 100644 --- a/web/src/pages/UserDataSharingPage.tsx +++ b/web/src/pages/UserDataSharingPage.tsx @@ -135,7 +135,8 @@ const UserDataSharingPage: React.FC = () => { - )} -

- {updateProfileError && ( - - Failed to update profile: {updateProfileError.message} - - )} - {isEditingDemographics ? ( -
- {/* Form Fields: Gender, Age, Country, Income */} -
- - -
-
- - -
-
- - -
-
- - -
-
+
+ {/* --- Demographics Section (Left Column on Large Screens) --- */} + +
+

+ + About You +

+ {!isEditingDemographics && ( - + )} +
+ {updateProfileError && ( + + Failed to update profile: {updateProfileError.message} + + )} + {isEditingDemographics ? ( + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + ) : ( +
+ + + +
- - ) : ( -
-

- - Gender: - {" "} - {userProfile?.gender || "Not set"} -

-

- - Age: - {" "} - {userProfile?.age || "Not set"} -

-

- - Country: - {" "} - {userProfile?.country || "Not set"} -

-

- - Income: - {" "} - {userProfile?.incomeBracket || "Not set"} -

+ )} + + + {/* --- Preferences Section (Right Column on Large Screens) --- */} + +
+

+ + Your Interests +

+
- )} -
- - {/* --- Preferences Section --- */} - -
-

- Your Interests -

- {/* Use the defined handler */} - -
- {updatePreferencesError && ( - - Failed to update preferences: {updatePreferencesError.message} - - )} - {isUpdatingPreferences && - !showPreferenceModal && ( // Only show global spinner if modal isn't open -
+ {updatePreferencesError && ( + + Failed to update preferences: {updatePreferencesError.message} + + )} + {isUpdatingPreferences && !showPreferenceModal && ( +
Saving...
)} -
- {preferencesData?.preferences && - preferencesData.preferences.length > 0 ? ( - preferencesData.preferences.map((pref, index) => ( -
-
- - {categoryMap.get(pref.category || "")?.name || - "Unknown Category"} - -
- {/* Use the defined handler */} - - -
-
-
- Score:{" "} - {pref.score !== null && pref.score !== undefined - ? Math.round(pref.score * 100) - : "N/A"} -
- {pref.attributes && Object.keys(pref.attributes).length > 0 && ( -
- {Object.entries(pref.attributes).map(([key, valueObj]) => { +
+ {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]; @@ -463,38 +487,84 @@ const UserPreferencesPage: React.FC = () => { } else if (typeof valueObj === "string") { displayValue = valueObj; } + return { key, displayValue }; + }); - return ( - - - {/* Look up attribute description/name */} - {attributeMap.get(pref.category || "")?.get(key) || - key} - : - {" "} - {displayValue} + return ( + +
+ + {categoryMap.get(pref.category || "")?.name || + "Unknown Category"} - ); - })} -
- )} -
- )) - ) : ( -

- You haven't added any specific interests yet. -

- )} -
- + + 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} + + ))} +
+ )} +
+
+ + +
+ + ); + })} + + ) : ( +

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

+ )} +
+ +
{/* --- Preference Add/Edit Modal --- */} !isMutating && setShowPreferenceModal(false)} + size="lg" // Slightly larger modal > {editingPreferenceIndex !== null @@ -502,7 +572,7 @@ const UserPreferencesPage: React.FC = () => { : "Add New Interest"}
- + {/* Category Select */}
@@ -513,14 +583,12 @@ const UserPreferencesPage: React.FC = () => { })} color={prefErrors.category ? "failure" : "gray"} disabled={editingPreferenceIndex !== null} // Disable category change when editing + required > - {/* Populate with categories from taxonomy */} {Array.from(categoryMap.values()) - // Optionally filter out categories that are parents? - // .filter(cat => !taxonomyData?.categories.some(c => c.parent_id === cat.id)) .sort((a, b) => a.name.localeCompare(b.name)) .map((cat) => (