From 39c75ec3cb6f30551fe36db6eee930b19ea6474c Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 20 Apr 2025 20:24:25 +0530 Subject: [PATCH 01/34] feat(preference): add endpoint to retrieve user's store opt-in/out lists and implement corresponding service logic --- api-service/api/openapi.yaml | 37 ++++++++++++++++++ .../controllers/PreferenceManagement.js | 11 ++++++ .../service/PreferenceManagementService.js | 39 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 791fa6b..9837065 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -390,6 +390,29 @@ paths: - oauth2: [user:write] x-swagger-router-controller: PreferenceManagement + /users/preferences/store-consent: + get: + tags: [Preference Management] + summary: Get user's store opt-in/out lists + description: Retrieves the lists of store IDs the user has explicitly opted into or opted out of sharing data with. + operationId: getStoreConsentLists + responses: + "200": + description: Successfully retrieved store consent lists. + content: + application/json: + schema: + $ref: "#/components/schemas/StoreConsentList" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] # Requires user read scope + x-swagger-router-controller: PreferenceManagement + /stores/api-keys: post: tags: [Store Management] @@ -1042,6 +1065,20 @@ components: count: type: integer + StoreConsentList: + type: object + properties: + optInStores: + type: array + items: + type: string + description: List of store IDs the user has opted into. + optOutStores: + type: array + items: + type: string + description: List of store IDs the user has opted out of. + HealthStatus: type: object properties: diff --git a/api-service/controllers/PreferenceManagement.js b/api-service/controllers/PreferenceManagement.js index 6841ec6..c9e21f9 100644 --- a/api-service/controllers/PreferenceManagement.js +++ b/api-service/controllers/PreferenceManagement.js @@ -39,4 +39,15 @@ module.exports.optOutFromStore = function optOutFromStore(req, res, next, storeI .catch((response) => { utils.writeJson(res, response); }); +}; + +// Add the new controller function +module.exports.getStoreConsentLists = function getStoreConsentLists(req, res, next) { + PreferenceManagement.getStoreConsentLists(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/PreferenceManagementService.js b/api-service/service/PreferenceManagementService.js index 34b165e..a1f1d54 100644 --- a/api-service/service/PreferenceManagementService.js +++ b/api-service/service/PreferenceManagementService.js @@ -252,3 +252,42 @@ exports.optInToStore = async function (req, storeId) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Get user's store opt-in/out lists + */ +exports.getStoreConsentLists = async function (req) { + try { + // Get user data - use req.user if available (from middleware) or fetch it + const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + + const db = getDB(); + + // Find user in database using Auth0 ID, projecting only necessary fields + const user = await db.collection('users').findOne( + { auth0Id: userData.sub }, + { projection: { 'privacySettings.optInStores': 1, 'privacySettings.optOutStores': 1, _id: 0 } } // Only get opt-in/out lists + ); + + if (!user) { + return respondWithCode(404, { + code: 404, + message: 'User not found', + }); + } + + // Prepare the response object, defaulting to empty arrays if fields don't exist + const consentLists = { + optInStores: user.privacySettings?.optInStores || [], + optOutStores: user.privacySettings?.optOutStores || [], + }; + + // Note: Caching could be added here if needed, potentially using a specific key + // or relying on the USER_DATA cache invalidation from opt-in/out actions. + + return respondWithCode(200, consentLists); + } catch (error) { + console.error('Get store consent lists failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error' }); + } +}; From 8e4a0216b10e4987ad07d69f82272aefc1f00eb9 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Sun, 20 Apr 2025 20:35:02 +0530 Subject: [PATCH 02/34] feat(taxonomy): add taxonomy management endpoints and service logic for retrieving categories --- api-service/api/openapi.yaml | 82 ++++++++++++++++++++++++++ api-service/controllers/Taxonomy.js | 12 ++++ api-service/service/TaxonomyService.js | 45 ++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 api-service/controllers/Taxonomy.js create mode 100644 api-service/service/TaxonomyService.js diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 9837065..fcb97f2 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -21,6 +21,8 @@ tags: description: Health and uptime monitoring - name: Admin description: Administrative operations for managing taxonomy and keyword mappings + - name: Taxonomy + description: Operations related to the product/interest taxonomy servers: - url: https://virtserver.swaggerhub.com/CHAMATHDEWMINA25/TAPIRO/1.0.0 description: SwaggerHub API Auto Mocking @@ -584,6 +586,29 @@ paths: $ref: "#/components/responses/InternalServerError" x-swagger-router-controller: Authentication + /taxonomy/categories: + get: + tags: [Taxonomy] + summary: Get Taxonomy Categories + description: Retrieves the full taxonomy structure from the database. + operationId: getTaxonomyCategories + responses: + "200": + description: Successfully retrieved the taxonomy. + content: + application/json: + schema: + $ref: "#/components/schemas/Taxonomy" + "404": + description: Taxonomy data not found in the database. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/InternalServerError" + x-swagger-router-controller: Taxonomy + components: schemas: AttributeDistribution: @@ -1151,6 +1176,63 @@ components: description: Whether registration process is complete description: User metadata from Auth0 + TaxonomyAttribute: + type: object + description: Attribute within a taxonomy category + required: + - name + - values + properties: + name: + type: string + values: + type: array + items: + type: string + description: + type: string + nullable: true + + TaxonomyCategory: + type: object + description: Category within a taxonomy system + required: + - id + - name + properties: + id: + type: string + name: + type: string + parent_id: + type: string + nullable: true + description: + type: string + nullable: true + attributes: + type: array + items: + $ref: "#/components/schemas/TaxonomyAttribute" + default: [] + + Taxonomy: + type: object + description: Complete taxonomy definition with categories and version + required: + - categories + - version + properties: + _id: + type: string + readOnly: true + categories: + type: array + items: + $ref: "#/components/schemas/TaxonomyCategory" + version: + type: string + responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/controllers/Taxonomy.js b/api-service/controllers/Taxonomy.js new file mode 100644 index 0000000..75a60e3 --- /dev/null +++ b/api-service/controllers/Taxonomy.js @@ -0,0 +1,12 @@ +const utils = require('../utils/writer.js'); +const TaxonomyService = require('../service/TaxonomyService'); + +module.exports.getTaxonomyCategories = function getTaxonomyCategories (req, res, next) { + TaxonomyService.getTaxonomyCategories() + .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/service/TaxonomyService.js b/api-service/service/TaxonomyService.js new file mode 100644 index 0000000..d22db4c --- /dev/null +++ b/api-service/service/TaxonomyService.js @@ -0,0 +1,45 @@ +const { getDB } = require('../utils/mongoUtil'); +const { respondWithCode } = require('../utils/writer'); +const { setCache, getCache } = require('../utils/redisUtil'); +const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); + +/** + * Get Taxonomy Categories + * Retrieves the full taxonomy structure from the MongoDB 'taxonomy' collection. + */ +exports.getTaxonomyCategories = async function () { + const cacheKey = CACHE_KEYS.TAXONOMY_FULL; + try { + // Check cache first + const cachedTaxonomy = await getCache(cacheKey); + if (cachedTaxonomy) { + console.log('Taxonomy retrieved from cache'); + // Parse and remove MongoDB _id before returning if it's not part of the defined schema response + const taxonomyData = JSON.parse(cachedTaxonomy); + // delete taxonomyData._id; // Optional: remove _id if not needed in response + return respondWithCode(200, taxonomyData); + } + + console.log('Fetching taxonomy from MongoDB'); + const db = getDB(); + // Assuming the taxonomy is stored as a single document in the 'taxonomy' collection. + // Adjust the query if the structure is different (e.g., findOne({ _id: 'current_taxonomy' })) + const taxonomyDoc = await db.collection('taxonomy').findOne({}); // Find the first/only document + + if (!taxonomyDoc) { + return respondWithCode(404, { code: 404, message: 'Taxonomy data not found in database' }); + } + + // Cache the result - Use a longer TTL for taxonomy structure + // Store the raw document including _id in cache + await setCache(cacheKey, JSON.stringify(taxonomyDoc), { EX: CACHE_TTL.TAXONOMY }); // Use TAXONOMY TTL + + // Remove MongoDB _id before returning if it's not part of the defined schema response + // delete taxonomyDoc._id; // Optional: remove _id if not needed in response + return respondWithCode(200, taxonomyDoc); + + } catch (error) { + console.error('Get taxonomy categories failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error retrieving taxonomy' }); + } +}; \ No newline at end of file From 35be6920de69006e8005b9fe668a993140d6edd6 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 06:03:39 +0530 Subject: [PATCH 03/34] feat: Add user analytics and consent management features - Implemented new hooks for user activity summary, spending analytics, and store consent lists. - Created new API endpoints for retrieving user activity summary, spending analytics, and store consent lists. - Added new types for user activity summary, spending analytics, and store consent details in data contracts. - Developed UserAnalyticsPage and UserConsentPage components for displaying analytics and managing consent. - Updated UserDashboard to include recent activity and spending patterns. - Created UserPreferencesPage for managing user preferences with a slider interface. - Enhanced caching settings and keys for new data types. - Added routes for new pages in the application. --- api-service/api/openapi.yaml | 161 +++++++- api-service/controllers/UserProfile.js | 20 + .../service/PreferenceManagementService.js | 51 ++- api-service/service/UserProfileService.js | 204 ++++++++- api-service/utils/cacheConfig.js | 12 +- web/package-lock.json | 386 +++++++++++++++++- web/package.json | 3 +- web/src/api/apiClient.ts | 3 +- web/src/api/hooks/useSystemHooks.ts | 16 +- web/src/api/hooks/useUserHooks.ts | 45 ++ web/src/api/types/Taxonomy.ts | 37 ++ web/src/api/types/Users.ts | 66 +++ web/src/api/types/data-contracts.ts | 91 +++++ web/src/api/utils/cache.ts | 25 ++ web/src/main.tsx | 22 +- web/src/pages/UserAnalyticsPage.tsx | 180 ++++++++ web/src/pages/UserConsentPage.tsx | 167 ++++++++ web/src/pages/UserDashboard.tsx | 349 +++++++++++++++- web/src/pages/UserPreferencesPage.tsx | 253 ++++++++++++ 19 files changed, 2043 insertions(+), 48 deletions(-) create mode 100644 web/src/api/types/Taxonomy.ts create mode 100644 web/src/pages/UserAnalyticsPage.tsx create mode 100644 web/src/pages/UserConsentPage.tsx create mode 100644 web/src/pages/UserPreferencesPage.tsx diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index fcb97f2..fedd85b 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -396,11 +396,11 @@ paths: get: tags: [Preference Management] summary: Get user's store opt-in/out lists - description: Retrieves the lists of store IDs the user has explicitly opted into or opted out of sharing data with. + description: Retrieves the lists of stores (with names and IDs) the user has explicitly opted into or opted out of sharing data with. operationId: getStoreConsentLists responses: "200": - description: Successfully retrieved store consent lists. + description: Successfully retrieved store consent lists with details. content: application/json: schema: @@ -412,7 +412,7 @@ paths: "500": $ref: "#/components/responses/InternalServerError" security: - - oauth2: [user:read] # Requires user read scope + - oauth2: [user:read] x-swagger-router-controller: PreferenceManagement /stores/api-keys: @@ -609,6 +609,52 @@ paths: $ref: "#/components/responses/InternalServerError" x-swagger-router-controller: Taxonomy + /users/activity/summary: + get: + tags: [User Management] + summary: Get User Activity Summary + description: Retrieves a summary of recent API usage and data submissions related to the authenticated user. + operationId: getUserActivitySummary + security: + - oauth2: [user:read] + responses: + "200": + description: Successfully retrieved activity summary. + content: + application/json: + schema: + $ref: "#/components/schemas/UserActivitySummary" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalServerError" + x-swagger-router-controller: UserProfile + + /users/analytics/spending: + get: + tags: [User Management] + summary: Get user spending analytics + description: Retrieves a breakdown of user spending by category based on purchase data. + operationId: getUserSpendingAnalytics + responses: + "200": + description: Spending analytics retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SpendingAnalyticsResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] + x-swagger-router-controller: UserProfile + components: schemas: AttributeDistribution: @@ -1090,19 +1136,37 @@ components: count: type: integer + StoreConsentDetail: + type: object + properties: + storeId: + type: string + description: The unique identifier for the store. + example: "60d5ecb8b48f4d001f9e8f8a" + name: + type: string + description: The name of the store. + example: "Example Electronics Store" + required: + - storeId + - name + StoreConsentList: type: object properties: optInStores: type: array + description: List of stores the user has opted into sharing data with. items: - type: string - description: List of store IDs the user has opted into. + $ref: "#/components/schemas/StoreConsentDetail" optOutStores: type: array + description: List of stores the user has opted out of sharing data with. items: - type: string - description: List of store IDs the user has opted out of. + $ref: "#/components/schemas/StoreConsentDetail" + required: + - optInStores + - optOutStores HealthStatus: type: object @@ -1233,6 +1297,89 @@ components: version: type: string + ActivityByStore: + type: object + properties: + storeId: + type: string + description: The unique identifier for the store. + name: + type: string + description: The name of the store. + count: + type: integer + description: The number of activities associated with this store. + required: + - storeId + - name + - count + + UserActivitySummary: + type: object + properties: + recentApiUsage: + type: object + properties: + total: + type: integer + description: Total number of API calls accessing the user's data recently. + byStore: + type: array + items: + $ref: "#/components/schemas/ActivityByStore" + description: Breakdown of API calls by store. + recentSubmissions: + type: object + properties: + total: + type: integer + description: Total number of data submissions made about the user recently. + byStore: + type: array + items: + $ref: "#/components/schemas/ActivityByStore" + description: Breakdown of data submissions by store. + required: + - recentApiUsage + - recentSubmissions + + SpendingBreakdownItem: + type: object + properties: + categoryId: + type: string + description: The ID of the spending category from the taxonomy. + categoryName: + type: string + description: The name of the spending category. + totalSpent: + type: number + format: double + description: The total amount spent in this category. + required: + - categoryId + - categoryName + - totalSpent + + SpendingAnalyticsResponse: + type: object + properties: + userId: + type: string + description: The user's unique identifier. + timeframe: + type: string + description: Description of the time period covered (e.g., "Last 30 days"). + example: "All Time" + spendingBreakdown: + type: array + items: + $ref: "#/components/schemas/SpendingBreakdownItem" + required: + - userId + - timeframe + - spendingBreakdown + responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/controllers/UserProfile.js b/api-service/controllers/UserProfile.js index b6868c5..645348d 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -29,4 +29,24 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.getUserActivitySummary = function getUserActivitySummary(req, res, next) { + UserProfile.getUserActivitySummary(req) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); +}; + +module.exports.getUserSpendingAnalytics = function getUserSpendingAnalytics(req, res, next) { + UserProfile.getUserSpendingAnalytics(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/PreferenceManagementService.js b/api-service/service/PreferenceManagementService.js index a1f1d54..fc9af83 100644 --- a/api-service/service/PreferenceManagementService.js +++ b/api-service/service/PreferenceManagementService.js @@ -254,7 +254,7 @@ exports.optInToStore = async function (req, storeId) { }; /** - * Get user's store opt-in/out lists + * Get user's store opt-in/out lists with store names */ exports.getStoreConsentLists = async function (req) { try { @@ -276,16 +276,55 @@ exports.getStoreConsentLists = async function (req) { }); } - // Prepare the response object, defaulting to empty arrays if fields don't exist - const consentLists = { - optInStores: user.privacySettings?.optInStores || [], - optOutStores: user.privacySettings?.optOutStores || [], + const optInIds = user.privacySettings?.optInStores || []; + const optOutIds = user.privacySettings?.optOutStores || []; + const allStoreIds = [...new Set([...optInIds, ...optOutIds])]; + + // Convert string IDs to ObjectIds for the query, handling potential errors + const storeObjectIds = allStoreIds.map(id => { + try { + // Ensure the ID is a valid ObjectId string before converting + if (ObjectId.isValid(id)) { + return new ObjectId(id); + } + console.warn(`Invalid ObjectId format in consent list: ${id}`); + return null; + } catch (e) { + console.warn(`Error converting ObjectId in consent list: ${id}`, e); + return null; + } + }).filter(id => id !== null); // Filter out invalid/null IDs + + + let storeNameMap = {}; + if (storeObjectIds.length > 0) { + const stores = await db.collection('stores').find( + { _id: { $in: storeObjectIds } }, + { projection: { _id: 1, name: 1 } } + ).toArray(); + + storeNameMap = stores.reduce((map, store) => { + map[store._id.toString()] = store.name; + return map; + }, {}); + } + + + // Map IDs to objects with names + const mapIdsToDetails = (ids) => ids.map(id => ({ + storeId: id, + name: storeNameMap[id] || 'Unknown Store' // Provide a fallback name + })); + + const consentListsWithDetails = { + optInStores: mapIdsToDetails(optInIds), + optOutStores: mapIdsToDetails(optOutIds), }; // Note: Caching could be added here if needed, potentially using a specific key // or relying on the USER_DATA cache invalidation from opt-in/out actions. - return respondWithCode(200, consentLists); + return respondWithCode(200, consentListsWithDetails); // Return the detailed lists } catch (error) { console.error('Get store consent lists 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 100219a..cefe794 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -3,8 +3,8 @@ const { setCache, getCache, invalidateCache } = require('../utils/redisUtil'); const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); -// Import the new function -const { updateUserMetadata, updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); +const {updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); +const { ObjectId } = require('mongodb'); // <-- Import ObjectId /** * Get User Profile @@ -214,3 +214,203 @@ exports.deleteUserProfile = async function (req) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Get User Activity Summary + * Retrieves a summary of recent API usage and data submissions for the authenticated user. + */ +exports.getUserActivitySummary = async function (req) { // <-- Existing Function + try { + const db = getDB(); + const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = userData.sub; + + // --- Get User ObjectId --- + const user = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { _id: 1 } }); + if (!user) { + return respondWithCode(404, { code: 404, message: 'User not found' }); + } + const userObjectId = user._id; + + // --- Cache Check --- + const cacheKey = `${CACHE_KEYS.USER_ACTIVITY_SUMMARY}${userObjectId}`; // Define a new cache key constant + const cachedSummary = await getCache(cacheKey); + if (cachedSummary) { + return respondWithCode(200, JSON.parse(cachedSummary)); + } + + // --- Define Timeframe (e.g., last 30 days) --- + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + // --- Aggregate API Usage --- + const apiUsagePipeline = [ + { $match: { userId: userObjectId, timestamp: { $gte: thirtyDaysAgo } } }, // Match user and timeframe + { $group: { _id: "$storeId", count: { $sum: 1 } } }, // Group by storeId and count + { $project: { _id: 0, storeId: "$_id", count: 1 } } // Reshape output + ]; + const apiUsageByStore = await db.collection('apiUsage').aggregate(apiUsagePipeline).toArray(); + const totalApiUsage = apiUsageByStore.reduce((sum, item) => sum + item.count, 0); + + // --- Aggregate Data Submissions --- + const dataSubmissionPipeline = [ + { $match: { userId: userObjectId, timestamp: { $gte: thirtyDaysAgo } } }, // Match user and timeframe + { $group: { _id: "$storeId", count: { $sum: 1 } } }, // Group by storeId and count + { $project: { _id: 0, storeId: "$_id", count: 1 } } // Reshape output + ]; + const submissionsByStore = await db.collection('userData').aggregate(dataSubmissionPipeline).toArray(); + const totalSubmissions = submissionsByStore.reduce((sum, item) => sum + item.count, 0); + + // --- Get Store Names --- + const allInvolvedStoreIds = [ + ...new Set([ + ...apiUsageByStore.map(item => item.storeId), + ...submissionsByStore.map(item => item.storeId) + ]) + ]; + + // Convert string IDs to ObjectIds for the query, handling potential errors + const storeObjectIds = allInvolvedStoreIds.map(id => { + try { + if (ObjectId.isValid(id)) { return new ObjectId(id); } + console.warn(`Invalid ObjectId format in activity summary store list: ${id}`); + return null; + } catch (e) { + console.warn(`Error converting ObjectId for store name lookup: ${id}`, e); + return null; + } + }).filter(id => id !== null); + + let storeNameMap = {}; + if (storeObjectIds.length > 0) { + const stores = await db.collection('stores').find( + { _id: { $in: storeObjectIds } }, + { projection: { _id: 1, name: 1 } } + ).toArray(); + storeNameMap = stores.reduce((map, store) => { + map[store._id.toString()] = store.name; + return map; + }, {}); + } + + // --- Format Results --- + const formatActivity = (activityList) => activityList.map(item => ({ + storeId: item.storeId, + name: storeNameMap[item.storeId] || 'Unknown Store', + count: item.count + })).sort((a, b) => b.count - a.count); // Sort by count descending + + const summary = { + recentApiUsage: { + total: totalApiUsage, + byStore: formatActivity(apiUsageByStore) + }, + recentSubmissions: { + total: totalSubmissions, + byStore: formatActivity(submissionsByStore) + } + }; + + // --- Cache Result --- + // Define a suitable TTL, e.g., USER_ACTIVITY_TTL + await setCache(cacheKey, JSON.stringify(summary), { EX: CACHE_TTL.USER_ACTIVITY || 3600 }); // e.g., 1 hour + + return respondWithCode(200, summary); + + } catch (error) { + console.error('Get user activity summary failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error retrieving activity summary' }); + } +}; + +/** + * Get User Spending Analytics + * Retrieves a breakdown of user spending by category based on purchase data. + */ +exports.getUserSpendingAnalytics = async function (req) { // <-- New Function + try { + const db = getDB(); + const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = userData.sub; + + // --- Get User ObjectId --- + const user = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { _id: 1 } }); + if (!user) { + return respondWithCode(404, { code: 404, message: 'User not found' }); + } + const userObjectId = user._id; + + // --- Cache Check --- + const cacheKey = `${CACHE_KEYS.USER_SPENDING_ANALYTICS}${userObjectId}`; + const cachedAnalytics = await getCache(cacheKey); + if (cachedAnalytics) { + console.log(`Spending analytics retrieved from cache for user ${userObjectId}`); + return respondWithCode(200, JSON.parse(cachedAnalytics)); + } + console.log(`Calculating spending analytics for user ${userObjectId}`); + + + // --- Aggregate Spending Data --- + // This pipeline assumes 'category' exists directly on purchase items. + // Adjust if category mapping happens differently (e.g., via product ID lookup). + const spendingPipeline = [ + { $match: { userId: userObjectId, dataType: 'purchase' } }, // Filter for user's purchase data + { $unwind: "$entries" }, // Deconstruct the entries array + { $unwind: "$entries.items" }, // Deconstruct the items array within each entry + { + $group: { + _id: "$entries.items.category", // Group by item category ID + totalSpent: { + $sum: { + // Calculate total spent per item (price * quantity), handle missing values + $multiply: [ + { $ifNull: ["$entries.items.price", 0] }, + { $ifNull: ["$entries.items.quantity", 1] } + ] + } + } + } + }, + { + $project: { // Reshape the output + _id: 0, // Exclude the default _id + categoryId: "$_id", // Rename _id to categoryId + totalSpent: 1 // Include the calculated totalSpent + } + }, + { $match: { categoryId: { $ne: null } } } // Ensure we only include items with a category + ]; + + const spendingByCategory = await db.collection('userData').aggregate(spendingPipeline).toArray(); + + // --- Get Taxonomy for Category Names --- + // Fetch the full taxonomy (could also be cached separately) + const taxonomyDoc = await db.collection('taxonomy').findOne({}); + const categoryNameMap = taxonomyDoc?.categories?.reduce((map, cat) => { + map[cat.id] = cat.name; + return map; + }, {}) || {}; + + // --- Format Results --- + const spendingBreakdown = spendingByCategory.map(item => ({ + categoryId: item.categoryId, + categoryName: categoryNameMap[item.categoryId] || item.categoryId, // Fallback to ID if name not found + totalSpent: item.totalSpent + })).sort((a, b) => b.totalSpent - a.totalSpent); // Sort descending by amount spent + + const analyticsResponse = { + userId: userObjectId.toString(), + timeframe: "All Time", // Placeholder - could be made dynamic later + spendingBreakdown: spendingBreakdown + }; + + // --- Cache Result --- + await setCache(cacheKey, JSON.stringify(analyticsResponse), { EX: CACHE_TTL.USER_ANALYTICS || 7200 }); + + return respondWithCode(200, analyticsResponse); + + } catch (error) { + console.error('Get user spending analytics failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error retrieving spending analytics' }); + } +}; diff --git a/api-service/utils/cacheConfig.js b/api-service/utils/cacheConfig.js index cd0fd6c..c4b7366 100644 --- a/api-service/utils/cacheConfig.js +++ b/api-service/utils/cacheConfig.js @@ -9,20 +9,26 @@ const CACHE_TTL = { API_KEY: 1800, // API keys - 30 minutes INVALIDATION: 1, // Short TTL for invalidation AI_REQUEST: 60, // AI service requests - 1 minute + TAXONOMY: 86400, // 24 hours + USER_ACTIVITY: 3600, // 1 hour (example) + USER_ANALYTICS: 7200, // 2 hours (example for spending analytics) // <-- New TTL }; /** * Cache key prefixes */ const CACHE_KEYS = { - USER_DATA: 'userdata:', // User data from Auth0 + USER_DATA: 'user:', // User data from Auth0 STORE_DATA: 'store:', // Store data from DB API_KEY: 'apikey:', // API key to store ID mapping SCOPES: 'scopes:', // Token to scopes mapping ADMIN_TOKEN: 'auth0_management_token', // Auth0 management token - PREFERENCES: 'preferences:', // User preferences - STORE_PREFERENCES: 'prefs:', // Store preferences + PREFERENCES: 'prefs:', // User preferences + STORE_PREFERENCES: 'store_prefs:', // Store preferences AI_REQUEST: 'ai_request:', // AI service request cache + TAXONOMY_FULL: 'taxonomy:full', + USER_ACTIVITY_SUMMARY: 'user_activity_summary:', // <-- New Key + USER_SPENDING_ANALYTICS: 'user_spending_analytics:', // <-- New Key }; module.exports = { CACHE_TTL, CACHE_KEYS }; diff --git a/web/package-lock.json b/web/package-lock.json index d16e59f..791b6ad 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,8 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", - "react-router": "^7.4.0" + "react-router": "^7.4.0", + "recharts": "^2.15.3" }, "devDependencies": { "@eslint/js": "^9.23.0", @@ -295,6 +296,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", @@ -1787,6 +1800,69 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -2732,6 +2808,15 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2845,9 +2930,129 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debounce": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", @@ -2877,6 +3082,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2926,6 +3137,16 @@ "node": ">=8" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -3300,6 +3521,12 @@ "url": "https://github.com/eta-dev/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/exsolve": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", @@ -3314,6 +3541,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3753,6 +3989,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3814,7 +4059,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4169,7 +4413,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -4179,6 +4422,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4469,6 +4724,15 @@ "node": ">= 6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -4744,6 +5008,23 @@ } } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4837,6 +5118,12 @@ "react": "*" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -4871,6 +5158,37 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4900,6 +5218,38 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", + "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, "node_modules/reftools": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", @@ -4910,6 +5260,12 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -5473,6 +5829,28 @@ "punycode": "^2.1.0" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", diff --git a/web/package.json b/web/package.json index 78dfae6..dc0fef0 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,8 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", - "react-router": "^7.4.0" + "react-router": "^7.4.0", + "recharts": "^2.15.3" }, "devDependencies": { "@eslint/js": "^9.23.0", diff --git a/web/src/api/apiClient.ts b/web/src/api/apiClient.ts index 1ee2a6c..3ecf6c9 100644 --- a/web/src/api/apiClient.ts +++ b/web/src/api/apiClient.ts @@ -2,7 +2,7 @@ import { Users } from "./types/Users"; import { Stores } from "./types/Stores"; import { Health } from "./types/Health"; import { Ping } from "./types/Ping"; -// Add useState import +import { Taxonomy } from "./types/Taxonomy"; // <-- Import Taxonomy client class import { useEffect, useMemo, useState } from "react"; import { useAuth } from "../hooks/useAuth"; // ← use your context import { ApiConfig } from "./types/http-client"; // Import ApiConfig @@ -33,6 +33,7 @@ export function createApiClients() { stores: new Stores(config), health: new Health(config), ping: new Ping(config), + taxonomy: new Taxonomy(config), // <-- Add Taxonomy client instance }; } diff --git a/web/src/api/hooks/useSystemHooks.ts b/web/src/api/hooks/useSystemHooks.ts index c02a272..e250153 100644 --- a/web/src/api/hooks/useSystemHooks.ts +++ b/web/src/api/hooks/useSystemHooks.ts @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; import { cacheKeys, cacheSettings } from "../utils/cache"; -import { HealthStatus, PingStatus } from "../types/data-contracts"; +import { HealthStatus, PingStatus, Taxonomy } from "../types/data-contracts"; // <-- Add Taxonomy type export function useHealthCheck() { // Destructure apiClients first, then get health from it @@ -31,3 +31,17 @@ export function usePing() { // Ping doesn't require auth, so no enabled check needed here }); } + +export function useTaxonomy() { + // <-- New Hook + const { apiClients } = useApiClients(); // No auth needed for taxonomy usually + + return useQuery({ + // <-- Use specific type + queryKey: cacheKeys.system.taxonomy(), + queryFn: () => + apiClients.taxonomy.getTaxonomyCategories().then((res) => res.data), + // Taxonomy is public, so no 'enabled' check based on auth needed + ...cacheSettings.taxonomy, // <-- Use specific cache settings + }); +} diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 22ee7fa..5e8f3de 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -5,6 +5,9 @@ import { UserPreferencesUpdate, UserUpdate, User, + UserActivitySummary, // <-- Import new type + SpendingAnalyticsResponse, // <-- Import new type + StoreConsentList, // <-- Import new type } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -63,6 +66,48 @@ export function useUpdateUserPreferences() { }); } +// --- New Hooks --- + +export function useUserActivitySummary() { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + return useQuery({ + // <-- Use specific type + queryKey: cacheKeys.users.activitySummary(), + queryFn: () => + apiClients.users.getUserActivitySummary().then((res) => res.data), + enabled: isAuthenticated && !authLoading && clientsReady, + ...cacheSettings.activitySummary, // <-- Use specific cache settings + }); +} + +export function useSpendingAnalytics() { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + return useQuery({ + // <-- Use specific type + queryKey: cacheKeys.users.spendingAnalytics(), + queryFn: () => + apiClients.users.getUserSpendingAnalytics().then((res) => res.data), + enabled: isAuthenticated && !authLoading && clientsReady, + ...cacheSettings.spendingAnalytics, // <-- Use specific cache settings + }); +} + +export function useStoreConsentLists() { + // <-- Hook for the modified endpoint + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + return useQuery({ + // <-- Use specific type + queryKey: cacheKeys.users.storeConsent(), + queryFn: () => + apiClients.users.getStoreConsentLists().then((res) => res.data), + enabled: isAuthenticated && !authLoading && clientsReady, + ...cacheSettings.storeConsent, // <-- Use specific cache settings + }); +} + export function useOptInToStore() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); diff --git a/web/src/api/types/Taxonomy.ts b/web/src/api/types/Taxonomy.ts new file mode 100644 index 0000000..c199b64 --- /dev/null +++ b/web/src/api/types/Taxonomy.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { Error, Taxonomy } from "./data-contracts"; +import { HttpClient, RequestParams } from "./http-client"; + +export class Taxonomy< + SecurityDataType = unknown, +> extends HttpClient { + /** + * @description Retrieves the full taxonomy structure from the database. + * + * @tags Taxonomy + * @name GetTaxonomyCategories + * @summary Get Taxonomy Categories + * @request GET:/taxonomy/categories + * @response `200` `Taxonomy` Successfully retrieved the taxonomy. + * @response `404` `Error` Taxonomy data not found in the database. + * @response `500` `Error` + */ + getTaxonomyCategories = (params: RequestParams = {}) => + this.request({ + path: `/taxonomy/categories`, + method: "GET", + format: "json", + ...params, + }); +} diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index 48d1c4a..fbf3702 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -12,7 +12,10 @@ import { Error, + SpendingAnalyticsResponse, + StoreConsentList, User, + UserActivitySummary, UserCreate, UserData, UserMetadataResponse, @@ -244,6 +247,27 @@ export class Users< secure: true, ...params, }); + /** + * @description Retrieves the lists of stores (with names and IDs) the user has explicitly opted into or opted out of sharing data with. + * + * @tags Preference Management + * @name GetStoreConsentLists + * @summary Get user's store opt-in/out lists + * @request GET:/users/preferences/store-consent + * @secure + * @response `200` `StoreConsentList` Successfully retrieved store consent lists with details. + * @response `401` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + getStoreConsentLists = (params: RequestParams = {}) => + this.request({ + path: `/users/preferences/store-consent`, + method: "GET", + secure: true, + format: "json", + ...params, + }); /** * @description Retrieve Auth0 metadata for the authenticated user * @@ -264,4 +288,46 @@ export class Users< format: "json", ...params, }); + /** + * @description Retrieves a summary of recent API usage and data submissions related to the authenticated user. + * + * @tags User Management + * @name GetUserActivitySummary + * @summary Get User Activity Summary + * @request GET:/users/activity/summary + * @secure + * @response `200` `UserActivitySummary` Successfully retrieved activity summary. + * @response `401` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + getUserActivitySummary = (params: RequestParams = {}) => + this.request({ + path: `/users/activity/summary`, + method: "GET", + secure: true, + format: "json", + ...params, + }); + /** + * @description Retrieves a breakdown of user spending by category based on purchase data. + * + * @tags User Management + * @name GetUserSpendingAnalytics + * @summary Get user spending analytics + * @request GET:/users/analytics/spending + * @secure + * @response `200` `SpendingAnalyticsResponse` Spending analytics retrieved successfully + * @response `401` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + getUserSpendingAnalytics = (params: RequestParams = {}) => + this.request({ + path: `/users/analytics/spending`, + method: "GET", + secure: true, + format: "json", + ...params, + }); } diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 074fe75..f4ef8ec 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -263,6 +263,26 @@ export interface ApiKeyUsage { }[]; } +export interface StoreConsentDetail { + /** + * The unique identifier for the store. + * @example "60d5ecb8b48f4d001f9e8f8a" + */ + storeId: string; + /** + * The name of the store. + * @example "Example Electronics Store" + */ + name: string; +} + +export interface StoreConsentList { + /** List of stores the user has opted into sharing data with. */ + optInStores: StoreConsentDetail[]; + /** List of stores the user has opted out of sharing data with. */ + optOutStores: StoreConsentDetail[]; +} + export interface HealthStatus { /** Overall health status of the Health */ status?: "healthy" | "degraded" | "unhealthy"; @@ -310,6 +330,77 @@ export interface UserMetadataResponse { }; } +/** Attribute within a taxonomy category */ +export interface TaxonomyAttribute { + name: string; + values: string[]; + description?: string | null; +} + +/** Category within a taxonomy system */ +export interface TaxonomyCategory { + id: string; + name: string; + parent_id?: string | null; + description?: string | null; + /** @default [] */ + attributes?: TaxonomyAttribute[]; +} + +/** Complete taxonomy definition with categories and version */ +export interface Taxonomy { + _id?: string; + categories: TaxonomyCategory[]; + version: string; +} + +export interface ActivityByStore { + /** The unique identifier for the store. */ + storeId: string; + /** The name of the store. */ + name: string; + /** The number of activities associated with this store. */ + count: number; +} + +export interface UserActivitySummary { + recentApiUsage: { + /** Total number of API calls accessing the user's data recently. */ + total?: number; + /** Breakdown of API calls by store. */ + byStore?: ActivityByStore[]; + }; + recentSubmissions: { + /** Total number of data submissions made about the user recently. */ + total?: number; + /** Breakdown of data submissions by store. */ + byStore?: ActivityByStore[]; + }; +} + +export interface SpendingBreakdownItem { + /** The ID of the spending category from the taxonomy. */ + categoryId: string; + /** The name of the spending category. */ + categoryName: string; + /** + * The total amount spent in this category. + * @format double + */ + totalSpent: number; +} + +export interface SpendingAnalyticsResponse { + /** The user's unique identifier. */ + userId: string; + /** + * Description of the time period covered (e.g., "Last 30 days"). + * @example "All Time" + */ + timeframe: string; + spendingBreakdown: SpendingBreakdownItem[]; +} + export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index c15dc28..fd872d5 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -26,6 +26,9 @@ export const cacheKeys = { all: ["users"], profile: () => [...cacheKeys.users.all, "profile"], preferences: () => [...cacheKeys.users.all, "preferences"], + activitySummary: () => [...cacheKeys.users.all, "activitySummary"], // <-- New + spendingAnalytics: () => [...cacheKeys.users.all, "spendingAnalytics"], // <-- New + storeConsent: () => [...cacheKeys.users.all, "storeConsent"], // <-- New (or rename if exists) }, stores: { all: ["stores"], @@ -40,6 +43,7 @@ export const cacheKeys = { system: { health: () => ["system", "health"], ping: () => ["system", "ping"], + taxonomy: () => ["system", "taxonomy"], // <-- New }, }; @@ -69,6 +73,27 @@ export const cacheSettings = { staleTime: CACHE_TIMES.SHORT, gcTime: CACHE_TIMES.SHORT * 2, }, + // Add settings for new data types + activitySummary: { + // <-- New + staleTime: CACHE_TIMES.MEDIUM, + gcTime: CACHE_TIMES.MEDIUM * 2, + }, + spendingAnalytics: { + // <-- New + staleTime: CACHE_TIMES.LONG, // Analytics might be less frequently updated + gcTime: CACHE_TIMES.LONG * 2, + }, + storeConsent: { + // <-- New + staleTime: CACHE_TIMES.MEDIUM, + gcTime: CACHE_TIMES.MEDIUM * 2, + }, + taxonomy: { + // <-- New + staleTime: CACHE_TIMES.LONG * 2, // Taxonomy changes infrequently + gcTime: CACHE_TIMES.LONG * 4, + }, }; // Helper for optimistic updates diff --git a/web/src/main.tsx b/web/src/main.tsx index d101181..f38874d 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -16,6 +16,9 @@ import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; import UserProfilePage from "./pages/UserProfilePage"; import StoreProfilePage from "./pages/StoreProfilePage"; +import UserPreferencesPage from "./pages/UserPreferencesPage"; +import UserConsentPage from "./pages/UserConsentPage"; +import UserAnalyticsPage from "./pages/UserAnalyticsPage"; const router = createBrowserRouter([ { @@ -46,11 +49,24 @@ const router = createBrowserRouter([ path: "dashboard/user", element: , }, - // Add User Profile Route { - path: "profile/user", + path: "profile/user", // Base profile page element: , }, + // --- Add these new routes --- + { + path: "profile/user/preferences", + element: , + }, + { + path: "profile/user/consent", + element: , + }, + { + path: "profile/user/analytics", + element: , + }, + // --- End of new routes --- ], }, // --- Protected Store Routes --- @@ -61,11 +77,11 @@ const router = createBrowserRouter([ path: "dashboard/store", element: , }, - // Add Store Profile Route { path: "profile/store", element: , }, + // Add store-specific sub-routes here if needed ], }, // --- Catch-all 404 Route --- diff --git a/web/src/pages/UserAnalyticsPage.tsx b/web/src/pages/UserAnalyticsPage.tsx new file mode 100644 index 0000000..a6b1371 --- /dev/null +++ b/web/src/pages/UserAnalyticsPage.tsx @@ -0,0 +1,180 @@ +import { + useUserActivitySummary, + useSpendingAnalytics, +} from "../api/hooks/useUserHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { + Card, + Table, + TableHeadCell, + TableHead, + TableBody, + TableCell, + TableRow, +} from "flowbite-react"; +import { + SpendingBreakdownItem, + ActivityByStore, +} from "../api/types/data-contracts"; // Import types + +export default function UserAnalyticsPage() { + const { + data: activitySummary, + isLoading: activityLoading, + error: activityError, + } = useUserActivitySummary(); + const { + data: spendingAnalytics, + isLoading: spendingLoading, + error: spendingError, + } = useSpendingAnalytics(); + + const isLoading = activityLoading || spendingLoading; + const error = activityError || spendingError; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + // Add types to variables + const apiUsageByStore: ActivityByStore[] = + activitySummary?.recentApiUsage?.byStore || []; + const submissionsByStore: ActivityByStore[] = + activitySummary?.recentSubmissions?.byStore || []; + const spendingBreakdown: SpendingBreakdownItem[] = + spendingAnalytics?.spendingBreakdown || []; + + return ( +
+

+ Detailed Analytics +

+

+ Review detailed breakdowns of your data usage, submissions, and spending + patterns. (More features like date filtering coming soon!) +

+ + {/* Data Usage Details */} + +

+ Data Usage by Stores (Last 30 Days) +

+ {apiUsageByStore.length > 0 ? ( + + + Store Name + Store ID + API Calls + + + {apiUsageByStore.map( + ( + item, // Type is inferred from apiUsageByStore + ) => ( + + + {item.name} + + {item.storeId} + {item.count} + + ), + )} + +
+ ) : ( +

+ No API usage data found for the last 30 days. +

+ )} +
+ + {/* Data Submission Details */} + +

+ Data Submissions by Stores (Last 30 Days) +

+ {submissionsByStore.length > 0 ? ( + + + Store Name + Store ID + Submissions + + + {submissionsByStore.map( + ( + item, // Type is inferred from submissionsByStore + ) => ( + + + {item.name} + + {item.storeId} + {item.count} + + ), + )} + +
+ ) : ( +

+ No data submissions found for the last 30 days. +

+ )} +
+ + {/* Spending Breakdown Details */} + +

+ Spending Breakdown by Category +

+ {spendingBreakdown.length > 0 ? ( + + + Category Name + Category ID + Total Spent + + + {/* Add explicit type SpendingBreakdownItem to item */} + {spendingBreakdown.map((item: SpendingBreakdownItem) => ( + + + {item.categoryName} + + {item.categoryId} + ${item.totalSpent.toFixed(2)} + + ))} + +
+ ) : ( +

+ No spending data available. +

+ )} +
+
+ ); +} diff --git a/web/src/pages/UserConsentPage.tsx b/web/src/pages/UserConsentPage.tsx new file mode 100644 index 0000000..e4bff3c --- /dev/null +++ b/web/src/pages/UserConsentPage.tsx @@ -0,0 +1,167 @@ +import { + useStoreConsentLists, + useOptInToStore, + useOptOutFromStore, +} from "../api/hooks/useUserHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { Card, List, Button, ListItem, Spinner } from "flowbite-react"; // Import Spinner +import { HiArrowRight, HiArrowLeft } from "react-icons/hi"; + +export default function UserConsentPage() { + const { + data: consentData, + isLoading: consentLoading, + error: consentError, + refetch, + } = useStoreConsentLists(); + + const optInMutation = useOptInToStore(); + const optOutMutation = useOptOutFromStore(); + + const handleOptOut = (storeId: string) => { + optOutMutation.mutate(storeId, { + onSettled: () => { + refetch(); + }, + }); + }; + + const handleOptIn = (storeId: string) => { + optInMutation.mutate(storeId, { + onSettled: () => { + refetch(); + }, + }); + }; + + if (consentLoading) { + return ; + } + + if (consentError) { + return ( + + ); + } + + const optedInStores = consentData?.optInStores || []; + const optedOutStores = consentData?.optOutStores || []; + + const isMutating = optInMutation.isPending || optOutMutation.isPending; + + return ( +
+

+ Manage Data Sharing Consent +

+

+ Control which stores are allowed to access your data for personalized + experiences and which are blocked. Moving a store to the 'Opted Out' + list prevents them from accessing your data via the API. +

+ +
+ {/* Opted-In Stores Card */} + +

+ Allowed Stores (Opted In) +

+ {optedInStores.length > 0 ? ( + + {optedInStores.map((store) => { + const isProcessingThis = + optOutMutation.isPending && + optOutMutation.variables === store.storeId; + return ( + + + {store.name} ({store.storeId.slice(-6)}) + + + + ); + })} + + ) : ( +

+ You haven't explicitly allowed any stores yet. Stores may be added + here automatically when you interact with them, unless they are in + the 'Opted Out' list. +

+ )} +
+ + {/* Opted-Out Stores Card */} + +

+ Blocked Stores (Opted Out) +

+ {optedOutStores.length > 0 ? ( + + {optedOutStores.map((store) => { + const isProcessingThis = + optInMutation.isPending && + optInMutation.variables === store.storeId; + return ( + + + {store.name} ({store.storeId.slice(-6)}) + + + + ); + })} + + ) : ( +

+ No stores are currently blocked. +

+ )} +
+
+
+ ); +} diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 8975452..623494d 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -1,10 +1,96 @@ -import { Card } from "flowbite-react"; -import { useUserProfile } from "../api/hooks/useUserHooks"; +import { + useUserProfile, + useUserActivitySummary, + useSpendingAnalytics, + useUserPreferences, +} from "../api/hooks/useUserHooks"; +import { useTaxonomy } from "../api/hooks/useSystemHooks"; +import { Link } from "react-router"; // Correct import for react-router v6+ +import { + Card, + List, + ListItem, + Progress, + Button, // Import Button +} from "flowbite-react"; +import { + ResponsiveContainer, + PieChart, + Pie, + Cell, + Tooltip, + Legend, +} from "recharts"; import LoadingSpinner from "../components/common/LoadingSpinner"; import ErrorDisplay from "../components/common/ErrorDisplay"; +import { SpendingBreakdownItem } from "../api/types/data-contracts"; +import { HiArrowRight } from "react-icons/hi"; // Import icon + +// Define some colors for the pie chart +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82ca9d", + "#FA8072", + "#7D3C98", +]; export default function UserDashboard() { - const { data: profile, isLoading, error } = useUserProfile(); + // Fetch all necessary data + const { + data: profile, + isLoading: profileLoading, + error: profileError, + } = useUserProfile(); + const { + data: activitySummary, + isLoading: activityLoading, + error: activityError, + } = useUserActivitySummary(); + const { + data: spendingAnalytics, + isLoading: spendingLoading, + error: spendingError, + } = useSpendingAnalytics(); + const { + data: userPreferences, + isLoading: preferencesLoading, + error: preferencesError, + } = useUserPreferences(); + const { + data: taxonomyData, + isLoading: taxonomyLoading, + error: taxonomyError, + } = useTaxonomy(); + + // Combined loading and error states + const isLoading = + profileLoading || + activityLoading || + spendingLoading || + preferencesLoading || + taxonomyLoading; + const error = + profileError || + activityError || + spendingError || + preferencesError || + taxonomyError; + + // Create taxonomy map once data is loaded + const taxonomyMap = + !taxonomyLoading && taxonomyData?.categories + ? taxonomyData.categories.reduce( + (map, cat) => { + map[cat.id] = cat.name; + return map; + }, + {} as Record, + ) + : {}; if (isLoading) { return ; @@ -14,30 +100,253 @@ export default function UserDashboard() { return ( ); } + // Prepare data for spending chart - ensure value is a number + const spendingChartData = + spendingAnalytics?.spendingBreakdown + ?.map((item: SpendingBreakdownItem) => ({ + name: taxonomyMap[item.categoryId] || item.categoryName, // Use taxonomy map for name + value: Number(item.totalSpent) || 0, // Ensure value is a number + })) + .filter((item) => item.value > 0) // Filter out zero-value items for cleaner chart + .sort((a, b) => b.value - a.value) || []; // Sort descending + + // Prepare simplified preference overview + const preferenceOverview = + userPreferences?.preferences + ?.slice(0, 5) // Take top 5 based on score (assuming sorted) + .sort((a, b) => b.score - a.score) || []; // Ensure sorted by score desc + return ( -
- {/* Add dark mode text color */} -

- User Dashboard +
+

+ Welcome, {profile?.username || "User"}!

- - {/* Add dark mode text color */} -

- Welcome back, {profile?.username || "User"}! -

- {/* Add dark mode text color */} -

- Here you can manage your preferences, control data sharing with - stores, and view your usage analytics. (Content coming soon!) -

- {/* Add User Preference Management and Analytics sections later */} -
+ + {/* Grid layout for cards */} +
+ {/* Activity Summary Card */} + +

+ Recent Activity (Last 30 Days) +

+
+
+

+ Data Usage by Stores +

+

+ Total API calls:{" "} + + {activitySummary?.recentApiUsage?.total ?? 0} + +

+ {activitySummary?.recentApiUsage?.byStore && + activitySummary.recentApiUsage.byStore.length > 0 ? ( + + {activitySummary.recentApiUsage.byStore + .slice(0, 3) // Show top 3 + .map((item) => ( + + {item.name}: {item.count} call(s) + + ))} + {activitySummary.recentApiUsage.byStore.length > 3 && ( + + ... and others + + )} + + ) : ( +

+ No recent API usage detected. +

+ )} +
+
+
+

+ New Data Submissions +

+

+ Total submissions:{" "} + + {activitySummary?.recentSubmissions?.total ?? 0} + +

+ {activitySummary?.recentSubmissions?.byStore && + activitySummary.recentSubmissions.byStore.length > 0 ? ( + + {activitySummary.recentSubmissions.byStore + .slice(0, 3) // Show top 3 + .map((item) => ( + + {item.name}: {item.count} submission(s) + + ))} + {activitySummary.recentSubmissions.byStore.length > 3 && ( + + ... and others + + )} + + ) : ( +

+ No recent data submissions detected. +

+ )} +
+
+
+ +
+
+ + {/* Spending Analytics Card */} + +

+ Spending Patterns (All Time) +

+ {spendingChartData.length > 0 ? ( +
+ + + `$${value.toFixed(2)}`} + /> + + `${(percent * 100).toFixed(0)}%`} + > + {spendingChartData.map((_entry, index) => ( + + ))} + + + +
+ ) : ( +
+

+ No spending data available to display chart. +

+
+ )} +
+ {/* Link to analytics page is already in the Activity card, maybe remove duplicate? Or keep for consistency */} + +
+
+ + {/* Preference Profile Overview Card */} + +

+ Your Preference Profile (Top 5) +

+ {preferenceOverview.length > 0 ? ( + + {preferenceOverview.map((pref) => ( + +
+ + {taxonomyMap[pref.category] || pref.category} + + + {(pref.score * 100).toFixed(0)}% + +
+ +
+ ))} +
+ ) : ( +

+ No preferences generated yet. Start interacting with services! +

+ )} +
+ +
+
+ + {/* Consent Management Link Card */} + +

+ Data Sharing Control +

+

+ Review and manage which stores can access your data for personalized + experiences. +

+
+ {" "} + {/* Push button to bottom */} + +
+
+
); } diff --git a/web/src/pages/UserPreferencesPage.tsx b/web/src/pages/UserPreferencesPage.tsx new file mode 100644 index 0000000..b608fe4 --- /dev/null +++ b/web/src/pages/UserPreferencesPage.tsx @@ -0,0 +1,253 @@ +import { useState, useEffect } from "react"; +import { + useUserPreferences, + useUpdateUserPreferences, +} from "../api/hooks/useUserHooks"; +import { useTaxonomy } from "../api/hooks/useSystemHooks"; +import LoadingSpinner from "../components/common/LoadingSpinner"; +import ErrorDisplay from "../components/common/ErrorDisplay"; +import { + Card, + RangeSlider, + Button, + Toast, + Spinner, + ToastToggle, + Table, // Import Table components + TableHead, + TableHeadCell, + TableBody, + TableRow, + TableCell, +} from "flowbite-react"; +import { HiCheck, HiExclamation } from "react-icons/hi"; +import { PreferenceItem } from "../api/types/data-contracts"; + +export default function UserPreferencesPage() { + const { + data: preferencesData, + isLoading: preferencesLoading, + error: preferencesError, + refetch, // Add refetch + } = useUserPreferences(); + const { + data: taxonomyData, + isLoading: taxonomyLoading, + error: taxonomyError, + } = useTaxonomy(); + const updateUserPreferences = useUpdateUserPreferences(); + + const [editablePreferences, setEditablePreferences] = useState< + PreferenceItem[] + >([]); + const [showSuccessToast, setShowSuccessToast] = useState(false); + const [showErrorToast, setShowErrorToast] = useState(false); + + useEffect(() => { + // Sort preferences alphabetically by category name using the map + if (preferencesData?.preferences && taxonomyData?.categories) { + const map = taxonomyData.categories.reduce( + (acc, cat) => { + acc[cat.id] = cat.name; + return acc; + }, + {} as Record, + ); + const sortedPrefs = [...preferencesData.preferences].sort((a, b) => { + const nameA = map[a.category] || a.category; + const nameB = map[b.category] || b.category; + return nameA.localeCompare(nameB); + }); + setEditablePreferences(sortedPrefs); + } else if (preferencesData?.preferences) { + // Fallback sort by ID if taxonomy isn't ready + const sortedById = [...preferencesData.preferences].sort((a, b) => + a.category.localeCompare(b.category), + ); + setEditablePreferences(sortedById); + } + }, [preferencesData, taxonomyData]); // Depend on both + + // Create taxonomy map once data is loaded + const taxonomyMap = + !taxonomyLoading && taxonomyData?.categories + ? taxonomyData.categories.reduce( + (map, cat) => { + map[cat.id] = cat.name; + return map; + }, + {} as Record, + ) + : {}; + + // Handle slider changes + const handleScoreChange = (category: string, newScore: number) => { + setEditablePreferences((prev) => + prev.map((pref) => + pref.category === category + ? { ...pref, score: newScore / 100 } // Ensure score is between 0 and 1 + : pref, + ), + ); + }; + + const handleSaveChanges = async () => { + setShowSuccessToast(false); + setShowErrorToast(false); + try { + await updateUserPreferences.mutateAsync( + { + preferences: editablePreferences, + }, + { + onSuccess: () => { + setShowSuccessToast(true); + refetch(); // Refetch preferences after successful save + }, + onError: () => { + setShowErrorToast(true); + }, + }, + ); + } catch (err) { + // This catch might not be needed if using onSuccess/onError callbacks + console.error("Failed to update preferences:", err); + setShowErrorToast(true); + } + }; + + const isLoading = preferencesLoading || taxonomyLoading; + const error = preferencesError || taxonomyError; + + if (isLoading) { + return ; + } + + if (error) { + return ( + + ); + } + + return ( +
+
+
+

+ Manage Your Preferences +

+

+ Adjust the scores below to reflect your interests (0% = Not + Interested, 100% = Very Interested). +

+
+ {/* Save Button - Moved to top right */} + +
+ + {/* Success Toast */} + {showSuccessToast && ( + + {" "} + {/* Position toast */} +
+ +
+
+ Preferences updated successfully. +
+ setShowSuccessToast(false)} /> +
+ )} + + {/* Error Toast */} + {showErrorToast && ( + + {" "} + {/* Position toast */} +
+ +
+
+ Failed to update preferences. Please try again. +
+ setShowErrorToast(false)} /> +
+ )} + + + {editablePreferences.length > 0 ? ( +
+ {" "} + {/* Make table responsive */} + + + Interest Category + + Interest Level + {" "} + {/* Min width for slider */} + Score + + + {editablePreferences.map((pref) => ( + + + {taxonomyMap[pref.category] || pref.category}{" "} + {/* Display Name */} + + + + handleScoreChange( + pref.category, + parseInt(e.target.value, 10), + ) + } + disabled={updateUserPreferences.isPending} + className="w-full" // Ensure slider takes width + /> + + + {(pref.score * 100).toFixed(0)}% {/* Display Score */} + + + ))} + +
+
+ ) : ( +

+ No preferences found. Your preferences will be generated as you + interact or submit data. +

+ )} +
+
+ ); +} From 2c436ae2eb17702cf9b41fcd282dbb5ea0ac90ed Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 15:14:31 +0530 Subject: [PATCH 04/34] Revert "feat: Add user analytics and consent management features" This reverts commit 35be6920de69006e8005b9fe668a993140d6edd6. --- api-service/api/openapi.yaml | 161 +------- api-service/controllers/UserProfile.js | 20 - .../service/PreferenceManagementService.js | 51 +-- api-service/service/UserProfileService.js | 204 +-------- api-service/utils/cacheConfig.js | 12 +- web/package-lock.json | 386 +----------------- web/package.json | 3 +- web/src/api/apiClient.ts | 3 +- web/src/api/hooks/useSystemHooks.ts | 16 +- web/src/api/hooks/useUserHooks.ts | 45 -- web/src/api/types/Taxonomy.ts | 37 -- web/src/api/types/Users.ts | 66 --- web/src/api/types/data-contracts.ts | 91 ----- web/src/api/utils/cache.ts | 25 -- web/src/main.tsx | 22 +- web/src/pages/UserAnalyticsPage.tsx | 180 -------- web/src/pages/UserConsentPage.tsx | 167 -------- web/src/pages/UserDashboard.tsx | 349 +--------------- web/src/pages/UserPreferencesPage.tsx | 253 ------------ 19 files changed, 48 insertions(+), 2043 deletions(-) delete mode 100644 web/src/api/types/Taxonomy.ts delete mode 100644 web/src/pages/UserAnalyticsPage.tsx delete mode 100644 web/src/pages/UserConsentPage.tsx delete mode 100644 web/src/pages/UserPreferencesPage.tsx diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index fedd85b..fcb97f2 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -396,11 +396,11 @@ paths: get: tags: [Preference Management] summary: Get user's store opt-in/out lists - description: Retrieves the lists of stores (with names and IDs) the user has explicitly opted into or opted out of sharing data with. + description: Retrieves the lists of store IDs the user has explicitly opted into or opted out of sharing data with. operationId: getStoreConsentLists responses: "200": - description: Successfully retrieved store consent lists with details. + description: Successfully retrieved store consent lists. content: application/json: schema: @@ -412,7 +412,7 @@ paths: "500": $ref: "#/components/responses/InternalServerError" security: - - oauth2: [user:read] + - oauth2: [user:read] # Requires user read scope x-swagger-router-controller: PreferenceManagement /stores/api-keys: @@ -609,52 +609,6 @@ paths: $ref: "#/components/responses/InternalServerError" x-swagger-router-controller: Taxonomy - /users/activity/summary: - get: - tags: [User Management] - summary: Get User Activity Summary - description: Retrieves a summary of recent API usage and data submissions related to the authenticated user. - operationId: getUserActivitySummary - security: - - oauth2: [user:read] - responses: - "200": - description: Successfully retrieved activity summary. - content: - application/json: - schema: - $ref: "#/components/schemas/UserActivitySummary" - "401": - $ref: "#/components/responses/UnauthorizedError" - "404": - $ref: "#/components/responses/NotFoundError" - "500": - $ref: "#/components/responses/InternalServerError" - x-swagger-router-controller: UserProfile - - /users/analytics/spending: - get: - tags: [User Management] - summary: Get user spending analytics - description: Retrieves a breakdown of user spending by category based on purchase data. - operationId: getUserSpendingAnalytics - responses: - "200": - description: Spending analytics retrieved successfully - content: - application/json: - schema: - $ref: "#/components/schemas/SpendingAnalyticsResponse" - "401": - $ref: "#/components/responses/UnauthorizedError" - "404": - $ref: "#/components/responses/NotFoundError" - "500": - $ref: "#/components/responses/InternalServerError" - security: - - oauth2: [user:read] - x-swagger-router-controller: UserProfile - components: schemas: AttributeDistribution: @@ -1136,37 +1090,19 @@ components: count: type: integer - StoreConsentDetail: - type: object - properties: - storeId: - type: string - description: The unique identifier for the store. - example: "60d5ecb8b48f4d001f9e8f8a" - name: - type: string - description: The name of the store. - example: "Example Electronics Store" - required: - - storeId - - name - StoreConsentList: type: object properties: optInStores: type: array - description: List of stores the user has opted into sharing data with. items: - $ref: "#/components/schemas/StoreConsentDetail" + type: string + description: List of store IDs the user has opted into. optOutStores: type: array - description: List of stores the user has opted out of sharing data with. items: - $ref: "#/components/schemas/StoreConsentDetail" - required: - - optInStores - - optOutStores + type: string + description: List of store IDs the user has opted out of. HealthStatus: type: object @@ -1297,89 +1233,6 @@ components: version: type: string - ActivityByStore: - type: object - properties: - storeId: - type: string - description: The unique identifier for the store. - name: - type: string - description: The name of the store. - count: - type: integer - description: The number of activities associated with this store. - required: - - storeId - - name - - count - - UserActivitySummary: - type: object - properties: - recentApiUsage: - type: object - properties: - total: - type: integer - description: Total number of API calls accessing the user's data recently. - byStore: - type: array - items: - $ref: "#/components/schemas/ActivityByStore" - description: Breakdown of API calls by store. - recentSubmissions: - type: object - properties: - total: - type: integer - description: Total number of data submissions made about the user recently. - byStore: - type: array - items: - $ref: "#/components/schemas/ActivityByStore" - description: Breakdown of data submissions by store. - required: - - recentApiUsage - - recentSubmissions - - SpendingBreakdownItem: - type: object - properties: - categoryId: - type: string - description: The ID of the spending category from the taxonomy. - categoryName: - type: string - description: The name of the spending category. - totalSpent: - type: number - format: double - description: The total amount spent in this category. - required: - - categoryId - - categoryName - - totalSpent - - SpendingAnalyticsResponse: - type: object - properties: - userId: - type: string - description: The user's unique identifier. - timeframe: - type: string - description: Description of the time period covered (e.g., "Last 30 days"). - example: "All Time" - spendingBreakdown: - type: array - items: - $ref: "#/components/schemas/SpendingBreakdownItem" - required: - - userId - - timeframe - - spendingBreakdown - responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/controllers/UserProfile.js b/api-service/controllers/UserProfile.js index 645348d..b6868c5 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -29,24 +29,4 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { .catch((response) => { utils.writeJson(res, response); }); -}; - -module.exports.getUserActivitySummary = function getUserActivitySummary(req, res, next) { - UserProfile.getUserActivitySummary(req) - .then((response) => { - utils.writeJson(res, response); - }) - .catch((response) => { - utils.writeJson(res, response); - }); -}; - -module.exports.getUserSpendingAnalytics = function getUserSpendingAnalytics(req, res, next) { - UserProfile.getUserSpendingAnalytics(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/PreferenceManagementService.js b/api-service/service/PreferenceManagementService.js index fc9af83..a1f1d54 100644 --- a/api-service/service/PreferenceManagementService.js +++ b/api-service/service/PreferenceManagementService.js @@ -254,7 +254,7 @@ exports.optInToStore = async function (req, storeId) { }; /** - * Get user's store opt-in/out lists with store names + * Get user's store opt-in/out lists */ exports.getStoreConsentLists = async function (req) { try { @@ -276,55 +276,16 @@ exports.getStoreConsentLists = async function (req) { }); } - const optInIds = user.privacySettings?.optInStores || []; - const optOutIds = user.privacySettings?.optOutStores || []; - const allStoreIds = [...new Set([...optInIds, ...optOutIds])]; - - // Convert string IDs to ObjectIds for the query, handling potential errors - const storeObjectIds = allStoreIds.map(id => { - try { - // Ensure the ID is a valid ObjectId string before converting - if (ObjectId.isValid(id)) { - return new ObjectId(id); - } - console.warn(`Invalid ObjectId format in consent list: ${id}`); - return null; - } catch (e) { - console.warn(`Error converting ObjectId in consent list: ${id}`, e); - return null; - } - }).filter(id => id !== null); // Filter out invalid/null IDs - - - let storeNameMap = {}; - if (storeObjectIds.length > 0) { - const stores = await db.collection('stores').find( - { _id: { $in: storeObjectIds } }, - { projection: { _id: 1, name: 1 } } - ).toArray(); - - storeNameMap = stores.reduce((map, store) => { - map[store._id.toString()] = store.name; - return map; - }, {}); - } - - - // Map IDs to objects with names - const mapIdsToDetails = (ids) => ids.map(id => ({ - storeId: id, - name: storeNameMap[id] || 'Unknown Store' // Provide a fallback name - })); - - const consentListsWithDetails = { - optInStores: mapIdsToDetails(optInIds), - optOutStores: mapIdsToDetails(optOutIds), + // Prepare the response object, defaulting to empty arrays if fields don't exist + const consentLists = { + optInStores: user.privacySettings?.optInStores || [], + optOutStores: user.privacySettings?.optOutStores || [], }; // Note: Caching could be added here if needed, potentially using a specific key // or relying on the USER_DATA cache invalidation from opt-in/out actions. - return respondWithCode(200, consentListsWithDetails); // Return the detailed lists + return respondWithCode(200, consentLists); } catch (error) { console.error('Get store consent lists 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 cefe794..100219a 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -3,8 +3,8 @@ const { setCache, getCache, invalidateCache } = require('../utils/redisUtil'); const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); -const {updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); -const { ObjectId } = require('mongodb'); // <-- Import ObjectId +// Import the new function +const { updateUserMetadata, updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); /** * Get User Profile @@ -214,203 +214,3 @@ exports.deleteUserProfile = async function (req) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; - -/** - * Get User Activity Summary - * Retrieves a summary of recent API usage and data submissions for the authenticated user. - */ -exports.getUserActivitySummary = async function (req) { // <-- Existing Function - try { - const db = getDB(); - const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - const auth0UserId = userData.sub; - - // --- Get User ObjectId --- - const user = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { _id: 1 } }); - if (!user) { - return respondWithCode(404, { code: 404, message: 'User not found' }); - } - const userObjectId = user._id; - - // --- Cache Check --- - const cacheKey = `${CACHE_KEYS.USER_ACTIVITY_SUMMARY}${userObjectId}`; // Define a new cache key constant - const cachedSummary = await getCache(cacheKey); - if (cachedSummary) { - return respondWithCode(200, JSON.parse(cachedSummary)); - } - - // --- Define Timeframe (e.g., last 30 days) --- - const thirtyDaysAgo = new Date(); - thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); - - // --- Aggregate API Usage --- - const apiUsagePipeline = [ - { $match: { userId: userObjectId, timestamp: { $gte: thirtyDaysAgo } } }, // Match user and timeframe - { $group: { _id: "$storeId", count: { $sum: 1 } } }, // Group by storeId and count - { $project: { _id: 0, storeId: "$_id", count: 1 } } // Reshape output - ]; - const apiUsageByStore = await db.collection('apiUsage').aggregate(apiUsagePipeline).toArray(); - const totalApiUsage = apiUsageByStore.reduce((sum, item) => sum + item.count, 0); - - // --- Aggregate Data Submissions --- - const dataSubmissionPipeline = [ - { $match: { userId: userObjectId, timestamp: { $gte: thirtyDaysAgo } } }, // Match user and timeframe - { $group: { _id: "$storeId", count: { $sum: 1 } } }, // Group by storeId and count - { $project: { _id: 0, storeId: "$_id", count: 1 } } // Reshape output - ]; - const submissionsByStore = await db.collection('userData').aggregate(dataSubmissionPipeline).toArray(); - const totalSubmissions = submissionsByStore.reduce((sum, item) => sum + item.count, 0); - - // --- Get Store Names --- - const allInvolvedStoreIds = [ - ...new Set([ - ...apiUsageByStore.map(item => item.storeId), - ...submissionsByStore.map(item => item.storeId) - ]) - ]; - - // Convert string IDs to ObjectIds for the query, handling potential errors - const storeObjectIds = allInvolvedStoreIds.map(id => { - try { - if (ObjectId.isValid(id)) { return new ObjectId(id); } - console.warn(`Invalid ObjectId format in activity summary store list: ${id}`); - return null; - } catch (e) { - console.warn(`Error converting ObjectId for store name lookup: ${id}`, e); - return null; - } - }).filter(id => id !== null); - - let storeNameMap = {}; - if (storeObjectIds.length > 0) { - const stores = await db.collection('stores').find( - { _id: { $in: storeObjectIds } }, - { projection: { _id: 1, name: 1 } } - ).toArray(); - storeNameMap = stores.reduce((map, store) => { - map[store._id.toString()] = store.name; - return map; - }, {}); - } - - // --- Format Results --- - const formatActivity = (activityList) => activityList.map(item => ({ - storeId: item.storeId, - name: storeNameMap[item.storeId] || 'Unknown Store', - count: item.count - })).sort((a, b) => b.count - a.count); // Sort by count descending - - const summary = { - recentApiUsage: { - total: totalApiUsage, - byStore: formatActivity(apiUsageByStore) - }, - recentSubmissions: { - total: totalSubmissions, - byStore: formatActivity(submissionsByStore) - } - }; - - // --- Cache Result --- - // Define a suitable TTL, e.g., USER_ACTIVITY_TTL - await setCache(cacheKey, JSON.stringify(summary), { EX: CACHE_TTL.USER_ACTIVITY || 3600 }); // e.g., 1 hour - - return respondWithCode(200, summary); - - } catch (error) { - console.error('Get user activity summary failed:', error); - return respondWithCode(500, { code: 500, message: 'Internal server error retrieving activity summary' }); - } -}; - -/** - * Get User Spending Analytics - * Retrieves a breakdown of user spending by category based on purchase data. - */ -exports.getUserSpendingAnalytics = async function (req) { // <-- New Function - try { - const db = getDB(); - const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - const auth0UserId = userData.sub; - - // --- Get User ObjectId --- - const user = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { _id: 1 } }); - if (!user) { - return respondWithCode(404, { code: 404, message: 'User not found' }); - } - const userObjectId = user._id; - - // --- Cache Check --- - const cacheKey = `${CACHE_KEYS.USER_SPENDING_ANALYTICS}${userObjectId}`; - const cachedAnalytics = await getCache(cacheKey); - if (cachedAnalytics) { - console.log(`Spending analytics retrieved from cache for user ${userObjectId}`); - return respondWithCode(200, JSON.parse(cachedAnalytics)); - } - console.log(`Calculating spending analytics for user ${userObjectId}`); - - - // --- Aggregate Spending Data --- - // This pipeline assumes 'category' exists directly on purchase items. - // Adjust if category mapping happens differently (e.g., via product ID lookup). - const spendingPipeline = [ - { $match: { userId: userObjectId, dataType: 'purchase' } }, // Filter for user's purchase data - { $unwind: "$entries" }, // Deconstruct the entries array - { $unwind: "$entries.items" }, // Deconstruct the items array within each entry - { - $group: { - _id: "$entries.items.category", // Group by item category ID - totalSpent: { - $sum: { - // Calculate total spent per item (price * quantity), handle missing values - $multiply: [ - { $ifNull: ["$entries.items.price", 0] }, - { $ifNull: ["$entries.items.quantity", 1] } - ] - } - } - } - }, - { - $project: { // Reshape the output - _id: 0, // Exclude the default _id - categoryId: "$_id", // Rename _id to categoryId - totalSpent: 1 // Include the calculated totalSpent - } - }, - { $match: { categoryId: { $ne: null } } } // Ensure we only include items with a category - ]; - - const spendingByCategory = await db.collection('userData').aggregate(spendingPipeline).toArray(); - - // --- Get Taxonomy for Category Names --- - // Fetch the full taxonomy (could also be cached separately) - const taxonomyDoc = await db.collection('taxonomy').findOne({}); - const categoryNameMap = taxonomyDoc?.categories?.reduce((map, cat) => { - map[cat.id] = cat.name; - return map; - }, {}) || {}; - - // --- Format Results --- - const spendingBreakdown = spendingByCategory.map(item => ({ - categoryId: item.categoryId, - categoryName: categoryNameMap[item.categoryId] || item.categoryId, // Fallback to ID if name not found - totalSpent: item.totalSpent - })).sort((a, b) => b.totalSpent - a.totalSpent); // Sort descending by amount spent - - const analyticsResponse = { - userId: userObjectId.toString(), - timeframe: "All Time", // Placeholder - could be made dynamic later - spendingBreakdown: spendingBreakdown - }; - - // --- Cache Result --- - await setCache(cacheKey, JSON.stringify(analyticsResponse), { EX: CACHE_TTL.USER_ANALYTICS || 7200 }); - - return respondWithCode(200, analyticsResponse); - - } catch (error) { - console.error('Get user spending analytics failed:', error); - return respondWithCode(500, { code: 500, message: 'Internal server error retrieving spending analytics' }); - } -}; diff --git a/api-service/utils/cacheConfig.js b/api-service/utils/cacheConfig.js index c4b7366..cd0fd6c 100644 --- a/api-service/utils/cacheConfig.js +++ b/api-service/utils/cacheConfig.js @@ -9,26 +9,20 @@ const CACHE_TTL = { API_KEY: 1800, // API keys - 30 minutes INVALIDATION: 1, // Short TTL for invalidation AI_REQUEST: 60, // AI service requests - 1 minute - TAXONOMY: 86400, // 24 hours - USER_ACTIVITY: 3600, // 1 hour (example) - USER_ANALYTICS: 7200, // 2 hours (example for spending analytics) // <-- New TTL }; /** * Cache key prefixes */ const CACHE_KEYS = { - USER_DATA: 'user:', // User data from Auth0 + USER_DATA: 'userdata:', // User data from Auth0 STORE_DATA: 'store:', // Store data from DB API_KEY: 'apikey:', // API key to store ID mapping SCOPES: 'scopes:', // Token to scopes mapping ADMIN_TOKEN: 'auth0_management_token', // Auth0 management token - PREFERENCES: 'prefs:', // User preferences - STORE_PREFERENCES: 'store_prefs:', // Store preferences + PREFERENCES: 'preferences:', // User preferences + STORE_PREFERENCES: 'prefs:', // Store preferences AI_REQUEST: 'ai_request:', // AI service request cache - TAXONOMY_FULL: 'taxonomy:full', - USER_ACTIVITY_SUMMARY: 'user_activity_summary:', // <-- New Key - USER_SPENDING_ANALYTICS: 'user_spending_analytics:', // <-- New Key }; module.exports = { CACHE_TTL, CACHE_KEYS }; diff --git a/web/package-lock.json b/web/package-lock.json index 791b6ad..d16e59f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,8 +17,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", - "react-router": "^7.4.0", - "recharts": "^2.15.3" + "react-router": "^7.4.0" }, "devDependencies": { "@eslint/js": "^9.23.0", @@ -296,18 +295,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", - "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", @@ -1800,69 +1787,6 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", - "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", - "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", - "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", - "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -2808,15 +2732,6 @@ "node": ">=12" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2930,129 +2845,9 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/debounce": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", @@ -3082,12 +2877,6 @@ } } }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3137,16 +2926,6 @@ "node": ">=8" } }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -3521,12 +3300,6 @@ "url": "https://github.com/eta-dev/eta?sponsor=1" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, "node_modules/exsolve": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", @@ -3541,15 +3314,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-equals": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", - "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3989,15 +3753,6 @@ "node": ">=0.8.19" } }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4059,6 +3814,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4413,6 +4169,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -4422,18 +4179,6 @@ "dev": true, "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4724,15 +4469,6 @@ "node": ">= 6" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -5008,23 +4744,6 @@ } } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5118,12 +4837,6 @@ "react": "*" } }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -5158,37 +4871,6 @@ } } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -5218,38 +4900,6 @@ "node": ">= 4" } }, - "node_modules/recharts": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", - "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, "node_modules/reftools": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", @@ -5260,12 +4910,6 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -5829,28 +5473,6 @@ "punycode": "^2.1.0" } }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "6.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", diff --git a/web/package.json b/web/package.json index dc0fef0..78dfae6 100644 --- a/web/package.json +++ b/web/package.json @@ -22,8 +22,7 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", - "react-router": "^7.4.0", - "recharts": "^2.15.3" + "react-router": "^7.4.0" }, "devDependencies": { "@eslint/js": "^9.23.0", diff --git a/web/src/api/apiClient.ts b/web/src/api/apiClient.ts index 3ecf6c9..1ee2a6c 100644 --- a/web/src/api/apiClient.ts +++ b/web/src/api/apiClient.ts @@ -2,7 +2,7 @@ import { Users } from "./types/Users"; import { Stores } from "./types/Stores"; import { Health } from "./types/Health"; import { Ping } from "./types/Ping"; -import { Taxonomy } from "./types/Taxonomy"; // <-- Import Taxonomy client class +// Add useState import import { useEffect, useMemo, useState } from "react"; import { useAuth } from "../hooks/useAuth"; // ← use your context import { ApiConfig } from "./types/http-client"; // Import ApiConfig @@ -33,7 +33,6 @@ export function createApiClients() { stores: new Stores(config), health: new Health(config), ping: new Ping(config), - taxonomy: new Taxonomy(config), // <-- Add Taxonomy client instance }; } diff --git a/web/src/api/hooks/useSystemHooks.ts b/web/src/api/hooks/useSystemHooks.ts index e250153..c02a272 100644 --- a/web/src/api/hooks/useSystemHooks.ts +++ b/web/src/api/hooks/useSystemHooks.ts @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; import { cacheKeys, cacheSettings } from "../utils/cache"; -import { HealthStatus, PingStatus, Taxonomy } from "../types/data-contracts"; // <-- Add Taxonomy type +import { HealthStatus, PingStatus } from "../types/data-contracts"; export function useHealthCheck() { // Destructure apiClients first, then get health from it @@ -31,17 +31,3 @@ export function usePing() { // Ping doesn't require auth, so no enabled check needed here }); } - -export function useTaxonomy() { - // <-- New Hook - const { apiClients } = useApiClients(); // No auth needed for taxonomy usually - - return useQuery({ - // <-- Use specific type - queryKey: cacheKeys.system.taxonomy(), - queryFn: () => - apiClients.taxonomy.getTaxonomyCategories().then((res) => res.data), - // Taxonomy is public, so no 'enabled' check based on auth needed - ...cacheSettings.taxonomy, // <-- Use specific cache settings - }); -} diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 5e8f3de..22ee7fa 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -5,9 +5,6 @@ import { UserPreferencesUpdate, UserUpdate, User, - UserActivitySummary, // <-- Import new type - SpendingAnalyticsResponse, // <-- Import new type - StoreConsentList, // <-- Import new type } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -66,48 +63,6 @@ export function useUpdateUserPreferences() { }); } -// --- New Hooks --- - -export function useUserActivitySummary() { - const { apiClients, clientsReady } = useApiClients(); - const { isAuthenticated, isLoading: authLoading } = useAuth(); - return useQuery({ - // <-- Use specific type - queryKey: cacheKeys.users.activitySummary(), - queryFn: () => - apiClients.users.getUserActivitySummary().then((res) => res.data), - enabled: isAuthenticated && !authLoading && clientsReady, - ...cacheSettings.activitySummary, // <-- Use specific cache settings - }); -} - -export function useSpendingAnalytics() { - const { apiClients, clientsReady } = useApiClients(); - const { isAuthenticated, isLoading: authLoading } = useAuth(); - return useQuery({ - // <-- Use specific type - queryKey: cacheKeys.users.spendingAnalytics(), - queryFn: () => - apiClients.users.getUserSpendingAnalytics().then((res) => res.data), - enabled: isAuthenticated && !authLoading && clientsReady, - ...cacheSettings.spendingAnalytics, // <-- Use specific cache settings - }); -} - -export function useStoreConsentLists() { - // <-- Hook for the modified endpoint - const { apiClients, clientsReady } = useApiClients(); - const { isAuthenticated, isLoading: authLoading } = useAuth(); - return useQuery({ - // <-- Use specific type - queryKey: cacheKeys.users.storeConsent(), - queryFn: () => - apiClients.users.getStoreConsentLists().then((res) => res.data), - enabled: isAuthenticated && !authLoading && clientsReady, - ...cacheSettings.storeConsent, // <-- Use specific cache settings - }); -} - export function useOptInToStore() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); diff --git a/web/src/api/types/Taxonomy.ts b/web/src/api/types/Taxonomy.ts deleted file mode 100644 index c199b64..0000000 --- a/web/src/api/types/Taxonomy.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable */ -/* tslint:disable */ -// @ts-nocheck -/* - * --------------------------------------------------------------- - * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## - * ## ## - * ## AUTHOR: acacode ## - * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## - * --------------------------------------------------------------- - */ - -import { Error, Taxonomy } from "./data-contracts"; -import { HttpClient, RequestParams } from "./http-client"; - -export class Taxonomy< - SecurityDataType = unknown, -> extends HttpClient { - /** - * @description Retrieves the full taxonomy structure from the database. - * - * @tags Taxonomy - * @name GetTaxonomyCategories - * @summary Get Taxonomy Categories - * @request GET:/taxonomy/categories - * @response `200` `Taxonomy` Successfully retrieved the taxonomy. - * @response `404` `Error` Taxonomy data not found in the database. - * @response `500` `Error` - */ - getTaxonomyCategories = (params: RequestParams = {}) => - this.request({ - path: `/taxonomy/categories`, - method: "GET", - format: "json", - ...params, - }); -} diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index fbf3702..48d1c4a 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -12,10 +12,7 @@ import { Error, - SpendingAnalyticsResponse, - StoreConsentList, User, - UserActivitySummary, UserCreate, UserData, UserMetadataResponse, @@ -247,27 +244,6 @@ export class Users< secure: true, ...params, }); - /** - * @description Retrieves the lists of stores (with names and IDs) the user has explicitly opted into or opted out of sharing data with. - * - * @tags Preference Management - * @name GetStoreConsentLists - * @summary Get user's store opt-in/out lists - * @request GET:/users/preferences/store-consent - * @secure - * @response `200` `StoreConsentList` Successfully retrieved store consent lists with details. - * @response `401` `Error` - * @response `404` `Error` - * @response `500` `Error` - */ - getStoreConsentLists = (params: RequestParams = {}) => - this.request({ - path: `/users/preferences/store-consent`, - method: "GET", - secure: true, - format: "json", - ...params, - }); /** * @description Retrieve Auth0 metadata for the authenticated user * @@ -288,46 +264,4 @@ export class Users< format: "json", ...params, }); - /** - * @description Retrieves a summary of recent API usage and data submissions related to the authenticated user. - * - * @tags User Management - * @name GetUserActivitySummary - * @summary Get User Activity Summary - * @request GET:/users/activity/summary - * @secure - * @response `200` `UserActivitySummary` Successfully retrieved activity summary. - * @response `401` `Error` - * @response `404` `Error` - * @response `500` `Error` - */ - getUserActivitySummary = (params: RequestParams = {}) => - this.request({ - path: `/users/activity/summary`, - method: "GET", - secure: true, - format: "json", - ...params, - }); - /** - * @description Retrieves a breakdown of user spending by category based on purchase data. - * - * @tags User Management - * @name GetUserSpendingAnalytics - * @summary Get user spending analytics - * @request GET:/users/analytics/spending - * @secure - * @response `200` `SpendingAnalyticsResponse` Spending analytics retrieved successfully - * @response `401` `Error` - * @response `404` `Error` - * @response `500` `Error` - */ - getUserSpendingAnalytics = (params: RequestParams = {}) => - this.request({ - path: `/users/analytics/spending`, - method: "GET", - secure: true, - format: "json", - ...params, - }); } diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index f4ef8ec..074fe75 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -263,26 +263,6 @@ export interface ApiKeyUsage { }[]; } -export interface StoreConsentDetail { - /** - * The unique identifier for the store. - * @example "60d5ecb8b48f4d001f9e8f8a" - */ - storeId: string; - /** - * The name of the store. - * @example "Example Electronics Store" - */ - name: string; -} - -export interface StoreConsentList { - /** List of stores the user has opted into sharing data with. */ - optInStores: StoreConsentDetail[]; - /** List of stores the user has opted out of sharing data with. */ - optOutStores: StoreConsentDetail[]; -} - export interface HealthStatus { /** Overall health status of the Health */ status?: "healthy" | "degraded" | "unhealthy"; @@ -330,77 +310,6 @@ export interface UserMetadataResponse { }; } -/** Attribute within a taxonomy category */ -export interface TaxonomyAttribute { - name: string; - values: string[]; - description?: string | null; -} - -/** Category within a taxonomy system */ -export interface TaxonomyCategory { - id: string; - name: string; - parent_id?: string | null; - description?: string | null; - /** @default [] */ - attributes?: TaxonomyAttribute[]; -} - -/** Complete taxonomy definition with categories and version */ -export interface Taxonomy { - _id?: string; - categories: TaxonomyCategory[]; - version: string; -} - -export interface ActivityByStore { - /** The unique identifier for the store. */ - storeId: string; - /** The name of the store. */ - name: string; - /** The number of activities associated with this store. */ - count: number; -} - -export interface UserActivitySummary { - recentApiUsage: { - /** Total number of API calls accessing the user's data recently. */ - total?: number; - /** Breakdown of API calls by store. */ - byStore?: ActivityByStore[]; - }; - recentSubmissions: { - /** Total number of data submissions made about the user recently. */ - total?: number; - /** Breakdown of data submissions by store. */ - byStore?: ActivityByStore[]; - }; -} - -export interface SpendingBreakdownItem { - /** The ID of the spending category from the taxonomy. */ - categoryId: string; - /** The name of the spending category. */ - categoryName: string; - /** - * The total amount spent in this category. - * @format double - */ - totalSpent: number; -} - -export interface SpendingAnalyticsResponse { - /** The user's unique identifier. */ - userId: string; - /** - * Description of the time period covered (e.g., "Last 30 days"). - * @example "All Time" - */ - timeframe: string; - spendingBreakdown: SpendingBreakdownItem[]; -} - export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index fd872d5..c15dc28 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -26,9 +26,6 @@ export const cacheKeys = { all: ["users"], profile: () => [...cacheKeys.users.all, "profile"], preferences: () => [...cacheKeys.users.all, "preferences"], - activitySummary: () => [...cacheKeys.users.all, "activitySummary"], // <-- New - spendingAnalytics: () => [...cacheKeys.users.all, "spendingAnalytics"], // <-- New - storeConsent: () => [...cacheKeys.users.all, "storeConsent"], // <-- New (or rename if exists) }, stores: { all: ["stores"], @@ -43,7 +40,6 @@ export const cacheKeys = { system: { health: () => ["system", "health"], ping: () => ["system", "ping"], - taxonomy: () => ["system", "taxonomy"], // <-- New }, }; @@ -73,27 +69,6 @@ export const cacheSettings = { staleTime: CACHE_TIMES.SHORT, gcTime: CACHE_TIMES.SHORT * 2, }, - // Add settings for new data types - activitySummary: { - // <-- New - staleTime: CACHE_TIMES.MEDIUM, - gcTime: CACHE_TIMES.MEDIUM * 2, - }, - spendingAnalytics: { - // <-- New - staleTime: CACHE_TIMES.LONG, // Analytics might be less frequently updated - gcTime: CACHE_TIMES.LONG * 2, - }, - storeConsent: { - // <-- New - staleTime: CACHE_TIMES.MEDIUM, - gcTime: CACHE_TIMES.MEDIUM * 2, - }, - taxonomy: { - // <-- New - staleTime: CACHE_TIMES.LONG * 2, // Taxonomy changes infrequently - gcTime: CACHE_TIMES.LONG * 4, - }, }; // Helper for optimistic updates diff --git a/web/src/main.tsx b/web/src/main.tsx index f38874d..d101181 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -16,9 +16,6 @@ import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; import UserProfilePage from "./pages/UserProfilePage"; import StoreProfilePage from "./pages/StoreProfilePage"; -import UserPreferencesPage from "./pages/UserPreferencesPage"; -import UserConsentPage from "./pages/UserConsentPage"; -import UserAnalyticsPage from "./pages/UserAnalyticsPage"; const router = createBrowserRouter([ { @@ -49,24 +46,11 @@ const router = createBrowserRouter([ path: "dashboard/user", element: , }, + // Add User Profile Route { - path: "profile/user", // Base profile page + path: "profile/user", element: , }, - // --- Add these new routes --- - { - path: "profile/user/preferences", - element: , - }, - { - path: "profile/user/consent", - element: , - }, - { - path: "profile/user/analytics", - element: , - }, - // --- End of new routes --- ], }, // --- Protected Store Routes --- @@ -77,11 +61,11 @@ const router = createBrowserRouter([ path: "dashboard/store", element: , }, + // Add Store Profile Route { path: "profile/store", element: , }, - // Add store-specific sub-routes here if needed ], }, // --- Catch-all 404 Route --- diff --git a/web/src/pages/UserAnalyticsPage.tsx b/web/src/pages/UserAnalyticsPage.tsx deleted file mode 100644 index a6b1371..0000000 --- a/web/src/pages/UserAnalyticsPage.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - useUserActivitySummary, - useSpendingAnalytics, -} from "../api/hooks/useUserHooks"; -import LoadingSpinner from "../components/common/LoadingSpinner"; -import ErrorDisplay from "../components/common/ErrorDisplay"; -import { - Card, - Table, - TableHeadCell, - TableHead, - TableBody, - TableCell, - TableRow, -} from "flowbite-react"; -import { - SpendingBreakdownItem, - ActivityByStore, -} from "../api/types/data-contracts"; // Import types - -export default function UserAnalyticsPage() { - const { - data: activitySummary, - isLoading: activityLoading, - error: activityError, - } = useUserActivitySummary(); - const { - data: spendingAnalytics, - isLoading: spendingLoading, - error: spendingError, - } = useSpendingAnalytics(); - - const isLoading = activityLoading || spendingLoading; - const error = activityError || spendingError; - - if (isLoading) { - return ; - } - - if (error) { - return ( - - ); - } - - // Add types to variables - const apiUsageByStore: ActivityByStore[] = - activitySummary?.recentApiUsage?.byStore || []; - const submissionsByStore: ActivityByStore[] = - activitySummary?.recentSubmissions?.byStore || []; - const spendingBreakdown: SpendingBreakdownItem[] = - spendingAnalytics?.spendingBreakdown || []; - - return ( -
-

- Detailed Analytics -

-

- Review detailed breakdowns of your data usage, submissions, and spending - patterns. (More features like date filtering coming soon!) -

- - {/* Data Usage Details */} - -

- Data Usage by Stores (Last 30 Days) -

- {apiUsageByStore.length > 0 ? ( - - - Store Name - Store ID - API Calls - - - {apiUsageByStore.map( - ( - item, // Type is inferred from apiUsageByStore - ) => ( - - - {item.name} - - {item.storeId} - {item.count} - - ), - )} - -
- ) : ( -

- No API usage data found for the last 30 days. -

- )} -
- - {/* Data Submission Details */} - -

- Data Submissions by Stores (Last 30 Days) -

- {submissionsByStore.length > 0 ? ( - - - Store Name - Store ID - Submissions - - - {submissionsByStore.map( - ( - item, // Type is inferred from submissionsByStore - ) => ( - - - {item.name} - - {item.storeId} - {item.count} - - ), - )} - -
- ) : ( -

- No data submissions found for the last 30 days. -

- )} -
- - {/* Spending Breakdown Details */} - -

- Spending Breakdown by Category -

- {spendingBreakdown.length > 0 ? ( - - - Category Name - Category ID - Total Spent - - - {/* Add explicit type SpendingBreakdownItem to item */} - {spendingBreakdown.map((item: SpendingBreakdownItem) => ( - - - {item.categoryName} - - {item.categoryId} - ${item.totalSpent.toFixed(2)} - - ))} - -
- ) : ( -

- No spending data available. -

- )} -
-
- ); -} diff --git a/web/src/pages/UserConsentPage.tsx b/web/src/pages/UserConsentPage.tsx deleted file mode 100644 index e4bff3c..0000000 --- a/web/src/pages/UserConsentPage.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - useStoreConsentLists, - useOptInToStore, - useOptOutFromStore, -} from "../api/hooks/useUserHooks"; -import LoadingSpinner from "../components/common/LoadingSpinner"; -import ErrorDisplay from "../components/common/ErrorDisplay"; -import { Card, List, Button, ListItem, Spinner } from "flowbite-react"; // Import Spinner -import { HiArrowRight, HiArrowLeft } from "react-icons/hi"; - -export default function UserConsentPage() { - const { - data: consentData, - isLoading: consentLoading, - error: consentError, - refetch, - } = useStoreConsentLists(); - - const optInMutation = useOptInToStore(); - const optOutMutation = useOptOutFromStore(); - - const handleOptOut = (storeId: string) => { - optOutMutation.mutate(storeId, { - onSettled: () => { - refetch(); - }, - }); - }; - - const handleOptIn = (storeId: string) => { - optInMutation.mutate(storeId, { - onSettled: () => { - refetch(); - }, - }); - }; - - if (consentLoading) { - return ; - } - - if (consentError) { - return ( - - ); - } - - const optedInStores = consentData?.optInStores || []; - const optedOutStores = consentData?.optOutStores || []; - - const isMutating = optInMutation.isPending || optOutMutation.isPending; - - return ( -
-

- Manage Data Sharing Consent -

-

- Control which stores are allowed to access your data for personalized - experiences and which are blocked. Moving a store to the 'Opted Out' - list prevents them from accessing your data via the API. -

- -
- {/* Opted-In Stores Card */} - -

- Allowed Stores (Opted In) -

- {optedInStores.length > 0 ? ( - - {optedInStores.map((store) => { - const isProcessingThis = - optOutMutation.isPending && - optOutMutation.variables === store.storeId; - return ( - - - {store.name} ({store.storeId.slice(-6)}) - - - - ); - })} - - ) : ( -

- You haven't explicitly allowed any stores yet. Stores may be added - here automatically when you interact with them, unless they are in - the 'Opted Out' list. -

- )} -
- - {/* Opted-Out Stores Card */} - -

- Blocked Stores (Opted Out) -

- {optedOutStores.length > 0 ? ( - - {optedOutStores.map((store) => { - const isProcessingThis = - optInMutation.isPending && - optInMutation.variables === store.storeId; - return ( - - - {store.name} ({store.storeId.slice(-6)}) - - - - ); - })} - - ) : ( -

- No stores are currently blocked. -

- )} -
-
-
- ); -} diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 623494d..8975452 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -1,96 +1,10 @@ -import { - useUserProfile, - useUserActivitySummary, - useSpendingAnalytics, - useUserPreferences, -} from "../api/hooks/useUserHooks"; -import { useTaxonomy } from "../api/hooks/useSystemHooks"; -import { Link } from "react-router"; // Correct import for react-router v6+ -import { - Card, - List, - ListItem, - Progress, - Button, // Import Button -} from "flowbite-react"; -import { - ResponsiveContainer, - PieChart, - Pie, - Cell, - Tooltip, - Legend, -} from "recharts"; +import { Card } from "flowbite-react"; +import { useUserProfile } from "../api/hooks/useUserHooks"; import LoadingSpinner from "../components/common/LoadingSpinner"; import ErrorDisplay from "../components/common/ErrorDisplay"; -import { SpendingBreakdownItem } from "../api/types/data-contracts"; -import { HiArrowRight } from "react-icons/hi"; // Import icon - -// Define some colors for the pie chart -const COLORS = [ - "#0088FE", - "#00C49F", - "#FFBB28", - "#FF8042", - "#8884D8", - "#82ca9d", - "#FA8072", - "#7D3C98", -]; export default function UserDashboard() { - // Fetch all necessary data - const { - data: profile, - isLoading: profileLoading, - error: profileError, - } = useUserProfile(); - const { - data: activitySummary, - isLoading: activityLoading, - error: activityError, - } = useUserActivitySummary(); - const { - data: spendingAnalytics, - isLoading: spendingLoading, - error: spendingError, - } = useSpendingAnalytics(); - const { - data: userPreferences, - isLoading: preferencesLoading, - error: preferencesError, - } = useUserPreferences(); - const { - data: taxonomyData, - isLoading: taxonomyLoading, - error: taxonomyError, - } = useTaxonomy(); - - // Combined loading and error states - const isLoading = - profileLoading || - activityLoading || - spendingLoading || - preferencesLoading || - taxonomyLoading; - const error = - profileError || - activityError || - spendingError || - preferencesError || - taxonomyError; - - // Create taxonomy map once data is loaded - const taxonomyMap = - !taxonomyLoading && taxonomyData?.categories - ? taxonomyData.categories.reduce( - (map, cat) => { - map[cat.id] = cat.name; - return map; - }, - {} as Record, - ) - : {}; + const { data: profile, isLoading, error } = useUserProfile(); if (isLoading) { return ; @@ -100,253 +14,30 @@ export default function UserDashboard() { return ( ); } - // Prepare data for spending chart - ensure value is a number - const spendingChartData = - spendingAnalytics?.spendingBreakdown - ?.map((item: SpendingBreakdownItem) => ({ - name: taxonomyMap[item.categoryId] || item.categoryName, // Use taxonomy map for name - value: Number(item.totalSpent) || 0, // Ensure value is a number - })) - .filter((item) => item.value > 0) // Filter out zero-value items for cleaner chart - .sort((a, b) => b.value - a.value) || []; // Sort descending - - // Prepare simplified preference overview - const preferenceOverview = - userPreferences?.preferences - ?.slice(0, 5) // Take top 5 based on score (assuming sorted) - .sort((a, b) => b.score - a.score) || []; // Ensure sorted by score desc - return ( -
-

- Welcome, {profile?.username || "User"}! +
+ {/* Add dark mode text color */} +

+ User Dashboard

- - {/* Grid layout for cards */} -
- {/* Activity Summary Card */} - -

- Recent Activity (Last 30 Days) -

-
-
-

- Data Usage by Stores -

-

- Total API calls:{" "} - - {activitySummary?.recentApiUsage?.total ?? 0} - -

- {activitySummary?.recentApiUsage?.byStore && - activitySummary.recentApiUsage.byStore.length > 0 ? ( - - {activitySummary.recentApiUsage.byStore - .slice(0, 3) // Show top 3 - .map((item) => ( - - {item.name}: {item.count} call(s) - - ))} - {activitySummary.recentApiUsage.byStore.length > 3 && ( - - ... and others - - )} - - ) : ( -

- No recent API usage detected. -

- )} -
-
-
-

- New Data Submissions -

-

- Total submissions:{" "} - - {activitySummary?.recentSubmissions?.total ?? 0} - -

- {activitySummary?.recentSubmissions?.byStore && - activitySummary.recentSubmissions.byStore.length > 0 ? ( - - {activitySummary.recentSubmissions.byStore - .slice(0, 3) // Show top 3 - .map((item) => ( - - {item.name}: {item.count} submission(s) - - ))} - {activitySummary.recentSubmissions.byStore.length > 3 && ( - - ... and others - - )} - - ) : ( -

- No recent data submissions detected. -

- )} -
-
-
- -
-
- - {/* Spending Analytics Card */} - -

- Spending Patterns (All Time) -

- {spendingChartData.length > 0 ? ( -
- - - `$${value.toFixed(2)}`} - /> - - `${(percent * 100).toFixed(0)}%`} - > - {spendingChartData.map((_entry, index) => ( - - ))} - - - -
- ) : ( -
-

- No spending data available to display chart. -

-
- )} -
- {/* Link to analytics page is already in the Activity card, maybe remove duplicate? Or keep for consistency */} - -
-
- - {/* Preference Profile Overview Card */} - -

- Your Preference Profile (Top 5) -

- {preferenceOverview.length > 0 ? ( - - {preferenceOverview.map((pref) => ( - -
- - {taxonomyMap[pref.category] || pref.category} - - - {(pref.score * 100).toFixed(0)}% - -
- -
- ))} -
- ) : ( -

- No preferences generated yet. Start interacting with services! -

- )} -
- -
-
- - {/* Consent Management Link Card */} - -

- Data Sharing Control -

-

- Review and manage which stores can access your data for personalized - experiences. -

-
- {" "} - {/* Push button to bottom */} - -
-
-
+ + {/* Add dark mode text color */} +

+ Welcome back, {profile?.username || "User"}! +

+ {/* Add dark mode text color */} +

+ Here you can manage your preferences, control data sharing with + stores, and view your usage analytics. (Content coming soon!) +

+ {/* Add User Preference Management and Analytics sections later */} +
); } diff --git a/web/src/pages/UserPreferencesPage.tsx b/web/src/pages/UserPreferencesPage.tsx deleted file mode 100644 index b608fe4..0000000 --- a/web/src/pages/UserPreferencesPage.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { useState, useEffect } from "react"; -import { - useUserPreferences, - useUpdateUserPreferences, -} from "../api/hooks/useUserHooks"; -import { useTaxonomy } from "../api/hooks/useSystemHooks"; -import LoadingSpinner from "../components/common/LoadingSpinner"; -import ErrorDisplay from "../components/common/ErrorDisplay"; -import { - Card, - RangeSlider, - Button, - Toast, - Spinner, - ToastToggle, - Table, // Import Table components - TableHead, - TableHeadCell, - TableBody, - TableRow, - TableCell, -} from "flowbite-react"; -import { HiCheck, HiExclamation } from "react-icons/hi"; -import { PreferenceItem } from "../api/types/data-contracts"; - -export default function UserPreferencesPage() { - const { - data: preferencesData, - isLoading: preferencesLoading, - error: preferencesError, - refetch, // Add refetch - } = useUserPreferences(); - const { - data: taxonomyData, - isLoading: taxonomyLoading, - error: taxonomyError, - } = useTaxonomy(); - const updateUserPreferences = useUpdateUserPreferences(); - - const [editablePreferences, setEditablePreferences] = useState< - PreferenceItem[] - >([]); - const [showSuccessToast, setShowSuccessToast] = useState(false); - const [showErrorToast, setShowErrorToast] = useState(false); - - useEffect(() => { - // Sort preferences alphabetically by category name using the map - if (preferencesData?.preferences && taxonomyData?.categories) { - const map = taxonomyData.categories.reduce( - (acc, cat) => { - acc[cat.id] = cat.name; - return acc; - }, - {} as Record, - ); - const sortedPrefs = [...preferencesData.preferences].sort((a, b) => { - const nameA = map[a.category] || a.category; - const nameB = map[b.category] || b.category; - return nameA.localeCompare(nameB); - }); - setEditablePreferences(sortedPrefs); - } else if (preferencesData?.preferences) { - // Fallback sort by ID if taxonomy isn't ready - const sortedById = [...preferencesData.preferences].sort((a, b) => - a.category.localeCompare(b.category), - ); - setEditablePreferences(sortedById); - } - }, [preferencesData, taxonomyData]); // Depend on both - - // Create taxonomy map once data is loaded - const taxonomyMap = - !taxonomyLoading && taxonomyData?.categories - ? taxonomyData.categories.reduce( - (map, cat) => { - map[cat.id] = cat.name; - return map; - }, - {} as Record, - ) - : {}; - - // Handle slider changes - const handleScoreChange = (category: string, newScore: number) => { - setEditablePreferences((prev) => - prev.map((pref) => - pref.category === category - ? { ...pref, score: newScore / 100 } // Ensure score is between 0 and 1 - : pref, - ), - ); - }; - - const handleSaveChanges = async () => { - setShowSuccessToast(false); - setShowErrorToast(false); - try { - await updateUserPreferences.mutateAsync( - { - preferences: editablePreferences, - }, - { - onSuccess: () => { - setShowSuccessToast(true); - refetch(); // Refetch preferences after successful save - }, - onError: () => { - setShowErrorToast(true); - }, - }, - ); - } catch (err) { - // This catch might not be needed if using onSuccess/onError callbacks - console.error("Failed to update preferences:", err); - setShowErrorToast(true); - } - }; - - const isLoading = preferencesLoading || taxonomyLoading; - const error = preferencesError || taxonomyError; - - if (isLoading) { - return ; - } - - if (error) { - return ( - - ); - } - - return ( -
-
-
-

- Manage Your Preferences -

-

- Adjust the scores below to reflect your interests (0% = Not - Interested, 100% = Very Interested). -

-
- {/* Save Button - Moved to top right */} - -
- - {/* Success Toast */} - {showSuccessToast && ( - - {" "} - {/* Position toast */} -
- -
-
- Preferences updated successfully. -
- setShowSuccessToast(false)} /> -
- )} - - {/* Error Toast */} - {showErrorToast && ( - - {" "} - {/* Position toast */} -
- -
-
- Failed to update preferences. Please try again. -
- setShowErrorToast(false)} /> -
- )} - - - {editablePreferences.length > 0 ? ( -
- {" "} - {/* Make table responsive */} - - - Interest Category - - Interest Level - {" "} - {/* Min width for slider */} - Score - - - {editablePreferences.map((pref) => ( - - - {taxonomyMap[pref.category] || pref.category}{" "} - {/* Display Name */} - - - - handleScoreChange( - pref.category, - parseInt(e.target.value, 10), - ) - } - disabled={updateUserPreferences.isPending} - className="w-full" // Ensure slider takes width - /> - - - {(pref.score * 100).toFixed(0)}% {/* Display Score */} - - - ))} - -
-
- ) : ( -

- No preferences found. Your preferences will be generated as you - interact or submit data. -

- )} -
-
- ); -} From 7cb495684737c359dcb217b7d22872b7e040a68a Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 16:19:34 +0530 Subject: [PATCH 05/34] feat: Add user management endpoints for recent data submissions and spending analytics --- api-service/api/openapi.yaml | 141 +++++++++++++++++++++ api-service/controllers/StoreProfile.js | 10 ++ api-service/controllers/UserProfile.js | 21 +++ api-service/service/StoreProfileService.js | 49 +++++++ api-service/service/TaxonomyService.js | 9 +- api-service/service/UserProfileService.js | 138 +++++++++++++++++++- api-service/utils/cacheConfig.js | 1 + compose.yml | 1 + web/src/api/types/Stores.ts | 24 ++++ web/src/api/types/Taxonomy.ts | 37 ++++++ web/src/api/types/Users.ts | 69 ++++++++++ web/src/api/types/data-contracts.ts | 83 ++++++++++++ 12 files changed, 578 insertions(+), 5 deletions(-) create mode 100644 web/src/api/types/Taxonomy.ts diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index fcb97f2..dce3e93 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -609,6 +609,97 @@ paths: $ref: "#/components/responses/InternalServerError" x-swagger-router-controller: Taxonomy + /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. + operationId: getRecentUserData + parameters: + - name: limit + in: query + description: Maximum number of records to return + required: false + schema: + type: integer + default: 10 + - name: page + in: query + description: Page number for pagination + required: false + schema: + type: integer + default: 1 + responses: + "200": + description: Recent data submissions retrieved successfully + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RecentUserDataEntry" + "401": + $ref: "#/components/responses/UnauthorizedError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] + x-swagger-router-controller: UserProfile + + /users/analytics/spending: + get: + tags: [User Management] + summary: Get User Spending Analytics + description: Retrieves aggregated spending data categorized by taxonomy for the authenticated user. + operationId: getSpendingAnalytics + responses: + "200": + description: Spending analytics retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SpendingAnalytics" + "401": + $ref: "#/components/responses/UnauthorizedError" + "500": + $ref: "#/components/responses/InternalServerError" + security: + - oauth2: [user:read] + x-swagger-router-controller: UserProfile + + /stores/lookup: + get: + tags: [Store Management] + summary: Lookup Store Details + description: Retrieves basic details (like name) for a list of store IDs. + operationId: lookupStores + parameters: + - name: ids + in: query + description: Comma-separated list of store IDs to lookup. + required: true + schema: + type: string + responses: + "200": + description: Store details 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: @@ -1233,6 +1324,56 @@ components: version: type: string + RecentUserDataEntry: + type: object + 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. + dataType: + type: string + enum: [purchase, search] + description: The type of data submitted. + timestamp: + type: string + format: date-time + description: When the data was submitted to Tapiro. + entryTimestamp: + type: string + format: date-time + description: The timestamp of the original event (e.g., purchase time). + details: + type: object + description: Simplified details (e.g., item count for purchase, query string for search) + + SpendingAnalytics: + type: object + description: Aggregated spending data per category. + additionalProperties: + type: number + format: float + description: Total amount spent in this category. + example: + "Electronics": 1299.99 + "Clothing": 250.50 + "Home": 85.00 + + StoreBasicInfo: + type: object + properties: + storeId: + type: string + description: The unique ID of the store. + name: + type: string + description: The name of the store. + required: + - storeId + - name + responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/controllers/StoreProfile.js b/api-service/controllers/StoreProfile.js index b75acfc..ef368da 100644 --- a/api-service/controllers/StoreProfile.js +++ b/api-service/controllers/StoreProfile.js @@ -29,4 +29,14 @@ module.exports.deleteStoreProfile = function deleteStoreProfile(req, res, next) .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.lookupStores = function lookupStores(req, res, next, ids) { + StoreProfile.lookupStores(req, ids) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((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 b6868c5..0364e2e 100644 --- a/api-service/controllers/UserProfile.js +++ b/api-service/controllers/UserProfile.js @@ -29,4 +29,25 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) { .catch((response) => { utils.writeJson(res, response); }); +}; + +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); + }) + .catch((response) => { + utils.writeJson(res, response); + }); +}; + +module.exports.getSpendingAnalytics = function getSpendingAnalytics(req, res, next) { + UserProfile.getSpendingAnalytics(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 f9a4e8f..ce5677b 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -4,6 +4,7 @@ const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); const { deleteAuth0User } = require('../utils/auth0Util'); +const { ObjectId } = require('mongodb'); // Import ObjectId /** * Get Store Profile @@ -117,3 +118,51 @@ exports.deleteStoreProfile = async function (req) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Lookup Store Details + * Retrieves basic details (like name) for a list of store IDs. + */ +exports.lookupStores = async function (req, ids) { + try { + if (!ids) { + return respondWithCode(400, { code: 400, message: 'Missing required query parameter: ids' }); + } + + const storeIds = ids.split(','); + + // Optional: Validate if IDs are in ObjectId format if needed + // const validObjectIds = storeIds.filter(id => ObjectId.isValid(id)).map(id => new ObjectId(id)); + // if (validObjectIds.length !== storeIds.length) { + // return respondWithCode(400, { code: 400, message: 'One or more invalid store ID formats provided.' }); + // } + + const db = getDB(); + + const stores = await db.collection('stores') + .find({ _id: { $in: storeIds.map(id => new ObjectId(id)) } }) // Use ObjectId for lookup if IDs are ObjectIds + // If store IDs are stored as strings in optIn/optOut lists, use: + // .find({ _id: { $in: storeIds } }) + .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 + })); + + // Caching could be considered if lookups for the same set of IDs are common, + // but the cache key generation might be complex. + + return respondWithCode(200, formattedStores); + + } catch (error) { + console.error('Lookup stores failed:', error); + // Handle potential ObjectId format errors if validation is strict + if (error.message.includes('Argument passed in must be a single String')) { + return respondWithCode(400, { code: 400, message: 'Invalid store ID format provided.' }); + } + return respondWithCode(500, { code: 500, message: 'Internal server error' }); + } +}; diff --git a/api-service/service/TaxonomyService.js b/api-service/service/TaxonomyService.js index d22db4c..6991488 100644 --- a/api-service/service/TaxonomyService.js +++ b/api-service/service/TaxonomyService.js @@ -8,7 +8,8 @@ const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); * Retrieves the full taxonomy structure from the MongoDB 'taxonomy' collection. */ exports.getTaxonomyCategories = async function () { - const cacheKey = CACHE_KEYS.TAXONOMY_FULL; + // Use the correct cache key defined in cacheConfig.js + const cacheKey = CACHE_KEYS.TAXONOMY; // Changed from TAXONOMY_FULL try { // Check cache first const cachedTaxonomy = await getCache(cacheKey); @@ -24,7 +25,7 @@ exports.getTaxonomyCategories = async function () { const db = getDB(); // Assuming the taxonomy is stored as a single document in the 'taxonomy' collection. // Adjust the query if the structure is different (e.g., findOne({ _id: 'current_taxonomy' })) - const taxonomyDoc = await db.collection('taxonomy').findOne({}); // Find the first/only document + const taxonomyDoc = await db.collection('taxonomy').findOne({ current: true }); // Find the current taxonomy if (!taxonomyDoc) { return respondWithCode(404, { code: 404, message: 'Taxonomy data not found in database' }); @@ -32,7 +33,9 @@ exports.getTaxonomyCategories = async function () { // Cache the result - Use a longer TTL for taxonomy structure // Store the raw document including _id in cache - await setCache(cacheKey, JSON.stringify(taxonomyDoc), { EX: CACHE_TTL.TAXONOMY }); // Use TAXONOMY TTL + // Use a specific TTL for taxonomy if defined, otherwise fallback or use a default + const taxonomyTTL = CACHE_TTL.TAXONOMY || CACHE_TTL.LONG || 3600 * 24; // Example: Use TAXONOMY TTL or fallback + await setCache(cacheKey, JSON.stringify(taxonomyDoc), { EX: taxonomyTTL }); // Remove MongoDB _id before returning if it's not part of the defined schema response // delete taxonomyDoc._id; // Optional: remove _id if not needed in response diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 100219a..3774f7a 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -3,8 +3,7 @@ const { setCache, getCache, invalidateCache } = require('../utils/redisUtil'); const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); -// Import the new function -const { updateUserMetadata, updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); +const {updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); /** * Get User Profile @@ -214,3 +213,138 @@ exports.deleteUserProfile = async function (req) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * 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) { + try { + const db = getDB(); + const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + + // Find user to get their internal _id + const user = await db.collection('users').findOne({ auth0Id: userData.sub }, { projection: { _id: 1 } }); + if (!user) { + 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); + + } catch (error) { + console.error('Get recent user data failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error' }); + } +}; + +/** + * Get User Spending Analytics + * Retrieves aggregated spending data categorized by taxonomy for the authenticated user. + */ +exports.getSpendingAnalytics = async function (req) { + try { + const db = getDB(); + const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + + // Find user to get their internal _id + const user = await db.collection('users').findOne({ auth0Id: userData.sub }, { projection: { _id: 1 } }); + if (!user) { + return respondWithCode(404, { code: 404, message: 'User not found' }); + } + + // Fetch the taxonomy once to map category IDs to names + // Use the filter { current: true } if you only want the active taxonomy + const taxonomyDoc = await db.collection('taxonomy').findOne({ current: true }); // Or findOne({}) if 'current' flag isn't always used + + // Correctly access the categories array via taxonomyDoc.data.categories + const categoryMap = (taxonomyDoc && taxonomyDoc.data && taxonomyDoc.data.categories) + ? taxonomyDoc.data.categories.reduce((map, cat) => { + map[cat.id] = cat.name; + return map; + }, {}) + : {}; // Default to empty map if taxonomy, data, or categories are missing + + + const pipeline = [ + { $match: { userId: user._id, dataType: 'purchase' } }, + { $unwind: '$entries' }, + { $unwind: '$entries.items' }, + { + $group: { + _id: '$entries.items.category', + totalSpent: { + $sum: { + $cond: { + if: { $and: [ + { $isNumber: '$entries.items.price' }, + { $isNumber: '$entries.items.quantity' } + ]}, + then: { $multiply: ['$entries.items.price', '$entries.items.quantity'] }, + else: { $cond: { if: { $isNumber: '$entries.items.price' }, then: '$entries.items.price', else: 0 } } + } + } + } + } + }, + { + $project: { + _id: 0, + category: '$_id', + totalSpent: 1 + } + } + ]; + + const results = await db.collection('userData').aggregate(pipeline).toArray(); + + // Transform results using the categoryMap (this part remains the same) + const spendingAnalytics = results.reduce((acc, item) => { + const categoryName = categoryMap[item.category] || item.category; // Use name from map, fallback to ID + acc[categoryName] = (acc[categoryName] || 0) + item.totalSpent; + return acc; + }, {}); + + // Caching could be added here + // const cacheKey = `${CACHE_KEYS.USER_SPENDING_ANALYTICS}${user._id}`; + // await setCache(cacheKey, JSON.stringify(spendingAnalytics), { EX: CACHE_TTL.MEDIUM }); + + return respondWithCode(200, spendingAnalytics); + + } catch (error) { + console.error('Get spending analytics failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error' }); + } +}; diff --git a/api-service/utils/cacheConfig.js b/api-service/utils/cacheConfig.js index cd0fd6c..a07e3ff 100644 --- a/api-service/utils/cacheConfig.js +++ b/api-service/utils/cacheConfig.js @@ -22,6 +22,7 @@ const CACHE_KEYS = { ADMIN_TOKEN: 'auth0_management_token', // Auth0 management token PREFERENCES: 'preferences:', // User preferences STORE_PREFERENCES: 'prefs:', // Store preferences + TAXONOMY: 'taxonomy:current', // <-- Add this line AI_REQUEST: 'ai_request:', // AI service request cache }; diff --git a/compose.yml b/compose.yml index 6c2062c..eb25bb3 100644 --- a/compose.yml +++ b/compose.yml @@ -29,6 +29,7 @@ services: - AUTH0_M2M_CLIENT_SECRET=${AUTH0_M2M_CLIENT_SECRET} - AI_SERVICE_URL=http://ml-service:8000/api - AI_SERVICE_API_KEY=${AI_SERVICE_API_KEY} + - AUTH0_DOMAIN=${AUTH0_DOMAIN} ports: - "3000:3000" depends_on: diff --git a/web/src/api/types/Stores.ts b/web/src/api/types/Stores.ts index 834e777..ce70675 100644 --- a/web/src/api/types/Stores.ts +++ b/web/src/api/types/Stores.ts @@ -17,7 +17,9 @@ import { ApiKeyUsage, Error, GetApiKeyUsagePayload, + LookupStoresParams, Store, + StoreBasicInfo, StoreCreate, StoreUpdate, } from "./data-contracts"; @@ -205,4 +207,26 @@ export class Stores< format: "json", ...params, }); + /** + * @description Retrieves basic details (like name) for a list of store IDs. + * + * @tags Store Management + * @name LookupStores + * @summary Lookup Store Details + * @request GET:/stores/lookup + * @secure + * @response `200` `(StoreBasicInfo)[]` Store details retrieved successfully. + * @response `400` `Error` + * @response `401` `Error` + * @response `500` `Error` + */ + lookupStores = (query: LookupStoresParams, params: RequestParams = {}) => + this.request({ + path: `/stores/lookup`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); } diff --git a/web/src/api/types/Taxonomy.ts b/web/src/api/types/Taxonomy.ts new file mode 100644 index 0000000..c199b64 --- /dev/null +++ b/web/src/api/types/Taxonomy.ts @@ -0,0 +1,37 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +import { Error, Taxonomy } from "./data-contracts"; +import { HttpClient, RequestParams } from "./http-client"; + +export class Taxonomy< + SecurityDataType = unknown, +> extends HttpClient { + /** + * @description Retrieves the full taxonomy structure from the database. + * + * @tags Taxonomy + * @name GetTaxonomyCategories + * @summary Get Taxonomy Categories + * @request GET:/taxonomy/categories + * @response `200` `Taxonomy` Successfully retrieved the taxonomy. + * @response `404` `Error` Taxonomy data not found in the database. + * @response `500` `Error` + */ + getTaxonomyCategories = (params: RequestParams = {}) => + this.request({ + path: `/taxonomy/categories`, + method: "GET", + format: "json", + ...params, + }); +} diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index 48d1c4a..54fff83 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -12,6 +12,10 @@ import { Error, + GetRecentUserDataParams, + RecentUserDataEntry, + SpendingAnalytics, + StoreConsentList, User, UserCreate, UserData, @@ -244,6 +248,27 @@ export class Users< secure: true, ...params, }); + /** + * @description Retrieves the lists of store IDs the user has explicitly opted into or opted out of sharing data with. + * + * @tags Preference Management + * @name GetStoreConsentLists + * @summary Get user's store opt-in/out lists + * @request GET:/users/preferences/store-consent + * @secure + * @response `200` `StoreConsentList` Successfully retrieved store consent lists. + * @response `401` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + getStoreConsentLists = (params: RequestParams = {}) => + this.request({ + path: `/users/preferences/store-consent`, + method: "GET", + secure: true, + format: "json", + ...params, + }); /** * @description Retrieve Auth0 metadata for the authenticated user * @@ -264,4 +289,48 @@ export class Users< format: "json", ...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` + */ + getRecentUserData = ( + query: GetRecentUserDataParams, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/data/recent`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }); + /** + * @description Retrieves aggregated spending data categorized by taxonomy for the authenticated user. + * + * @tags User Management + * @name GetSpendingAnalytics + * @summary Get User Spending Analytics + * @request GET:/users/analytics/spending + * @secure + * @response `200` `SpendingAnalytics` Spending analytics retrieved successfully + * @response `401` `Error` + * @response `500` `Error` + */ + getSpendingAnalytics = (params: RequestParams = {}) => + this.request({ + path: `/users/analytics/spending`, + method: "GET", + secure: true, + format: "json", + ...params, + }); } diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 074fe75..27679d1 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -263,6 +263,13 @@ export interface ApiKeyUsage { }[]; } +export interface StoreConsentList { + /** List of store IDs the user has opted into. */ + optInStores?: string[]; + /** List of store IDs the user has opted out of. */ + optOutStores?: string[]; +} + export interface HealthStatus { /** Overall health status of the Health */ status?: "healthy" | "degraded" | "unhealthy"; @@ -310,6 +317,64 @@ export interface UserMetadataResponse { }; } +/** Attribute within a taxonomy category */ +export interface TaxonomyAttribute { + name: string; + values: string[]; + description?: string | null; +} + +/** Category within a taxonomy system */ +export interface TaxonomyCategory { + id: string; + name: string; + parent_id?: string | null; + description?: string | null; + /** @default [] */ + attributes?: TaxonomyAttribute[]; +} + +/** Complete taxonomy definition with categories and version */ +export interface Taxonomy { + _id?: string; + categories: TaxonomyCategory[]; + version: string; +} + +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 + */ + timestamp?: string; + /** + * The timestamp of the original event (e.g., purchase time). + * @format date-time + */ + entryTimestamp?: string; + /** Simplified details (e.g., item count for purchase, query string for search) */ + details?: object; +} + +/** + * Aggregated spending data per category. + * @example {"Electronics":1299.99,"Clothing":250.5,"Home":85} + */ +export type SpendingAnalytics = Record; + +export interface StoreBasicInfo { + /** The unique ID of the store. */ + storeId: string; + /** The name of the store. */ + name: string; +} + export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data @@ -322,3 +387,21 @@ export interface GetApiKeyUsagePayload { */ endDate?: string; } + +export interface GetRecentUserDataParams { + /** + * Maximum number of records to return + * @default 10 + */ + limit?: number; + /** + * Page number for pagination + * @default 1 + */ + page?: number; +} + +export interface LookupStoresParams { + /** Comma-separated list of store IDs to lookup. */ + ids: string; +} From 18579bfb250638e223b7df59e974791ee258c6c8 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 16:50:37 +0530 Subject: [PATCH 06/34] feat: add recharts dependency and implement taxonomy client - Updated package.json and package-lock.json to include recharts. - Enhanced apiClient to instantiate the Taxonomy client. - Created useTaxonomy hook for fetching taxonomy categories. - Added new hooks in useUserHooks for recent user data, spending analytics, and store consent lists. - Updated cache utility to include new cache keys for recent data and spending analytics. - Expanded routing in main.tsx to include new user dashboard sub-routes for preferences, data sharing, and analytics. --- web/package-lock.json | 386 +++++++++++++++++++++++++- web/package.json | 3 +- web/src/api/apiClient.ts | 17 +- web/src/api/hooks/useSystemHooks.ts | 2 +- web/src/api/hooks/useTaxonomyHooks.ts | 21 ++ web/src/api/hooks/useUserHooks.ts | 66 ++++- web/src/api/utils/cache.ts | 9 + web/src/main.tsx | 16 ++ 8 files changed, 504 insertions(+), 16 deletions(-) create mode 100644 web/src/api/hooks/useTaxonomyHooks.ts diff --git a/web/package-lock.json b/web/package-lock.json index d16e59f..791b6ad 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -17,7 +17,8 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", - "react-router": "^7.4.0" + "react-router": "^7.4.0", + "recharts": "^2.15.3" }, "devDependencies": { "@eslint/js": "^9.23.0", @@ -295,6 +296,18 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", @@ -1787,6 +1800,69 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "license": "MIT" }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", + "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -2732,6 +2808,15 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2845,9 +2930,129 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debounce": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", @@ -2877,6 +3082,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2926,6 +3137,16 @@ "node": ">=8" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "16.5.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", @@ -3300,6 +3521,12 @@ "url": "https://github.com/eta-dev/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, "node_modules/exsolve": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.4.tgz", @@ -3314,6 +3541,15 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -3753,6 +3989,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3814,7 +4059,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4169,7 +4413,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -4179,6 +4422,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4469,6 +4724,15 @@ "node": ">= 6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -4744,6 +5008,23 @@ } } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -4837,6 +5118,12 @@ "react": "*" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -4871,6 +5158,37 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -4900,6 +5218,38 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.3.tgz", + "integrity": "sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, "node_modules/reftools": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", @@ -4910,6 +5260,12 @@ "url": "https://github.com/Mermade/oas-kit?sponsor=1" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -5473,6 +5829,28 @@ "punycode": "^2.1.0" } }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.6.tgz", diff --git a/web/package.json b/web/package.json index 78dfae6..dc0fef0 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,8 @@ "react-dom": "^19.0.0", "react-hook-form": "^7.56.0", "react-icons": "^5.5.0", - "react-router": "^7.4.0" + "react-router": "^7.4.0", + "recharts": "^2.15.3" }, "devDependencies": { "@eslint/js": "^9.23.0", diff --git a/web/src/api/apiClient.ts b/web/src/api/apiClient.ts index 1ee2a6c..a6429ee 100644 --- a/web/src/api/apiClient.ts +++ b/web/src/api/apiClient.ts @@ -2,6 +2,7 @@ import { Users } from "./types/Users"; import { Stores } from "./types/Stores"; import { Health } from "./types/Health"; import { Ping } from "./types/Ping"; +import { Taxonomy } from "./types/Taxonomy"; // <-- Import Taxonomy client // Add useState import import { useEffect, useMemo, useState } from "react"; import { useAuth } from "../hooks/useAuth"; // ← use your context @@ -24,6 +25,9 @@ export function createApiClients() { }, }; } + // Return an empty object or undefined if no securityData is present + // to avoid potential issues with Axios/fetch expecting an object. + return {}; }, }; @@ -33,6 +37,7 @@ export function createApiClients() { stores: new Stores(config), health: new Health(config), ping: new Ping(config), + taxonomy: new Taxonomy(config), // <-- Instantiate Taxonomy client }; } @@ -53,15 +58,16 @@ export function useApiClients() { try { const token = await getAccessToken(); // This now throws on error if (isMounted) { - Object.values(apiClients).forEach( - (c) => c.setSecurityData(token || null), // Should always have token here if no error + // Ensure all clients get the security data + Object.values(apiClients).forEach((c) => + c.setSecurityData(token || null), ); setClientsReady(true); // <-- Set clients as ready AFTER token is set } } catch { - // <-- Remove 'e' from here - // Error fetching token (already logged in getAccessToken) + // Error fetching token if (isMounted) { + // Ensure all clients have security data cleared on error Object.values(apiClients).forEach((c) => c.setSecurityData(null)); setClientsReady(false); // <-- Clients are not ready } @@ -69,6 +75,7 @@ export function useApiClients() { })(); } else { // If not authenticated or still loading, ensure clients are not ready and have no token + // Ensure all clients have security data cleared Object.values(apiClients).forEach((c) => c.setSecurityData(null)); setClientsReady(false); // <-- Clients are not ready } @@ -76,8 +83,6 @@ export function useApiClients() { return () => { isMounted = false; }; - // Add clientsReady to dependency array? No, causes infinite loop. - // The effect should run based on auth state changes. }, [isAuthenticated, authLoading, getAccessToken, apiClients]); // Return clients and the readiness state diff --git a/web/src/api/hooks/useSystemHooks.ts b/web/src/api/hooks/useSystemHooks.ts index c02a272..4a2257b 100644 --- a/web/src/api/hooks/useSystemHooks.ts +++ b/web/src/api/hooks/useSystemHooks.ts @@ -1,7 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; import { cacheKeys, cacheSettings } from "../utils/cache"; -import { HealthStatus, PingStatus } from "../types/data-contracts"; +import { HealthStatus, PingStatus, Error } from "../types/data-contracts"; export function useHealthCheck() { // Destructure apiClients first, then get health from it diff --git a/web/src/api/hooks/useTaxonomyHooks.ts b/web/src/api/hooks/useTaxonomyHooks.ts new file mode 100644 index 0000000..a0dcce1 --- /dev/null +++ b/web/src/api/hooks/useTaxonomyHooks.ts @@ -0,0 +1,21 @@ +import { useQuery } from "@tanstack/react-query"; +import { useApiClients } from "../apiClient"; +import { cacheKeys, CACHE_TIMES } from "../utils/cache"; +import { Taxonomy, Error } from "../types/data-contracts"; + +export function useTaxonomy() { + // Taxonomy doesn't strictly need auth, but uses the same client setup + const { apiClients } = useApiClients(); // No clientsReady check needed if endpoint is public + + return useQuery({ + // Expect Taxonomy type + queryKey: cacheKeys.system.taxonomy(), + queryFn: () => + // Use the taxonomy client + apiClients.taxonomy.getTaxonomyCategories().then((res) => res.data), + // Taxonomy changes infrequently, use longer cache times + staleTime: CACHE_TIMES.LONG, + gcTime: CACHE_TIMES.LONG * 2, + // enabled: clientsReady, // Only needed if endpoint requires auth + }); +} diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 22ee7fa..8b07fe0 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -5,6 +5,9 @@ import { UserPreferencesUpdate, UserUpdate, User, + RecentUserDataEntry, // <-- Add import + SpendingAnalytics, // <-- Add import + StoreConsentList, // <-- Add import } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -92,8 +95,10 @@ export function useOptInToStore() { if (context?.queryKey) { queryClient.invalidateQueries({ queryKey: context.queryKey }); } - // Also invalidate preferences cache as opt-in/out might affect derived data? - // queryClient.invalidateQueries({ queryKey: cacheKeys.users.preferences() }); + // Also invalidate the consent list cache + queryClient.invalidateQueries({ + queryKey: cacheKeys.users.storeConsent(), + }); }, }); } @@ -127,8 +132,10 @@ export function useOptOutFromStore() { if (context?.queryKey) { queryClient.invalidateQueries({ queryKey: context.queryKey }); } - // Also invalidate preferences cache as opt-in/out might affect derived data? - // queryClient.invalidateQueries({ queryKey: cacheKeys.users.preferences() }); + // Also invalidate the consent list cache + queryClient.invalidateQueries({ + queryKey: cacheKeys.users.storeConsent(), + }); }, }); } @@ -147,3 +154,54 @@ export function useDeleteUserProfile() { }, }); } + +// --- New Hooks --- + +export function useRecentUserData(limit: number = 10, page: number = 1) { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + return useQuery({ + // Expect an array + queryKey: cacheKeys.users.recentData(limit, page), + queryFn: () => + apiClients.users + .getRecentUserData({ limit, page }) + .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, + }); +} + +export function useSpendingAnalytics() { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + return useQuery({ + // Expect SpendingAnalytics type + queryKey: cacheKeys.users.spendingAnalytics(), + queryFn: () => + apiClients.users.getSpendingAnalytics().then((res) => res.data), + enabled: isAuthenticated && !authLoading && clientsReady, + // Add specific cache settings if needed + // ...cacheSettings.analytics, // Example + }); +} + +export function useStoreConsentLists() { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + return useQuery({ + // Expect StoreConsentList type + queryKey: cacheKeys.users.storeConsent(), + queryFn: () => + apiClients.users.getStoreConsentLists().then((res) => res.data), + enabled: isAuthenticated && !authLoading && clientsReady, + // Add specific cache settings if needed + // ...cacheSettings.consent, // Example + }); +} diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index c15dc28..2a46e2b 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -26,6 +26,13 @@ export const cacheKeys = { all: ["users"], profile: () => [...cacheKeys.users.all, "profile"], preferences: () => [...cacheKeys.users.all, "preferences"], + recentData: (limit: number, page: number) => [ + ...cacheKeys.users.all, + "recentData", + { limit, page }, + ], + spendingAnalytics: () => [...cacheKeys.users.all, "spendingAnalytics"], + storeConsent: () => [...cacheKeys.users.all, "storeConsent"], }, stores: { all: ["stores"], @@ -36,10 +43,12 @@ export const cacheKeys = { keyId, "usage", ], + lookup: (ids: string[]) => [...cacheKeys.stores.all, "lookup", ids], }, system: { health: () => ["system", "health"], ping: () => ["system", "ping"], + taxonomy: () => ["system", "taxonomy"], }, }; diff --git a/web/src/main.tsx b/web/src/main.tsx index d101181..aea723f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -16,6 +16,9 @@ import PrivateRoute from "./components/auth/PrivateRoute"; import NotFoundPage from "./pages/static/NotFoundPage"; import UserProfilePage from "./pages/UserProfilePage"; import StoreProfilePage from "./pages/StoreProfilePage"; +import UserPreferencesPage from "./pages/UserPreferencesPage"; +import UserDataSharingPage from "./pages/UserDataSharingPage"; +import UserAnalyticsPage from "./pages/UserAnalyticsPage"; const router = createBrowserRouter([ { @@ -51,6 +54,19 @@ const router = createBrowserRouter([ path: "profile/user", element: , }, + // --- Add New User Dashboard Sub-routes --- + { + path: "profile/user/preferences", // Route for preferences + element: , + }, + { + path: "profile/user/sharing", // Route for data sharing + element: , + }, + { + path: "profile/user/analytics", // Route for analytics + element: , + }, ], }, // --- Protected Store Routes --- From 37165434edffccfb8f4125ca1864f4b6e4e86a9e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 16:55:37 +0530 Subject: [PATCH 07/34] feat: add UserAnalyticsPage, UserDataSharingPage, and UserPreferencesPage components --- web/src/pages/UserAnalyticsPage.tsx | 21 +++++++++++++++++++++ web/src/pages/UserDataSharingPage.tsx | 22 ++++++++++++++++++++++ web/src/pages/UserPreferencesPage.tsx | 21 +++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 web/src/pages/UserAnalyticsPage.tsx create mode 100644 web/src/pages/UserDataSharingPage.tsx create mode 100644 web/src/pages/UserPreferencesPage.tsx diff --git a/web/src/pages/UserAnalyticsPage.tsx b/web/src/pages/UserAnalyticsPage.tsx new file mode 100644 index 0000000..5d1265e --- /dev/null +++ b/web/src/pages/UserAnalyticsPage.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Card } from "flowbite-react"; + +const UserAnalyticsPage: React.FC = () => { + return ( +
+

+ Your Data Insights +

+ +

+ 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 */} +
+
+ ); +}; + +export default UserAnalyticsPage; diff --git a/web/src/pages/UserDataSharingPage.tsx b/web/src/pages/UserDataSharingPage.tsx new file mode 100644 index 0000000..abab8c4 --- /dev/null +++ b/web/src/pages/UserDataSharingPage.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { Card } from "flowbite-react"; + +const UserDataSharingPage: React.FC = () => { + 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 */} +
+
+ ); +}; + +export default UserDataSharingPage; diff --git a/web/src/pages/UserPreferencesPage.tsx b/web/src/pages/UserPreferencesPage.tsx new file mode 100644 index 0000000..97e80d0 --- /dev/null +++ b/web/src/pages/UserPreferencesPage.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Card } from "flowbite-react"; + +const UserPreferencesPage: React.FC = () => { + return ( +
+

+ Manage Your Preferences +

+ +

+ 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 */} +
+
+ ); +}; + +export default UserPreferencesPage; From 4276356c98bd266801c5422902aafdd2ee24e383 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 17:02:27 +0530 Subject: [PATCH 08/34] feat: add useLookupStores hook for retrieving multiple stores by IDs --- web/src/api/hooks/useStoreHooks.ts | 37 ++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/web/src/api/hooks/useStoreHooks.ts b/web/src/api/hooks/useStoreHooks.ts index d4fde6f..a92f26a 100644 --- a/web/src/api/hooks/useStoreHooks.ts +++ b/web/src/api/hooks/useStoreHooks.ts @@ -1,7 +1,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; -import { cacheKeys, cacheSettings } from "../utils/cache"; -import { ApiKeyCreate, StoreUpdate } from "../types/data-contracts"; +import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache"; // <-- Import CACHE_TIMES +import { + ApiKeyCreate, + StoreUpdate, + StoreBasicInfo, // <-- Import StoreBasicInfo +} from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth export function useStoreProfile() { @@ -86,6 +90,35 @@ export function useApiKeyUsage(keyId: string) { }); } +// --- New Hook --- + +// Hook to lookup multiple stores by their IDs +export function useLookupStores(storeIds: string[]) { + const { apiClients, clientsReady } = useApiClients(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + // Filter out empty IDs and join for the query key and API call + const validIds = storeIds.filter((id) => id); + const idsQueryParam = validIds.join(","); + + return useQuery({ + // Expect an array of StoreBasicInfo + // Include the sorted list of valid IDs in the query key + queryKey: cacheKeys.stores.lookup(validIds.sort()), + queryFn: () => + // Pass the comma-separated string of IDs to the API client method + apiClients.stores + .lookupStores({ ids: idsQueryParam }) + .then((res) => res.data), + // Only enable if there are valid IDs and the client is ready + enabled: + validIds.length > 0 && isAuthenticated && !authLoading && clientsReady, + // Cache settings can be specific or default + staleTime: CACHE_TIMES.LONG, // Store names don't change often + gcTime: CACHE_TIMES.LONG * 2, + }); +} + export function useDeleteStoreProfile() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); From 0f2518f0af8d94aa625ea916ff7fcbd368014629 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 17:06:21 +0530 Subject: [PATCH 09/34] feat: enhance UserDashboard with new components and improved data handling --- web/src/pages/UserDashboard.tsx | 406 ++++++++++++++++++++++++++++++-- 1 file changed, 383 insertions(+), 23 deletions(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 8975452..565f4bd 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -1,43 +1,403 @@ -import { Card } from "flowbite-react"; -import { useUserProfile } from "../api/hooks/useUserHooks"; +import { useMemo } from "react"; // <-- Import React +import { Link } from "react-router"; // <-- Correct import +import { + Card, + Alert, + List, + Timeline, + TimelineItem, + TimelinePoint, + TimelineContent, + TimelineTime, + TimelineTitle, + TimelineBody, + ListItem, +} from "flowbite-react"; +import { + HiArrowRight, + HiClock, + HiInformationCircle, + HiOutlineNewspaper, // Icon for activity + HiOutlineCurrencyDollar, // Icon for spending + HiOutlineShare, // Icon for sharing + HiOutlineAdjustments, // Icon for preferences +} from "react-icons/hi"; +import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from "recharts"; // Import Recharts components + +import { + useUserProfile, + useRecentUserData, + useSpendingAnalytics, + useStoreConsentLists, + useUserPreferences, +} from "../api/hooks/useUserHooks"; +import { useLookupStores } from "../api/hooks/useStoreHooks"; // Import useLookupStores import LoadingSpinner from "../components/common/LoadingSpinner"; import ErrorDisplay from "../components/common/ErrorDisplay"; +import { + RecentUserDataEntry, + StoreBasicInfo, // <-- Import StoreBasicInfo + PreferenceItem, // <-- Import PreferenceItem +} from "../api/types/data-contracts"; // Import types + +// Helper function to format date +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", + }); +}; + +// Define colors for the pie chart +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", +]; export default function UserDashboard() { - const { data: profile, isLoading, error } = useUserProfile(); + // --- Fetch Data (Hooks called unconditionally at the top) --- + const { + data: profile, + isLoading: profileLoading, + error: profileError, + } = useUserProfile(); + const { + data: recentActivity, + isLoading: activityLoading, + error: activityError, + } = useRecentUserData(5); // Fetch latest 5 activities + const { + data: spendingData, + isLoading: spendingLoading, + error: spendingError, + } = useSpendingAnalytics(); + const { + data: consentLists, + isLoading: consentLoading, + error: consentError, + } = useStoreConsentLists(); + const { + data: preferencesData, + isLoading: preferencesLoading, + error: preferencesError, + } = useUserPreferences(); + + // --- Prepare Derived Data (useMemo hooks called unconditionally) --- + // Note: Hooks now handle potentially undefined data internally + + const optInStoreIds = useMemo( + () => consentLists?.optInStores || [], // Handle undefined consentLists + [consentLists], + ); + + const { + data: storeDetails, + isLoading: storesLoading, + error: storesError, + } = useLookupStores(optInStoreIds); // Hook call is unconditional + + const storeNameMap = useMemo(() => { + const map = new Map(); + // Handle undefined storeDetails + storeDetails?.forEach((store: StoreBasicInfo) => { + // <-- Add type annotation + map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); + }); + return map; + }, [storeDetails]); + + const spendingChartData = useMemo(() => { + if (!spendingData) return []; // Handle undefined spendingData + return Object.entries(spendingData) + .map(([name, value]) => ({ name, value })) + .sort((a, b) => b.value - a.value); + }, [spendingData]); + + const topPreferences = useMemo(() => { + if (!preferencesData?.preferences) return []; // Handle undefined preferencesData + return [...preferencesData.preferences] + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + .slice(0, 5); + }, [preferencesData]); + + // --- Loading and Error States (Checked AFTER hooks) --- + const isLoading = + profileLoading || + activityLoading || + spendingLoading || + consentLoading || + preferencesLoading || + storesLoading; + + const combinedError = // Combine errors for a single display if needed + profileError || + activityError || + spendingError || + consentError || + preferencesError || + storesError; if (isLoading) { - return ; + return ; } - if (error) { + // Display a general error if any query failed + if (combinedError) { return ( ); } + // --- Render Dashboard --- return ( -
- {/* Add dark mode text color */} -

- User Dashboard +
+

+ Welcome back, {profile?.username || "User"}!

- - {/* Add dark mode text color */} -

- Welcome back, {profile?.username || "User"}! -

- {/* Add dark mode text color */} -

- Here you can manage your preferences, control data sharing with - stores, and view your usage analytics. (Content coming soon!) -

- {/* Add User Preference Management and Analytics sections later */} -
+ +
+ {/* --- Recent Activity Card --- */} + +

+ + Recent Activity +

+ {activityError ? ( // Show specific error if only this section failed + + Could not load recent activity. + + ) : !recentActivity || recentActivity.length === 0 ? ( +

+ No recent activity found. +

+ ) : ( + + {recentActivity.map((activity: RecentUserDataEntry) => ( + + {" "} + {/* Use TimelineItem */} + {/* Use TimelinePoint */} + + {" "} + {/* Use TimelineContent */} + + {" "} + {/* Use TimelineTime */} + {formatDate(activity.timestamp)} + + + {" "} + {/* Use TimelineTitle */} + {activity.dataType === "purchase" + ? "Purchase Data Submitted" + : "Search Data Submitted"} + + + {" "} + {/* Use TimelineBody */} + From:{" "} + {/* Handle potentially undefined activity.storeId */} + {activity.storeId + ? storeNameMap.get(activity.storeId) || + `Store ID: ${activity.storeId}` + : "Unknown Store"} + + + + ))} + + )} + + View All Activity + +
+ + {/* --- Spending Overview Card --- */} + +

+ + Spending Overview +

+ {spendingError ? ( // Show specific error + + Could not load spending data. + + ) : !spendingChartData || spendingChartData.length === 0 ? ( +

+ No spending data available yet. +

+ ) : ( +
+ + + + `${name} ${(percent * 100).toFixed(0)}%` + } + > + {/* Removed unused 'entry' variable */} + {spendingChartData.map((_, index) => ( + + ))} + + + {/* */} + + +
+ )} + + View Detailed Analytics + +
+ + {/* --- Data Sharing Card --- */} + +

+ + Data Sharing +

+ {consentError || storesError ? ( // Show specific error + + Could not load sharing status. + + ) : // Use optional chaining and nullish coalescing for safety + (consentLists?.optInStores?.length ?? 0) === 0 ? ( +

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

+ ) : ( + <> +

+ {/* Use optional chaining */} + You are sharing data with { + consentLists?.optInStores?.length + }{" "} + store(s): +

+ + {/* Use optional chaining */} + {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + ))} + {/* Use optional chaining */} + {(consentLists?.optInStores?.length ?? 0) > 5 && ( + + {" "} + {/* <-- Use ListItem */} + ... and more + + )} + + + )} + + Manage Sharing Settings + +
+ + {/* --- Preferences Summary Card --- */} + +

+ + Your Preferences +

+ {preferencesError ? ( // Show specific error + + Could not load preferences. + + ) : !topPreferences || topPreferences.length === 0 ? ( +

+ No preferences set yet. +

+ ) : ( + <> +

+ Here are some of your top interests: +

+ + {topPreferences.map( + ( + pref: PreferenceItem, // <-- Add type + ) => ( + + {/* Use attributes (plural) */} + {pref.attributes + ? `${pref.category}: ${pref.attributes}` // Assuming attributes is the correct property + : pref.category} + {/* Optional: Show score */} + {/* ({(pref.score * 100).toFixed(0)}%) */} + + ), + )} + + + )} + + Manage All Preferences + +
+ + {/* --- Quick Links/Actions Card (Optional - Uncomment to use Button/Icons) --- */} + {/* +

Quick Links

+
+ + + +
+
*/} +
); } From c784f3eb995d3994524c5493407b6f3917e73a0e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 18:27:23 +0530 Subject: [PATCH 10/34] feat: update UserDashboard to use BarCharts for spending and preferences, enhance layout, and improve data handling --- web/src/pages/UserDashboard.tsx | 425 ++++++++++++++++++-------------- 1 file changed, 237 insertions(+), 188 deletions(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 565f4bd..557b2ac 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -12,7 +12,7 @@ import { TimelineTitle, TimelineBody, ListItem, -} from "flowbite-react"; +} from "flowbite-react"; // <-- Keep existing imports import { HiArrowRight, HiClock, @@ -22,7 +22,18 @@ import { HiOutlineShare, // Icon for sharing HiOutlineAdjustments, // Icon for preferences } from "react-icons/hi"; -import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from "recharts"; // Import Recharts components +// Import Recharts components - Added BarChart, XAxis, YAxis, CartesianGrid, Bar, Legend +import { + ResponsiveContainer, + BarChart, // Changed from PieChart + Bar, // Added Bar + XAxis, // Added XAxis + YAxis, // Added YAxis + CartesianGrid, // Added CartesianGrid + Tooltip, + Legend, // Added Legend + Cell, // Keep Cell for color mapping if needed later, but maybe not for BarChart +} from "recharts"; import { useUserProfile, @@ -52,7 +63,7 @@ const formatDate = (dateString: string | Date | undefined) => { }); }; -// Define colors for the pie chart +// Define colors for the pie chart (can be reused or adapted for BarChart) const COLORS = [ "#0088FE", "#00C49F", @@ -62,6 +73,16 @@ const COLORS = [ "#82CA9D", ]; +// Helper to format currency for Tooltip/Axis +const formatCurrency = (value: number) => { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", // Adjust currency as needed + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); +}; + export default function UserDashboard() { // --- Fetch Data (Hooks called unconditionally at the top) --- const { @@ -114,6 +135,7 @@ export default function UserDashboard() { return map; }, [storeDetails]); + // Data for Spending Bar Chart const spendingChartData = useMemo(() => { if (!spendingData) return []; // Handle undefined spendingData return Object.entries(spendingData) @@ -121,11 +143,19 @@ export default function UserDashboard() { .sort((a, b) => b.value - a.value); }, [spendingData]); - const topPreferences = useMemo(() => { + // Data for Preferences Bar Chart + const topPreferencesChartData = useMemo(() => { if (!preferencesData?.preferences) return []; // Handle undefined preferencesData - return [...preferencesData.preferences] - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - .slice(0, 5); + return ( + [...preferencesData.preferences] + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + .slice(0, 5) + // Format for BarChart (horizontal) + .map((pref) => ({ + name: pref.category, // Use category name for the axis label + score: pref.score * 100, // Convert score to percentage for display + })) + ); }, [preferencesData]); // --- Loading and Error States (Checked AFTER hooks) --- @@ -167,216 +197,235 @@ export default function UserDashboard() { Welcome back, {profile?.username || "User"}!

+ {/* Adjusted grid layout */}
- {/* --- Recent Activity Card --- */} - -

- - Recent Activity -

- {activityError ? ( // Show specific error if only this section failed - - Could not load recent activity. - - ) : !recentActivity || recentActivity.length === 0 ? ( -

- No recent activity found. -

- ) : ( - - {recentActivity.map((activity: RecentUserDataEntry) => ( - - {" "} - {/* Use TimelineItem */} - {/* Use TimelinePoint */} - - {" "} - {/* Use TimelineContent */} - - {" "} - {/* Use TimelineTime */} - {formatDate(activity.timestamp)} - - - {" "} - {/* Use TimelineTitle */} - {activity.dataType === "purchase" - ? "Purchase Data Submitted" - : "Search Data Submitted"} - - - {" "} - {/* Use TimelineBody */} - From:{" "} - {/* Handle potentially undefined activity.storeId */} - {activity.storeId - ? storeNameMap.get(activity.storeId) || - `Store ID: ${activity.storeId}` - : "Unknown Store"} - - - - ))} - - )} + {/* --- Recent Activity Card --- Adjusted span */} + + {" "} + {/* Changed lg:col-span-2 to col-span-1, added flex flex-col */} +
+ {" "} + {/* Added flex-grow to push link down */} +

+ {" "} + {/* Added mb-4 */} + + Recent Activity +

+ {activityError ? ( // Show specific error if only this section failed + + Could not load recent activity. + + ) : !recentActivity || recentActivity.length === 0 ? ( +

+ No recent activity found. +

+ ) : ( + + {recentActivity.map((activity: RecentUserDataEntry) => ( + + + + + {formatDate(activity.timestamp)} + + + {activity.dataType === "purchase" + ? "Purchase Data Submitted" + : "Search Data Submitted"} + + + From:{" "} + {activity.storeId + ? storeNameMap.get(activity.storeId) || + `Store ID: ${activity.storeId}` + : "Unknown Store"} + + + + ))} + + )} +
+ {/* Moved Link to the bottom */} View All Activity
- {/* --- Spending Overview Card --- */} - -

- - Spending Overview -

- {spendingError ? ( // Show specific error - - Could not load spending data. - - ) : !spendingChartData || spendingChartData.length === 0 ? ( -

- No spending data available yet. -

- ) : ( -
- - - + {" "} + {/* Changed col-span-1 to md:col-span-2, added flex flex-col */} +
+ {" "} + {/* Added flex-grow */} +

+ {" "} + {/* Added mb-4 */} + + Spending Overview +

+ {spendingError ? ( // Show specific error + + Could not load spending data. + + ) : !spendingChartData || spendingChartData.length === 0 ? ( +

+ No spending data available yet. +

+ ) : ( + // Changed to BarChart +
+ + - `${name} ${(percent * 100).toFixed(0)}%` - } + margin={{ top: 5, right: 30, left: 20, bottom: 5 }} > - {/* Removed unused 'entry' variable */} - {spendingChartData.map((_, index) => ( - - ))} - - - {/* */} - - -
- )} + + + + formatCurrency(value)} + /> + + + {spendingChartData.map((entry, index) => ( + + ))} + + + +
+ )} +
+ {/* Moved Link to the bottom */} View Detailed Analytics
{/* --- Data Sharing Card --- */} - -

- - Data Sharing -

- {consentError || storesError ? ( // Show specific error - - Could not load sharing status. - - ) : // Use optional chaining and nullish coalescing for safety - (consentLists?.optInStores?.length ?? 0) === 0 ? ( -

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

- ) : ( - <> -

- {/* Use optional chaining */} - You are sharing data with { - consentLists?.optInStores?.length - }{" "} - store(s): + + {" "} + {/* Added flex flex-col */} +

+ {" "} + {/* Added flex-grow */} +

+ {" "} + {/* Added mb-4 */} + + Data Sharing +

+ {consentError || storesError ? ( // Show specific error + + Could not load sharing status. + + ) : // Use optional chaining and nullish coalescing for safety + (consentLists?.optInStores?.length ?? 0) === 0 ? ( +

+ You are not currently sharing data with any stores.

- - {/* Use optional chaining */} - {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( - - {storeNameMap.get(storeId) || `Store ID: ${storeId}`} - - ))} - {/* Use optional chaining */} - {(consentLists?.optInStores?.length ?? 0) > 5 && ( - - {" "} - {/* <-- Use ListItem */} - ... and more - - )} - - - )} + ) : ( + <> +

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

+ + {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + ))} + {(consentLists?.optInStores?.length ?? 0) > 5 && ( + + ... and more + + )} + + + )} +
+ {/* Moved Link to the bottom */} Manage Sharing Settings
- {/* --- Preferences Summary Card --- */} - -

- - Your Preferences -

- {preferencesError ? ( // Show specific error - - Could not load preferences. - - ) : !topPreferences || topPreferences.length === 0 ? ( -

- No preferences set yet. -

- ) : ( - <> -

- Here are some of your top interests: + {/* --- Preferences Summary Card --- Adjusted span and content */} + + {" "} + {/* Changed lg:col-span-2 to md:col-span-2, added flex flex-col */} +

+ {" "} + {/* Added flex-grow */} +

+ {" "} + {/* Added mb-4 */} + + Your Top Preferences +

+ {preferencesError ? ( // Show specific error + + Could not load preferences. + + ) : !topPreferencesChartData || + topPreferencesChartData.length === 0 ? ( +

+ No preferences set yet.

- - {topPreferences.map( - ( - pref: PreferenceItem, // <-- Add type - ) => ( - - {/* Use attributes (plural) */} - {pref.attributes - ? `${pref.category}: ${pref.attributes}` // Assuming attributes is the correct property - : pref.category} - {/* Optional: Show score */} - {/* ({(pref.score * 100).toFixed(0)}%) */} - - ), - )} - - - )} + ) : ( + // Changed to Horizontal BarChart +
+ + + + {" "} + {/* Score axis */} + {" "} + {/* Category axis */} + `${value.toFixed(0)}%`} + /> + {/* */} + + {topPreferencesChartData.map((entry, index) => ( + + ))} + + + +
+ )} +
+ {/* Moved Link to the bottom */} Manage All Preferences From fd07dc5baf38937b98566b1ebfd3032eee6154e1 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Mon, 21 Apr 2025 19:59:04 +0530 Subject: [PATCH 11/34] feat: enhance spending analytics with date range filtering, update data structures, and improve UserDashboard layout --- api-service/api/openapi.yaml | 66 +++- api-service/service/UserProfileService.js | 102 ++++-- web/src/api/hooks/useUserHooks.ts | 28 +- web/src/api/types/Users.ts | 16 +- web/src/api/types/data-contracts.ts | 33 +- web/src/api/utils/cache.ts | 7 +- web/src/pages/UserDashboard.tsx | 368 +++++++++++++--------- 7 files changed, 422 insertions(+), 198 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index dce3e93..1360c71 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -651,17 +651,34 @@ paths: get: tags: [User Management] summary: Get User Spending Analytics - description: Retrieves aggregated spending data categorized by taxonomy for the authenticated user. + description: Retrieves aggregated spending data by category and month for the authenticated user. operationId: getSpendingAnalytics + parameters: + - name: startDate + in: query + required: false + schema: + type: string + format: date + description: Filter results from this date onwards (YYYY-MM-DD). + - name: endDate + in: query + required: false + schema: + type: string + format: date + description: Filter results up to this date (YYYY-MM-DD). responses: "200": - description: Spending analytics retrieved successfully + description: Spending analytics retrieved successfully. content: application/json: schema: - $ref: "#/components/schemas/SpendingAnalytics" + $ref: "#/components/schemas/MonthlySpendingAnalytics" "401": $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" "500": $ref: "#/components/responses/InternalServerError" security: @@ -1351,15 +1368,18 @@ components: SpendingAnalytics: type: object - description: Aggregated spending data per category. + description: > + Aggregated spending data per category over time. + 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: number - format: float - description: Total amount spent in this category. + type: object # Example: { "YYYY-MM": { "Category1": 100, "Category2": 50 } } + additionalProperties: + type: number + format: float example: - "Electronics": 1299.99 - "Clothing": 250.50 - "Home": 85.00 + "2025-01": { "Electronics": 1299.99, "Clothing": 150.5 } + "2025-02": { "Clothing": 100, "Home": 85 } StoreBasicInfo: type: object @@ -1374,6 +1394,32 @@ components: - storeId - name + MonthlySpendingItem: + type: object + properties: + month: + type: string + format: date + description: The month of the spending data (e.g., "2024-01"). + spending: + type: object + description: An object mapping category names to the total amount spent in that category for the month. + additionalProperties: + type: number + format: float + required: + - month + - spending + example: + month: "2024-01" + spending: { "Electronics": 1299.99, "Clothing": 150.50 } + + MonthlySpendingAnalytics: + type: array + description: An array of monthly spending breakdowns. + items: + $ref: "#/components/schemas/MonthlySpendingItem" + responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 3774f7a..32ad9c8 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -4,6 +4,7 @@ const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); const {updateUserPhone, updateAuth0Username, deleteAuth0User } = require('../utils/auth0Util'); +const { ObjectId } = require('mongodb'); // Ensure ObjectId is imported /** * Get User Profile @@ -279,33 +280,65 @@ exports.getSpendingAnalytics = async function (req) { const db = getDB(); const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + // --- Date Range Handling --- + const { startDate, endDate } = req.query; + const dateMatch = {}; + if (startDate) { + try { + dateMatch['$gte'] = new Date(startDate); + } catch (e) { + console.warn('Invalid startDate format:', startDate); + } + } + if (endDate) { + try { + // Add 1 day to endDate to include the whole day + const end = new Date(endDate); + end.setDate(end.getDate() + 1); + dateMatch['$lt'] = end; + } catch (e) { + console.warn('Invalid endDate format:', endDate); + } + } + const hasDateFilter = Object.keys(dateMatch).length > 0; + // --- End Date Range Handling --- + + // Find user to get their internal _id const user = await db.collection('users').findOne({ auth0Id: userData.sub }, { projection: { _id: 1 } }); if (!user) { return respondWithCode(404, { code: 404, message: 'User not found' }); } - // Fetch the taxonomy once to map category IDs to names - // Use the filter { current: true } if you only want the active taxonomy - const taxonomyDoc = await db.collection('taxonomy').findOne({ current: true }); // Or findOne({}) if 'current' flag isn't always used - - // Correctly access the categories array via taxonomyDoc.data.categories + // Fetch the taxonomy once (remains the same) + const taxonomyDoc = await db.collection('taxonomy').findOne({ current: true }); const categoryMap = (taxonomyDoc && taxonomyDoc.data && taxonomyDoc.data.categories) ? taxonomyDoc.data.categories.reduce((map, cat) => { - map[cat.id] = cat.name; + map[cat.id] = cat.name; // Assuming category ID is used in items + map[cat.name] = cat.name; // Allow matching by name too, just in case return map; }, {}) - : {}; // Default to empty map if taxonomy, data, or categories are missing - + : {}; const pipeline = [ + // Match user and data type { $match: { userId: user._id, dataType: 'purchase' } }, + // Unwind entries array { $unwind: '$entries' }, + // --- Add Date Filtering Stage --- + ...(hasDateFilter ? [{ $match: { 'entries.timestamp': dateMatch } }] : []), + // Unwind items array { $unwind: '$entries.items' }, + // --- Group by Month and Category --- { $group: { - _id: '$entries.items.category', - totalSpent: { + _id: { + // Group by year-month and category + yearMonth: { $dateToString: { format: "%Y-%m", date: "$entries.timestamp" } }, + category: '$entries.items.category' // Use the category field from item + }, + // Calculate total spent for this category in this month + monthlyTotal: { $sum: { $cond: { if: { $and: [ @@ -313,34 +346,59 @@ exports.getSpendingAnalytics = async function (req) { { $isNumber: '$entries.items.quantity' } ]}, then: { $multiply: ['$entries.items.price', '$entries.items.quantity'] }, + // Handle cases where quantity might be missing but price exists else: { $cond: { if: { $isNumber: '$entries.items.price' }, then: '$entries.items.price', else: 0 } } } } } } }, + // --- Group by Month to structure categories --- + { + $group: { + _id: '$_id.yearMonth', // Group by month string (e.g., "2025-01") + categories: { + $push: { // Create an array of category-spend pairs for the month + k: { $ifNull: [ { $toString: '$_id.category' }, "Unknown" ] }, // Category name (or ID as string) + v: '$monthlyTotal' + } + } + } + }, + // --- Convert categories array to object and sort --- { $project: { - _id: 0, - category: '$_id', - totalSpent: 1 + _id: 0, // Exclude the default _id + month: '$_id', // Rename _id to month + spending: { $arrayToObject: '$categories' } // Convert [{k: "Cat1", v: 100}, ...] to { "Cat1": 100, ... } } - } + }, + // Sort by month ascending + { $sort: { month: 1 } } ]; const results = await db.collection('userData').aggregate(pipeline).toArray(); - // Transform results using the categoryMap (this part remains the same) - const spendingAnalytics = results.reduce((acc, item) => { - const categoryName = categoryMap[item.category] || item.category; // Use name from map, fallback to ID - acc[categoryName] = (acc[categoryName] || 0) + item.totalSpent; - return acc; - }, {}); + // --- Map category IDs/names to proper names from taxonomy --- + const spendingAnalytics = results.map(monthlyData => { + const mappedSpending = {}; + for (const categoryKey in monthlyData.spending) { + const categoryName = categoryMap[categoryKey] || categoryKey; // Use mapped name or original key + mappedSpending[categoryName] = monthlyData.spending[categoryKey]; + } + return { + month: monthlyData.month, + spending: mappedSpending + }; + }); + // --- End Mapping --- + - // Caching could be added here - // const cacheKey = `${CACHE_KEYS.USER_SPENDING_ANALYTICS}${user._id}`; + // Caching could be added here, considering date range in the key + // const cacheKey = `${CACHE_KEYS.USER_SPENDING_ANALYTICS}${user._id}:${startDate || 'all'}:${endDate || 'all'}`; // await setCache(cacheKey, JSON.stringify(spendingAnalytics), { EX: CACHE_TTL.MEDIUM }); + // Return the array structure: [{ month: "YYYY-MM", spending: { "Category1": 100, ... } }, ...] return respondWithCode(200, spendingAnalytics); } catch (error) { diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 8b07fe0..512f32d 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -5,9 +5,11 @@ import { UserPreferencesUpdate, UserUpdate, User, - RecentUserDataEntry, // <-- Add import - SpendingAnalytics, // <-- Add import - StoreConsentList, // <-- Add import + RecentUserDataEntry, + // SpendingAnalytics, // <-- Remove old type if not used elsewhere + StoreConsentList, + MonthlySpendingAnalytics, // <-- Import new type + GetSpendingAnalyticsParams, // <-- Import params type } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -176,18 +178,28 @@ export function useRecentUserData(limit: number = 10, page: number = 1) { }); } -export function useSpendingAnalytics() { +export function useSpendingAnalytics( + params?: GetSpendingAnalyticsParams, // Accept optional params +) { const { apiClients, clientsReady } = useApiClients(); const { isAuthenticated, isLoading: authLoading } = useAuth(); - return useQuery({ - // Expect SpendingAnalytics type - queryKey: cacheKeys.users.spendingAnalytics(), + // Destructure params for queryKey dependency, provide defaults + const { startDate, endDate } = params || {}; + + return useQuery({ + // <-- Use new response type + // Update queryKey to include dates for unique caching + queryKey: cacheKeys.users.spendingAnalytics(startDate, endDate), queryFn: () => - apiClients.users.getSpendingAnalytics().then((res) => res.data), + // Pass params to the API call + apiClients.users + .getSpendingAnalytics({ startDate, endDate }) + .then((res) => res.data), enabled: isAuthenticated && !authLoading && clientsReady, // Add specific cache settings if needed // ...cacheSettings.analytics, // Example + placeholderData: (previousData) => previousData, // Keep placeholderData for smoother transitions }); } diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index 54fff83..df15d2a 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -13,8 +13,9 @@ import { Error, GetRecentUserDataParams, + GetSpendingAnalyticsParams, + MonthlySpendingAnalytics, RecentUserDataEntry, - SpendingAnalytics, StoreConsentList, User, UserCreate, @@ -314,21 +315,26 @@ export class Users< ...params, }); /** - * @description Retrieves aggregated spending data categorized by taxonomy for the authenticated user. + * @description Retrieves aggregated spending data by category and month for the authenticated user. * * @tags User Management * @name GetSpendingAnalytics * @summary Get User Spending Analytics * @request GET:/users/analytics/spending * @secure - * @response `200` `SpendingAnalytics` Spending analytics retrieved successfully + * @response `200` `MonthlySpendingAnalytics` Spending analytics retrieved successfully. * @response `401` `Error` + * @response `404` `Error` * @response `500` `Error` */ - getSpendingAnalytics = (params: RequestParams = {}) => - this.request({ + getSpendingAnalytics = ( + query: GetSpendingAnalyticsParams, + params: RequestParams = {}, + ) => + this.request({ path: `/users/analytics/spending`, 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 27679d1..d9621eb 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -363,10 +363,10 @@ export interface RecentUserDataEntry { } /** - * Aggregated spending data per category. - * @example {"Electronics":1299.99,"Clothing":250.5,"Home":85} + * Aggregated spending data per category over time. The structure might vary based on implementation (e.g., object keyed by month/year, or an array of objects each representing a time point). + * @example {"2025-01":{"Electronics":1299.99,"Clothing":150.5},"2025-02":{"Clothing":100,"Home":85}} */ -export type SpendingAnalytics = Record; +export type SpendingAnalytics = Record>; export interface StoreBasicInfo { /** The unique ID of the store. */ @@ -375,6 +375,20 @@ export interface StoreBasicInfo { name: string; } +/** @example {"month":"2024-01","spending":{"Electronics":1299.99,"Clothing":150.5}} */ +export interface MonthlySpendingItem { + /** + * The month of the spending data (e.g., "2024-01"). + * @format date + */ + month: string; + /** An object mapping category names to the total amount spent in that category for the month. */ + spending: Record; +} + +/** An array of monthly spending breakdowns. */ +export type MonthlySpendingAnalytics = MonthlySpendingItem[]; + export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data @@ -401,6 +415,19 @@ export interface GetRecentUserDataParams { page?: number; } +export interface GetSpendingAnalyticsParams { + /** + * Filter results from this date onwards (YYYY-MM-DD). + * @format date + */ + startDate?: string; + /** + * Filter results up to this date (YYYY-MM-DD). + * @format date + */ + endDate?: string; +} + export interface LookupStoresParams { /** Comma-separated list of store IDs to lookup. */ ids: string; diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index 2a46e2b..78593bf 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -31,7 +31,12 @@ export const cacheKeys = { "recentData", { limit, page }, ], - spendingAnalytics: () => [...cacheKeys.users.all, "spendingAnalytics"], + // Update spendingAnalytics to accept optional dates + spendingAnalytics: (startDate?: string, endDate?: string) => [ + ...cacheKeys.users.all, + "spendingAnalytics", + { startDate: startDate ?? "all", endDate: endDate ?? "all" }, // Use 'all' if undefined + ], storeConsent: () => [...cacheKeys.users.all, "storeConsent"], }, stores: { diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 557b2ac..f9c7cf9 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -1,5 +1,5 @@ -import { useMemo } from "react"; // <-- Import React -import { Link } from "react-router"; // <-- Correct import +import { useMemo, useState } from "react"; +import { Link } from "react-router"; // <-- Corrected import import { Card, Alert, @@ -12,27 +12,31 @@ import { TimelineTitle, TimelineBody, ListItem, -} from "flowbite-react"; // <-- Keep existing imports + Datepicker, + Button, +} from "flowbite-react"; import { HiArrowRight, HiClock, HiInformationCircle, - HiOutlineNewspaper, // Icon for activity - HiOutlineCurrencyDollar, // Icon for spending - HiOutlineShare, // Icon for sharing - HiOutlineAdjustments, // Icon for preferences + HiOutlineNewspaper, + HiOutlineCurrencyDollar, + HiOutlineShare, + HiOutlineAdjustments, + HiCalendar, } from "react-icons/hi"; -// Import Recharts components - Added BarChart, XAxis, YAxis, CartesianGrid, Bar, Legend import { ResponsiveContainer, - BarChart, // Changed from PieChart - Bar, // Added Bar - XAxis, // Added XAxis - YAxis, // Added YAxis - CartesianGrid, // Added CartesianGrid + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, Tooltip, - Legend, // Added Legend - Cell, // Keep Cell for color mapping if needed later, but maybe not for BarChart + Legend, + BarChart, + Bar, + Cell, } from "recharts"; import { @@ -42,16 +46,16 @@ import { useStoreConsentLists, useUserPreferences, } from "../api/hooks/useUserHooks"; -import { useLookupStores } from "../api/hooks/useStoreHooks"; // Import useLookupStores +import { useLookupStores } from "../api/hooks/useStoreHooks"; import LoadingSpinner from "../components/common/LoadingSpinner"; import ErrorDisplay from "../components/common/ErrorDisplay"; import { RecentUserDataEntry, - StoreBasicInfo, // <-- Import StoreBasicInfo - PreferenceItem, // <-- Import PreferenceItem -} from "../api/types/data-contracts"; // Import types + StoreBasicInfo, + MonthlySpendingItem, +} from "../api/types/data-contracts"; -// Helper function to format date +// Helper function to format date (keep existing) const formatDate = (dateString: string | Date | undefined) => { if (!dateString) return "N/A"; return new Date(dateString).toLocaleDateString("en-US", { @@ -63,17 +67,21 @@ const formatDate = (dateString: string | Date | undefined) => { }); }; -// Define colors for the pie chart (can be reused or adapted for BarChart) -const COLORS = [ +// Define colors for the lines (can reuse or define new ones) +const LINE_COLORS = [ "#0088FE", "#00C49F", "#FFBB28", "#FF8042", "#8884D8", "#82CA9D", + "#FF5733", + "#C70039", + "#900C3F", + "#581845", ]; -// Helper to format currency for Tooltip/Axis +// Helper to format currency (keep existing) const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { style: "currency", @@ -83,7 +91,31 @@ const formatCurrency = (value: number) => { }).format(value); }; +// Helper to format YYYY-MM date string for display +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; // Fallback + } +}; + +// Helper to format Date object to YYYY-MM-DD string +const formatDateToISO = (date: Date | null | undefined): string | undefined => { + if (!date) return undefined; + return date.toISOString().split("T")[0]; +}; + export default function UserDashboard() { + // --- State for Date Range --- + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + // --- Fetch Data (Hooks called unconditionally at the top) --- const { data: profile, @@ -94,12 +126,15 @@ export default function UserDashboard() { data: recentActivity, isLoading: activityLoading, error: activityError, - } = useRecentUserData(5); // Fetch latest 5 activities + } = useRecentUserData(5); const { data: spendingData, isLoading: spendingLoading, error: spendingError, - } = useSpendingAnalytics(); + } = useSpendingAnalytics({ + startDate: formatDateToISO(startDate), + endDate: formatDateToISO(endDate), + }); const { data: consentLists, isLoading: consentLoading, @@ -112,10 +147,8 @@ export default function UserDashboard() { } = useUserPreferences(); // --- Prepare Derived Data (useMemo hooks called unconditionally) --- - // Note: Hooks now handle potentially undefined data internally - const optInStoreIds = useMemo( - () => consentLists?.optInStores || [], // Handle undefined consentLists + () => consentLists?.optInStores || [], [consentLists], ); @@ -123,39 +156,60 @@ export default function UserDashboard() { data: storeDetails, isLoading: storesLoading, error: storesError, - } = useLookupStores(optInStoreIds); // Hook call is unconditional + } = useLookupStores(optInStoreIds); const storeNameMap = useMemo(() => { const map = new Map(); - // Handle undefined storeDetails storeDetails?.forEach((store: StoreBasicInfo) => { - // <-- Add type annotation map.set(store.storeId, store.name || `Store ID: ${store.storeId}`); }); return map; }, [storeDetails]); - // Data for Spending Bar Chart - const spendingChartData = useMemo(() => { - if (!spendingData) return []; // Handle undefined spendingData - return Object.entries(spendingData) - .map(([name, value]) => ({ name, value })) - .sort((a, b) => b.value - a.value); + // --- Data Transformation for Spending Line Chart --- + 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]); // Data for Preferences Bar Chart const topPreferencesChartData = useMemo(() => { - if (!preferencesData?.preferences) return []; // Handle undefined preferencesData - return ( - [...preferencesData.preferences] - .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) - .slice(0, 5) - // Format for BarChart (horizontal) - .map((pref) => ({ - name: pref.category, // Use category name for the axis label - score: pref.score * 100, // Convert score to percentage for display - })) - ); + 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, + })); }, [preferencesData]); // --- Loading and Error States (Checked AFTER hooks) --- @@ -167,7 +221,7 @@ export default function UserDashboard() { preferencesLoading || storesLoading; - const combinedError = // Combine errors for a single display if needed + const combinedError = profileError || activityError || spendingError || @@ -175,12 +229,11 @@ export default function UserDashboard() { preferencesError || storesError; - if (isLoading) { + if (isLoading && !spendingData) { return ; } - // Display a general error if any query failed - if (combinedError) { + if (combinedError && !spendingData) { return ( - {/* Adjusted grid layout */}
- {/* --- Recent Activity Card --- Adjusted span */} + {/* --- Recent Activity Card --- */} - {" "} - {/* Changed lg:col-span-2 to col-span-1, added flex flex-col */}
- {" "} - {/* Added flex-grow to push link down */}

- {" "} - {/* Added mb-4 */} Recent Activity

- {activityError ? ( // Show specific error if only this section failed + {activityError ? ( Could not load recent activity. @@ -247,68 +293,102 @@ export default function UserDashboard() { )}
- {/* Moved Link to the bottom */} View All Activity
- {/* --- Spending Overview Card --- Adjusted span and chart type */} + {/* --- Spending Overview Card --- */} - {" "} - {/* Changed col-span-1 to md:col-span-2, added flex flex-col */}
- {" "} - {/* Added flex-grow */} -

- {" "} - {/* Added mb-4 */} - - Spending Overview -

- {spendingError ? ( // Show specific error +
+

+ + Spending Overview +

+
+ setStartDate(date)} + maxDate={endDate || undefined} + className="w-full" + placeholder="Start Date" + /> + setEndDate(date)} + minDate={startDate || undefined} + className="w-full" + placeholder="End Date" + /> + {(startDate || endDate) && ( + + )} +
+
+ + {spendingError ? ( - Could not load spending data. + Could not load spending data for the selected range. - ) : !spendingChartData || spendingChartData.length === 0 ? ( + ) : spendingLoading ? ( +
+ +
+ ) : !lineChartData || lineChartData.length === 0 ? (

- No spending data available yet. + No spending data available + {startDate || endDate ? " for this period" : " yet"}.

) : ( - // Changed to BarChart -
+
- - + formatCurrency(value)} + labelFormatter={formatMonth} /> - - {spendingChartData.map((entry, index) => ( - - ))} - - + {categories.map((category, index) => ( + + ))} +
)}
- {/* Moved Link to the bottom */} View Detailed Analytics @@ -316,23 +396,16 @@ export default function UserDashboard() { {/* --- Data Sharing Card --- */} - {" "} - {/* Added flex flex-col */}
- {" "} - {/* Added flex-grow */}

- {" "} - {/* Added mb-4 */} Data Sharing

- {consentError || storesError ? ( // Show specific error + {consentError || storesError ? ( Could not load sharing status. - ) : // Use optional chaining and nullish coalescing for safety - (consentLists?.optInStores?.length ?? 0) === 0 ? ( + ) : (consentLists?.optInStores?.length ?? 0) === 0 ? (

You are not currently sharing data with any stores.

@@ -360,60 +433,74 @@ export default function UserDashboard() { )}
- {/* Moved Link to the bottom */} Manage Sharing Settings
- {/* --- Preferences Summary Card --- Adjusted span and content */} + {/* --- Preferences Summary Card --- */} - {" "} - {/* Changed lg:col-span-2 to md:col-span-2, added flex flex-col */}
- {" "} - {/* Added flex-grow */}

- {" "} - {/* Added mb-4 */} - Your Top Preferences + Top Preferences

- {preferencesError ? ( // Show specific error + {preferencesError ? ( - Could not load preferences. + Could not load preferences data. + ) : preferencesLoading ? ( +
+ +
) : !topPreferencesChartData || topPreferencesChartData.length === 0 ? (

- No preferences set yet. + No preference data available yet.

) : ( - // Changed to Horizontal BarChart -
- +
+ - - {" "} - {/* Score axis */} - {" "} - {/* Category axis */} + + `${value}%`} + fontSize={12} + tick={{ fill: "currentColor" }} + /> + `${value.toFixed(0)}%`} + formatter={(value: number) => `${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) => ( + + + {topPreferencesChartData.map((_entry, index) => ( ))} @@ -422,30 +509,13 @@ export default function UserDashboard() {
)}
- {/* Moved Link to the bottom */} Manage All Preferences - - {/* --- Quick Links/Actions Card (Optional - Uncomment to use Button/Icons) --- */} - {/* -

Quick Links

-
- - - -
-
*/}
); From 0f3f4b6b4762cafe5764124f5be37469be38613f Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 02:28:26 +0530 Subject: [PATCH 12/34] fix: update layout of Spending Overview section to use flex-row for better alignment --- 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 f9c7cf9..368a4ee 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -309,7 +309,7 @@ export default function UserDashboard() { Spending Overview

-
+
Date: Tue, 22 Apr 2025 02:57:46 +0530 Subject: [PATCH 13/34] fix: update schema version to 2.0.2 and modify price field to accept both double and int types --- api-service/utils/dbSchemas.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index c4a8dab..c99920c 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.1'; +const SCHEMA_VERSION = '2.0.2'; const userSchema = { validator: { @@ -186,7 +186,7 @@ const userDataSchema = { properties: { name: { bsonType: 'string' }, category: { bsonType: 'string' }, - price: { bsonType: 'double' }, + price: { bsonType: ['double', 'int'] }, quantity: { bsonType: 'int' }, attributes: { bsonType: 'object', From ff7fc574fb702a58a8d509fc036393174142875e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 04:52:40 +0530 Subject: [PATCH 14/34] feat: add user demographics fields to UserProfile and update schemas for provided and inferred demographics --- api-service/api/openapi.yaml | 47 +++++++++++++++++++++++ api-service/service/UserProfileService.js | 37 +++++++++++++++--- api-service/utils/dbSchemas.js | 43 +++++++++++++++++++++ web/src/api/types/data-contracts.ts | 39 +++++++++++++++++++ 4 files changed, 161 insertions(+), 5 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 1360c71..958994d 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -753,6 +753,11 @@ components: phone: type: string pattern: ^\+?[\d\s-]+$ + providedDemographics: + $ref: "#/components/schemas/DemographicsProvided" + inferredDemographics: + $ref: "#/components/schemas/DemographicsInferred" + readOnly: true privacySettings: type: object properties: @@ -913,6 +918,8 @@ components: items: type: string description: List of domains allowed to access user data via API keys + providedDemographics: + $ref: "#/components/schemas/DemographicsProvided" ApiKey: type: object @@ -1420,6 +1427,46 @@ components: items: $ref: "#/components/schemas/MonthlySpendingItem" + DemographicsProvided: + type: object + description: Demographic information provided by the user. + properties: + ageRange: + type: string + description: e.g., 25-34 + example: "25-34" + location: + type: string + description: e.g., CA, USA + example: "CA" + additionalProperties: false + + DemographicsInferredItem: + type: object + properties: + value: + type: string + description: The inferred value. + confidence: + type: number + format: float + minimum: 0.0 + maximum: 1.0 + description: Confidence score of the inference (0.0 to 1.0). + required: [value, confidence] + + DemographicsInferred: + type: object + description: Demographic information inferred by the AI service. + properties: + gender: + $ref: "#/components/schemas/DemographicsInferredItem" + ageRange: + $ref: "#/components/schemas/DemographicsInferredItem" + incomeBracket: + $ref: "#/components/schemas/DemographicsInferredItem" + additionalProperties: false + responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 32ad9c8..fdb262e 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -106,8 +106,17 @@ exports.updateUserProfile = async function (req, body) { updatedAt: new Date(), }; // Update local DB username only if Auth0 update was successful (or not attempted) - if (body.username !== undefined) updateData.username = body.username; - if (body.phone !== undefined) updateData.phone = body.phone; + if (body.username !== undefined) { + // Check for uniqueness before setting + const existingUser = await db.collection('users').findOne({ username: body.username, auth0Id: { $ne: auth0UserId } }); + if (existingUser) { + return respondWithCode(409, { code: 409, message: 'Username already taken' }); + } + updateData.username = body.username; + } + if (body.phone !== undefined) { + updateData.phone = body.phone; + } // Only update allowed privacy settings if (body.privacySettings !== undefined) { @@ -118,17 +127,31 @@ exports.updateUserProfile = async function (req, body) { if (body.privacySettings.anonymizeData !== undefined) { updateData.privacySettings.anonymizeData = body.privacySettings.anonymizeData; } - // DO NOT update optInStores or optOutStores here + // Note: optInStores/optOutStores are managed via separate endpoints + } + + if (body.dataAccess !== undefined) { + updateData.dataAccess = {}; + if (body.dataAccess.allowedDomains !== undefined) { + updateData.dataAccess.allowedDomains = body.dataAccess.allowedDomains; + } } - if (body.dataAccess !== undefined) updateData.dataAccess = body.dataAccess; + // --- ADDED: Handle providedDemographics --- + // Add providedDemographics to the update if present in the body + // The schema validation will ensure it has the correct structure + if (body.providedDemographics !== undefined) { + updateData.providedDemographics = body.providedDemographics; + } + // --- End Added --- + const result = await db .collection('users') .findOneAndUpdate( { auth0Id: auth0UserId }, { $set: updateData }, - { returnDocument: 'after', projection: { preferences: 0 } }, + { returnDocument: 'after', projection: { preferences: 0 } }, // Exclude preferences ); if (!result) { @@ -156,6 +179,10 @@ exports.updateUserProfile = async function (req, body) { } catch (error) { // Catch errors not handled specifically above console.error('Update profile failed:', error); + // Check for specific MongoDB duplicate key errors (e.g., if email index exists) + if (error.code === 11000) { + return respondWithCode(409, { code: 409, message: 'Conflict: A field value is already in use.' }); + } return respondWithCode(500, { code: 500, message: 'Internal server error during profile update' }); } }; diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index c99920c..69ac809 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -31,6 +31,49 @@ const userSchema = { bsonType: ['string', 'null'], description: 'Phone number', }, + // --- ADDED: User-provided demographics --- + providedDemographics: { + bsonType: 'object', + description: 'Demographic information provided by the user', + properties: { + // Add specific fields as needed, e.g.: + ageRange: { bsonType: 'string', description: 'e.g., 25-34' }, + location: { bsonType: 'string', description: 'e.g., CA, USA' }, + // Add other fields like gender, income bracket, etc. + }, + additionalProperties: false, // Prevent extra fields unless intended + }, + // --- ADDED: AI-inferred demographics --- + inferredDemographics: { + bsonType: 'object', + description: 'Demographic information inferred by the AI service', + properties: { + // Add specific fields with value and confidence, e.g.: + gender: { + bsonType: 'object', + properties: { + value: { bsonType: 'string' }, + confidence: { bsonType: 'double', minimum: 0.0, maximum: 1.0 }, + }, + }, + ageRange: { + bsonType: 'object', + properties: { + value: { bsonType: 'string' }, + confidence: { bsonType: 'double', minimum: 0.0, maximum: 1.0 }, + }, + }, + incomeBracket: { + bsonType: 'object', + properties: { + value: { bsonType: 'string' }, + confidence: { bsonType: 'double', minimum: 0.0, maximum: 1.0 }, + }, + }, + // Add other inferred fields + }, + additionalProperties: false, // Prevent extra fields unless intended + }, preferences: { bsonType: 'array', description: 'User interests and preferences', diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index d9621eb..ee7f9d1 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -28,6 +28,10 @@ export interface User { username?: string; /** @pattern ^\+?[\d\s-]+$ */ phone?: string; + /** Demographic information provided by the user. */ + providedDemographics?: DemographicsProvided; + /** Demographic information inferred by the AI service. */ + inferredDemographics?: DemographicsInferred; privacySettings: { /** @default false */ dataSharingConsent?: boolean; @@ -99,6 +103,8 @@ export interface UserUpdate { dataAccess?: { allowedDomains?: string[]; }; + /** Demographic information provided by the user. */ + providedDemographics?: DemographicsProvided; } export interface ApiKey { @@ -389,6 +395,39 @@ export interface MonthlySpendingItem { /** An array of monthly spending breakdowns. */ export type MonthlySpendingAnalytics = MonthlySpendingItem[]; +/** Demographic information provided by the user. */ +export interface DemographicsProvided { + /** + * e.g., 25-34 + * @example "25-34" + */ + ageRange?: string; + /** + * e.g., CA, USA + * @example "CA" + */ + location?: string; +} + +export interface DemographicsInferredItem { + /** The inferred value. */ + value: string; + /** + * Confidence score of the inference (0.0 to 1.0). + * @format float + * @min 0 + * @max 1 + */ + confidence: number; +} + +/** Demographic information inferred by the AI service. */ +export interface DemographicsInferred { + gender?: DemographicsInferredItem; + ageRange?: DemographicsInferredItem; + incomeBracket?: DemographicsInferredItem; +} + export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data From e84c5e61b7a7ad489983a25cc08db213cd7ec4db Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 05:54:15 +0530 Subject: [PATCH 15/34] Revert "feat: add user demographics fields to UserProfile and update schemas for provided and inferred demographics" This reverts commit ff7fc574fb702a58a8d509fc036393174142875e. --- api-service/api/openapi.yaml | 47 ----------------------- api-service/service/UserProfileService.js | 37 +++--------------- api-service/utils/dbSchemas.js | 43 --------------------- web/src/api/types/data-contracts.ts | 39 ------------------- 4 files changed, 5 insertions(+), 161 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 958994d..1360c71 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -753,11 +753,6 @@ components: phone: type: string pattern: ^\+?[\d\s-]+$ - providedDemographics: - $ref: "#/components/schemas/DemographicsProvided" - inferredDemographics: - $ref: "#/components/schemas/DemographicsInferred" - readOnly: true privacySettings: type: object properties: @@ -918,8 +913,6 @@ components: items: type: string description: List of domains allowed to access user data via API keys - providedDemographics: - $ref: "#/components/schemas/DemographicsProvided" ApiKey: type: object @@ -1427,46 +1420,6 @@ components: items: $ref: "#/components/schemas/MonthlySpendingItem" - DemographicsProvided: - type: object - description: Demographic information provided by the user. - properties: - ageRange: - type: string - description: e.g., 25-34 - example: "25-34" - location: - type: string - description: e.g., CA, USA - example: "CA" - additionalProperties: false - - DemographicsInferredItem: - type: object - properties: - value: - type: string - description: The inferred value. - confidence: - type: number - format: float - minimum: 0.0 - maximum: 1.0 - description: Confidence score of the inference (0.0 to 1.0). - required: [value, confidence] - - DemographicsInferred: - type: object - description: Demographic information inferred by the AI service. - properties: - gender: - $ref: "#/components/schemas/DemographicsInferredItem" - ageRange: - $ref: "#/components/schemas/DemographicsInferredItem" - incomeBracket: - $ref: "#/components/schemas/DemographicsInferredItem" - additionalProperties: false - responses: BadRequestError: description: Bad request - invalid input diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index fdb262e..32ad9c8 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -106,17 +106,8 @@ exports.updateUserProfile = async function (req, body) { updatedAt: new Date(), }; // Update local DB username only if Auth0 update was successful (or not attempted) - if (body.username !== undefined) { - // Check for uniqueness before setting - const existingUser = await db.collection('users').findOne({ username: body.username, auth0Id: { $ne: auth0UserId } }); - if (existingUser) { - return respondWithCode(409, { code: 409, message: 'Username already taken' }); - } - updateData.username = body.username; - } - if (body.phone !== undefined) { - updateData.phone = body.phone; - } + if (body.username !== undefined) updateData.username = body.username; + if (body.phone !== undefined) updateData.phone = body.phone; // Only update allowed privacy settings if (body.privacySettings !== undefined) { @@ -127,31 +118,17 @@ exports.updateUserProfile = async function (req, body) { if (body.privacySettings.anonymizeData !== undefined) { updateData.privacySettings.anonymizeData = body.privacySettings.anonymizeData; } - // Note: optInStores/optOutStores are managed via separate endpoints - } - - if (body.dataAccess !== undefined) { - updateData.dataAccess = {}; - if (body.dataAccess.allowedDomains !== undefined) { - updateData.dataAccess.allowedDomains = body.dataAccess.allowedDomains; - } + // DO NOT update optInStores or optOutStores here } - // --- ADDED: Handle providedDemographics --- - // Add providedDemographics to the update if present in the body - // The schema validation will ensure it has the correct structure - if (body.providedDemographics !== undefined) { - updateData.providedDemographics = body.providedDemographics; - } - // --- End Added --- - + if (body.dataAccess !== undefined) updateData.dataAccess = body.dataAccess; const result = await db .collection('users') .findOneAndUpdate( { auth0Id: auth0UserId }, { $set: updateData }, - { returnDocument: 'after', projection: { preferences: 0 } }, // Exclude preferences + { returnDocument: 'after', projection: { preferences: 0 } }, ); if (!result) { @@ -179,10 +156,6 @@ exports.updateUserProfile = async function (req, body) { } catch (error) { // Catch errors not handled specifically above console.error('Update profile failed:', error); - // Check for specific MongoDB duplicate key errors (e.g., if email index exists) - if (error.code === 11000) { - return respondWithCode(409, { code: 409, message: 'Conflict: A field value is already in use.' }); - } return respondWithCode(500, { code: 500, message: 'Internal server error during profile update' }); } }; diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 69ac809..c99920c 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -31,49 +31,6 @@ const userSchema = { bsonType: ['string', 'null'], description: 'Phone number', }, - // --- ADDED: User-provided demographics --- - providedDemographics: { - bsonType: 'object', - description: 'Demographic information provided by the user', - properties: { - // Add specific fields as needed, e.g.: - ageRange: { bsonType: 'string', description: 'e.g., 25-34' }, - location: { bsonType: 'string', description: 'e.g., CA, USA' }, - // Add other fields like gender, income bracket, etc. - }, - additionalProperties: false, // Prevent extra fields unless intended - }, - // --- ADDED: AI-inferred demographics --- - inferredDemographics: { - bsonType: 'object', - description: 'Demographic information inferred by the AI service', - properties: { - // Add specific fields with value and confidence, e.g.: - gender: { - bsonType: 'object', - properties: { - value: { bsonType: 'string' }, - confidence: { bsonType: 'double', minimum: 0.0, maximum: 1.0 }, - }, - }, - ageRange: { - bsonType: 'object', - properties: { - value: { bsonType: 'string' }, - confidence: { bsonType: 'double', minimum: 0.0, maximum: 1.0 }, - }, - }, - incomeBracket: { - bsonType: 'object', - properties: { - value: { bsonType: 'string' }, - confidence: { bsonType: 'double', minimum: 0.0, maximum: 1.0 }, - }, - }, - // Add other inferred fields - }, - additionalProperties: false, // Prevent extra fields unless intended - }, preferences: { bsonType: 'array', description: 'User interests and preferences', diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index ee7f9d1..d9621eb 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -28,10 +28,6 @@ export interface User { username?: string; /** @pattern ^\+?[\d\s-]+$ */ phone?: string; - /** Demographic information provided by the user. */ - providedDemographics?: DemographicsProvided; - /** Demographic information inferred by the AI service. */ - inferredDemographics?: DemographicsInferred; privacySettings: { /** @default false */ dataSharingConsent?: boolean; @@ -103,8 +99,6 @@ export interface UserUpdate { dataAccess?: { allowedDomains?: string[]; }; - /** Demographic information provided by the user. */ - providedDemographics?: DemographicsProvided; } export interface ApiKey { @@ -395,39 +389,6 @@ export interface MonthlySpendingItem { /** An array of monthly spending breakdowns. */ export type MonthlySpendingAnalytics = MonthlySpendingItem[]; -/** Demographic information provided by the user. */ -export interface DemographicsProvided { - /** - * e.g., 25-34 - * @example "25-34" - */ - ageRange?: string; - /** - * e.g., CA, USA - * @example "CA" - */ - location?: string; -} - -export interface DemographicsInferredItem { - /** The inferred value. */ - value: string; - /** - * Confidence score of the inference (0.0 to 1.0). - * @format float - * @min 0 - * @max 1 - */ - confidence: number; -} - -/** Demographic information inferred by the AI service. */ -export interface DemographicsInferred { - gender?: DemographicsInferredItem; - ageRange?: DemographicsInferredItem; - incomeBracket?: DemographicsInferredItem; -} - export interface GetApiKeyUsagePayload { /** * Optional start date for filtering usage data From 697c2c159903dc1d715935a97387d2154984ac6a Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 06:07:07 +0530 Subject: [PATCH 16/34] feat: add demographic fields to user model and update related services and schemas --- api-service/api/openapi.yaml | 55 ++++++++++++++++++++ api-service/service/AuthenticationService.js | 15 +++++- api-service/service/UserProfileService.js | 11 ++-- api-service/utils/dbSchemas.js | 23 +++++++- web/src/api/types/data-contracts.ts | 43 +++++++++++++++ 5 files changed, 141 insertions(+), 6 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 1360c71..e19be92 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -753,6 +753,27 @@ components: phone: type: string pattern: ^\+?[\d\s-]+$ + gender: + type: string + nullable: true + description: User gender identity (e.g., 'male', 'female', 'non-binary', 'prefer_not_to_say') + example: "female" + incomeBracket: + type: string + nullable: true + description: User income bracket category (e.g., '<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say') + example: "50k-100k" + country: + type: string + nullable: true + description: User country of residence (ISO 3166-1 alpha-2 code) + example: "US" + age: + type: integer + format: int32 + nullable: true + description: User age + example: 35 privacySettings: type: object properties: @@ -849,6 +870,23 @@ components: dataSharingConsent: type: boolean description: User's consent for data sharing + gender: + type: string + nullable: true + description: User gender identity + incomeBracket: + type: string + nullable: true + description: User income bracket category + country: + type: string + nullable: true + description: User country of residence (ISO 3166-1 alpha-2 code) + age: + type: integer + format: int32 + nullable: true + description: User age StoreCreate: type: object @@ -913,6 +951,23 @@ components: items: type: string description: List of domains allowed to access user data via API keys + gender: + type: string + nullable: true + description: User gender identity + incomeBracket: + type: string + nullable: true + description: User income bracket category + country: + type: string + nullable: true + description: User country of residence (ISO 3166-1 alpha-2 code) + age: + type: integer + format: int32 + nullable: true + description: User age ApiKey: type: object diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index 94e5e36..404c2e8 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -13,7 +13,15 @@ const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); exports.registerUser = async function (req, body) { try { const db = getDB(); - const { preferences, dataSharingConsent } = body; + // Destructure new demographic fields + const { + preferences, + dataSharingConsent, + gender, + incomeBracket, + country, + age, + } = body; // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); @@ -92,6 +100,10 @@ exports.registerUser = async function (req, body) { username: userData.username, email: userData.email, phone: userData.phone_number || null, + gender: gender || null, // Add new fields, defaulting to null if not provided + incomeBracket: incomeBracket || null, + country: country || null, + age: age || null, preferences: preferences || [], privacySettings: { dataSharingConsent, @@ -117,6 +129,7 @@ exports.registerUser = async function (req, body) { }); // Also cache user preferences + // Note: Demographic data is NOT typically included in the preferences cache const cachePreferences = { userId: user._id.toString(), preferences: user.preferences || [], // Fixed: consistent naming diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 32ad9c8..455135c 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -76,11 +76,8 @@ exports.updateUserProfile = async function (req, body) { if (body.username) { try { await updateAuth0Username(auth0UserId, body.username); - // Optionally: Update nickname in metadata as well if desired - // await updateUserMetadata(auth0UserId, { nickname: body.username }); + await updateUserMetadata(auth0UserId, { nickname: body.username }); } catch (auth0Error) { - // If Auth0 update fails (e.g., username exists in Auth0 connection), return an error - // You might want to check the specific error type from auth0Error console.error(`Auth0 username update failed for ${auth0UserId}:`, auth0Error); return respondWithCode(409, { // Use 409 Conflict or appropriate code code: 409, @@ -109,6 +106,12 @@ exports.updateUserProfile = async function (req, body) { if (body.username !== undefined) updateData.username = body.username; if (body.phone !== undefined) updateData.phone = body.phone; + // Add demographic fields to updateData if provided + if (body.gender !== undefined) updateData.gender = body.gender; + if (body.incomeBracket !== undefined) updateData.incomeBracket = body.incomeBracket; + if (body.country !== undefined) updateData.country = body.country; + if (body.age !== undefined) updateData.age = body.age; + // Only update allowed privacy settings if (body.privacySettings !== undefined) { updateData.privacySettings = {}; diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index c99920c..d02b145 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.2'; +const SCHEMA_VERSION = '2.0.3'; const userSchema = { validator: { @@ -31,6 +31,27 @@ const userSchema = { bsonType: ['string', 'null'], description: 'Phone number', }, + gender: { + bsonType: ['string', 'null'], + description: 'User gender identity', + // Optional: Add enum validation if desired + enum: ['male', 'female', 'non-binary', 'prefer_not_to_say', null] + }, + incomeBracket: { + bsonType: ['string', 'null'], + description: 'User income bracket category', + // Optional: Add enum validation if desired + enum: ['<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say', null] + }, + country: { + bsonType: ['string', 'null'], + description: 'User country of residence (e.g., ISO 3166-1 alpha-2 code)', + }, + age: { + bsonType: ['int', 'null'], + description: 'User age', + minimum: 0, // Optional: Add validation + }, preferences: { bsonType: 'array', description: 'User interests and preferences', diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index d9621eb..a765bfb 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -28,6 +28,27 @@ export interface User { username?: string; /** @pattern ^\+?[\d\s-]+$ */ phone?: string; + /** + * User gender identity (e.g., 'male', 'female', 'non-binary', 'prefer_not_to_say') + * @example "female" + */ + gender?: string | null; + /** + * User income bracket category (e.g., '<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say') + * @example "50k-100k" + */ + incomeBracket?: string | null; + /** + * User country of residence (ISO 3166-1 alpha-2 code) + * @example "US" + */ + country?: string | null; + /** + * User age + * @format int32 + * @example 35 + */ + age?: number | null; privacySettings: { /** @default false */ dataSharingConsent?: boolean; @@ -71,6 +92,17 @@ export interface UserCreate { preferences?: PreferenceItem[]; /** User's consent for data sharing */ dataSharingConsent: boolean; + /** User gender identity */ + gender?: string | null; + /** User income bracket category */ + incomeBracket?: string | null; + /** User country of residence (ISO 3166-1 alpha-2 code) */ + country?: string | null; + /** + * User age + * @format int32 + */ + age?: number | null; } export interface StoreCreate { @@ -99,6 +131,17 @@ export interface UserUpdate { dataAccess?: { allowedDomains?: string[]; }; + /** User gender identity */ + gender?: string | null; + /** User income bracket category */ + incomeBracket?: string | null; + /** User country of residence (ISO 3166-1 alpha-2 code) */ + country?: string | null; + /** + * User age + * @format int32 + */ + age?: number | null; } export interface ApiKey { From d8855b88a376289dd8d3b74bb34fe25eca719c5d Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 06:24:45 +0530 Subject: [PATCH 17/34] feat: enhance user registration form with demographic fields and update user creation logic --- api-service/service/AuthenticationService.js | 2 +- .../components/auth/UserRegistrationForm.tsx | 105 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index 404c2e8..7ab7ba9 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -97,7 +97,7 @@ exports.registerUser = async function (req, body) { // Create user in database const user = { auth0Id: userData.sub, - username: userData.username, + username: userData.username || userData.nickname || userData.sub, email: userData.email, phone: userData.phone_number || null, gender: gender || null, // Add new fields, defaulting to null if not provided diff --git a/web/src/components/auth/UserRegistrationForm.tsx b/web/src/components/auth/UserRegistrationForm.tsx index 09e8913..ad4951b 100644 --- a/web/src/components/auth/UserRegistrationForm.tsx +++ b/web/src/components/auth/UserRegistrationForm.tsx @@ -8,6 +8,8 @@ import { ModalBody, ModalFooter, Popover, // <-- Import Popover + Select, // <-- Import Select + TextInput, // <-- Import TextInput } from "flowbite-react"; import { UserCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; @@ -18,6 +20,25 @@ interface UserRegistrationFormProps { isLoading: boolean; } +// Define options for selects +const genderOptions = [ + { value: "", label: "Select Gender (Optional)" }, + { value: "male", label: "Male" }, + { value: "female", label: "Female" }, + { value: "non-binary", label: "Non-binary" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + +const incomeOptions = [ + { value: "", label: "Select Income Bracket (Optional)" }, + { value: "<25k", label: "< $25,000" }, + { value: "25k-50k", label: "$25,000 - $49,999" }, + { value: "50k-100k", label: "$50,000 - $99,999" }, + { value: "100k-200k", label: "$100,000 - $199,999" }, + { value: ">200k", label: "> $200,000" }, + { value: "prefer_not_to_say", label: "Prefer not to say" }, +]; + export function UserRegistrationForm({ onSubmit, isLoading, @@ -29,6 +50,12 @@ export function UserRegistrationForm({ // State to track if consent has been explicitly accepted via the modal const [consentAccepted, setConsentAccepted] = useState(false); + // Add state for demographic fields + const [gender, setGender] = useState(null); + const [incomeBracket, setIncomeBracket] = useState(null); + const [country, setCountry] = useState(null); + const [age, setAge] = useState(null); + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); // Ensure consent was actually accepted via the modal flow if required @@ -43,6 +70,11 @@ export function UserRegistrationForm({ // Use the state variable linked to the checkbox dataSharingConsent: dataSharingConsent, preferences: [], // We can leave this empty for now + // Add demographic data, ensuring null if empty string or invalid number + gender: gender || null, + incomeBracket: incomeBracket || null, + country: country || null, + age: age !== null && !isNaN(age) ? Number(age) : null, }; onSubmit(userData); @@ -77,6 +109,79 @@ export function UserRegistrationForm({ Complete User Registration

+ {/* Demographic Information Section */} +
+

+ Demographic Information (Optional) +

+ {/* Gender Select */} +
+ + +
+ {/* Income Bracket Select */} +
+ + +
+ {/* Country Input */} +
+ + + setCountry(e.target.value ? e.target.value.toUpperCase() : null) + } + maxLength={2} // ISO 3166-1 alpha-2 + className="mt-1" + /> +
+ {/* Age Input */} +
+ + { + const val = parseInt(e.target.value); + setAge(isNaN(val) ? null : val); + }} + min="0" + className="mt-1" + /> +
+
+ {/* Consent Section */}
{/* Conditionally wrap Checkbox/Label in Popover */} From b69a53369925ceb4da8e74401bfe2c0605a5dbd6 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 06:26:36 +0530 Subject: [PATCH 18/34] feat: enhance taxonomy.yaml with detailed attribute descriptions and value lists for categories --- ml-service/app/data/taxonomy.yaml | 1113 ++++++++++++++++++----------- 1 file changed, 709 insertions(+), 404 deletions(-) diff --git a/ml-service/app/data/taxonomy.yaml b/ml-service/app/data/taxonomy.yaml index a19c7bb..e92716a 100644 --- a/ml-service/app/data/taxonomy.yaml +++ b/ml-service/app/data/taxonomy.yaml @@ -6,35 +6,38 @@ categories: description: "Electronic devices and accessories" attributes: - name: "brand" - values: - [ - "Apple", - "Samsung", - "Sony", - "LG", - "Google", - "Microsoft", - "Dell", - "HP", - "Lenovo", - "Asus", - ] + description: "Manufacturer of the product" + values: + - Apple + - Samsung + - Sony + - LG + - Google + - Microsoft + - Dell + - HP + - Lenovo + - Asus - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] - - name: "color" + description: "General price tier" values: - [ - "black", - "white", - "silver", - "gold", - "blue", - "red", - "green", - "purple", - "pink", - "orange", - ] + - budget + - mid_range + - premium + - luxury + - name: "color" + description: "Available color options" + values: + - black + - white + - silver + - gold + - blue + - red + - green + - purple + - pink + - orange - id: "101" name: "Smartphones" @@ -42,41 +45,72 @@ categories: description: "Mobile phones and smartphones" attributes: - name: "brand" - values: - [ - "Apple", - "Samsung", - "Google", - "OnePlus", - "Xiaomi", - "Huawei", - "Motorola", - "Nokia", - "Sony", - "Nothing", - ] + description: "Manufacturer" + values: + - Apple + - Samsung + - Google + - OnePlus + - Xiaomi + - Huawei + - Motorola + - Nokia + - Sony + - Nothing - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] - - name: "color" + description: "Price segment" values: - [ - "black", - "white", - "silver", - "gold", - "blue", - "red", - "green", - "purple", - "pink", - "yellow", - ] + - budget + - mid_range + - premium + - luxury + - name: "color" + description: "Color options" + values: + - black + - white + - silver + - gold + - blue + - red + - green + - purple + - pink + - yellow - name: "storage" - values: ["64GB", "128GB", "256GB", "512GB", "1TB"] + description: "Built-in storage capacity" + values: + - 64GB + - 128GB + - 256GB + - 512GB + - 1TB - name: "screen_size" - values: ["compact", "standard", "large"] + description: "Relative screen size" + values: + - compact + - standard + - large - name: "os" - values: ["iOS", "Android"] + description: "Operating system" + values: + - iOS + - Android + - name: "battery_capacity" + description: "Battery capacity in mAh" + values: + - "2000-3000" + - "3001-4000" + - "4001-5000" + - "5001+" + - name: "connectivity" + description: "Network connectivity standards" + values: + - 4G + - 5G + - WiFi + - Bluetooth + - NFC - id: "102" name: "Laptops" @@ -84,42 +118,93 @@ categories: description: "Laptop computers and notebooks" attributes: - name: "brand" - values: - [ - "Apple", - "Dell", - "HP", - "Lenovo", - "Asus", - "Acer", - "Microsoft", - "Samsung", - "MSI", - "Razer", - ] + description: "Manufacturer" + values: + - Apple + - Dell + - HP + - Lenovo + - Asus + - Acer + - Microsoft + - Samsung + - MSI + - Razer - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] + description: "Price segment" + values: + - budget + - mid_range + - premium + - luxury - name: "usage_type" - values: ["everyday", "business", "gaming", "creative", "student"] + description: "Intended usage scenario" + values: + - everyday + - business + - gaming + - creative + - student - name: "screen_size" - values: ["13-inch", "14-inch", "15-inch", "16-inch", "17-inch"] - - name: "processor" + description: "Size of the display" values: - [ - "Intel i3", - "Intel i5", - "Intel i7", - "Intel i9", - "AMD Ryzen 3", - "AMD Ryzen 5", - "AMD Ryzen 7", - "AMD Ryzen 9", - "Apple M1", - "Apple M2", - "Apple M3", - ] + - "13-inch" + - "14-inch" + - "15-inch" + - "16-inch" + - "17-inch" + - name: "processor" + description: "CPU model" + values: + - Intel i3 + - Intel i5 + - Intel i7 + - Intel i9 + - AMD Ryzen 3 + - AMD Ryzen 5 + - AMD Ryzen 7 + - AMD Ryzen 9 + - Apple M1 + - Apple M2 + - Apple M3 - name: "os" - values: ["Windows", "macOS", "Chrome OS", "Linux"] + description: "Operating system" + values: + - Windows + - macOS + - Chrome OS + - Linux + - name: "ram" + description: "Amount of system memory" + values: + - 8GB + - 16GB + - 32GB + - "64GB+" + - name: "storage_type" + description: "Type of primary storage drive" + values: + - SSD + - HDD + - name: "storage_size" + description: "Capacity of primary storage drive" + values: + - 128GB + - 256GB + - 512GB + - 1TB + - "2TB+" + - name: "gpu_type" + description: "Type of graphics processing unit" + values: + - integrated + - dedicated_nvidia + - dedicated_amd + - name: "touchscreen" + description: "Whether the laptop has a touchscreen" + values: + - yes + - no - id: "103" name: "Tablets" @@ -127,24 +212,45 @@ categories: description: "Tablet computers" attributes: - name: "brand" - values: - [ - "Apple", - "Samsung", - "Microsoft", - "Amazon", - "Lenovo", - "Huawei", - "Google", - ] + description: "Manufacturer" + values: + - Apple + - Samsung + - Microsoft + - Amazon + - Lenovo + - Huawei + - Google - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] + description: "Price tier" + values: + - budget + - mid_range + - premium + - luxury - name: "screen_size" - values: ["small", "medium", "large"] + description: "Relative display size" + values: + - small + - medium + - large - name: "os" - values: ["iOS", "Android", "Windows", "FireOS"] + description: "Operating system" + values: + - iOS + - Android + - Windows + - FireOS - name: "connectivity" - values: ["WiFi", "WiFi+Cellular"] + description: "Network connectivity" + values: + - WiFi + - WiFi+Cellular + - name: "pen_support" + description: "Stylus support" + values: + - yes + - no - id: "104" name: "Wearables" @@ -152,23 +258,43 @@ categories: description: "Smartwatches and fitness trackers" attributes: - name: "brand" - values: - [ - "Apple", - "Samsung", - "Fitbit", - "Garmin", - "Amazfit", - "Huawei", - "Fossil", - "Withings", - ] + description: "Manufacturer" + values: + - Apple + - Samsung + - Fitbit + - Garmin + - Amazfit + - Huawei + - Fossil + - Withings - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] + description: "Price tier" + values: + - budget + - mid_range + - premium + - luxury - name: "type" - values: ["smartwatch", "fitness_tracker", "hybrid", "sport"] + description: "Wearable category" + values: + - smartwatch + - fitness_tracker + - hybrid + - sport - name: "connectivity" - values: ["bluetooth", "bluetooth+cellular", "wifi+bluetooth"] + description: "Connectivity options" + values: + - bluetooth + - bluetooth+cellular + - wifi+bluetooth + - name: "water_resistance" + description: "Water resistance rating" + values: + - none + - water_resistant + - swimproof + - diveproof - id: "105" name: "Audio" @@ -176,33 +302,46 @@ categories: description: "Headphones, earbuds, and speakers" attributes: - name: "brand" - values: - [ - "Sony", - "Bose", - "Apple", - "Samsung", - "Sennheiser", - "JBL", - "Audio-Technica", - "Sonos", - "Beats", - "Jabra", - ] + description: "Manufacturer" + values: + - Sony + - Bose + - Apple + - Samsung + - Sennheiser + - JBL + - Audio-Technica + - Sonos + - Beats + - Jabra - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] + description: "Price tier" + values: + - budget + - mid_range + - premium + - luxury - name: "type" - values: ["headphones", "earbuds", "speakers", "soundbars"] + description: "Product form factor" + values: + - headphones + - earbuds + - speakers + - soundbars - name: "connectivity" - values: ["wireless", "wired", "bluetooth", "wifi"] + description: "Connection type" + values: + - wireless + - wired + - bluetooth + - wifi - name: "feature" + description: "Key feature" values: - [ - "noise_cancellation", - "water_resistant", - "smart_assistant", - "spatial_audio", - ] + - noise_cancellation + - water_resistant + - smart_assistant + - spatial_audio # Fashion Category Tree - id: "200" @@ -210,66 +349,99 @@ categories: description: "Clothing, shoes, and accessories" attributes: - name: "gender" - values: ["women", "men", "unisex", "kids"] + description: "Intended gender" + values: + - women + - men + - unisex + - kids - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] + description: "Price tier" + values: + - budget + - mid_range + - premium + - luxury - name: "season" - values: ["spring", "summer", "fall", "winter", "all_season"] + description: "Season suitability" + values: + - spring + - summer + - fall + - winter + - all_season - id: "201" name: "Clothing" parent_id: "200" - description: "Shirts, pants, dresses and other apparel" + description: "Shirts, pants, dresses, and other apparel" attributes: - name: "type" - values: - [ - "shirts", - "t-shirts", - "pants", - "jeans", - "dresses", - "skirts", - "sweaters", - "jackets", - "coats", - "underwear", - "socks", - "activewear", - ] + description: "Clothing type" + values: + - shirts + - t-shirts + - pants + - jeans + - dresses + - skirts + - sweaters + - jackets + - coats + - underwear + - socks + - activewear - name: "gender" - values: ["women", "men", "unisex", "kids"] + description: "Intended gender" + values: + - women + - men + - unisex + - kids - name: "size" - values: ["XS", "S", "M", "L", "XL", "XXL", "XXXL"] + description: "Available sizes" + values: + - XS + - S + - M + - L + - XL + - XXL + - XXXL - name: "material" - values: - [ - "cotton", - "polyester", - "wool", - "silk", - "leather", - "denim", - "linen", - "synthetic", - ] + description: "Fabric material" + values: + - cotton + - polyester + - wool + - silk + - leather + - denim + - linen + - synthetic - name: "color" - values: - [ - "black", - "white", - "blue", - "red", - "green", - "yellow", - "purple", - "pink", - "gray", - "brown", - "multicolor", - ] + description: "Color options" + values: + - black + - white + - blue + - red + - green + - yellow + - purple + - pink + - gray + - brown + - multicolor - name: "occasion" - values: ["casual", "formal", "business", "party", "sports", "lounge"] + description: "Suitable occasion" + values: + - casual + - formal + - business + - party + - sports + - lounge - id: "202" name: "Shoes" @@ -277,28 +449,63 @@ categories: description: "Footwear of all types" attributes: - name: "type" - values: - [ - "sneakers", - "boots", - "sandals", - "flats", - "heels", - "loafers", - "athletic", - "dress_shoes", - "slippers", - ] + description: "Shoe style" + values: + - sneakers + - boots + - sandals + - flats + - heels + - loafers + - athletic + - dress_shoes + - slippers - name: "gender" - values: ["women", "men", "unisex", "kids"] + description: "Intended gender" + values: + - women + - men + - unisex + - kids - name: "size" - values: ["5", "6", "7", "8", "9", "10", "11", "12", "13", "14"] + description: "US shoe size" + values: + - "5" + - "6" + - "7" + - "8" + - "9" + - "10" + - "11" + - "12" + - "13" + - "14" - name: "material" - values: ["leather", "canvas", "synthetic", "suede", "mesh", "rubber"] + description: "Material type" + values: + - leather + - canvas + - synthetic + - suede + - mesh + - rubber - name: "color" - values: ["black", "white", "brown", "blue", "red", "multicolor"] + description: "Color options" + values: + - black + - white + - brown + - blue + - red + - multicolor - name: "occasion" - values: ["casual", "formal", "athletic", "outdoor", "special_occasion"] + description: "Suitable occasion" + values: + - casual + - formal + - athletic + - outdoor + - special_occasion # Home & Garden Category Tree - id: "300" @@ -306,22 +513,32 @@ categories: description: "Furniture, decor, and home improvement" attributes: - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] - - name: "room" + description: "Price tier" values: - ["living_room", "bedroom", "kitchen", "bathroom", "office", "outdoor"] + - budget + - mid_range + - premium + - luxury + - name: "room" + description: "Target room" + values: + - living_room + - bedroom + - kitchen + - bathroom + - office + - outdoor - name: "style" - values: - [ - "modern", - "traditional", - "minimalist", - "rustic", - "industrial", - "scandinavian", - "bohemian", - "farmhouse", - ] + description: "Design style" + values: + - modern + - traditional + - minimalist + - rustic + - industrial + - scandinavian + - bohemian + - farmhouse - id: "301" name: "Furniture" @@ -329,36 +546,51 @@ categories: description: "Tables, chairs, sofas, and other furniture" attributes: - name: "type" - values: - [ - "sofa", - "chair", - "table", - "bed", - "desk", - "shelf", - "cabinet", - "dresser", - ] + description: "Furniture type" + values: + - sofa + - chair + - table + - bed + - desk + - shelf + - cabinet + - dresser - name: "material" - values: ["wood", "metal", "glass", "plastic", "fabric", "leather"] + description: "Primary material" + values: + - wood + - metal + - glass + - plastic + - fabric + - leather - name: "color" - values: - [ - "black", - "white", - "brown", - "gray", - "beige", - "blue", - "green", - "multicolor", - ] + description: "Color options" + values: + - black + - white + - brown + - gray + - beige + - blue + - green + - multicolor - name: "room" - values: - ["living_room", "bedroom", "kitchen", "bathroom", "office", "outdoor"] + description: "Target room" + values: + - living_room + - bedroom + - kitchen + - bathroom + - office + - outdoor - name: "assembly_required" - values: ["yes", "no", "partial"] + description: "Assembly requirement" + values: + - yes + - no + - partial - id: "302" name: "Kitchen" @@ -366,45 +598,50 @@ categories: description: "Cookware, appliances, and kitchen accessories" attributes: - name: "type" - values: - [ - "cookware", - "bakeware", - "utensils", - "small_appliance", - "large_appliance", - "cutlery", - "dinnerware", - "storage", - ] + description: "Kitchen product type" + values: + - cookware + - bakeware + - utensils + - small_appliance + - large_appliance + - cutlery + - dinnerware + - storage - name: "material" - values: - [ - "stainless_steel", - "cast_iron", - "ceramic", - "glass", - "plastic", - "silicone", - "wood", - ] + description: "Material composition" + values: + - stainless_steel + - cast_iron + - ceramic + - glass + - plastic + - silicone + - wood - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] - - name: "brand" + description: "Price tier" values: - [ - "KitchenAid", - "Cuisinart", - "All-Clad", - "Le Creuset", - "Instant Pot", - "Ninja", - "OXO", - "Calphalon", - "Breville", - ] + - budget + - mid_range + - premium + - luxury + - name: "brand" + description: "Manufacturer" + values: + - KitchenAid + - Cuisinart + - All-Clad + - Le Creuset + - Instant Pot + - Ninja + - OXO + - Calphalon + - Breville - name: "dishwasher_safe" - values: ["yes", "no"] + description: "Dishwasher compatibility" + values: + - yes + - no # Beauty & Personal Care - id: "400" @@ -412,26 +649,42 @@ categories: description: "Makeup, skincare, and personal care products" attributes: - name: "price_range" - values: ["budget", "mid_range", "premium", "luxury"] + description: "Price tier" + values: + - budget + - mid_range + - premium + - luxury - name: "skin_type" - values: ["dry", "oily", "combination", "sensitive", "normal"] - - name: "concern" + description: "Suitable skin type" values: - ["acne", "aging", "dryness", "dullness", "redness", "sun_protection"] + - dry + - oily + - combination + - sensitive + - normal + - name: "concern" + description: "Skin concern" + values: + - acne + - aging + - dryness + - dullness + - redness + - sun_protection - name: "brand" - values: - [ - "MAC", - "Fenty", - "Glossier", - "The Ordinary", - "Cetaphil", - "Neutrogena", - "L'Oreal", - "Estée Lauder", - "Olay", - "CeraVe", - ] + description: "Manufacturer" + values: + - MAC + - Fenty + - Glossier + - The Ordinary + - Cetaphil + - Neutrogena + - L'Oreal + - Estée Lauder + - Olay + - CeraVe - id: "401" name: "Skincare" @@ -439,43 +692,46 @@ categories: description: "Products for skin health and appearance" attributes: - name: "type" - values: - [ - "cleanser", - "moisturizer", - "serum", - "mask", - "exfoliant", - "toner", - "sunscreen", - "eye_cream", - "treatment", - ] + description: "Product type" + values: + - cleanser + - moisturizer + - serum + - mask + - exfoliant + - toner + - sunscreen + - eye_cream + - treatment - name: "skin_type" - values: ["dry", "oily", "combination", "sensitive", "normal"] - - name: "concern" + description: "Suitable skin type" values: - [ - "acne", - "aging", - "dryness", - "dullness", - "redness", - "sun_protection", - "dark_spots", - ] + - dry + - oily + - combination + - sensitive + - normal + - name: "concern" + description: "Skin concern" + values: + - acne + - aging + - dryness + - dullness + - redness + - sun_protection + - dark_spots - name: "ingredient" - values: - [ - "hyaluronic_acid", - "retinol", - "vitamin_c", - "niacinamide", - "salicylic_acid", - "glycolic_acid", - "ceramides", - "peptides", - ] + description: "Key ingredients" + values: + - hyaluronic_acid + - retinol + - vitamin_c + - niacinamide + - salicylic_acid + - glycolic_acid + - ceramides + - peptides - id: "402" name: "Makeup" @@ -483,38 +739,55 @@ categories: description: "Cosmetics for face, eyes, and lips" attributes: - name: "type" - values: - [ - "foundation", - "concealer", - "eyeshadow", - "mascara", - "eyeliner", - "lipstick", - "blush", - "powder", - "bronzer", - "highlighter", - ] + description: "Makeup type" + values: + - foundation + - concealer + - eyeshadow + - mascara + - eyeliner + - lipstick + - blush + - powder + - bronzer + - highlighter - name: "finish" - values: ["matte", "dewy", "satin", "natural", "radiant", "shimmer"] + description: "Finish type" + values: + - matte + - dewy + - satin + - natural + - radiant + - shimmer - name: "coverage" - values: ["sheer", "light", "medium", "full"] - - name: "color_family" + description: "Coverage level" values: - [ - "nude", - "pink", - "red", - "berry", - "coral", - "brown", - "plum", - "orange", - "neutral", - ] + - sheer + - light + - medium + - full + - name: "color_family" + description: "Primary color family" + values: + - nude + - pink + - red + - berry + - coral + - brown + - plum + - orange + - neutral - name: "formulation" - values: ["liquid", "cream", "powder", "gel", "pencil", "stick"] + description: "Product formulation" + values: + - liquid + - cream + - powder + - gel + - pencil + - stick # Books, Movies & Music - id: "500" @@ -522,9 +795,18 @@ categories: description: "Books, movies, music, and digital entertainment" attributes: - name: "format" - values: ["physical", "digital", "streaming"] + description: "Content format" + values: + - physical + - digital + - streaming - name: "audience" - values: ["children", "young_adult", "adult", "all_ages"] + description: "Target audience" + values: + - children + - young_adult + - adult + - all_ages - id: "501" name: "Books" @@ -532,28 +814,43 @@ categories: description: "Printed and digital reading materials" attributes: - name: "format" - values: ["hardcover", "paperback", "ebook", "audiobook"] - - name: "genre" + description: "Book format" values: - [ - "fiction", - "non_fiction", - "science_fiction", - "fantasy", - "mystery", - "romance", - "biography", - "history", - "self_help", - "cooking", - "business", - ] + - hardcover + - paperback + - ebook + - audiobook + - name: "genre" + description: "Literary genre" + values: + - fiction + - non_fiction + - science_fiction + - fantasy + - mystery + - romance + - biography + - history + - self_help + - cooking + - business - name: "audience" - values: ["children", "young_adult", "adult"] + description: "Target audience" + values: + - children + - young_adult + - adult - name: "bestseller" - values: ["yes", "no"] + description: "Bestseller status" + values: + - yes + - no - name: "release_timeframe" - values: ["new_release", "recent", "classic"] + description: "Release timeframe" + values: + - new_release + - recent + - classic - id: "502" name: "Movies" @@ -561,34 +858,42 @@ categories: description: "Films and video content" attributes: - name: "format" - values: ["dvd", "blu_ray", "4k_uhd", "digital", "streaming"] - - name: "genre" + description: "Media format" values: - [ - "action", - "comedy", - "drama", - "horror", - "sci_fi", - "fantasy", - "romance", - "thriller", - "documentary", - "animation", - ] + - dvd + - blu_ray + - 4k_uhd + - digital + - streaming + - name: "genre" + description: "Film genre" + values: + - action + - comedy + - drama + - horror + - sci_fi + - fantasy + - romance + - thriller + - documentary + - animation - name: "rating" - values: - [ - "G", - "PG", - "PG-13", - "R", - "NC-17", - "TV-Y", - "TV-G", - "TV-PG", - "TV-14", - "TV-MA", - ] + description: "Content rating" + values: + - G + - PG + - "PG-13" + - R + - NC-17 + - TV-Y + - TV-G + - TV-PG + - TV-14 + - TV-MA - name: "release_timeframe" - values: ["new_release", "recent", "classic"] + description: "Release timeframe" + values: + - new_release + - recent + - classic From e05b2da5e06db9d807bd23c04eee6919388d668e Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 15:36:15 +0530 Subject: [PATCH 19/34] feat: implement InterestFormModal for user preferences selection with taxonomy data --- web/src/api/hooks/useTaxonomyHooks.ts | 33 ++- web/src/api/utils/cache.ts | 5 + web/src/components/auth/InterestFormModal.tsx | 240 ++++++++++++++++++ 3 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 web/src/components/auth/InterestFormModal.tsx diff --git a/web/src/api/hooks/useTaxonomyHooks.ts b/web/src/api/hooks/useTaxonomyHooks.ts index a0dcce1..f843cea 100644 --- a/web/src/api/hooks/useTaxonomyHooks.ts +++ b/web/src/api/hooks/useTaxonomyHooks.ts @@ -1,21 +1,32 @@ import { useQuery } from "@tanstack/react-query"; import { useApiClients } from "../apiClient"; -import { cacheKeys, CACHE_TIMES } from "../utils/cache"; +import { cacheKeys, cacheSettings } from "../utils/cache"; import { Taxonomy, Error } from "../types/data-contracts"; +// Define an interface for the actual API response structure +interface TaxonomyApiResponse { + _id: string; + version: string; + current: boolean; + data: Taxonomy; // The nested object matching the Taxonomy type + updated_at: string; +} + export function useTaxonomy() { - // Taxonomy doesn't strictly need auth, but uses the same client setup - const { apiClients } = useApiClients(); // No clientsReady check needed if endpoint is public + const { apiClients, clientsReady } = useApiClients(); + // The useQuery hook should return the inner 'Taxonomy' type return useQuery({ - // Expect Taxonomy type queryKey: cacheKeys.system.taxonomy(), - queryFn: () => - // Use the taxonomy client - apiClients.taxonomy.getTaxonomyCategories().then((res) => res.data), - // Taxonomy changes infrequently, use longer cache times - staleTime: CACHE_TIMES.LONG, - gcTime: CACHE_TIMES.LONG * 2, - // enabled: clientsReady, // Only needed if endpoint requires auth + queryFn: async () => { + // Fetch the full response + const res = await apiClients.taxonomy.getTaxonomyCategories(); + // Explicitly cast the response data to the actual API structure + const responseData = res.data as unknown as TaxonomyApiResponse; + // Return the nested 'data' property which matches the 'Taxonomy' type + return responseData.data; + }, + enabled: clientsReady, // Only fetch when API client is ready + ...cacheSettings.taxonomy, // Use specific cache settings }); } diff --git a/web/src/api/utils/cache.ts b/web/src/api/utils/cache.ts index 78593bf..7772a20 100644 --- a/web/src/api/utils/cache.ts +++ b/web/src/api/utils/cache.ts @@ -83,6 +83,11 @@ export const cacheSettings = { staleTime: CACHE_TIMES.SHORT, gcTime: CACHE_TIMES.SHORT * 2, }, + taxonomy: { + // <-- Add specific settings for taxonomy (cache longer) + staleTime: CACHE_TIMES.LONG, + gcTime: CACHE_TIMES.LONG * 2, + }, }; // Helper for optimistic updates diff --git a/web/src/components/auth/InterestFormModal.tsx b/web/src/components/auth/InterestFormModal.tsx new file mode 100644 index 0000000..c6ebb67 --- /dev/null +++ b/web/src/components/auth/InterestFormModal.tsx @@ -0,0 +1,240 @@ +import { useState, useEffect, useMemo } from "react"; // <-- Import useMemo +import { + Modal, + Button, + Card, + Spinner, + ModalBody, + ModalFooter, + ModalHeader, + Badge, // <-- Import Badge for subcategories +} from "flowbite-react"; +import { + PreferenceItem, + TaxonomyCategory, +} from "../../api/types/data-contracts"; // <-- Import TaxonomyCategory +import { HiChevronDown, HiChevronUp } from "react-icons/hi"; // <-- Icons for expand/collapse +import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; // <-- Import useTaxonomy +import { useUpdateUserPreferences } from "../../api/hooks/useUserHooks"; // <-- Import useUpdateUserPreferences +import ErrorDisplay from "../common/ErrorDisplay"; // <-- Import ErrorDisplay + +interface InterestFormModalProps { + show: boolean; + onClose: () => void; +} + +export function InterestFormModal({ show, onClose }: InterestFormModalProps) { + const { + data: taxonomyData, + isLoading: isLoadingTaxonomy, + error: taxonomyError, + } = useTaxonomy(); + const updateUserPreferences = useUpdateUserPreferences(); + + // State for selected sub-category IDs + const [selectedSubCategoryIds, setSelectedSubCategoryIds] = useState< + string[] + >([]); + // State to track the currently expanded top-level category + const [expandedCategoryId, setExpandedCategoryId] = useState( + null, + ); + + // Memoize top-level categories + const topLevelCategories = useMemo(() => { + return taxonomyData?.categories.filter((cat) => !cat.parent_id) || []; + }, [taxonomyData]); + + // Memoize subcategories mapped by their parent ID + const subCategoriesMap = useMemo(() => { + const map = new Map(); + if (taxonomyData?.categories) { + for (const category of taxonomyData.categories) { + if (category.parent_id) { + const children = map.get(category.parent_id) || []; + children.push(category); + map.set(category.parent_id, children); + } + } + } + return map; + }, [taxonomyData]); + + // Handler to expand/collapse a top-level category + const handleExpandCategory = (categoryId: string) => { + setExpandedCategoryId((prev) => (prev === categoryId ? null : categoryId)); + }; + + // Handler to select/deselect a subcategory + const handleSelectSubCategory = (subCategoryId: string) => { + setSelectedSubCategoryIds((prev) => + prev.includes(subCategoryId) + ? prev.filter((id) => id !== subCategoryId) + : [...prev, subCategoryId], + ); + }; + + const handleSubmit = async () => { + // Submit only the selected subcategory IDs + const preferences: PreferenceItem[] = selectedSubCategoryIds.map((id) => ({ + category: id, + score: 1.0, + })); + + try { + await updateUserPreferences.mutateAsync({ preferences }); + onClose(); + } catch (err) { + console.error("Failed to save preferences:", err); + } + }; + + useEffect(() => { + if (taxonomyError) { + console.error("Taxonomy failed to load, closing interest modal."); + onClose(); + } + }, [taxonomyError, onClose]); + + return ( + + {" "} + {/* Increased size */} +
+ Tell us what you're interested in +
+ + {isLoadingTaxonomy && ( +
+ +
+ )} + {taxonomyError && ( + + )} + {!isLoadingTaxonomy && + !taxonomyError && + taxonomyData && + taxonomyData.categories && ( +
+

+ Select topics to personalize your experience. Click a main topic + to see more options. +

+ {/* Render Top-Level Categories */} +
+ {topLevelCategories.map((category) => { + const isExpanded = expandedCategoryId === category.id; + const subCategories = subCategoriesMap.get(category.id) || []; + const hasSubCategories = subCategories.length > 0; + + return ( +
+ handleExpandCategory(category.id) + : undefined + } // Only expandable if it has children + className={`h-full cursor-pointer transition-all duration-150 ${isExpanded ? "ring-2 ring-blue-500 dark:ring-blue-400" : "hover:bg-gray-50 dark:hover:bg-gray-600"}`} // Added h-full + > +
+ {" "} + {/* Added h-full and justify-between */} +
+ {" "} + {/* Wrap text content */} +
+ {category.name} +
+ {category.description && ( +

+ {category.description} +

+ )} +
+ {/* Add expand/collapse icon if it has subcategories */} + {hasSubCategories && ( +
+ {" "} + {/* Keep margin-top */} + {isExpanded ? ( + + ) : ( + + )} +
+ )} + {/* Add a placeholder div if no subcategories to maintain structure */} + {!hasSubCategories && ( +
+ )} +
+
+ + {/* Render Subcategories if Expanded */} + {isExpanded && hasSubCategories && ( +
+
+ Refine '{category.name}' +
+
+ {subCategories.map((subCat) => { + const isSelected = + selectedSubCategoryIds.includes(subCat.id); + return ( + + handleSelectSubCategory(subCat.id) + } + className="cursor-pointer px-2 py-1 text-sm" // Adjusted padding/size + // title={subCat.description || undefined} // Optional: show description on hover + > + {subCat.name} + + ); + })} +
+
+ )} +
+ ); + })} +
+
+ )} +
+ + + + +
+ ); +} From d2ffde52e4ef0d3d3f53222cacbfa035f27ef12c Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 16:33:45 +0530 Subject: [PATCH 20/34] feat: add InterestFormModal to UserDashboard for preference collection and navigation handling --- web/src/api/hooks/useAuthHooks.ts | 8 +- .../auth/RegistrationCompletionModal.tsx | 5 + web/src/pages/UserDashboard.tsx | 547 +++++++++--------- 3 files changed, 296 insertions(+), 264 deletions(-) diff --git a/web/src/api/hooks/useAuthHooks.ts b/web/src/api/hooks/useAuthHooks.ts index 8e2c8a8..66da8f4 100644 --- a/web/src/api/hooks/useAuthHooks.ts +++ b/web/src/api/hooks/useAuthHooks.ts @@ -3,6 +3,7 @@ import { useAuth } from "../../hooks/useAuth"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { UserCreate, StoreCreate, User, Store } from "../types/data-contracts"; import { cacheSettings, cacheKeys } from "../utils/cache"; // Import cacheSettings +import { useNavigate } from "react-router"; export function useUserMetadata() { // Get clientsReady state along with apiClients @@ -22,21 +23,26 @@ export function useRegisterUser() { const { apiClients } = useApiClients(); const queryClient = useQueryClient(); const auth = useAuth(); + const navigate = useNavigate(); return useMutation({ mutationFn: (userData: UserCreate) => apiClients.users.registerUser(userData).then((res) => res.data), onSuccess: async () => { + // Invalidate metadata and refresh tokens first await queryClient.invalidateQueries({ queryKey: ["auth", "metadata"] }); await auth.refreshTokens(); + // Navigate to user dashboard after token refresh + navigate("/dashboard/user"); + + // Invalidate other queries after navigation is triggered await queryClient.invalidateQueries({ queryKey: cacheKeys.users.profile(), }); await queryClient.invalidateQueries({ queryKey: cacheKeys.users.preferences(), }); - // Add any other relevant query invalidations here }, }); } diff --git a/web/src/components/auth/RegistrationCompletionModal.tsx b/web/src/components/auth/RegistrationCompletionModal.tsx index dfc552d..ecad6aa 100644 --- a/web/src/components/auth/RegistrationCompletionModal.tsx +++ b/web/src/components/auth/RegistrationCompletionModal.tsx @@ -17,6 +17,7 @@ export function RegistrationCompletionModal() { "user" | "store" | null >(null); const [error, setError] = useState(null); + const [isNavigating, setIsNavigating] = useState(false); const registerUserMutation = useRegisterUser(); const registerStoreMutation = useRegisterStore(); @@ -31,9 +32,13 @@ export function RegistrationCompletionModal() { const handleUserSubmit = async (userData: UserCreate) => { setError(null); + setIsNavigating(true); // Add this state to track navigation + try { await registerUserMutation.mutateAsync(userData); + // Navigation will happen via the mutation's onSuccess handler } catch (err) { + setIsNavigating(false); console.error("Failed to complete user registration:", err); const message = err instanceof Error diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 368a4ee..8f0a4b0 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useMemo, useState, useEffect } from "react"; import { Link } from "react-router"; // <-- Corrected import import { Card, @@ -54,6 +54,7 @@ import { StoreBasicInfo, MonthlySpendingItem, } from "../api/types/data-contracts"; +import { InterestFormModal } from "../components/auth/InterestFormModal"; // Helper function to format date (keep existing) const formatDate = (dateString: string | Date | undefined) => { @@ -229,6 +230,19 @@ export default function UserDashboard() { preferencesError || storesError; + const [showInterestForm, setShowInterestForm] = useState(false); + + useEffect(() => { + // If preferences loaded and are empty, show the form + if ( + !preferencesLoading && + preferencesData && + (!preferencesData.preferences || preferencesData.preferences.length === 0) + ) { + setShowInterestForm(true); + } + }, [preferencesData, preferencesLoading]); + if (isLoading && !spendingData) { return ; } @@ -245,278 +259,285 @@ export default function UserDashboard() { // --- Render Dashboard --- return ( -
-

- Welcome back, {profile?.username || "User"}! -

- -
- {/* --- Recent Activity Card --- */} - -
-

- - Recent Activity -

- {activityError ? ( - - Could not load recent activity. - - ) : !recentActivity || recentActivity.length === 0 ? ( -

- No recent activity found. -

- ) : ( - - {recentActivity.map((activity: RecentUserDataEntry) => ( - - - - - {formatDate(activity.timestamp)} - - - {activity.dataType === "purchase" - ? "Purchase Data Submitted" - : "Search Data Submitted"} - - - From:{" "} - {activity.storeId - ? storeNameMap.get(activity.storeId) || - `Store ID: ${activity.storeId}` - : "Unknown Store"} - - - - ))} - - )} -
- - View All Activity - -
+ <> +
+

+ Welcome back, {profile?.username || "User"}! +

- {/* --- Spending Overview Card --- */} - -
-
-

- - Spending Overview +
+ {/* --- Recent Activity Card --- */} + +
+

+ + Recent Activity

-
- setStartDate(date)} - maxDate={endDate || undefined} - className="w-full" - placeholder="Start Date" - /> - setEndDate(date)} - minDate={startDate || undefined} - className="w-full" - placeholder="End Date" - /> - {(startDate || endDate) && ( - - )} -
+ {activityError ? ( + + Could not load recent activity. + + ) : !recentActivity || recentActivity.length === 0 ? ( +

+ No recent activity found. +

+ ) : ( + + {recentActivity.map((activity: RecentUserDataEntry) => ( + + + + + {formatDate(activity.timestamp)} + + + {activity.dataType === "purchase" + ? "Purchase Data Submitted" + : "Search Data Submitted"} + + + From:{" "} + {activity.storeId + ? storeNameMap.get(activity.storeId) || + `Store ID: ${activity.storeId}` + : "Unknown Store"} + + + + ))} + + )}
+ + View All Activity + +
- {spendingError ? ( - - Could not load spending data for the selected range. - - ) : spendingLoading ? ( -
- -
- ) : !lineChartData || lineChartData.length === 0 ? ( -

- No spending data available - {startDate || endDate ? " for this period" : " yet"}. -

- ) : ( -
- - - - - - formatCurrency(value)} - labelFormatter={formatMonth} - /> - - {categories.map((category, index) => ( - - ))} - - + {/* --- Spending Overview Card --- */} + +
+
+

+ + Spending Overview +

+
+ setStartDate(date)} + maxDate={endDate || undefined} + className="w-full" + placeholder="Start Date" + /> + setEndDate(date)} + minDate={startDate || undefined} + className="w-full" + placeholder="End Date" + /> + {(startDate || endDate) && ( + + )} +
- )} -
- - View Detailed Analytics - -
- {/* --- Data Sharing Card --- */} - -
-

- - Data Sharing -

- {consentError || storesError ? ( - - Could not load sharing status. - - ) : (consentLists?.optInStores?.length ?? 0) === 0 ? ( -

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

- ) : ( - <> -

- You are sharing data with {consentLists?.optInStores?.length}{" "} - store(s): + {spendingError ? ( + + Could not load spending data for the selected range. + + ) : spendingLoading ? ( +

+ +
+ ) : !lineChartData || lineChartData.length === 0 ? ( +

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

- - {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( - + + - {storeNameMap.get(storeId) || `Store ID: ${storeId}`} - - ))} - {(consentLists?.optInStores?.length ?? 0) > 5 && ( - - ... and more - - )} - - - )} -
- - Manage Sharing Settings - -
- - {/* --- Preferences Summary Card --- */} - -
-

- - Top Preferences -

- {preferencesError ? ( - - Could not load preferences data. - - ) : preferencesLoading ? ( -
- -
- ) : !topPreferencesChartData || - topPreferencesChartData.length === 0 ? ( -

- No preference data available yet. -

- ) : ( -
- - - - `${value}%`} - fontSize={12} - tick={{ fill: "currentColor" }} - /> - - `${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) => ( - + + + formatCurrency(value)} + labelFormatter={formatMonth} + /> + + {categories.map((category, index) => ( + ))} - - - -
- )} -
- - Manage All Preferences - -
+ + +
+ )} +
+ + View Detailed Analytics + + + + {/* --- Data Sharing Card --- */} + +
+

+ + Data Sharing +

+ {consentError || storesError ? ( + + Could not load sharing status. + + ) : (consentLists?.optInStores?.length ?? 0) === 0 ? ( +

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

+ ) : ( + <> +

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

+ + {consentLists?.optInStores?.slice(0, 5).map((storeId) => ( + + {storeNameMap.get(storeId) || `Store ID: ${storeId}`} + + ))} + {(consentLists?.optInStores?.length ?? 0) > 5 && ( + + ... and more + + )} + + + )} +
+ + Manage Sharing Settings + +
+ + {/* --- Preferences Summary Card --- */} + +
+

+ + Top Preferences +

+ {preferencesError ? ( + + Could not load preferences data. + + ) : preferencesLoading ? ( +
+ +
+ ) : !topPreferencesChartData || + topPreferencesChartData.length === 0 ? ( +

+ No preference data available yet. +

+ ) : ( +
+ + + + `${value}%`} + fontSize={12} + tick={{ fill: "currentColor" }} + /> + + `${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 + +
+

-
+ + setShowInterestForm(false)} + /> + ); } From 9b0968c81d09b4ccb45265282690c56b7b3a3809 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 17:23:42 +0530 Subject: [PATCH 21/34] feat: remove taxonomyService and taxonomyUtil files to streamline API services --- api-service/clients/taxonomyService.js | 36 ------- api-service/utils/taxonomyUtil.js | 140 ------------------------- 2 files changed, 176 deletions(-) delete mode 100644 api-service/clients/taxonomyService.js delete mode 100644 api-service/utils/taxonomyUtil.js diff --git a/api-service/clients/taxonomyService.js b/api-service/clients/taxonomyService.js deleted file mode 100644 index 8618f15..0000000 --- a/api-service/clients/taxonomyService.js +++ /dev/null @@ -1,36 +0,0 @@ -const axios = require('axios'); -const { getCache, setCache } = require('../utils/redisUtil'); -const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); - -// Axios Instance -const axiosInstance = axios.create(); - -// Get environment variables - use config fallback for backward compatibility -const AI_SERVICE_URL = process.env.AI_SERVICE_URL; -const AI_SERVICE_API_KEY = process.env.AI_SERVICE_API_KEY; - -// Add these constants at the top of taxonomyService.js -const REQUEST_TIMEOUTS = { - DEFAULT: 5000, // Standard endpoints - SHORT: 2000, // Health checks - LONG: 10000, // Complex operations -}; - -/** - * Check taxonomy service health - * @returns {Promise} Health status - */ -exports.checkHealth = async function () { - try { - const response = await axiosInstance.get(`${AI_SERVICE_URL}/taxonomy/health`, { - headers: { - 'X-API-Key': AI_SERVICE_API_KEY, - }, - timeout: REQUEST_TIMEOUTS.SHORT, - }); - return { status: 'connected', details: response.data }; - } catch (error) { - console.error('Health check failed:', error?.response?.data || error); - return { status: 'disconnected', details: error.message }; - } -}; diff --git a/api-service/utils/taxonomyUtil.js b/api-service/utils/taxonomyUtil.js deleted file mode 100644 index 890039f..0000000 --- a/api-service/utils/taxonomyUtil.js +++ /dev/null @@ -1,140 +0,0 @@ -const taxonomyService = require('../clients/taxonomyService'); -const { respondWithCode } = require('./writer'); - -/** - * Validate category and attributes - * @param {string} categoryId - Category ID - * @param {Object} attributes - Attributes to validate - * @returns {Promise<{valid: boolean, response: Object|null}>} Validation result - */ -exports.validateCategoryAndAttributes = async function (categoryId, attributes) { - // Validate category exists - const isValidCategory = await taxonomyService - .getCategoryAttributes(categoryId) - .then((attrs) => !!attrs) - .catch(() => false); - - if (!isValidCategory) { - return { - valid: false, - response: respondWithCode(400, { - code: 400, - message: `Invalid category: ${categoryId}`, - }), - }; - } - - // Validate attributes if provided - if (attributes) { - const validationResult = await taxonomyService.validateAttributes(categoryId, attributes); - if (!validationResult.valid) { - return { - valid: false, - response: respondWithCode(400, { - code: 400, - message: validationResult.message || `Invalid attributes for category ${categoryId}`, - }), - }; - } - } - - return { valid: true, response: null }; -}; - -/** - * Validate multiple items at once - * @param {Array} items - Items with category and attributes - * @returns {Promise} Validation results with index keys - */ -exports.validateBatch = async function (items) { - try { - const productsToValidate = items.map((item) => ({ - category: item.category, - attributes: item.attributes || {}, - })); - - return await taxonomyService.validateBatch(productsToValidate); - } catch (error) { - console.error('Batch validation failed:', error); - return Object.fromEntries(items.map((_, index) => [index.toString(), { valid: false, message: 'Batch validation failed' }])); - } -}; - -/** - * Get price range for an amount - * @param {number} amount - Price amount - * @param {string} categoryId - Optional category ID - * @returns {Promise} Price range label - */ -exports.getPriceRange = async function (amount, categoryId = null) { - try { - const result = await taxonomyService.getPriceRangeForAmount(amount, categoryId); - return result.range || 'unknown'; - } catch (error) { - console.error(`Failed to get price range for ${amount}:`, error); - return 'unknown'; - } -}; - -/** - * Validate purchase entry - * @param {Object} entry - Purchase entry to validate - * @returns {Object|null} - Response object if invalid, null if valid - */ -exports.validatePurchaseEntry = async function (entry) { - if (!entry.timestamp || !entry.items || !Array.isArray(entry.items)) { - return { - code: 400, - message: 'Purchase entries require timestamp and items array', - }; - } - - // Validate each item has a name - for (const item of entry.items) { - if (!item.name) { - return { - code: 400, - message: 'Each purchase item requires a name', - }; - } - } - - return null; -}; - -/** - * Validate search entry - * @param {Object} entry - Search entry to validate - * @returns {Object|null} - Response object if invalid, null if valid - */ -exports.validateSearchEntry = async function (entry) { - if (!entry.timestamp || !entry.query) { - return { - code: 400, - message: 'Search entries require timestamp and query', - }; - } - - return null; -}; - -/** - * Validate category for search entry - * @param {string} categoryId - Category ID to validate - * @returns {Promise} - Response object if invalid, null if valid - */ -exports.validateSearchCategory = async function (categoryId) { - const isValidCategory = await taxonomyService - .getCategoryAttributes(categoryId) - .then((attributes) => !!attributes) - .catch(() => false); - - if (!isValidCategory) { - return { - code: 400, - message: `Invalid category: ${categoryId}`, - }; - } - - return null; -}; From dd28c6f29032ddc57309a9da8df192f3f2dcb6cb Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 17:25:24 +0530 Subject: [PATCH 22/34] feat: remove AIService integration and update user preferences logic with taxonomy validation --- api-service/clients/AIService.js | 42 -------- .../service/PreferenceManagementService.js | 97 +++++++++++++------ api-service/utils/dbSchemas.js | 4 +- ml-service/app/api/endpoints/preferences.py | 31 +----- .../app/services/preferenceProcessor.py | 50 +--------- 5 files changed, 72 insertions(+), 152 deletions(-) diff --git a/api-service/clients/AIService.js b/api-service/clients/AIService.js index fc78402..39c5eb6 100644 --- a/api-service/clients/AIService.js +++ b/api-service/clients/AIService.js @@ -9,48 +9,6 @@ const axiosInstance = axios.create(); const AI_SERVICE_URL = process.env.AI_SERVICE_URL; const AI_SERVICE_API_KEY = process.env.AI_SERVICE_API_KEY; -/** - * Update user preferences directly through the FastAPI service - * @param {string} auth0Id - User Auth0 ID - * @param {string} email - User email - * @param {Array} preferences - User preferences to update - * @returns {Promise} Updated preferences - */ -exports.updateUserPreferences = async function (auth0Id, email, preferences) { - try { - const response = await axiosInstance.post( - `${AI_SERVICE_URL}/preferences/update`, - { auth0Id, email, preferences }, - { - headers: { - 'X-API-Key': AI_SERVICE_API_KEY, - 'Content-Type': 'application/json', - }, - timeout: 10000, // Using longer timeout for preference operations - } - ); - - return response.data; - } catch (error) { - console.error('Failed to update preferences through AI service:', error?.response?.data || error); - - // More specific error handling - if (error.response) { - if (error.response.status === 401) { - throw new Error('AI service authentication failed'); - } else if (error.response.status === 404) { - throw new Error('User not found in AI service'); - } else if (error.response.status >= 500) { - throw new Error('AI service internal error'); - } - } else if (error.request) { - throw new Error('AI service connection failed'); - } - - throw error; - } -}; - /** * Process user data by sending it to AI service * @param {Object} userData - The user data to process diff --git a/api-service/service/PreferenceManagementService.js b/api-service/service/PreferenceManagementService.js index a1f1d54..ed4998d 100644 --- a/api-service/service/PreferenceManagementService.js +++ b/api-service/service/PreferenceManagementService.js @@ -4,7 +4,9 @@ const { setCache, getCache, invalidateCache } = require('../utils/redisUtil'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); const { ObjectId } = require('mongodb'); -const AIService = require('../clients/AIService'); +// Removed AIService require as it's no longer used here +// const AIService = require('../clients/AIService'); +const TaxonomyService = require('../service/TaxonomyService'); // Import TaxonomyService exports.getUserOwnPreferences = async function (req) { try { @@ -117,55 +119,87 @@ exports.optOutFromStore = async function (req, storeId) { */ exports.updateUserPreferences = async function (req, body) { try { - // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - const db = getDB(); - // Find user in database using Auth0 ID const user = await db.collection('users').findOne({ auth0Id: userData.sub }); if (!user) { - return respondWithCode(404, { - code: 404, - message: 'User not found', - }); + return respondWithCode(404, { code: 404, message: 'User not found' }); } - // If preferences are provided, send to FastAPI for processing + let validatedPreferences = []; if (body.preferences) { + // --- Validation --- + if (!Array.isArray(body.preferences)) { + return respondWithCode(400, { code: 400, message: 'Preferences must be an array.' }); + } + + // Fetch taxonomy for validation + let taxonomyDoc; try { - // Call the AI service to process preferences - await AIService.updateUserPreferences( - userData.sub, - user.email, // Use email from the found user document - body.preferences - ); - } catch (error) { - console.error('Failed to process preferences through AI service:', error); - // Continue with the update, we'll use the raw preferences without validation for now + // 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 validCategoryIds = new Set(taxonomyDoc?.data?.categories?.map(cat => cat.id) || []); + + 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 + } + // --- 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.' }); } - // Update preferences in the database + // 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: body.preferences || [], + preferences: validatedPreferences, // Use the validated array updatedAt: new Date(), }, }, ); - // Fetch the updated user data to get the latest timestamp + // 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 related caches - await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); + const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; + await invalidateCache(userCacheKey); // Clear store-specific preference caches as preferences changed if (user.privacySettings?.optInStores) { @@ -174,20 +208,25 @@ exports.updateUserPreferences = async function (req, body) { } } - // Return updated preferences object (without privacySettings) + // Return updated preferences object const preferencesResponse = { userId: user._id.toString(), - preferences: updatedUser.preferences || [], + preferences: updatedUser.preferences || [], // Use actual updated preferences updatedAt: updatedUser.updatedAt, // Use the actual updated timestamp }; - // Update the cache with the minimal response - const cacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; - await setCache(cacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); - + // Update the cache with the new minimal response + await setCache(userCacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); return respondWithCode(200, preferencesResponse); + } catch (error) { + // Catch MongoDB validation errors specifically if needed + if (error.code === 121) { // MongoDB validation error code + // Log the full details for better debugging + console.error('Update user preferences failed MongoDB validation:', JSON.stringify(error.errInfo?.details, null, 2) || error.message); + return respondWithCode(400, { code: 400, message: 'Preferences failed database validation.', details: error.errInfo?.details }); // Keep details for client if needed + } console.error('Update user preferences failed:', error); return respondWithCode(500, { code: 500, message: 'Internal server error' }); } diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index d02b145..04e4db6 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.3'; +const SCHEMA_VERSION = '2.0.4'; const userSchema = { validator: { @@ -61,7 +61,7 @@ const userSchema = { properties: { category: { bsonType: 'string' }, score: { - bsonType: 'double', + bsonType: ['double', 'int'], minimum: 0.0, maximum: 1.0, }, diff --git a/ml-service/app/api/endpoints/preferences.py b/ml-service/app/api/endpoints/preferences.py index a5b2144..f156651 100644 --- a/ml-service/app/api/endpoints/preferences.py +++ b/ml-service/app/api/endpoints/preferences.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, BackgroundTasks from app.models.preferences import UserDataEntry, UserPreferences, UserPreference from app.db.mongodb import get_database -from app.services.preferenceProcessor import process_user_data, update_user_preferences +from app.services.preferenceProcessor import process_user_data from app.utils.preference_utils import mark_processing_failed from app.utils.redis_util import invalidate_cache, CACHE_KEYS from typing import List @@ -61,33 +61,4 @@ async def process_user_data_endpoint( raise HTTPException( status_code=500, detail=f"Error processing data: {str(e)}" - ) - -@router.post( - "/preferences/update", - response_model=UserPreferences, - description="Update user preferences directly", - summary="Update preferences" -) -async def update_preferences_endpoint( - preferences: List[UserPreference] = Body(...), - auth0_id: str = Body(...), - email: str = Body(...), - db=Depends(get_database) -): - """Update user preferences directly from the API service""" - logger.info(f"Updating preferences for user: {auth0_id}") - logger.info(f"Number of preference categories: {len(preferences)}") - - try: - # Call the processor function instead of handling processing here - result = await update_user_preferences(auth0_id, email, preferences, db) - return result - except HTTPException: - raise - except Exception as e: - logger.error(f"Error updating preferences: {str(e)}") - raise HTTPException( - status_code=500, - detail=f"Error updating preferences: {str(e)}" ) \ No newline at end of file diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index facc377..65f0339 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -278,52 +278,4 @@ async def normalize_categories(preferences, taxonomy): pref["category"] = name_to_id[category.lower()] normalized.append(pref) - return normalized - -async def update_user_preferences(auth0_id: str, email: str, preferences: List[UserPreference], db) -> UserPreferences: - """Update user preferences directly""" - - logger.info(f"Processing preference update for user {auth0_id}") - - # Get taxonomy service for validation - taxonomy = await get_taxonomy_service(db) - - # Validate preferences against taxonomy - try: - taxonomy.validate_preferences(preferences) - except ValueError as e: - logger.error(f"Preference validation failed: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) - - # Find the user in the database - user = await db.users.find_one({"auth0Id": auth0_id}) - if not user: - # Try finding by email as fallback - user = await db.users.find_one({"email": email}) - if not user: - logger.error(f"User not found: {email}") - raise HTTPException(status_code=404, detail="User not found") - - # Update user preferences - update_result = await db.users.update_one( - {"_id": user["_id"]}, - { - "$set": { - "preferences": [pref.dict() for pref in preferences], - "updatedAt": datetime.now() - } - } - ) - - if update_result.modified_count == 0: - logger.warning(f"No changes made to preferences for user {auth0_id}") - - # Invalidate cache - await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") - - # Return updated preferences - return UserPreferences( - user_id=str(user["_id"]), - preferences=preferences, - updated_at=datetime.now() - ) \ No newline at end of file + return normalized \ No newline at end of file From ad05e9412bad78c75c1694bf3e4d6120a2a144ae Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 18:11:57 +0530 Subject: [PATCH 23/34] feat: enhance user profile update logic to track demographic and privacy setting changes, including cache invalidation --- api-service/service/UserProfileService.js | 60 ++- .../app/services/preferenceProcessor.py | 342 +++++++++++++----- 2 files changed, 303 insertions(+), 99 deletions(-) diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 455135c..821343f 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -102,30 +102,59 @@ exports.updateUserProfile = async function (req, body) { const updateData = { updatedAt: new Date(), }; + let demographicsChanged = false; // Flag to track if demographics were updated + // Update local DB username only if Auth0 update was successful (or not attempted) if (body.username !== undefined) updateData.username = body.username; if (body.phone !== undefined) updateData.phone = body.phone; - // Add demographic fields to updateData if provided - if (body.gender !== undefined) updateData.gender = body.gender; - if (body.incomeBracket !== undefined) updateData.incomeBracket = body.incomeBracket; - if (body.country !== undefined) updateData.country = body.country; - if (body.age !== undefined) updateData.age = body.age; + // Add demographic fields to updateData if provided and track changes + if (body.gender !== undefined) { + updateData.gender = body.gender; + demographicsChanged = true; + } + if (body.incomeBracket !== undefined) { + updateData.incomeBracket = body.incomeBracket; + demographicsChanged = true; + } + if (body.country !== undefined) { + updateData.country = body.country; + demographicsChanged = true; + } + if (body.age !== undefined) { + updateData.age = body.age; + demographicsChanged = true; + } // Only update allowed privacy settings + let privacySettingsChanged = false; // Flag for privacy changes if (body.privacySettings !== undefined) { updateData.privacySettings = {}; if (body.privacySettings.dataSharingConsent !== undefined) { updateData.privacySettings.dataSharingConsent = body.privacySettings.dataSharingConsent; + privacySettingsChanged = true; } if (body.privacySettings.anonymizeData !== undefined) { updateData.privacySettings.anonymizeData = body.privacySettings.anonymizeData; + privacySettingsChanged = true; } // DO NOT update optInStores or optOutStores here } if (body.dataAccess !== undefined) updateData.dataAccess = body.dataAccess; + // Check if there's anything to update + if (Object.keys(updateData).length <= 1 && !demographicsChanged && !privacySettingsChanged) { + // Only updatedAt is set, nothing else changed + // Fetch current profile to return if needed, or return specific message + const currentUser = await db.collection('users').findOne( + { auth0Id: auth0UserId }, + { projection: { preferences: 0 } } + ); + return respondWithCode(200, currentUser || { message: "No changes detected." }); + } + + const result = await db .collection('users') .findOneAndUpdate( @@ -141,18 +170,27 @@ exports.updateUserProfile = async function (req, body) { // --- Cache Invalidation & Update --- const cacheKey = `${CACHE_KEYS.USER_DATA}${auth0UserId}`; - await invalidateCache(cacheKey); + await invalidateCache(cacheKey); // Invalidate user data cache + + // Invalidate general preferences cache if demographics changed + if (demographicsChanged) { + await invalidateCache(`${CACHE_KEYS.PREFERENCES}${auth0UserId}`); + console.log(`Invalidated general preferences cache for ${auth0UserId} due to demographic update.`); + } - // Invalidate store preferences if privacy settings changed - if (updateData.privacySettings && result.privacySettings?.optInStores) { - const userObjectId = result._id; + // Invalidate store-specific preferences if demographics or relevant privacy settings changed + // Also invalidate if the optInStores list exists (safer to clear on any profile update) + if ((demographicsChanged || privacySettingsChanged) && result.privacySettings?.optInStores) { + const userObjectId = result._id; // Use the _id from the updated result + console.log(`Invalidating store preferences for user ${userObjectId} due to update.`); for (const storeId of result.privacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`); + const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`; + await invalidateCache(storePrefCacheKey); + console.log(`Invalidated cache: ${storePrefCacheKey}`); } } // Update cache with the new data (without preferences) - // Note: This happens *after* invalidation, ensuring fresh data is set if needed immediately await setCache(cacheKey, JSON.stringify(result), { EX: CACHE_TTL.USER_DATA }); return respondWithCode(200, result); diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index 65f0339..a234eb3 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -4,7 +4,7 @@ from bson import ObjectId import logging from app.utils.redis_util import invalidate_cache, CACHE_KEYS -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from app.services.taxonomyService import get_taxonomy_service from collections import defaultdict @@ -21,8 +21,9 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: logger.info(f"Processing data for user {user_id or email}, type: {data_type}") - # Fetch existing user preferences from MongoDB + # Fetch the full user document from MongoDB to get demographics user = None + user_demographics = {} if user_id and ObjectId.is_valid(user_id): user = await db.users.find_one({"_id": ObjectId(user_id)}) @@ -32,7 +33,21 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: if not user: logger.error(f"User not found: {email}") raise HTTPException(status_code=404, detail="User not found") - + + # Extract demographics if user found + if user: + user_demographics = { + "gender": user.get("gender"), + "incomeBracket": user.get("incomeBracket"), + "country": user.get("country"), + "age": user.get("age"), + # Add inferred fields here later if needed + } + logger.info(f"Fetched demographics for user {email}: {user_demographics}") + else: + logger.warning(f"Could not fetch demographics for user {email}") + + # Get current preferences from the user object user_preferences = user.get("preferences", []) @@ -42,19 +57,20 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: # Get taxonomy service taxonomy = await get_taxonomy_service(db) - # Process entries based on data type + # Process entries based on data type, passing demographics try: if data_type == "purchase": - await process_purchase_data(entries, preference_dict, taxonomy) + await process_purchase_data(entries, preference_dict, taxonomy, user_demographics) elif data_type == "search": - await process_search_data(entries, preference_dict, taxonomy) + await process_search_data(entries, preference_dict, taxonomy, user_demographics) else: logger.warning(f"Unknown data type: {data_type}") except Exception as e: logger.error(f"Error processing {data_type} data: {str(e)}") # Fall back to using embedding model for all data try: - await process_with_embeddings(entries, data_type, preference_dict, taxonomy) + # Pass demographics to fallback as well + await process_with_embeddings(entries, data_type, preference_dict, taxonomy, user_demographics) except Exception as fallback_error: logger.error(f"Fallback processing also failed: {str(fallback_error)}") raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}") @@ -94,6 +110,14 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: auth0_id = user["auth0Id"] await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") logger.info(f"Invalidated preferences cache for user {auth0_id}") + + # Invalidate store-specific caches for this user + if user.get("privacySettings", {}).get("optInStores"): + user_object_id = str(user["_id"]) + for store_id in user["privacySettings"]["optInStores"]: + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_object_id}:{store_id}") + logger.info(f"Invalidated store-specific caches for user {auth0_id}") + # Return updated preferences in the expected format return UserPreferences( @@ -108,108 +132,225 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: updated_at=datetime.now() ) -async def process_purchase_data(entries, preference_dict, taxonomy): - """Process purchase data using rule-based system""" +async def process_purchase_data(entries, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): + """Process purchase data using rule-based system, considering demographics and buying patterns""" category_counts = defaultdict(int) + # Store attribute counts AND total price/item count per category for override logic attribute_counts = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) - - # Count purchases by category and attribute + category_price_totals = defaultdict(float) + category_item_counts = defaultdict(int) + + # --- Refined Demographic Usage --- + demographics = demographics or {} # Ensure demographics is a dict + gender = demographics.get("gender") + age = demographics.get("age") + income = demographics.get("incomeBracket") + country = demographics.get("country") + # --- End Refined Demographic Usage --- + + # Count purchases, attributes, and track prices for entry in entries: - if "items" not in entry: - continue - - for item in entry["items"]: + for item in entry.get("items", []): category = item.get("category") if not category: continue - + + quantity = item.get("quantity", 1) + price = item.get("price") # Get item price + # Increment category count - category_counts[category] += item.get("quantity", 1) - + category_counts[category] += quantity + + # Track price for override logic + if price is not None and price > 0: # Only consider valid prices + category_price_totals[category] += price * quantity + category_item_counts[category] += quantity + # Process attributes if "attributes" in item: for attr_name, attr_value in item["attributes"].items(): - attribute_counts[category][attr_name][attr_value] += item.get("quantity", 1) - - # Update preference scores - total_items = sum(category_counts.values()) - if total_items > 0: + attribute_counts[category][attr_name][attr_value] += quantity + + # Update preference scores (Category level) + total_items_overall = sum(category_counts.values()) + if total_items_overall > 0: for category, count in category_counts.items(): - # Calculate category score (normalized) - score = min(count / (total_items * 0.5), 1.0) # Cap at 1.0 - - # Create or update preference + # --- Category Score Calculation (remains largely the same) --- + base_score = min(count / (total_items_overall * 0.5), 1.0) + boost_factor = 1.0 + category_name = taxonomy.get_category_name(category) + + # Apply demographic boosts (gender, age) + if gender == "female" and category_name in ["Fashion", "Beauty", "Skincare", "Makeup"]: + boost_factor *= 1.1 + elif gender == "male" and category_name in ["Electronics", "Tools", "Laptops"]: + boost_factor *= 1.05 + if age: + if 18 <= age <= 30 and category_name in ["Smartphones", "Wearables", "Audio", "Gaming"]: # Added Gaming + boost_factor *= 1.05 + elif age >= 50 and category_name in ["Health", "Home"]: + boost_factor *= 1.08 + + final_score = min(base_score * boost_factor, 1.0) + + # Update category score in preference_dict (using EMA) if category not in preference_dict: preference_dict[category] = { "category": category, - "score": score, + "score": final_score, "attributes": {} } else: - # Use exponential moving average to blend new score with existing - alpha = 0.3 # Blend factor + alpha = 0.3 old_score = preference_dict[category]["score"] - preference_dict[category]["score"] = alpha * score + (1 - alpha) * old_score - - # Process attributes + preference_dict[category]["score"] = alpha * final_score + (1 - alpha) * old_score + # --- End Category Score Calculation --- + + + # --- Process Attributes for this Category --- if category in attribute_counts: + # Calculate average price for this category in this batch (for override) + avg_price_in_batch = (category_price_totals[category] / category_item_counts[category]) \ + if category_item_counts[category] > 0 else 0 + + # Define a 'low price threshold' (EXAMPLE - needs tuning per category) + # This is highly dependent on your product mix and taxonomy. + # You might fetch these thresholds from config or taxonomy definition. + low_price_thresholds = { + "Laptops": 700, + "Smartphones": 400, + "Clothing": 30, + "Shoes": 40, + # ... add more categories + } + is_buying_cheap = avg_price_in_batch > 0 and \ + avg_price_in_batch < low_price_thresholds.get(category_name, float('inf')) + + if is_buying_cheap: + logger.info(f"User buying pattern override triggered for category '{category_name}' (Avg Price: {avg_price_in_batch:.2f})") + + for attr_name, attr_values in attribute_counts[category].items(): - # Get total for this attribute attr_total = sum(attr_values.values()) - - # Create attribute distribution if "attributes" not in preference_dict[category]: preference_dict[category]["attributes"] = {} - if attr_name not in preference_dict[category]["attributes"]: preference_dict[category]["attributes"][attr_name] = {} - - # Calculate normalized values + for value, value_count in attr_values.items(): normalized_score = value_count / attr_total - - # Use exponential moving average if attribute value exists + attribute_boost = 1.0 + + # --- Apply Demographic Influence (with potential override) --- + + # 1. Income influence on price_range + if attr_name == "price_range" and income: + if income in ['100k-200k', '>200k']: + if value in ['premium', 'luxury']: + attribute_boost *= 1.2 # Stronger boost + # OVERRIDE: If buying cheap, negate the high-income boost + if is_buying_cheap: + attribute_boost /= 1.3 # Reduce significantly + elif value in ['budget', 'mid_range']: + # If high income but buying cheap, slightly boost lower ranges + if is_buying_cheap: + attribute_boost *= 1.1 + + elif income in ['<25k', '25k-50k']: + if value in ['budget', 'mid_range']: + attribute_boost *= 1.15 + # If low income but buying expensive (less likely override needed, but possible) + # elif value in ['premium', 'luxury'] and not is_buying_cheap: + # attribute_boost *= 0.9 # Slightly penalize? + + # 2. Gender influence on color (example) + if category_name == "Fashion" and attr_name == "color" and gender: + if gender == "female" and value in ["pink", "purple", "rose_gold"]: attribute_boost *= 1.1 + elif gender == "male" and value in ["navy", "gray", "black"]: attribute_boost *= 1.05 + + # 3. Age influence on brand (example) + if category_name == "Electronics" and attr_name == "brand" and age: + if age <= 25 and value in ["Apple", "Beats", "Razer"]: attribute_boost *= 1.05 + elif age >= 45 and value in ["Sony", "Bose", "Dell"]: attribute_boost *= 1.05 + + # 4. Income influence on brand (example - add luxury brands) + # luxury_brands = ["Gucci", "Prada", "Rolex", "Le Creuset", "All-Clad"] # Example + # if attr_name == "brand" and income in ['100k-200k', '>200k'] and value in luxury_brands: + # attribute_boost *= 1.1 + # # OVERRIDE: If buying cheap, negate boost for luxury brands + # if is_buying_cheap: + # attribute_boost /= 1.2 + + + final_attribute_score = min(normalized_score * attribute_boost, 1.0) + # --- End Attribute Influence --- + + # Update attribute score using EMA + alpha_attr = 0.3 # Use same alpha or different one for attributes if value in preference_dict[category]["attributes"][attr_name]: old_value = preference_dict[category]["attributes"][attr_name][value] preference_dict[category]["attributes"][attr_name][value] = \ - alpha * normalized_score + (1 - alpha) * old_value + alpha_attr * final_attribute_score + (1 - alpha_attr) * old_value else: - preference_dict[category]["attributes"][attr_name][value] = normalized_score + preference_dict[category]["attributes"][attr_name][value] = final_attribute_score -async def process_search_data(entries, preference_dict, taxonomy): - """Process search data using embedding model""" - # Dictionary to track category relevance from searches +async def process_search_data(entries, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): + """Process search data using embedding model, considering demographics""" search_relevance = defaultdict(float) + # --- Example Demographic Usage --- + demographics = demographics or {} + gender = demographics.get("gender") + age = demographics.get("age") + # --- End Example --- + for entry in entries: query = entry.get("query") - if not query: - continue - - # If category is already provided - if entry.get("category"): - category = entry["category"] - # A direct category search is strong signal - search_relevance[category] += 1.0 - continue - - # Use embeddings to match query to category - try: - match_result = await taxonomy.match_category(query) - if match_result["threshold_met"]: - category = match_result["category"] - # Weight by confidence score - search_relevance[category] += match_result["score"] - except Exception as e: - logger.error(f"Error matching query '{query}': {str(e)}") - + provided_category = entry.get("category") # Category explicitly sent with search + matched_category = None + match_score = 0.0 + + if not query and not provided_category: + continue # Skip if no query and no category provided + + # Determine the category + if provided_category: + matched_category = provided_category + match_score = 1.0 # Assume full relevance if category is provided + elif query: + # Use embeddings to match query to category + try: + match_result = await taxonomy.match_category(query) + if match_result["threshold_met"]: + matched_category = match_result["category"] + match_score = match_result["score"] + except Exception as e: + logger.error(f"Error matching query '{query}': {str(e)}") + + # If a category was determined, calculate boosted relevance + if matched_category: + relevance_boost = 1.0 + category_name = taxonomy.get_category_name(matched_category) # Get name for logic + + # --- Apply Demographic Boost to Relevance --- + if gender == 'female' and category_name in ['Fashion', 'Beauty', 'Skincare']: + relevance_boost *= 1.1 + elif gender == 'male' and category_name in ['Electronics', 'Tools', 'Laptops']: + relevance_boost *= 1.05 + + if age and 18 <= age <= 30 and category_name in ["Smartphones", "Gaming"]: # Add Gaming if exists + relevance_boost *= 1.05 + # --- End Demographic Boost --- + + # Add boosted score to search relevance dict + search_relevance[matched_category] += match_score * relevance_boost + # Normalize search relevance scores if search_relevance: - max_relevance = max(search_relevance.values()) + max_relevance = max(search_relevance.values()) if search_relevance else 0 # Handle empty dict if max_relevance > 0: # Update preferences for category, relevance in search_relevance.items(): - # Normalize to 0-1 range score = min(relevance / max_relevance, 1.0) if category not in preference_dict: @@ -219,15 +360,20 @@ async def process_search_data(entries, preference_dict, taxonomy): "attributes": {} } else: - # Use exponential moving average - alpha = 0.2 # Lower weight for searches vs purchases + alpha = 0.2 old_score = preference_dict[category]["score"] preference_dict[category]["score"] = alpha * score + (1 - alpha) * old_score -async def process_with_embeddings(entries, data_type, preference_dict, taxonomy): - """Fallback processing using embeddings for all data types""" +async def process_with_embeddings(entries, data_type, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): + """Fallback processing using embeddings, potentially considering demographics""" logger.info("Using embedding fallback processing") + # --- Demographic Usage --- + demographics = demographics or {} + gender = demographics.get("gender") + age = demographics.get("age") + # --- End Demographic Usage --- + # For purchase data if data_type == "purchase": items = [] @@ -235,32 +381,45 @@ async def process_with_embeddings(entries, data_type, preference_dict, taxonomy) if "items" in entry: items.extend([item.get("name", "") for item in entry["items"]]) - # Process each item name for item_name in items: try: match_result = await taxonomy.match_category(item_name) if match_result["threshold_met"]: category = match_result["category"] score = match_result["score"] - + boost_factor = 1.0 + category_name = taxonomy.get_category_name(category) + + # --- Apply Demographic Boost (Similar to purchase logic) --- + if gender == "female" and category_name in ["Fashion", "Beauty"]: + boost_factor *= 1.1 + # Add other demographic boosts (age, etc.) here if desired + if age and age >= 50 and category_name == "Health": + boost_factor *= 1.08 + + final_score = min(score * boost_factor, 1.0) + # --- End Demographic Boost --- + + # Update preference dict (using final_score) if category not in preference_dict: preference_dict[category] = { "category": category, - "score": score, + "score": final_score, # Use boosted score "attributes": {} } else: - # Update using max - preference_dict[category]["score"] = max( - preference_dict[category]["score"], - score * 0.8 # Reduce confidence for embedding-based matches - ) + # Blend score (maybe use a lower weight for embedding matches?) + alpha_embed = 0.2 + old_score = preference_dict[category]["score"] + preference_dict[category]["score"] = alpha_embed * final_score + (1 - alpha_embed) * old_score + # Alternative: Just take the max? + # preference_dict[category]["score"] = max(old_score, final_score * 0.8) except Exception as e: - logger.error(f"Error processing item '{item_name}': {str(e)}") - - # For search data, same as regular processing + logger.error(f"Error processing item '{item_name}' via embedding: {str(e)}") + + # For search data, call the updated search processor (already passes demographics) elif data_type == "search": - await process_search_data(entries, preference_dict, taxonomy) + await process_search_data(entries, preference_dict, taxonomy, demographics) async def normalize_categories(preferences, taxonomy): """Ensure all categories use IDs instead of names""" @@ -268,14 +427,21 @@ async def normalize_categories(preferences, taxonomy): # Build name-to-id mapping name_to_id = {} + id_to_name = {} # Also build reverse mapping for safety check for cat in taxonomy.taxonomy.categories: name_to_id[cat.name.lower()] = cat.id + id_to_name[cat.id] = cat.name # Store ID to Name mapping for pref in preferences: - category = pref["category"] - # If category is a name rather than ID, convert it - if category.lower() in name_to_id: - pref["category"] = name_to_id[category.lower()] + category_key = pref["category"] + # Check if the key is a name and needs conversion + if isinstance(category_key, str) and category_key.lower() in name_to_id: + pref["category"] = name_to_id[category_key.lower()] + # Safety check: Ensure the final category key is a valid ID present in the taxonomy + elif category_key not in id_to_name: + logger.warning(f"Category '{category_key}' not found in taxonomy IDs during normalization. Skipping.") + continue # Skip this preference if the category ID is invalid + normalized.append(pref) return normalized \ No newline at end of file From f85088f6e036e59f7adad9f83b0d76a69d869c33 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 19:41:08 +0530 Subject: [PATCH 24/34] feat: Update demographic inference and taxonomy schemas - Incremented schema version from 2.0.4 to 2.0.5. - Added inferred demographic fields to user schema: inferredHasKids, inferredRelationshipStatus, inferredEmploymentStatus, inferredEducationLevel, inferredAgeBracket. - Expanded taxonomy.yaml with new categories and attributes for kids' clothing, gardening, tools & home improvement, and more. - Implemented demographic inference functions for employment status, education level, and age bracket. - Integrated demographic inference into preference processing, enhancing user data with inferred demographics. - Added new boosts in recommendation logic based on inferred demographics. --- api-service/utils/dbSchemas.js | 31 +- ml-service/app/data/taxonomy.yaml | 535 +++++++++++++++++- .../app/services/demographicInference.py | 287 ++++++++++ .../app/services/preferenceProcessor.py | 190 ++++++- 4 files changed, 1023 insertions(+), 20 deletions(-) create mode 100644 ml-service/app/services/demographicInference.py diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 04e4db6..16b4309 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.4'; +const SCHEMA_VERSION = '2.0.5'; const userSchema = { validator: { @@ -52,6 +52,35 @@ const userSchema = { description: 'User age', minimum: 0, // Optional: Add validation }, + // --- Start: Add inferred demographic fields --- + inferredHasKids: { + bsonType: ['bool', 'null'], + description: 'Inferred: Does the user likely have children? (null if unknown)', + }, + inferredRelationshipStatus: { + bsonType: ['string', 'null'], + description: 'Inferred: User relationship status (null if unknown)', + enum: ['single', 'relationship', 'married', null], // Example enum + }, + // --- Start: Add NEW inferred fields --- + inferredEmploymentStatus: { + bsonType: ['string', 'null'], + description: 'Inferred: User employment status (null if unknown)', + enum: ['employed', 'unemployed', 'student', null], // Example enum + }, + inferredEducationLevel: { + bsonType: ['string', 'null'], + description: 'Inferred: User education level (null if unknown)', + enum: ['high_school', 'bachelors', 'masters', 'doctorate', null], // Example enum + }, + inferredAgeBracket: { + bsonType: ['string', 'null'], + description: 'Inferred: User age bracket if age not provided (null if unknown)', + // Example brackets - adjust as needed + enum: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+', null], + }, + // --- End: Add NEW inferred fields --- + // --- End: Add inferred demographic fields --- preferences: { bsonType: 'array', description: 'User interests and preferences', diff --git a/ml-service/app/data/taxonomy.yaml b/ml-service/app/data/taxonomy.yaml index e92716a..a96d457 100644 --- a/ml-service/app/data/taxonomy.yaml +++ b/ml-service/app/data/taxonomy.yaml @@ -145,6 +145,7 @@ categories: - gaming - creative - student + - professional - name: "screen_size" description: "Size of the display" values: @@ -408,6 +409,20 @@ categories: - XL - XXL - XXXL + - kids_xs + - kids_s + - kids_m + - kids_l + - kids_xl + - toddler_2t + - toddler_3t + - toddler_4t + - infant_0-3m + - infant_3-6m + - infant_6-9m + - infant_9-12m + - infant_12-18m + - infant_18-24m - name: "material" description: "Fabric material" values: @@ -442,6 +457,16 @@ categories: - party - sports - lounge + - name: "style" + description: "Fashion style" + values: + - fast_fashion + - business_casual + - formal_wear + - comfort + - streetwear + - vintage + - sustainable - id: "202" name: "Shoes" @@ -468,7 +493,7 @@ categories: - unisex - kids - name: "size" - description: "US shoe size" + description: "US shoe size (Adult & Kids)" values: - "5" - "6" @@ -480,6 +505,19 @@ categories: - "12" - "13" - "14" + - kids_1 + - kids_2 + - kids_3 + - kids_4 + - kids_5 + - kids_6 + - kids_7 + - kids_8 + - kids_9 + - kids_10 + - kids_11 + - kids_12 + - kids_13 - name: "material" description: "Material type" values: @@ -643,6 +681,52 @@ categories: - yes - no + - id: "303" + name: "Gardening" + parent_id: "300" + description: "Gardening tools, plants, and supplies" + attributes: + - name: "type" + values: + - tools + - seeds + - plants + - soil + - fertilizer + - pots_planters + - pest_control + - name: "plant_type" + values: + - flower + - vegetable + - herb + - shrub + - tree + - indoor + + - id: "304" + name: "Tools & Home Improvement" + parent_id: "300" + description: "Hand tools, power tools, hardware, and improvement supplies" + attributes: + - name: "type" + values: + - hand_tool + - power_tool + - hardware + - plumbing + - electrical + - paint + - safety_gear + - name: "brand" + values: + - DeWalt + - Milwaukee + - Ryobi + - Craftsman + - Stanley + - Bosch + # Beauty & Personal Care - id: "400" name: "Beauty" @@ -834,6 +918,13 @@ categories: - self_help - cooking - business + - science + - technology + - academic + - textbook + - travel + - art + - poetry - name: "audience" description: "Target audience" values: @@ -897,3 +988,445 @@ categories: - new_release - recent - classic + + - id: "503" + name: "Streaming Services" + parent_id: "500" + description: "Digital streaming subscriptions for video and music" + attributes: + - name: "service_type" + values: + - video + - music + - gaming + - name: "provider" + values: + - Netflix + - Hulu + - Disney+ + - Spotify + - Apple Music + - Xbox Game Pass + + - id: "504" + name: "Academic Journals" + parent_id: "500" + description: "Scholarly publications and research articles" + attributes: + - name: "field" + values: + - science + - medicine + - engineering + - humanities + - social_sciences + - name: "format" + values: + - digital_subscription + - print + - individual_article + + # Health & Wellness (Top Level) + - id: "600" + name: "Health & Wellness" + description: "Health, wellness, and medical products" + attributes: + - name: "price_range" + description: "Price tier" + values: + - budget + - mid_range + - premium + - name: "concern" + description: "Health concern addressed" + values: + - general_wellness + - pain_relief + - mobility + - sleep + - nutrition + - specific_condition + + # Health Sub-categories + - id: "601" + name: "Vitamins & Supplements" + parent_id: "600" + description: "Dietary supplements and vitamins" + attributes: + - name: "type" + values: + - multivitamin + - vitamin_c + - vitamin_d + - protein + - herbal + - mineral + - name: "form" + values: + - capsule + - tablet + - powder + - liquid + - gummy + + - id: "602" + name: "Personal Care" + parent_id: "600" + description: "Personal hygiene and care items (non-beauty focus)" + attributes: + - name: "type" + values: + - oral_care + - first_aid + - feminine_hygiene + - incontinence + - foot_care + + - id: "603" + name: "Medical Supplies" + parent_id: "600" + description: "Medical equipment and supplies" + attributes: + - name: "type" + values: + - monitoring_device + - mobility_aid + - first_aid_kit + - diagnostic_test + + - id: "604" + name: "Comfort Footwear" + parent_id: "600" + description: "Footwear designed for comfort and support" + attributes: + - name: "feature" + values: + - orthopedic + - arch_support + - wide_fit + - diabetic + - name: "gender" + values: + - women + - men + - unisex + - name: "size" + values: + - "7" + - "8" + - "9" + - "10" + - "11" + - "12" + - wide_7 + - wide_8 + + # Toys & Baby (Top Level) + - id: "700" + name: "Toys & Games" + description: "Toys, games, and collectibles for all ages" + attributes: + - name: "age_range" + values: + - "0-1" + - "1-3" + - "3-5" + - "5-7" + - "8-11" + - "12+" + - adult + - name: "type" + values: + - action_figure + - board_game + - puzzle + - educational + - outdoor + - plush + - building_blocks + - video_game_related + + # Toys & Baby Sub-categories + - id: "701" + name: "Baby Gear" + parent_id: "700" + description: "Strollers, car seats, feeding supplies, nursery items" + attributes: + - name: "type" + values: + - stroller + - car_seat + - high_chair + - baby_monitor + - crib + - feeding_bottle + - diapering + - name: "brand" + values: + - Graco + - Chicco + - Evenflo + - Fisher-Price + - Philips Avent + + - id: "702" + name: "Kids Clothing" + parent_id: "200" + description: "Clothing specifically for children and babies" + attributes: + - name: "age_group" + values: + - baby + - toddler + - kids + - tween + - name: "gender" + values: + - boy + - girl + - unisex + - name: "size" + values: + - infant_0-3m + - infant_3-6m + - toddler_2t + - kids_s + - kids_m + - kids_l + - name: "type" + values: + - onesie + - t-shirt + - pants + - dress + - outerwear + - sleepwear + + # Office & Business (Top Level) + - id: "800" + name: "Office Supplies" + description: "Stationery, office basics, and equipment" + attributes: + - name: "price_range" + values: + - budget + - standard + - premium + - name: "category" + values: + - writing + - paper + - organization + - desk_accessories + - office_electronics + + # Office & Business Sub-categories + - id: "801" + name: "Stationery" + parent_id: "800" + description: "Pens, pencils, notebooks, paper, and related items" + attributes: + - name: "type" + values: + - pen + - pencil + - notebook + - paper + - envelope + - marker + - highlighter + + - id: "802" + name: "Business Wear" + parent_id: "200" + description: "Formal and business-casual attire for professional settings" + attributes: + - name: "type" + values: + - suit + - blazer + - dress_shirt + - blouse + - trousers + - skirt + - dress_shoes + - name: "gender" + values: + - men + - women + - name: "occasion" + values: + - business_formal + - business_casual + + # Entertainment & Hobbies (Top Level) + - id: "900" + name: "Gaming" + description: "Video games, consoles, and accessories" + attributes: + - name: "platform" + values: + - pc + - playstation + - xbox + - nintendo_switch + - mobile + - name: "genre" + values: + - action + - rpg + - strategy + - simulation + - sports + - puzzle + - indie + - name: "type" + values: + - console + - game + - accessory + - gaming_laptop + - gaming_pc_component + + # Travel (Top Level) + - id: "1000" + name: "Travel" + description: "Luggage, travel accessories, and booking services" + attributes: + - name: "type" + values: + - luggage + - travel_accessories + - booking + - travel_gear + - name: "travel_style" + values: + - business + - leisure + - budget + - luxury + - adventure + - family + + # Food & Grocery (Top Level) + - id: "1100" + name: "Grocery" + description: "Food and beverage items" + attributes: + - name: "category" + values: + - fresh_produce + - pantry_staples + - snacks + - beverages + - frozen_foods + - dairy + - meat_seafood + - name: "dietary_preference" + values: + - organic + - gluten_free + - vegan + - vegetarian + - keto + + # Food & Grocery Sub-categories + - id: "1101" + name: "Budget Food" + parent_id: "1100" + description: "Value-focused food items and bulk goods" + attributes: + - name: "type" + values: + - canned_goods + - pasta_rice + - frozen_value + - store_brand + + # Luxury & Gifting (Top Level) + - id: "1200" + name: "Jewelry & Watches" + description: "Fine and fashion jewelry, watches" + attributes: + - name: "type" + values: + - ring + - necklace + - bracelet + - earrings + - watch + - name: "material" + values: + - gold + - silver + - platinum + - diamond + - gemstone + - stainless_steel + - name: "price_range" + values: + - fashion + - mid_range + - fine + - luxury + - name: "gender" + values: + - women + - men + - unisex + + - id: "1300" + name: "Gifts" + description: "Gift sets, special occasion items, and experiences" + attributes: + - name: "occasion" + values: + - birthday + - anniversary + - holiday + - wedding + - thank_you + - corporate + - name: "recipient" + values: + - for_her + - for_him + - for_kids + - for_couple + - for_pet + - name: "type" + values: + - gift_basket + - experience + - personalized_item + - novelty_gift + + # Software (Top Level) + - id: "1400" + name: "Software" + description: "Applications, operating systems, and software tools" + attributes: + - name: "type" + values: + - productivity + - creative + - security + - utility + - operating_system + - business + - educational + - gaming + - name: "license" + values: + - subscription + - perpetual + - freeware + - open_source + - name: "platform" + values: + - windows + - macos + - linux + - web + - mobile_ios + - mobile_android diff --git a/ml-service/app/services/demographicInference.py b/ml-service/app/services/demographicInference.py new file mode 100644 index 0000000..7904b39 --- /dev/null +++ b/ml-service/app/services/demographicInference.py @@ -0,0 +1,287 @@ +import logging +from collections import defaultdict +from typing import List, Dict, Any, Optional, Tuple +from bson import ObjectId +from datetime import datetime # Import datetime +from app.utils.redis_util import invalidate_cache, CACHE_KEYS # Import cache utilities + +logger = logging.getLogger(__name__) + +# --- Keyword Definitions (Examples - Expand significantly) --- +KIDS_KEYWORDS = { + "baby", "toddler", "child", "kid", "infant", "diaper", "stroller", + "crib", "formula", "nursery", "maternity", "school supplies", "toy", + "lego", "barbie", "playstation", "nintendo", # Be careful with broad terms +} +RELATIONSHIP_KEYWORDS = { + "wedding", "engagement", "anniversary", "couple", "partner", "spouse", + "boyfriend", "girlfriend", "husband", "wife", "romantic", "valentine", +} +MARRIED_KEYWORDS = { + "wedding", "anniversary", "spouse", "husband", "wife", "married", +} +SINGLE_KEYWORDS = { + "single", "dating app", "matchmaking", +} + +# --- NEW Keyword Sets --- +EMPLOYMENT_KEYWORDS = { + "job search", "linkedin", "resume", "interview suit", "office supplies", + "business travel", "conference", "work laptop", "unemployment benefits", + "career fair", "networking event", +} +STUDENT_KEYWORDS = { + "student discount", "university", "college", "textbook", "dorm room", + "student loan", "internship", "campus", "study guide", "backpack", + "school supplies", # Overlap with KIDS_KEYWORDS, context matters +} +EDUCATION_KEYWORDS = { + "university", "college", "bachelor's degree", "master's degree", "phd", + "doctorate", "thesis", "dissertation", "academic journal", "textbook", + "research paper", "graduate school", +} +# Age bracket keywords are very unreliable, use with extreme caution or alternative methods +AGE_BRACKET_YOUNG_ADULT_KEYWORDS = { # Approx 18-24 + "college", "university", "first apartment", "internship", "study abroad", + "spring break", "starter job", +} +AGE_BRACKET_MID_CAREER_KEYWORDS = { # Approx 35-54 + "mortgage", "kids' college fund", "management training", "midlife crisis", # Joking, but maybe? + "retirement planning", "executive", +} +AGE_BRACKET_SENIOR_KEYWORDS = { # Approx 65+ + "retirement", "pension", "senior discount", "medicare", "grandchild", + "assisted living", "downsizing home", +} +# --- End NEW Keyword Sets --- + + +# --- Helper Function to Extract Text --- +def _extract_text_from_entries(entries: List[Dict[str, Any]]) -> List[str]: + """Extracts relevant text (item names, search queries) from entries.""" + texts = [] + for entry in entries: + if entry.get("dataType") == "purchase": + texts.extend([item.get("name", "").lower() for item in entry.get("items", [])]) + elif entry.get("dataType") == "search": + texts.append(entry.get("query", "").lower()) + return [text for text in texts if text] # Filter out empty strings + +# --- Inference Functions --- + +async def infer_has_kids(entries: List[Dict[str, Any]]) -> Optional[bool]: + """Infer if user has kids based on purchase/search keywords.""" + kid_evidence_count = 0 + texts = _extract_text_from_entries(entries) + for text in texts: + if any(keyword in text for keyword in KIDS_KEYWORDS): + kid_evidence_count += 1 + logger.debug(f"Kid keyword found: {text}") + + if kid_evidence_count >= 2: # Require multiple pieces of evidence + return True + return None # Not enough evidence + +async def infer_relationship_status(entries: List[Dict[str, Any]]) -> Optional[str]: + """Infer relationship status (single, relationship, married) based on keywords.""" + married_evidence = 0 + relationship_evidence = 0 + single_evidence = 0 # Less reliable + texts = _extract_text_from_entries(entries) + + for text in texts: + if any(keyword in text for keyword in MARRIED_KEYWORDS): + married_evidence += 1 + logger.debug(f"Married keyword found: {text}") + elif any(keyword in text for keyword in RELATIONSHIP_KEYWORDS): + relationship_evidence += 1 + logger.debug(f"Relationship keyword found: {text}") + elif any(keyword in text for keyword in SINGLE_KEYWORDS): + single_evidence += 1 + logger.debug(f"Single keyword found: {text}") + + # Prioritize married > relationship > single based on evidence threshold + if married_evidence >= 1: # Lower threshold for specific events like wedding + return "married" + elif relationship_evidence >= 2: + return "relationship" + # elif single_evidence >= 1: # Be very cautious enabling this + # return "single" + return None # Not enough evidence + +# --- NEW Inference Functions --- + +async def infer_employment_status(entries: List[Dict[str, Any]]) -> Optional[str]: + """Infer employment status (employed, student, unemployed) based on keywords.""" + student_evidence = 0 + employment_evidence = 0 + # Inferring 'unemployed' directly from keywords is very difficult/unreliable + texts = _extract_text_from_entries(entries) + + for text in texts: + # Check student first due to potential overlap (e.g., "school supplies") + if any(keyword in text for keyword in STUDENT_KEYWORDS): + student_evidence += 1 + logger.debug(f"Student keyword found: {text}") + elif any(keyword in text for keyword in EMPLOYMENT_KEYWORDS): + employment_evidence += 1 + logger.debug(f"Employment keyword found: {text}") + + # Prioritize student if strong evidence, otherwise employed + if student_evidence >= 2: + return "student" + elif employment_evidence >= 2: + return "employed" + # Add more sophisticated logic? Check for conflicting terms? + return None # Not enough evidence + +async def infer_education_level(entries: List[Dict[str, Any]]) -> Optional[str]: + """Infer education level (high_school, bachelors, masters, doctorate) - Very Speculative.""" + doctorate_evidence = 0 + masters_evidence = 0 + bachelors_evidence = 0 + texts = _extract_text_from_entries(entries) + + for text in texts: + # Check most specific first + if any(keyword in text for keyword in ["phd", "doctorate", "dissertation"]): + doctorate_evidence += 1 + logger.debug(f"Doctorate keyword found: {text}") + elif any(keyword in text for keyword in ["master's degree", "graduate school", "thesis"]): + masters_evidence += 1 + logger.debug(f"Masters keyword found: {text}") + elif any(keyword in text for keyword in ["bachelor's degree", "university", "college", "undergrad"]): + bachelors_evidence += 1 + logger.debug(f"Bachelors keyword found: {text}") + + # Prioritize highest level found with some evidence threshold + if doctorate_evidence >= 1: + return "doctorate" + elif masters_evidence >= 1: + return "masters" + elif bachelors_evidence >= 2: # Require slightly more for bachelors + return "bachelors" + # Inferring 'high_school' is difficult, maybe default if other evidence is weak? + return None # Very uncertain + +async def infer_age_bracket(entries: List[Dict[str, Any]]) -> Optional[str]: + """Infer age bracket based on keywords - EXTREMELY SPECULATIVE AND UNRELIABLE.""" + young_adult_evidence = 0 + mid_career_evidence = 0 + senior_evidence = 0 + texts = _extract_text_from_entries(entries) + + for text in texts: + if any(keyword in text for keyword in AGE_BRACKET_SENIOR_KEYWORDS): + senior_evidence += 1 + logger.debug(f"Senior age keyword found: {text}") + elif any(keyword in text for keyword in AGE_BRACKET_MID_CAREER_KEYWORDS): + mid_career_evidence += 1 + logger.debug(f"Mid-career age keyword found: {text}") + elif any(keyword in text for keyword in AGE_BRACKET_YOUNG_ADULT_KEYWORDS): + young_adult_evidence += 1 + logger.debug(f"Young adult age keyword found: {text}") + + # Simple thresholding - needs much refinement or a different approach + if senior_evidence >= 1: + return "65+" + elif mid_career_evidence >= 2: + # Could try to differentiate 35-44 vs 45-54 based on keywords, but very hard + return "35-54" # Combine for now + elif young_adult_evidence >= 2: + return "18-24" + + logger.warning("Age bracket inference based on keywords is highly unreliable.") + return None # Highly uncertain + +# --- Main Inference Runner --- + +async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) -> bool: + """ + Runs demographic inference based on recent user data and updates the user document if changes are found. + Returns True if the user document was updated, False otherwise. + """ + logger.info(f"Running demographic inference for user {user_id} ({email})") + updated = False + try: + user_object_id = ObjectId(user_id) + user = await db.users.find_one({"_id": user_object_id}) + if not user: + logger.error(f"Inference: User not found by ID {user_id}") + return False + + # Fetch recent userData entries for the user + recent_data = await db.userData.find( + {"userId": user_object_id} + ).sort("timestamp", -1).limit(limit).to_list(length=limit) + + if not recent_data: + logger.info(f"Inference: No recent data found for user {user_id}") + return False + + # --- Run inference functions --- + inferred_kids = await infer_has_kids(recent_data) + inferred_status = await infer_relationship_status(recent_data) + inferred_employment = await infer_employment_status(recent_data) + inferred_education = await infer_education_level(recent_data) # Very speculative + inferred_age_bracket = None + # Only infer age bracket if age is not already set + if user.get("age") is None: + inferred_age_bracket = await infer_age_bracket(recent_data) # Highly speculative + + # --- Prepare update payload --- + update_payload = {} + current_kids = user.get("inferredHasKids") + current_status = user.get("inferredRelationshipStatus") + current_employment = user.get("inferredEmploymentStatus") + current_education = user.get("inferredEducationLevel") + current_age_bracket = user.get("inferredAgeBracket") + + if inferred_kids is not None and inferred_kids != current_kids: + update_payload["inferredHasKids"] = inferred_kids + logger.info(f"Inference update for {email}: inferredHasKids -> {inferred_kids}") + if inferred_status is not None and inferred_status != current_status: + update_payload["inferredRelationshipStatus"] = inferred_status + logger.info(f"Inference update for {email}: inferredRelationshipStatus -> {inferred_status}") + if inferred_employment is not None and inferred_employment != current_employment: + update_payload["inferredEmploymentStatus"] = inferred_employment + logger.info(f"Inference update for {email}: inferredEmploymentStatus -> {inferred_employment}") + if inferred_education is not None and inferred_education != current_education: + update_payload["inferredEducationLevel"] = inferred_education + logger.info(f"Inference update for {email}: inferredEducationLevel -> {inferred_education}") + if inferred_age_bracket is not None and inferred_age_bracket != current_age_bracket: + update_payload["inferredAgeBracket"] = inferred_age_bracket + logger.info(f"Inference update for {email}: inferredAgeBracket -> {inferred_age_bracket}") + # --- End Prepare update payload --- + + # Update user document in DB if there are changes + if update_payload: + update_payload["updatedAt"] = datetime.now() # Update timestamp + result = await db.users.update_one( + {"_id": user_object_id}, + {"$set": update_payload} + ) + if result.modified_count > 0: + updated = True + logger.info(f"Inference: Updated user document for {email}") + + # --- Invalidate Caches on Successful Update --- + auth0_id = user.get("auth0Id") + if auth0_id: + await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") + await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") + logger.info(f"Inference: Invalidated USER_DATA and PREFERENCES cache for {auth0_id}") + + if user.get("privacySettings", {}).get("optInStores"): + user_object_id_str = str(user_object_id) + for store_id in user["privacySettings"]["optInStores"]: + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_object_id_str}:{store_id}") + logger.info(f"Inference: Invalidated STORE_PREFERENCES caches for {auth0_id}") + # --- End Cache Invalidation --- + else: + logger.warning(f"Inference: Update payload generated but DB modify count was 0 for {email}") + + except Exception as e: + logger.error(f"Error during demographic inference for user {user_id}: {str(e)}", exc_info=True) + + return updated diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index a234eb3..7ec945b 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -7,6 +7,7 @@ from typing import List, Dict, Any, Optional from app.services.taxonomyService import get_taxonomy_service from collections import defaultdict +from app.services.demographicInference import run_inference_for_user # Import the inference runner logger = logging.getLogger(__name__) @@ -41,11 +42,20 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: "incomeBracket": user.get("incomeBracket"), "country": user.get("country"), "age": user.get("age"), - # Add inferred fields here later if needed + "inferredHasKids": user.get("inferredHasKids"), + "inferredRelationshipStatus": user.get("inferredRelationshipStatus"), + "inferredEmploymentStatus": user.get("inferredEmploymentStatus"), + "inferredEducationLevel": user.get("inferredEducationLevel"), + "inferredAgeBracket": user.get("inferredAgeBracket"), } logger.info(f"Fetched demographics for user {email}: {user_demographics}") else: logger.warning(f"Could not fetch demographics for user {email}") + # If user wasn't found initially, raise the error + if not user_id: # Only raise if we couldn't find by email either + raise HTTPException(status_code=404, detail="User not found") + # If we searched by ID but didn't find, maybe log and continue without demographics? + # Or try email fallback again here? For now, we assume user is found if ID is valid. # Get current preferences from the user object @@ -105,21 +115,40 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: except Exception as e: logger.error(f"Failed to update userData status: {str(e)}") - # Invalidate user preferences cache using auth0Id + # --- Run Demographic Inference (After main processing) --- + # NOTE: Running this synchronously adds latency. Consider a background task later. + inference_updated_user = False + try: + inference_updated_user = await run_inference_for_user(str(user["_id"]), email, db) + if inference_updated_user: + logger.info(f"Demographic inference updated user document for {email}") + # Cache invalidation is handled within run_inference_for_user + except Exception as inference_error: + logger.error(f"Demographic inference failed for user {email}: {inference_error}", exc_info=True) + # --- End Demographic Inference --- + + + # Invalidate user preferences cache using auth0Id (if not already done by inference) + # This ensures caches are cleared even if inference didn't run or update if user.get("auth0Id"): auth0_id = user["auth0Id"] - await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") - logger.info(f"Invalidated preferences cache for user {auth0_id}") - - # Invalidate store-specific caches for this user - if user.get("privacySettings", {}).get("optInStores"): - user_object_id = str(user["_id"]) - for store_id in user["privacySettings"]["optInStores"]: - await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_object_id}:{store_id}") - logger.info(f"Invalidated store-specific caches for user {auth0_id}") + # Check if inference already invalidated caches for this user + if not inference_updated_user: + await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") + logger.info(f"Invalidated PREFERENCES cache for user {auth0_id} (post-processing)") + + if user.get("privacySettings", {}).get("optInStores"): + user_object_id = str(user["_id"]) + for store_id in user["privacySettings"]["optInStores"]: + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_object_id}:{store_id}") + logger.info(f"Invalidated STORE_PREFERENCES caches for user {auth0_id} (post-processing)") + else: + logger.info(f"Skipping post-processing cache invalidation as inference already handled it for {auth0_id}") + - # Return updated preferences in the expected format + # Note: This returns preferences based on the state *before* inference ran in this cycle. + # The *next* call will use the newly inferred data. return UserPreferences( user_id=str(user["_id"]), preferences=[ @@ -129,7 +158,7 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: attributes=item.get("attributes") ) for item in normalized_preferences ], - updated_at=datetime.now() + updated_at=user.get("updatedAt", datetime.now()) # Use the latest update time ) async def process_purchase_data(entries, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): @@ -146,6 +175,13 @@ async def process_purchase_data(entries, preference_dict, taxonomy, demographics age = demographics.get("age") income = demographics.get("incomeBracket") country = demographics.get("country") + # --- Add inferred data --- + has_kids = demographics.get("inferredHasKids") # Boolean or None + relationship_status = demographics.get("inferredRelationshipStatus") # String or None + # --- Add NEW inferred data --- + employment_status = demographics.get("inferredEmploymentStatus") # String or None + education_level = demographics.get("inferredEducationLevel") # String or None + age_bracket = demographics.get("inferredAgeBracket") # String or None # --- End Refined Demographic Usage --- # Count purchases, attributes, and track prices @@ -182,14 +218,49 @@ async def process_purchase_data(entries, preference_dict, taxonomy, demographics # Apply demographic boosts (gender, age) if gender == "female" and category_name in ["Fashion", "Beauty", "Skincare", "Makeup"]: - boost_factor *= 1.1 + boost_factor *= 1.1 # Boost common female-associated categories elif gender == "male" and category_name in ["Electronics", "Tools", "Laptops"]: - boost_factor *= 1.05 + boost_factor *= 1.05 # Slightly boost common male-associated categories if age: - if 18 <= age <= 30 and category_name in ["Smartphones", "Wearables", "Audio", "Gaming"]: # Added Gaming + # Boost tech/gaming for younger adults + if 18 <= age <= 30 and category_name in ["Smartphones", "Wearables", "Audio", "Gaming"]: boost_factor *= 1.05 + # Boost health/home for older adults elif age >= 50 and category_name in ["Health", "Home"]: - boost_factor *= 1.08 + boost_factor *= 1.08 # Slightly higher boost for potential health needs + + # --- Apply inferred demographic boosts --- + if has_kids is True and category_name in ["Toys", "Baby", "Kids Clothing"]: # Add relevant categories + boost_factor *= 1.15 # Stronger boost for likely parents in child-related categories + logger.debug(f"Applying 'has_kids' boost to category {category_name}") + + if relationship_status == "married" and category_name in ["Home Goods", "Furniture", "Jewelry"]: # Example categories + boost_factor *= 1.05 # Slight boost for categories related to shared living/gifting + logger.debug(f"Applying 'married' boost to category {category_name}") + + # --- Apply NEW inferred boosts (Examples) --- + if employment_status == "student" and category_name in ["Laptops", "Books", "Stationery", "Budget Food"]: # Add relevant categories + boost_factor *= 1.1 # Boost student-related items + logger.debug(f"Applying 'student' boost to category {category_name}") + if employment_status == "employed" and category_name in ["Business Wear", "Office Supplies", "Travel"]: # Add relevant categories + boost_factor *= 1.05 # Slight boost for work-related items + logger.debug(f"Applying 'employed' boost to category {category_name}") + + if education_level in ["masters", "doctorate"] and category_name in ["Books", "Academic Journals", "Software"]: # Add relevant categories + boost_factor *= 1.08 # Speculative boost for academic/professional interests + logger.debug(f"Applying 'higher_education' boost to category {category_name}") + + # Use inferred age bracket ONLY if actual age is missing + effective_age_info = age if age is not None else age_bracket + if effective_age_info: + # Example using age bracket (less precise than actual age) + if effective_age_info == "18-24" and category_name in ["Fast Fashion", "Gaming", "Streaming Services"]: + boost_factor *= 1.05 # Boost categories popular with young adults + logger.debug(f"Applying '18-24' boost to category {category_name}") + elif effective_age_info == "65+" and category_name in ["Health", "Gardening", "Comfort Footwear"]: + boost_factor *= 1.1 # Boost categories relevant to seniors + logger.debug(f"Applying '65+' boost to category {category_name}") + # --- End NEW inferred boosts --- final_score = min(base_score * boost_factor, 1.0) @@ -281,6 +352,33 @@ async def process_purchase_data(entries, preference_dict, taxonomy, demographics # if is_buying_cheap: # attribute_boost /= 1.2 + # --- Apply inferred attribute boosts --- + if has_kids is True and attr_name == "size" and category_name == "Clothing" and value in ["kids", "toddler", "infant"]: + attribute_boost *= 1.2 # Boost kids sizes if kids inferred + logger.debug(f"Applying 'has_kids' boost to attribute {attr_name}={value}") + + # Example: Boost 'gift' attribute if relationship status is known? + # if relationship_status in ["relationship", "married"] and attr_name == "purpose" and value == "gift": + # attribute_boost *= 1.1 + # --- End inferred attribute boosts --- + + # --- Apply NEW inferred attribute boosts (Examples) --- + if employment_status == "student" and attr_name == "price_range" and value == "budget": + attribute_boost *= 1.15 # Boost budget items for students + logger.debug(f"Applying 'student' boost to attribute {attr_name}={value}") + if employment_status == "student" and attr_name == "usage_type" and category_name == "Laptops" and value == "student": + attribute_boost *= 1.1 + logger.debug(f"Applying 'student' boost to attribute {attr_name}={value}") + + if education_level in ["masters", "doctorate"] and attr_name == "genre" and category_name == "Books" and value in ["non_fiction", "history", "science"]: + attribute_boost *= 1.1 # Boost non-fiction for higher education + logger.debug(f"Applying 'higher_education' boost to attribute {attr_name}={value}") + + # Example using age bracket for attribute + if age is None and age_bracket == "18-24" and attr_name == "brand" and category_name == "Fashion" and value in ["H&M", "Zara", "ASOS"]: # Example fast fashion brands + attribute_boost *= 1.1 + logger.debug(f"Applying '18-24' boost to attribute {attr_name}={value}") + # --- End NEW inferred attribute boosts --- final_attribute_score = min(normalized_score * attribute_boost, 1.0) # --- End Attribute Influence --- @@ -302,6 +400,13 @@ async def process_search_data(entries, preference_dict, taxonomy, demographics: demographics = demographics or {} gender = demographics.get("gender") age = demographics.get("age") + # --- Add inferred data --- + has_kids = demographics.get("inferredHasKids") + relationship_status = demographics.get("inferredRelationshipStatus") + # --- Add NEW inferred data --- + employment_status = demographics.get("inferredEmploymentStatus") + education_level = demographics.get("inferredEducationLevel") + age_bracket = demographics.get("inferredAgeBracket") # --- End Example --- for entry in entries: @@ -340,7 +445,29 @@ async def process_search_data(entries, preference_dict, taxonomy, demographics: if age and 18 <= age <= 30 and category_name in ["Smartphones", "Gaming"]: # Add Gaming if exists relevance_boost *= 1.05 - # --- End Demographic Boost --- + + # --- Apply inferred boosts --- + if has_kids is True and category_name in ["Toys", "Baby", "Kids Clothing"]: + relevance_boost *= 1.15 + logger.debug(f"Applying 'has_kids' boost to search relevance for {category_name}") + + if relationship_status == "married" and category_name in ["Home Goods", "Furniture", "Jewelry"]: + relevance_boost *= 1.05 + logger.debug(f"Applying 'married' boost to search relevance for {category_name}") + + # --- Apply NEW inferred boosts (Examples) --- + if employment_status == "student" and category_name in ["Laptops", "Books", "Stationery"]: + relevance_boost *= 1.1 + if education_level in ["masters", "doctorate"] and category_name in ["Books", "Academic Journals"]: + relevance_boost *= 1.08 + + effective_age_info = age if age is not None else age_bracket + if effective_age_info: + if effective_age_info == "18-24" and category_name in ["Fast Fashion", "Gaming"]: + relevance_boost *= 1.05 + elif effective_age_info == "65+" and category_name in ["Health", "Gardening"]: + relevance_boost *= 1.1 + # --- End NEW inferred boosts --- # Add boosted score to search relevance dict search_relevance[matched_category] += match_score * relevance_boost @@ -372,6 +499,13 @@ async def process_with_embeddings(entries, data_type, preference_dict, taxonomy, demographics = demographics or {} gender = demographics.get("gender") age = demographics.get("age") + # --- Add inferred data --- + has_kids = demographics.get("inferredHasKids") + relationship_status = demographics.get("inferredRelationshipStatus") + # --- Add NEW inferred data --- + employment_status = demographics.get("inferredEmploymentStatus") + education_level = demographics.get("inferredEducationLevel") + age_bracket = demographics.get("inferredAgeBracket") # --- End Demographic Usage --- # For purchase data @@ -397,6 +531,26 @@ async def process_with_embeddings(entries, data_type, preference_dict, taxonomy, if age and age >= 50 and category_name == "Health": boost_factor *= 1.08 + # --- Apply inferred boosts --- + if has_kids is True and category_name in ["Toys", "Baby", "Kids Clothing"]: + boost_factor *= 1.15 + if relationship_status == "married" and category_name in ["Home Goods", "Furniture", "Jewelry"]: + boost_factor *= 1.05 + + # --- Apply NEW inferred boosts (Examples) --- + if employment_status == "student" and category_name in ["Laptops", "Books"]: + boost_factor *= 1.1 + if education_level in ["masters", "doctorate"] and category_name in ["Books"]: + boost_factor *= 1.08 + + effective_age_info = age if age is not None else age_bracket + if effective_age_info: + if effective_age_info == "18-24" and category_name in ["Gaming"]: + boost_factor *= 1.05 + elif effective_age_info == "65+" and category_name in ["Health"]: + boost_factor *= 1.1 + # --- End NEW inferred boosts --- + final_score = min(score * boost_factor, 1.0) # --- End Demographic Boost --- From 73d9a1e8b811187e6f60c35073dfdb2479ad4a32 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 20:08:31 +0530 Subject: [PATCH 25/34] feat: Refactor InterestFormModal to enhance category icon mapping and improve subcategory handling --- web/src/components/auth/InterestFormModal.tsx | 122 ++++++++++++------ 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/web/src/components/auth/InterestFormModal.tsx b/web/src/components/auth/InterestFormModal.tsx index c6ebb67..3ef7a32 100644 --- a/web/src/components/auth/InterestFormModal.tsx +++ b/web/src/components/auth/InterestFormModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; // <-- Import useMemo +import { useState, useEffect, useMemo } from "react"; import { Modal, Button, @@ -7,16 +7,56 @@ import { ModalBody, ModalFooter, ModalHeader, - Badge, // <-- Import Badge for subcategories + Badge, } from "flowbite-react"; import { PreferenceItem, TaxonomyCategory, -} from "../../api/types/data-contracts"; // <-- Import TaxonomyCategory -import { HiChevronDown, HiChevronUp } from "react-icons/hi"; // <-- Icons for expand/collapse -import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; // <-- Import useTaxonomy -import { useUpdateUserPreferences } from "../../api/hooks/useUserHooks"; // <-- Import useUpdateUserPreferences -import ErrorDisplay from "../common/ErrorDisplay"; // <-- Import ErrorDisplay +} from "../../api/types/data-contracts"; +import { + HiChevronDown, + HiChevronUp, + // Import icons for categories + HiOutlineDesktopComputer, // Electronics + HiOutlineShoppingBag, // Fashion + HiOutlineHome, // Home & Garden (covers Home) + HiOutlineSparkles, // Beauty & Personal Care (covers Beauty) + HiOutlineBookOpen, // Media (Books, Movies, etc.) + HiOutlineHeart, // Health & Wellness + HiOutlinePuzzle, // Toys & Games + HiOutlineBriefcase, // Office Supplies + HiOutlineKey, // Gaming (Changed from HiOutlineKey) + HiOutlineGlobeAlt, // Travel + HiOutlineShoppingCart, // Grocery + HiOutlineGift, // Jewelry & Watches, Gifts + HiOutlineCode, // Software + HiQuestionMarkCircle, // Default +} from "react-icons/hi"; +import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; +import { useUpdateUserPreferences } from "../../api/hooks/useUserHooks"; +import ErrorDisplay from "../common/ErrorDisplay"; + +// --- Icon Mapping --- +const categoryIcons: { [key: string]: React.ElementType } = { + Electronics: HiOutlineDesktopComputer, + Fashion: HiOutlineShoppingBag, + Home: HiOutlineHome, // Covers Home & Garden, Tools, Furniture etc. + Beauty: HiOutlineSparkles, + Media: HiOutlineBookOpen, + "Health & Wellness": HiOutlineHeart, + "Toys & Games": HiOutlinePuzzle, + "Office Supplies": HiOutlineBriefcase, + Gaming: HiOutlineKey, // Corrected Icon + Travel: HiOutlineGlobeAlt, + Grocery: HiOutlineShoppingCart, + "Jewelry & Watches": HiOutlineGift, // Covers Jewelry + Gifts: HiOutlineGift, + Software: HiOutlineCode, + // Add mappings for other new top-level categories if needed + // If a category doesn't have a specific icon, it will use DefaultIcon +}; +const DefaultIcon = HiQuestionMarkCircle; +// --- End Icon Mapping --- interface InterestFormModalProps { show: boolean; @@ -31,21 +71,19 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { } = useTaxonomy(); const updateUserPreferences = useUpdateUserPreferences(); - // State for selected sub-category IDs const [selectedSubCategoryIds, setSelectedSubCategoryIds] = useState< string[] >([]); - // State to track the currently expanded top-level category - const [expandedCategoryId, setExpandedCategoryId] = useState( - null, - ); + const [expandedCategoryIds, setExpandedCategoryIds] = useState([]); - // Memoize top-level categories const topLevelCategories = useMemo(() => { - return taxonomyData?.categories.filter((cat) => !cat.parent_id) || []; + return ( + taxonomyData?.categories + .filter((cat) => !cat.parent_id) + .sort((a, b) => a.name.localeCompare(b.name)) || [] + ); }, [taxonomyData]); - // Memoize subcategories mapped by their parent ID const subCategoriesMap = useMemo(() => { const map = new Map(); if (taxonomyData?.categories) { @@ -53,19 +91,24 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { if (category.parent_id) { const children = map.get(category.parent_id) || []; children.push(category); - map.set(category.parent_id, children); + map.set( + category.parent_id, + children.sort((a, b) => a.name.localeCompare(b.name)), + ); } } } return map; }, [taxonomyData]); - // Handler to expand/collapse a top-level category const handleExpandCategory = (categoryId: string) => { - setExpandedCategoryId((prev) => (prev === categoryId ? null : categoryId)); + setExpandedCategoryIds((prev) => + prev.includes(categoryId) + ? prev.filter((id) => id !== categoryId) + : [...prev, categoryId], + ); }; - // Handler to select/deselect a subcategory const handleSelectSubCategory = (subCategoryId: string) => { setSelectedSubCategoryIds((prev) => prev.includes(subCategoryId) @@ -75,7 +118,6 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { }; const handleSubmit = async () => { - // Submit only the selected subcategory IDs const preferences: PreferenceItem[] = selectedSubCategoryIds.map((id) => ({ category: id, score: 1.0, @@ -98,8 +140,6 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { return ( - {" "} - {/* Increased size */}
Tell us what you're interested in
@@ -122,14 +162,16 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {

Select topics to personalize your experience. Click a main topic - to see more options. + to see more options. Choose at least one specific interest.

- {/* Render Top-Level Categories */}
{topLevelCategories.map((category) => { - const isExpanded = expandedCategoryId === category.id; + const isExpanded = expandedCategoryIds.includes(category.id); const subCategories = subCategoriesMap.get(category.id) || []; const hasSubCategories = subCategories.length > 0; + // --- Ensure icon mapping uses the correct category name --- + const IconComponent = + categoryIcons[category.name] || DefaultIcon; return (
@@ -138,15 +180,17 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { hasSubCategories ? () => handleExpandCategory(category.id) : undefined - } // Only expandable if it has children - className={`h-full cursor-pointer transition-all duration-150 ${isExpanded ? "ring-2 ring-blue-500 dark:ring-blue-400" : "hover:bg-gray-50 dark:hover:bg-gray-600"}`} // Added h-full + } + className={`h-full cursor-pointer transition-all duration-150 ${isExpanded ? "ring-2 ring-blue-500 dark:ring-blue-400" : "hover:bg-gray-50 dark:hover:bg-gray-600"}`} > -
- {" "} - {/* Added h-full and justify-between */} -
+ {/* --- Centering Content --- */} + {/* The flex container with items-center should center the content horizontally */} +
+ {/* Content Block (Icon, Title, Description) */} +
{" "} - {/* Wrap text content */} + {/* Ensure this inner div also centers its items */} +
{category.name}
@@ -156,11 +200,9 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {

)}
- {/* Add expand/collapse icon if it has subcategories */} + {/* Chevron Block */} {hasSubCategories && (
- {" "} - {/* Keep margin-top */} {isExpanded ? ( ) : ( @@ -168,14 +210,13 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { )}
)} - {/* Add a placeholder div if no subcategories to maintain structure */} {!hasSubCategories && ( -
+
// Placeholder for alignment )}
+ {/* --- End Centering Content --- */} - {/* Render Subcategories if Expanded */} {isExpanded && hasSubCategories && (
@@ -192,8 +233,7 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { onClick={() => handleSelectSubCategory(subCat.id) } - className="cursor-pointer px-2 py-1 text-sm" // Adjusted padding/size - // title={subCat.description || undefined} // Optional: show description on hover + className="cursor-pointer px-2 py-1 text-sm" > {subCat.name} @@ -214,7 +254,7 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { onClick={handleSubmit} disabled={ isLoadingTaxonomy || - selectedSubCategoryIds.length === 0 || // Disable if no subcategories selected + selectedSubCategoryIds.length === 0 || updateUserPreferences.isPending } > From 90408ccddb1775a99b0495f7fbfa7b8ffbb014a7 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Tue, 22 Apr 2025 23:19:58 +0530 Subject: [PATCH 26/34] feat: Add comprehensive API documentation including authentication, data submission, and taxonomy guides --- README.md | 34 +++++++ api-service/api/openapi.yaml | 131 ++++++++++++++++++--------- authentication.md | 9 ++ data_submission.md | 70 ++++++++++++++ preference_retrieval.md | 83 +++++++++++++++++ taxonomy.md | 48 ++++++++++ web/src/pages/static/ApiDocsPage.tsx | 24 +++++ 7 files changed, 357 insertions(+), 42 deletions(-) create mode 100644 README.md create mode 100644 authentication.md create mode 100644 data_submission.md create mode 100644 preference_retrieval.md create mode 100644 taxonomy.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..6994896 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Tapiro Store API Documentation + +Welcome to the Tapiro Store API! This documentation provides everything you need to integrate your store's systems with Tapiro to leverage AI-driven personalized advertising and insights. + +By integrating with Tapiro, you can: + +- Submit user interaction data (purchases, searches) for analysis. +- Retrieve processed user preferences to personalize user experiences (e.g., targeted ads, product recommendations). + +## Getting Started + +1. **Obtain an API Key:** Generate API keys from your Store Dashboard within the Tapiro web application. Keep these keys secure. +2. **Authentication:** All API requests must be authenticated using your API key. See the [Authentication Guide](./authentication.md). +3. **Understand Endpoints:** Familiarize yourself with the primary endpoints for stores: + - [Data Submission](./data_submission.md): Send user purchase and search data. + - [Preference Retrieval](./preference_retrieval.md): Get user preferences. +4. **Review the Taxonomy:** Accurate data categorization is crucial for effective personalization. Understand how to use the [Tapiro Taxonomy](./taxonomy.md). +5. **Error Handling:** Be prepared to handle potential API errors. Refer to the [Error Handling Guide](./error_handling.md). + +## Core Concepts + +- **API Key:** Your unique secret key authenticates your store's requests. It identifies your store to Tapiro. +- **User Identification:** Users are primarily identified by their **email address** when submitting data or retrieving preferences via the API. This email _must_ correspond to a registered user within the Tapiro ecosystem. +- **Consent:** Tapiro respects user privacy choices. Data submission will only be processed if the user has given `dataSharingConsent` within Tapiro _and_ has not explicitly opted out of sharing data with _your specific store_. Preference retrieval will fail (403 Forbidden) if consent is not granted for your store. +- **Taxonomy:** A hierarchical system for categorizing products and interests. Using correct category IDs or names from the taxonomy when submitting data is essential for the AI models. + +## API Base URL + +The base URL for the production API is: `https://api.tapiro.com/v1` +_(Note: Use the appropriate URL provided for development or staging environments.)_ + +## Need Help? + +If you encounter issues or have questions, please contact Tapiro Support at `tapirosupport@gmail.com`. diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index e19be92..9814089 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -994,39 +994,54 @@ components: type: object required: - email - - dataType + - dataType # Make dataType required - entries properties: email: type: string - description: User's email address + format: email + description: User's email address (used as identifier for API key auth). Must match a registered Tapiro user. dataType: type: string enum: [purchase, search] - description: Type of data being submitted + description: Specifies the type of data contained in the 'entries' array. entries: type: array - description: Array of data entries + description: > + 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 # Require at least one entry metadata: type: object - description: Additional information about the collection event + description: Additional metadata about the collection event (e.g., source, device). properties: - userId: - type: string - description: Optional user ID if known source: type: string - description: Source of the data (web, mobile, pos, etc) + description: Source of the data (e.g., 'web', 'mobile_app', 'pos'). deviceType: type: string - description: Type of device used + description: Type of device used (e.g., 'desktop', 'mobile', 'tablet'). sessionId: type: string - description: Unique identifier for the user session + description: Identifier for the user's session. + example: + source: "web" + deviceType: "desktop" + sessionId: "abc-123-xyz-789" + example: # Example for a purchase submission + email: "user@example.com" + dataType: "purchase" + entries: + - $ref: "#/components/schemas/PurchaseEntry/example" + metadata: + source: "web" + deviceType: "desktop" + sessionId: "abc-123-xyz-789" PurchaseEntry: type: object @@ -1037,62 +1052,81 @@ components: timestamp: type: string format: date-time + description: ISO 8601 timestamp of when the purchase occurred. items: type: array + description: List of items included in the purchase. items: $ref: "#/components/schemas/PurchaseItem" - totalAmount: + totalValue: type: number format: float + description: Optional total value of the purchase event. + example: + timestamp: "2024-05-15T14:30:00Z" + items: + - $ref: "#/components/schemas/PurchaseItem/example" # Reference the example above + - sku: "ABC-789" + name: "Running Shorts" + category: "201" # Clothing + price: 39.95 + quantity: 1 + attributes: + color: "black" + size: "M" + material: "polyester" + totalValue: 91.93 PurchaseItem: type: object required: - name - - category + - category # Making category required for better processing properties: sku: type: string + description: Stock Keeping Unit or unique product identifier. name: type: string + description: Name of the purchased item. category: type: string - description: Category ID (e.g., "101") or name (e.g., "smartphones") - quantity: - type: integer - default: 1 + description: > + Category ID or name matching the Tapiro taxonomy (e.g., "101" or "Smartphones"). + Providing the most specific category ID is recommended. price: type: number format: float + description: Price of a single unit of the item. + quantity: + type: integer + description: Number of units purchased. + default: 1 attributes: $ref: "#/components/schemas/ItemAttributes" + example: + sku: "XYZ-123" + name: "Men's Cotton T-Shirt" + category: "201" # Example: Clothing ID + price: 25.99 + quantity: 2 + attributes: + color: "navy" + size: "M" + material: "cotton" ItemAttributes: type: object - description: Category-specific attributes - properties: - price_range: - type: string - enum: [budget, mid_range, premium, luxury] - brand: - type: string - color: - type: string - material: - type: string - style: - type: string - room: - type: string - size: - type: string - feature: - type: string - season: - type: string - gender: - type: string - additionalProperties: false + description: > + Key-value pairs representing product attributes based on the taxonomy. + Keys should be attribute names (e.g., "color", "size", "brand") and + values should be the specific attribute value (e.g., "blue", "large", "Acme"). + additionalProperties: + type: string + example: + color: "blue" + size: "L" + material: "cotton" SearchEntry: type: object @@ -1103,16 +1137,29 @@ components: timestamp: type: string format: date-time + description: ISO 8601 timestamp of when the search occurred. query: type: string + description: The search query string entered by the user. category: type: string + description: > + Optional category context provided during the search (e.g., user was browsing 'Electronics'). + Should match a Tapiro taxonomy ID or name. results: type: integer + description: Optional number of results returned for the search query. clicked: type: array + description: Optional list of product IDs or SKUs clicked from the search results. items: type: string + example: + timestamp: "2024-05-15T10:15:00Z" + query: "noise cancelling headphones" + category: "105" # Example: Audio ID + results: 25 + clicked: ["Bose-QC45", "Sony-WH1000XM5"] UserPreferences: type: object diff --git a/authentication.md b/authentication.md new file mode 100644 index 0000000..7176963 --- /dev/null +++ b/authentication.md @@ -0,0 +1,9 @@ +# Authentication Guide + +All requests to the Tapiro Store API must be authenticated using an API key. + +## Using Your API Key + +You must include your API key in the `X-API-Key` header for every request you make to store-specific endpoints. + +**Header Format:** diff --git a/data_submission.md b/data_submission.md new file mode 100644 index 0000000..ed2c7a3 --- /dev/null +++ b/data_submission.md @@ -0,0 +1,70 @@ +--- + +**3. `data_submission.md`** + +````markdown +# Data Submission Guide + +Stores can submit user interaction data, such as purchases and searches, to Tapiro for analysis and preference building. + +## Endpoint + +`POST /users/data` + +## Purpose + +To send batches of user interaction data (purchases or searches) associated with a specific user email address. + +## Authentication + +Requires a valid API key in the `X-API-Key` header. See [Authentication Guide](./authentication.md). + +## Request Body + +The request body must be a JSON object conforming to the `UserData` schema: + +```json +{ + "email": "user@example.com", + "dataType": "purchase", + "entries": [ + { + "timestamp": "2024-05-15T14:30:00Z", + "items": [ + { + "sku": "XYZ-123", + "name": "Men's Cotton T-Shirt", + "category": "201", + "price": 25.99, + "quantity": 2, + "attributes": { + "color": "navy", + "size": "M", + "material": "cotton" + } + }, + { + "sku": "ABC-789", + "name": "Running Shorts", + "category": "Clothing", // Can use name or ID + "price": 39.95, + "quantity": 1, + "attributes": { + "color": "black", + "size": "M", + "material": "polyester" + } + } + ], + "totalValue": 91.93 + } + // Add more PurchaseEntry objects if submitting multiple purchases in one batch + ], + "metadata": { + "source": "web", + "deviceType": "desktop", + "sessionId": "abc-123-xyz-789" + } +} +``` +```` diff --git a/preference_retrieval.md b/preference_retrieval.md new file mode 100644 index 0000000..d3a4adf --- /dev/null +++ b/preference_retrieval.md @@ -0,0 +1,83 @@ +--- + +**4. `preference_retrieval.md`** + +````markdown +# Preference Retrieval Guide + +Stores can retrieve processed user preferences from Tapiro to personalize experiences like targeted advertising or product recommendations. + +## Endpoint + +`GET /users/{userId}/preferences` + +## Purpose + +To retrieve the calculated interest preferences for a specific user, based on data submitted to Tapiro. + +## Authentication + +Requires a valid API key in the `X-API-Key` header. See [Authentication Guide](./authentication.md). + +## Path Parameter + +- `{userId}` (string, required): The **email address** of the user whose preferences you want to retrieve. + +**Example URL:** + +`/users/user@example.com/preferences` + +## Response + +- **`200 OK`**: Successfully retrieved user preferences. The response body will contain a `UserPreferences` object. + + ```json + { + "userId": "60d5ecb8b48f4a001f9e8f6a", // Tapiro's internal User ID + "preferences": [ + { + "category": "101", // Category ID from Taxonomy + "score": 0.85, + "attributes": { + "brand": { "Apple": 0.7, "Samsung": 0.3 }, + "color": { "black": 0.6, "blue": 0.4 } + } + }, + { + "category": "201", // Clothing + "score": 0.62, + "attributes": { + "material": { "cotton": 0.9, "polyester": 0.1 }, + "size": { "M": 0.7, "L": 0.3 } + } + } + // ... other preferences + ], + "updatedAt": "2024-05-20T10:00:00Z" + } + ``` + + **Fields:** + + - `userId` (string): Tapiro's internal unique identifier for the user. + - `preferences` (array): A list of `PreferenceItem` objects. + - `category` (string): The category ID from the [Tapiro Taxonomy](./taxonomy.md). + - `score` (number): A value between 0.0 and 1.0 indicating the user's interest level in this category. Higher is stronger. + - `attributes` (object, optional): A breakdown of preferences for specific attributes within the category (e.g., preferred brands, colors, sizes). The structure may vary. Values typically represent relative preference scores (0.0-1.0). + - `updatedAt` (string): ISO 8601 timestamp of when the preferences were last updated. + +- **`401 Unauthorized`**: Invalid or missing `X-API-Key`. +- **`403 Forbidden`**: Access denied. This occurs if: + - The user has _not_ provided `dataSharingConsent` in Tapiro. + - The user _has_ explicitly opted out of sharing data with _your specific store_. + **You should treat this response as "no preferences available" and avoid personalization based on Tapiro data for this user.** +- **`404 Not Found`**: The user specified by the email address (`{userId}`) does not exist in Tapiro. +- **`500 Internal Server Error`**: An unexpected error occurred on the server. + +## Important Considerations + +- **Consent is Key:** Always check the HTTP status code. A `403 Forbidden` response means you cannot use Tapiro preferences for that user due to their privacy settings. +- **User Identifier:** Remember to use the user's **email address** in the URL path (`{userId}`). +- **Caching:** Consider caching preference responses on your end for a reasonable duration (e.g., minutes to hours) to reduce API calls, but be mindful of the `updatedAt` timestamp if freshness is critical. Tapiro may also employ server-side caching. +- **Use Preferences:** Use the retrieved scores and attribute preferences to tailor advertising, recommendations, or other user experiences. +```` diff --git a/taxonomy.md b/taxonomy.md new file mode 100644 index 0000000..161f6a9 --- /dev/null +++ b/taxonomy.md @@ -0,0 +1,48 @@ +# Taxonomy Guide + +The Tapiro Taxonomy is a hierarchical classification system used to categorize products, services, and user interests. Accurate use of this taxonomy is **essential** for the effectiveness of Tapiro's AI models in generating meaningful user preferences. + +## Purpose + +- **Standardization:** Ensures that data submitted by different stores uses a consistent language for products and interests. +- **AI Training:** Provides structured input for the machine learning models that analyze user behavior and build preference profiles. +- **Preference Granularity:** Allows for preferences to be understood at different levels (e.g., general interest in "Electronics" vs. specific interest in "Smartphones" with a preference for "Apple" brand). + +## Structure + +The taxonomy consists of: + +- **Categories:** Broad groupings (e.g., "Electronics", "Fashion", "Home"). +- **Sub-categories:** More specific groupings within a parent category (e.g., "Smartphones" under "Electronics"). Categories have unique IDs (e.g., `"101"`) and names (e.g., `"Smartphones"`). +- **Attributes:** Characteristics relevant to a specific category (e.g., "brand", "color", "size" for "Smartphones"). Attributes have defined possible values. + +**Example Snippet (Conceptual):** + +```yaml +version: "1.0.0" +categories: + - id: "100" + name: "Electronics" + # ... attributes for Electronics ... + - id: "101" + name: "Smartphones" + parent_id: "100" + attributes: + - name: "brand" + values: [Apple, Samsung, Google, ...] + - name: "color" + values: [black, white, blue, ...] + # ... other smartphone attributes ... + - id: "200" + name: "Fashion" + # ... attributes for Fashion ... + - id: "201" + name: "Clothing" + parent_id: "200" + attributes: + - name: "type" + values: [shirts, pants, dresses, ...] + - name: "material" + values: [cotton, polyester, wool, ...] + # ... other clothing attributes ... +``` diff --git a/web/src/pages/static/ApiDocsPage.tsx b/web/src/pages/static/ApiDocsPage.tsx index 380c575..dd6b4ea 100644 --- a/web/src/pages/static/ApiDocsPage.tsx +++ b/web/src/pages/static/ApiDocsPage.tsx @@ -54,6 +54,7 @@ export default function ApiDocsPage() { {`POST /v1/interactions Authorization: Bearer YOUR_API_KEY Content-Type: application/json +X-API-Key: YOUR_STORE_API_KEY // Corrected Header { "userId": "store-specific-user-id-123", @@ -71,6 +72,29 @@ Content-Type: application/json "source": "web", "deviceType": "desktop" } +}`} + + + {/* Add dark mode text color */} +
+ POST /v1/users/data +
+ {/* Add dark mode text color */} +

+ Sends user data (e.g., email, purchase data) to Tapiro for analysis. +

+ {/* Adjusted pre/code dark mode styles */} +
+          
+            {`POST /v1/users/data HTTP/1.1
+Host: api.tapiro.com
+Content-Type: application/json
+X-API-Key: YOUR_API_KEY  // Changed from Authorization: Bearer
+
+{
+  "email": "user@example.com",
+  "dataType": "purchase",
+  "entries": [ ... ]
 }`}
           
         
From 7061b77d182e3c8e0cf25205dfc7fa7097123f06 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 00:10:47 +0530 Subject: [PATCH 27/34] fix: Update taxonomy values to use quoted strings for consistency --- ml-service/app/data/taxonomy.yaml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ml-service/app/data/taxonomy.yaml b/ml-service/app/data/taxonomy.yaml index a96d457..4b4529a 100644 --- a/ml-service/app/data/taxonomy.yaml +++ b/ml-service/app/data/taxonomy.yaml @@ -204,8 +204,8 @@ categories: - name: "touchscreen" description: "Whether the laptop has a touchscreen" values: - - yes - - no + - "yes" + - "no" - id: "103" name: "Tablets" @@ -250,8 +250,8 @@ categories: - name: "pen_support" description: "Stylus support" values: - - yes - - no + - "yes" + - "no" - id: "104" name: "Wearables" @@ -626,9 +626,9 @@ categories: - name: "assembly_required" description: "Assembly requirement" values: - - yes - - no - - partial + - "yes" + - "no" + - "partial" - id: "302" name: "Kitchen" @@ -678,8 +678,8 @@ categories: - name: "dishwasher_safe" description: "Dishwasher compatibility" values: - - yes - - no + - "yes" + - "no" - id: "303" name: "Gardening" @@ -934,8 +934,8 @@ categories: - name: "bestseller" description: "Bestseller status" values: - - yes - - no + - "yes" + - "no" - name: "release_timeframe" description: "Release timeframe" values: From 4b10b26870475bb50f1d047557289c2eb1922703 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 02:21:41 +0530 Subject: [PATCH 28/34] feat: Update UserProfileService to handle demographic data in a nested object structure - Refactored updateUserProfile to use dot notation for demographic fields within a new demographicData object. - Added validation for age input to ensure it is either null or an integer. - Enhanced privacy settings update logic to use dot notation. - Improved logging for user updates and cache invalidation. chore: Increment schema version and update user schema to reflect demographicData structure - Updated SCHEMA_VERSION to '2.0.7'. - Refactored user schema to include demographicData object with relevant fields. - Added inferred demographic fields within the demographicData object. fix: Update taxonomy.yaml version to '1.0.2' - Incremented version number for taxonomy file. feat: Enhance demographic inference logic with detailed logging - Added debug logging to demographic inference functions for better traceability. - Updated inference functions to handle demographic data from the new structure. - Implemented a fallback mechanism for processing user data and marking failures. refactor: Improve preference processing logic and error handling - Refactored process_user_data to extract demographics from the nested demographicData object. - Enhanced error handling and logging for user data processing. - Added a helper function to mark processing as failed in the database. --- api-service/service/UserProfileService.js | 64 ++++-- api-service/utils/dbSchemas.js | 123 ++++++----- ml-service/app/data/taxonomy.yaml | 2 +- .../app/services/demographicInference.py | 79 +++++-- .../app/services/preferenceProcessor.py | 198 +++++++++++------- ml-service/app/services/taxonomyService.py | 89 ++++++-- 6 files changed, 369 insertions(+), 186 deletions(-) diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 821343f..cc56439 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -104,49 +104,68 @@ exports.updateUserProfile = async function (req, body) { }; let demographicsChanged = false; // Flag to track if demographics were updated - // Update local DB username only if Auth0 update was successful (or not attempted) + // Update local DB username and phone if (body.username !== undefined) updateData.username = body.username; if (body.phone !== undefined) updateData.phone = body.phone; - // Add demographic fields to updateData if provided and track changes + // --- Update Demographic Data --- + // Use dot notation to set fields within the demographicData object if (body.gender !== undefined) { - updateData.gender = body.gender; + updateData['demographicData.gender'] = body.gender; demographicsChanged = true; } if (body.incomeBracket !== undefined) { - updateData.incomeBracket = body.incomeBracket; + updateData['demographicData.incomeBracket'] = body.incomeBracket; demographicsChanged = true; } if (body.country !== undefined) { - updateData.country = body.country; + updateData['demographicData.country'] = body.country; demographicsChanged = true; } if (body.age !== undefined) { - updateData.age = body.age; - demographicsChanged = true; + // Ensure age is null or an integer + const ageValue = body.age === null ? null : parseInt(body.age); + if (ageValue === null || !isNaN(ageValue)) { + updateData['demographicData.age'] = ageValue; + demographicsChanged = true; + // If age is being set, clear the inferred age bracket + updateData['demographicData.inferredAgeBracket'] = null; + } else { + console.warn(`Invalid age value provided for user ${auth0UserId}: ${body.age}`); + // Optionally return a 400 error here + } } + // --- End Update Demographic Data --- + // Only update allowed privacy settings let privacySettingsChanged = false; // Flag for privacy changes if (body.privacySettings !== undefined) { - updateData.privacySettings = {}; + // Use dot notation for nested privacy settings updates if (body.privacySettings.dataSharingConsent !== undefined) { - updateData.privacySettings.dataSharingConsent = body.privacySettings.dataSharingConsent; + updateData['privacySettings.dataSharingConsent'] = body.privacySettings.dataSharingConsent; privacySettingsChanged = true; } if (body.privacySettings.anonymizeData !== undefined) { - updateData.privacySettings.anonymizeData = body.privacySettings.anonymizeData; + updateData['privacySettings.anonymizeData'] = body.privacySettings.anonymizeData; privacySettingsChanged = true; } // DO NOT update optInStores or optOutStores here } - if (body.dataAccess !== undefined) updateData.dataAccess = body.dataAccess; + // Update dataAccess using dot notation if necessary, or as a whole object + if (body.dataAccess !== undefined && body.dataAccess.allowedDomains !== undefined) { + updateData['dataAccess.allowedDomains'] = body.dataAccess.allowedDomains; + } else if (body.dataAccess !== undefined) { + // If updating the whole object (less common for partial updates) + // updateData.dataAccess = body.dataAccess; + } + - // Check if there's anything to update - if (Object.keys(updateData).length <= 1 && !demographicsChanged && !privacySettingsChanged) { - // Only updatedAt is set, nothing else changed - // Fetch current profile to return if needed, or return specific message + // Check if there's anything to update (excluding updatedAt) + const updateKeys = Object.keys(updateData).filter(key => key !== 'updatedAt'); + if (updateKeys.length === 0) { + // Nothing changed const currentUser = await db.collection('users').findOne( { auth0Id: auth0UserId }, { projection: { preferences: 0 } } @@ -154,6 +173,7 @@ exports.updateUserProfile = async function (req, body) { return respondWithCode(200, currentUser || { message: "No changes detected." }); } + console.log(`Updating user ${auth0UserId} with data:`, updateData); const result = await db .collection('users') @@ -164,7 +184,6 @@ exports.updateUserProfile = async function (req, body) { ); if (!result) { - // This case might occur if the user was deleted between checks return respondWithCode(404, { code: 404, message: 'User not found during final update' }); } @@ -180,10 +199,11 @@ exports.updateUserProfile = async function (req, body) { // Invalidate store-specific preferences if demographics or relevant privacy settings changed // Also invalidate if the optInStores list exists (safer to clear on any profile update) - if ((demographicsChanged || privacySettingsChanged) && result.privacySettings?.optInStores) { - const userObjectId = result._id; // Use the _id from the updated result + const updatedUserDoc = result; // Use the returned document from findOneAndUpdate + if ((demographicsChanged || privacySettingsChanged) && updatedUserDoc.privacySettings?.optInStores) { + const userObjectId = updatedUserDoc._id; // Use the _id from the updated result console.log(`Invalidating store preferences for user ${userObjectId} due to update.`); - for (const storeId of result.privacySettings.optInStores) { + for (const storeId of updatedUserDoc.privacySettings.optInStores) { const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`; await invalidateCache(storePrefCacheKey); console.log(`Invalidated cache: ${storePrefCacheKey}`); @@ -191,12 +211,12 @@ exports.updateUserProfile = async function (req, body) { } // Update cache with the new data (without preferences) - await setCache(cacheKey, JSON.stringify(result), { EX: CACHE_TTL.USER_DATA }); + await setCache(cacheKey, JSON.stringify(updatedUserDoc), { EX: CACHE_TTL.USER_DATA }); - return respondWithCode(200, result); + return respondWithCode(200, updatedUserDoc); } catch (error) { - // Catch errors not handled specifically above console.error('Update profile failed:', error); + // Check for specific MongoDB errors if needed (e.g., validation errors) return respondWithCode(500, { code: 500, message: 'Internal server error during profile update' }); } }; diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 16b4309..4a61954 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.5'; +const SCHEMA_VERSION = '2.0.7'; // Incremented version const userSchema = { validator: { @@ -14,6 +14,7 @@ const userSchema = { schemaVersion: { bsonType: 'string', description: 'Schema version for tracking changes', + // Consider adding enum: [SCHEMA_VERSION] if strict enforcement is needed }, auth0Id: { bsonType: 'string', @@ -31,56 +32,58 @@ const userSchema = { bsonType: ['string', 'null'], description: 'Phone number', }, - gender: { - bsonType: ['string', 'null'], - description: 'User gender identity', - // Optional: Add enum validation if desired - enum: ['male', 'female', 'non-binary', 'prefer_not_to_say', null] - }, - incomeBracket: { - bsonType: ['string', 'null'], - description: 'User income bracket category', - // Optional: Add enum validation if desired - enum: ['<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say', null] - }, - country: { - bsonType: ['string', 'null'], - description: 'User country of residence (e.g., ISO 3166-1 alpha-2 code)', - }, - age: { - bsonType: ['int', 'null'], - description: 'User age', - minimum: 0, // Optional: Add validation - }, - // --- Start: Add inferred demographic fields --- - inferredHasKids: { - bsonType: ['bool', 'null'], - description: 'Inferred: Does the user likely have children? (null if unknown)', - }, - inferredRelationshipStatus: { - bsonType: ['string', 'null'], - description: 'Inferred: User relationship status (null if unknown)', - enum: ['single', 'relationship', 'married', null], // Example enum - }, - // --- Start: Add NEW inferred fields --- - inferredEmploymentStatus: { - bsonType: ['string', 'null'], - description: 'Inferred: User employment status (null if unknown)', - enum: ['employed', 'unemployed', 'student', null], // Example enum - }, - inferredEducationLevel: { - bsonType: ['string', 'null'], - description: 'Inferred: User education level (null if unknown)', - enum: ['high_school', 'bachelors', 'masters', 'doctorate', null], // Example enum - }, - inferredAgeBracket: { - bsonType: ['string', 'null'], - description: 'Inferred: User age bracket if age not provided (null if unknown)', - // Example brackets - adjust as needed - enum: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+', null], + // --- Start: Demographic Data Object --- + demographicData: { + bsonType: 'object', + description: 'User-provided and inferred demographic information', + properties: { + gender: { + bsonType: ['string', 'null'], + description: 'User gender identity', + enum: ['male', 'female', 'non-binary', 'prefer_not_to_say', null] + }, + incomeBracket: { + bsonType: ['string', 'null'], + description: 'User income bracket category', + enum: ['<25k', '25k-50k', '50k-100k', '100k-200k', '>200k', 'prefer_not_to_say', null] + }, + country: { + bsonType: ['string', 'null'], + description: 'User country of residence (e.g., ISO 3166-1 alpha-2 code)', + }, + age: { + bsonType: ['int', 'null'], + description: 'User age', + minimum: 0, + }, + // --- Inferred fields within demographicData --- + inferredHasKids: { + bsonType: ['bool', 'null'], + description: 'Inferred: Does the user likely have children? (null if unknown)', + }, + inferredRelationshipStatus: { + bsonType: ['string', 'null'], + description: 'Inferred: User relationship status (null if unknown)', + enum: ['single', 'relationship', 'married', null], + }, + inferredEmploymentStatus: { + bsonType: ['string', 'null'], + description: 'Inferred: User employment status (null if unknown)', + enum: ['employed', 'unemployed', 'student', null], + }, + inferredEducationLevel: { + bsonType: ['string', 'null'], + description: 'Inferred: User education level (null if unknown)', + enum: ['high_school', 'bachelors', 'masters', 'doctorate', null], + }, + inferredAgeBracket: { + bsonType: ['string', 'null'], + description: 'Inferred: User age bracket if age not provided (null if unknown)', + enum: ['18-24', '25-34', '35-44', '45-54', '55-64', '65+', null], + }, + } }, - // --- End: Add NEW inferred fields --- - // --- End: Add inferred demographic fields --- + // --- End: Demographic Data Object --- preferences: { bsonType: 'array', description: 'User interests and preferences', @@ -96,6 +99,16 @@ const userSchema = { }, attributes: { bsonType: 'object', + // Attributes can have any key, and the value is another object + additionalProperties: { + bsonType: 'object', + // The inner object has attribute values as keys and scores as values + additionalProperties: { + bsonType: ['double', 'int'], + minimum: 0.0, + maximum: 1.0, + } + } }, }, }, @@ -106,14 +119,14 @@ const userSchema = { properties: { dataSharingConsent: { bsonType: 'bool' }, anonymizeData: { bsonType: 'bool' }, - optInStores: { bsonType: 'array' }, - optOutStores: { bsonType: 'array' }, + optInStores: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type + optOutStores: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type }, }, dataAccess: { bsonType: 'object', properties: { - allowedDomains: { bsonType: 'array' }, + allowedDomains: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type }, }, createdAt: { bsonType: 'date' }, @@ -121,8 +134,8 @@ const userSchema = { }, }, }, - validationLevel: 'moderate', - validationAction: 'error', + validationLevel: 'moderate', // Changed from 'strict' to 'moderate' during dev if needed + validationAction: 'warn', // Changed from 'error' to 'warn' during dev if needed }; // Store schema diff --git a/ml-service/app/data/taxonomy.yaml b/ml-service/app/data/taxonomy.yaml index 4b4529a..95502d4 100644 --- a/ml-service/app/data/taxonomy.yaml +++ b/ml-service/app/data/taxonomy.yaml @@ -1,4 +1,4 @@ -version: "1.0.0" +version: "1.0.2" categories: # Electronics Category Tree - id: "100" diff --git a/ml-service/app/services/demographicInference.py b/ml-service/app/services/demographicInference.py index 7904b39..cf80305 100644 --- a/ml-service/app/services/demographicInference.py +++ b/ml-service/app/services/demographicInference.py @@ -73,13 +73,16 @@ async def infer_has_kids(entries: List[Dict[str, Any]]) -> Optional[bool]: """Infer if user has kids based on purchase/search keywords.""" kid_evidence_count = 0 texts = _extract_text_from_entries(entries) + logger.debug(f"Inferring 'has_kids' from {len(texts)} text entries.") for text in texts: if any(keyword in text for keyword in KIDS_KEYWORDS): kid_evidence_count += 1 logger.debug(f"Kid keyword found: {text}") if kid_evidence_count >= 2: # Require multiple pieces of evidence + logger.debug(f"Inferring 'has_kids' = True (evidence count: {kid_evidence_count})") return True + logger.debug(f"Inferring 'has_kids' = None (evidence count: {kid_evidence_count})") return None # Not enough evidence async def infer_relationship_status(entries: List[Dict[str, Any]]) -> Optional[str]: @@ -88,8 +91,10 @@ async def infer_relationship_status(entries: List[Dict[str, Any]]) -> Optional[s relationship_evidence = 0 single_evidence = 0 # Less reliable texts = _extract_text_from_entries(entries) + logger.debug(f"Inferring 'relationship_status' from {len(texts)} text entries.") for text in texts: + # Check married first for priority if any(keyword in text for keyword in MARRIED_KEYWORDS): married_evidence += 1 logger.debug(f"Married keyword found: {text}") @@ -102,11 +107,15 @@ async def infer_relationship_status(entries: List[Dict[str, Any]]) -> Optional[s # Prioritize married > relationship > single based on evidence threshold if married_evidence >= 1: # Lower threshold for specific events like wedding + logger.debug(f"Inferring 'relationship_status' = 'married' (evidence count: {married_evidence})") return "married" elif relationship_evidence >= 2: + logger.debug(f"Inferring 'relationship_status' = 'relationship' (evidence count: {relationship_evidence})") return "relationship" # elif single_evidence >= 1: # Be very cautious enabling this + # logger.debug(f"Inferring 'relationship_status' = 'single' (evidence count: {single_evidence})") # return "single" + logger.debug("Inferring 'relationship_status' = None (insufficient evidence)") return None # Not enough evidence # --- NEW Inference Functions --- @@ -117,6 +126,7 @@ async def infer_employment_status(entries: List[Dict[str, Any]]) -> Optional[str employment_evidence = 0 # Inferring 'unemployed' directly from keywords is very difficult/unreliable texts = _extract_text_from_entries(entries) + logger.debug(f"Inferring 'employment_status' from {len(texts)} text entries.") for text in texts: # Check student first due to potential overlap (e.g., "school supplies") @@ -129,10 +139,13 @@ async def infer_employment_status(entries: List[Dict[str, Any]]) -> Optional[str # Prioritize student if strong evidence, otherwise employed if student_evidence >= 2: + logger.debug(f"Inferring 'employment_status' = 'student' (evidence count: {student_evidence})") return "student" elif employment_evidence >= 2: + logger.debug(f"Inferring 'employment_status' = 'employed' (evidence count: {employment_evidence})") return "employed" # Add more sophisticated logic? Check for conflicting terms? + logger.debug("Inferring 'employment_status' = None (insufficient evidence)") return None # Not enough evidence async def infer_education_level(entries: List[Dict[str, Any]]) -> Optional[str]: @@ -141,6 +154,7 @@ async def infer_education_level(entries: List[Dict[str, Any]]) -> Optional[str]: masters_evidence = 0 bachelors_evidence = 0 texts = _extract_text_from_entries(entries) + logger.debug(f"Inferring 'education_level' from {len(texts)} text entries.") for text in texts: # Check most specific first @@ -156,12 +170,16 @@ async def infer_education_level(entries: List[Dict[str, Any]]) -> Optional[str]: # Prioritize highest level found with some evidence threshold if doctorate_evidence >= 1: + logger.debug(f"Inferring 'education_level' = 'doctorate' (evidence count: {doctorate_evidence})") return "doctorate" elif masters_evidence >= 1: + logger.debug(f"Inferring 'education_level' = 'masters' (evidence count: {masters_evidence})") return "masters" elif bachelors_evidence >= 2: # Require slightly more for bachelors + logger.debug(f"Inferring 'education_level' = 'bachelors' (evidence count: {bachelors_evidence})") return "bachelors" # Inferring 'high_school' is difficult, maybe default if other evidence is weak? + logger.debug("Inferring 'education_level' = None (insufficient evidence)") return None # Very uncertain async def infer_age_bracket(entries: List[Dict[str, Any]]) -> Optional[str]: @@ -170,6 +188,7 @@ async def infer_age_bracket(entries: List[Dict[str, Any]]) -> Optional[str]: mid_career_evidence = 0 senior_evidence = 0 texts = _extract_text_from_entries(entries) + logger.debug(f"Inferring 'age_bracket' from {len(texts)} text entries.") for text in texts: if any(keyword in text for keyword in AGE_BRACKET_SENIOR_KEYWORDS): @@ -184,14 +203,18 @@ async def infer_age_bracket(entries: List[Dict[str, Any]]) -> Optional[str]: # Simple thresholding - needs much refinement or a different approach if senior_evidence >= 1: + logger.debug(f"Inferring 'age_bracket' = '65+' (evidence count: {senior_evidence})") return "65+" elif mid_career_evidence >= 2: # Could try to differentiate 35-44 vs 45-54 based on keywords, but very hard + logger.debug(f"Inferring 'age_bracket' = '35-54' (evidence count: {mid_career_evidence})") return "35-54" # Combine for now elif young_adult_evidence >= 2: + logger.debug(f"Inferring 'age_bracket' = '18-24' (evidence count: {young_adult_evidence})") return "18-24" logger.warning("Age bracket inference based on keywords is highly unreliable.") + logger.debug("Inferring 'age_bracket' = None (insufficient evidence)") return None # Highly uncertain # --- Main Inference Runner --- @@ -218,6 +241,9 @@ async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) if not recent_data: logger.info(f"Inference: No recent data found for user {user_id}") return False + else: + logger.info(f"Inference: Found {len(recent_data)} recent data entries for user {user_id}") + # --- Run inference functions --- inferred_kids = await infer_has_kids(recent_data) @@ -225,37 +251,45 @@ async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) inferred_employment = await infer_employment_status(recent_data) inferred_education = await infer_education_level(recent_data) # Very speculative inferred_age_bracket = None - # Only infer age bracket if age is not already set - if user.get("age") is None: + # Only infer age bracket if age is not already set in demographicData + current_demographics = user.get("demographicData", {}) + if current_demographics.get("age") is None: + logger.info(f"Inference: User {email} has no age set, attempting age bracket inference.") inferred_age_bracket = await infer_age_bracket(recent_data) # Highly speculative + else: + logger.info(f"Inference: User {email} has age set ({current_demographics.get('age')}), skipping age bracket inference.") + # --- Prepare update payload --- update_payload = {} - current_kids = user.get("inferredHasKids") - current_status = user.get("inferredRelationshipStatus") - current_employment = user.get("inferredEmploymentStatus") - current_education = user.get("inferredEducationLevel") - current_age_bracket = user.get("inferredAgeBracket") - + # Read current values from the nested demographicData object + current_kids = current_demographics.get("inferredHasKids") + current_status = current_demographics.get("inferredRelationshipStatus") + current_employment = current_demographics.get("inferredEmploymentStatus") + current_education = current_demographics.get("inferredEducationLevel") + current_age_bracket = current_demographics.get("inferredAgeBracket") + + # Use dot notation for updates within the nested object if inferred_kids is not None and inferred_kids != current_kids: - update_payload["inferredHasKids"] = inferred_kids - logger.info(f"Inference update for {email}: inferredHasKids -> {inferred_kids}") + update_payload["demographicData.inferredHasKids"] = inferred_kids + logger.info(f"Inference update for {email}: demographicData.inferredHasKids -> {inferred_kids} (was {current_kids})") if inferred_status is not None and inferred_status != current_status: - update_payload["inferredRelationshipStatus"] = inferred_status - logger.info(f"Inference update for {email}: inferredRelationshipStatus -> {inferred_status}") + update_payload["demographicData.inferredRelationshipStatus"] = inferred_status + logger.info(f"Inference update for {email}: demographicData.inferredRelationshipStatus -> {inferred_status} (was {current_status})") if inferred_employment is not None and inferred_employment != current_employment: - update_payload["inferredEmploymentStatus"] = inferred_employment - logger.info(f"Inference update for {email}: inferredEmploymentStatus -> {inferred_employment}") + update_payload["demographicData.inferredEmploymentStatus"] = inferred_employment + logger.info(f"Inference update for {email}: demographicData.inferredEmploymentStatus -> {inferred_employment} (was {current_employment})") if inferred_education is not None and inferred_education != current_education: - update_payload["inferredEducationLevel"] = inferred_education - logger.info(f"Inference update for {email}: inferredEducationLevel -> {inferred_education}") + update_payload["demographicData.inferredEducationLevel"] = inferred_education + logger.info(f"Inference update for {email}: demographicData.inferredEducationLevel -> {inferred_education} (was {current_education})") if inferred_age_bracket is not None and inferred_age_bracket != current_age_bracket: - update_payload["inferredAgeBracket"] = inferred_age_bracket - logger.info(f"Inference update for {email}: inferredAgeBracket -> {inferred_age_bracket}") + update_payload["demographicData.inferredAgeBracket"] = inferred_age_bracket + logger.info(f"Inference update for {email}: demographicData.inferredAgeBracket -> {inferred_age_bracket} (was {current_age_bracket})") # --- End Prepare update payload --- # Update user document in DB if there are changes if update_payload: + logger.info(f"Inference: Found updates for {email}: {update_payload.keys()}") update_payload["updatedAt"] = datetime.now() # Update timestamp result = await db.users.update_one( {"_id": user_object_id}, @@ -263,15 +297,17 @@ async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) ) if result.modified_count > 0: updated = True - logger.info(f"Inference: Updated user document for {email}") + logger.info(f"Inference: Successfully updated user document for {email}") # --- Invalidate Caches on Successful Update --- auth0_id = user.get("auth0Id") if auth0_id: + # Invalidate user data and general preferences await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") logger.info(f"Inference: Invalidated USER_DATA and PREFERENCES cache for {auth0_id}") + # Invalidate store-specific preferences for opt-in stores if user.get("privacySettings", {}).get("optInStores"): user_object_id_str = str(user_object_id) for store_id in user["privacySettings"]["optInStores"]: @@ -279,7 +315,10 @@ async def run_inference_for_user(user_id: str, email: str, db, limit: int = 50) logger.info(f"Inference: Invalidated STORE_PREFERENCES caches for {auth0_id}") # --- End Cache Invalidation --- else: - logger.warning(f"Inference: Update payload generated but DB modify count was 0 for {email}") + logger.warning(f"Inference: Update payload generated but DB modify count was 0 for {email}. Payload: {update_payload}") + else: + logger.info(f"Inference: No demographic updates found for {email}") + except Exception as e: logger.error(f"Error during demographic inference for user {user_id}: {str(e)}", exc_info=True) diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index 7ec945b..f383e84 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -1,8 +1,8 @@ +import logging from app.models.preferences import UserDataEntry, UserPreferences, UserPreference from datetime import datetime from fastapi import HTTPException from bson import ObjectId -import logging from app.utils.redis_util import invalidate_cache, CACHE_KEYS from typing import List, Dict, Any, Optional from app.services.taxonomyService import get_taxonomy_service @@ -13,116 +13,153 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: """Process user data and update their preferences""" - + # Extract user info - user_id = data.metadata.get("userId") if data.metadata else None + user_id_from_meta = data.metadata.get("userId") if data.metadata else None email = data.email data_type = data.data_type entries = data.entries - - logger.info(f"Processing data for user {user_id or email}, type: {data_type}") - + + logger.info(f"Processing data for user email {email} (ID from meta: {user_id_from_meta}), type: {data_type}") + # Fetch the full user document from MongoDB to get demographics user = None - user_demographics = {} - if user_id and ObjectId.is_valid(user_id): - user = await db.users.find_one({"_id": ObjectId(user_id)}) - + if user_id_from_meta and ObjectId.is_valid(user_id_from_meta): + user = await db.users.find_one({"_id": ObjectId(user_id_from_meta)}) + if user and user.get("email") != email: + logger.warning(f"User ID {user_id_from_meta} provided in metadata maps to email {user.get('email')}, but processing request is for {email}. Proceeding with email lookup.") + user = None # Force email lookup if mismatch + if not user: # Fallback to find by email user = await db.users.find_one({"email": email}) if not user: - logger.error(f"User not found: {email}") + logger.error(f"User not found by email: {email}") + # Mark as failed before raising + await mark_processing_failed(db, email) # Assuming mark_processing_failed exists raise HTTPException(status_code=404, detail="User not found") - # Extract demographics if user found - if user: - user_demographics = { - "gender": user.get("gender"), - "incomeBracket": user.get("incomeBracket"), - "country": user.get("country"), - "age": user.get("age"), - "inferredHasKids": user.get("inferredHasKids"), - "inferredRelationshipStatus": user.get("inferredRelationshipStatus"), - "inferredEmploymentStatus": user.get("inferredEmploymentStatus"), - "inferredEducationLevel": user.get("inferredEducationLevel"), - "inferredAgeBracket": user.get("inferredAgeBracket"), - } - logger.info(f"Fetched demographics for user {email}: {user_demographics}") - else: - logger.warning(f"Could not fetch demographics for user {email}") - # If user wasn't found initially, raise the error - if not user_id: # Only raise if we couldn't find by email either - raise HTTPException(status_code=404, detail="User not found") - # If we searched by ID but didn't find, maybe log and continue without demographics? - # Or try email fallback again here? For now, we assume user is found if ID is valid. + user_id = str(user["_id"]) # Use the confirmed user ID from DB + logger.info(f"Found user {email} with DB ID {user_id}") + + # Extract demographics from the nested 'demographicData' field + user_demographics_nested = user.get("demographicData", {}) + # Flatten the dictionary to pass to processing functions + user_demographics_flat = { + "gender": user_demographics_nested.get("gender"), + "incomeBracket": user_demographics_nested.get("incomeBracket"), + "country": user_demographics_nested.get("country"), + "age": user_demographics_nested.get("age"), + "inferredHasKids": user_demographics_nested.get("inferredHasKids"), + "inferredRelationshipStatus": user_demographics_nested.get("inferredRelationshipStatus"), + "inferredEmploymentStatus": user_demographics_nested.get("inferredEmploymentStatus"), + "inferredEducationLevel": user_demographics_nested.get("inferredEducationLevel"), + "inferredAgeBracket": user_demographics_nested.get("inferredAgeBracket"), + } + # Filter out None values if desired, but processing functions handle None + # user_demographics_flat = {k: v for k, v in user_demographics_flat.items() if v is not None} + + logger.info(f"Using demographics for user {email}: {user_demographics_flat}") # Get current preferences from the user object user_preferences = user.get("preferences", []) - - # Convert to dictionary for easier updates - preference_dict = {pref["category"]: pref for pref in user_preferences} - + + # Convert to dictionary for easier updates {category_id: preference_object} + preference_dict = {} + for pref in user_preferences: + if isinstance(pref, dict) and "category" in pref: + preference_dict[pref["category"]] = pref + else: + logger.warning(f"Skipping invalid preference item for user {email}: {pref}") + + # Get taxonomy service taxonomy = await get_taxonomy_service(db) - - # Process entries based on data type, passing demographics + + # Process entries based on data type, passing flattened demographics try: if data_type == "purchase": - await process_purchase_data(entries, preference_dict, taxonomy, user_demographics) + await process_purchase_data(entries, preference_dict, taxonomy, user_demographics_flat) elif data_type == "search": - await process_search_data(entries, preference_dict, taxonomy, user_demographics) + await process_search_data(entries, preference_dict, taxonomy, user_demographics_flat) else: logger.warning(f"Unknown data type: {data_type}") + # Optionally, still try embedding fallback for unknown types + await process_with_embeddings(entries, data_type, preference_dict, taxonomy, user_demographics_flat) + except Exception as e: - logger.error(f"Error processing {data_type} data: {str(e)}") + logger.error(f"Error processing {data_type} data for {email}: {str(e)}", exc_info=True) # Fall back to using embedding model for all data try: - # Pass demographics to fallback as well - await process_with_embeddings(entries, data_type, preference_dict, taxonomy, user_demographics) + logger.info(f"Attempting fallback embedding processing for {email} due to error.") + # Pass flattened demographics to fallback as well + await process_with_embeddings(entries, data_type, preference_dict, taxonomy, user_demographics_flat) except Exception as fallback_error: - logger.error(f"Fallback processing also failed: {str(fallback_error)}") + logger.error(f"Fallback processing also failed for {email}: {str(fallback_error)}", exc_info=True) + # Mark as failed before raising + await mark_processing_failed(db, email) # Assuming mark_processing_failed exists raise HTTPException(status_code=500, detail=f"Processing failed: {str(e)}") - + # Convert preference_dict back to list - updated_preferences = list(preference_dict.values()) - + updated_preferences_list = list(preference_dict.values()) + # Add normalization before database update - normalized_preferences = await normalize_categories(updated_preferences, taxonomy) - + # Ensure normalize_categories handles potential issues gracefully + try: + normalized_preferences = await normalize_categories(updated_preferences_list, taxonomy) + except Exception as norm_error: + logger.error(f"Error normalizing categories for {email}: {norm_error}", exc_info=True) + normalized_preferences = updated_preferences_list # Use unnormalized as fallback + # Update user preferences in database with normalized data + update_time = datetime.now() await db.users.update_one( {"_id": user["_id"]}, { "$set": { "preferences": normalized_preferences, - "updatedAt": datetime.now() + "updatedAt": update_time } } ) - + logger.info(f"Successfully updated preferences for user {email} in DB.") + # Update the userData collection's processedStatus to "processed" + # Find the specific document(s) related to this submission batch if possible, + # otherwise update the oldest pending one for the user. + # This assumes the AI service passes back an ID or we can match based on content/timestamp. + # For simplicity, updating the first pending entry found for the email. try: + # Ideally, match on a unique ID for the submission batch if available + # submission_id = data.metadata.get("submissionId") + # if submission_id: + # match_criteria = {"_id": ObjectId(submission_id)} + # else: + match_criteria = {"email": email, "processedStatus": "pending"} + result = await db.userData.update_one( - { - "email": email, - "processedStatus": "pending" - }, + match_criteria, {"$set": {"processedStatus": "processed"}} + # Consider adding sort if multiple pending exist and no ID is available ) - logger.info(f"Updated userData status to 'processed' for {email}, modified: {result.modified_count}") + if result.modified_count > 0: + logger.info(f"Updated userData status to 'processed' for {email}, modified: {result.modified_count}") + else: + logger.warning(f"Could not find pending userData entry for {email} to mark as processed.") except Exception as e: - logger.error(f"Failed to update userData status: {str(e)}") - + logger.error(f"Failed to update userData status for {email}: {str(e)}") + # --- Run Demographic Inference (After main processing) --- - # NOTE: Running this synchronously adds latency. Consider a background task later. inference_updated_user = False try: - inference_updated_user = await run_inference_for_user(str(user["_id"]), email, db) + logger.info(f"Starting demographic inference for user {email} ({user_id})") + inference_updated_user = await run_inference_for_user(user_id, email, db) if inference_updated_user: logger.info(f"Demographic inference updated user document for {email}") # Cache invalidation is handled within run_inference_for_user + else: + logger.info(f"Demographic inference did not result in updates for user {email}") except Exception as inference_error: logger.error(f"Demographic inference failed for user {email}: {inference_error}", exc_info=True) # --- End Demographic Inference --- @@ -130,37 +167,58 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: # Invalidate user preferences cache using auth0Id (if not already done by inference) # This ensures caches are cleared even if inference didn't run or update - if user.get("auth0Id"): - auth0_id = user["auth0Id"] + auth0_id = user.get("auth0Id") + if auth0_id: # Check if inference already invalidated caches for this user if not inference_updated_user: + logger.info(f"Running post-processing cache invalidation for {auth0_id} as inference didn't update.") await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") logger.info(f"Invalidated PREFERENCES cache for user {auth0_id} (post-processing)") + # Invalidate store-specific caches if opt-in stores exist if user.get("privacySettings", {}).get("optInStores"): - user_object_id = str(user["_id"]) for store_id in user["privacySettings"]["optInStores"]: - await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_object_id}:{store_id}") + store_pref_key = f"{CACHE_KEYS['STORE_PREFERENCES']}{user_id}:{store_id}" + await invalidate_cache(store_pref_key) logger.info(f"Invalidated STORE_PREFERENCES caches for user {auth0_id} (post-processing)") else: logger.info(f"Skipping post-processing cache invalidation as inference already handled it for {auth0_id}") + else: + logger.warning(f"Cannot invalidate caches for user {email} as auth0Id is missing.") # Return updated preferences in the expected format - # Note: This returns preferences based on the state *before* inference ran in this cycle. - # The *next* call will use the newly inferred data. return UserPreferences( - user_id=str(user["_id"]), + user_id=user_id, preferences=[ UserPreference( - category=item["category"], + category=item["category"], score=item["score"], attributes=item.get("attributes") - ) for item in normalized_preferences + ) for item in normalized_preferences # Use normalized preferences ], - updated_at=user.get("updatedAt", datetime.now()) # Use the latest update time + updated_at=update_time # Use the time of this update ) +# ... process_purchase_data, process_search_data, process_with_embeddings, normalize_categories ... +# (No changes needed inside these functions as they receive the flattened demographics dict) + +# --- Add helper for marking failed --- +async def mark_processing_failed(db, email: str): + """Marks the oldest pending userData entry for the email as failed.""" + try: + result = await db.userData.update_one( + {"email": email, "processedStatus": "pending"}, + {"$set": {"processedStatus": "failed"}}, + # sort={"timestamp": 1} # Optional: ensure oldest is marked if multiple exist + ) + if result.modified_count > 0: + logger.info(f"Marked a pending userData entry as 'failed' for {email}") + else: + logger.warning(f"Could not find pending userData entry for {email} to mark as failed.") + except Exception as e: + logger.error(f"Failed to mark userData as failed for {email}: {str(e)}") + async def process_purchase_data(entries, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): """Process purchase data using rule-based system, considering demographics and buying patterns""" category_counts = defaultdict(int) diff --git a/ml-service/app/services/taxonomyService.py b/ml-service/app/services/taxonomyService.py index 9423003..6987a22 100644 --- a/ml-service/app/services/taxonomyService.py +++ b/ml-service/app/services/taxonomyService.py @@ -15,34 +15,55 @@ class TaxonomyService: def __init__(self, db=None): self.db = db - self.taxonomy = None + self.taxonomy: Optional[Taxonomy] = None # Add type hint self.embedding_model = None self.category_embeddings = {} - + # --- Add mappings for efficient lookups --- + self._id_to_name_map: Dict[str, str] = {} + self._name_to_id_map: Dict[str, str] = {} + # --- End Add mappings --- + async def initialize(self): """Initialize taxonomy from file and DB""" # Try loading from DB first - if self.db is not None: # Changed from 'if self.db:' + if self.db is not None: cached = await self.db.taxonomy.find_one({"current": True}) if cached: - self.taxonomy = Taxonomy(**cached["data"]) - logger.info(f"Loaded taxonomy from DB: {self.taxonomy.version}") - + try: + self.taxonomy = Taxonomy(**cached["data"]) + self._build_lookup_maps() # Build maps after loading + logger.info(f"Loaded taxonomy from DB: {self.taxonomy.version}") + except Exception as e: + logger.error(f"Failed to parse taxonomy from DB: {e}") + self.taxonomy = None # Ensure taxonomy is None if parsing fails + # If not in DB or load failed, use file if not self.taxonomy: - self._load_from_file() - - # Save to DB if available - if self.db is not None: + self._load_from_file() # This already calls _build_lookup_maps + + # Save to DB if available and loaded successfully + if self.db is not None and self.taxonomy: await self.db.taxonomy.update_one( {"current": True}, {"$set": {"data": self.taxonomy.dict(), "updated_at": datetime.now()}}, upsert=True ) - + # Initialize embedding model (try Redis cache first) - await self._initialize_embeddings() - + if self.taxonomy: # Only initialize embeddings if taxonomy loaded + await self._initialize_embeddings() + else: + logger.error("Taxonomy could not be loaded. Skipping embedding initialization.") + + + def _build_lookup_maps(self): + """Builds ID-to-Name and Name-to-ID maps from the loaded taxonomy.""" + if not self.taxonomy: + return + self._id_to_name_map = {cat.id: cat.name for cat in self.taxonomy.categories} + self._name_to_id_map = {cat.name.lower(): cat.id for cat in self.taxonomy.categories} # Use lower case for name lookup + logger.debug(f"Built taxonomy lookup maps: {len(self._id_to_name_map)} categories.") + def _load_from_file(self): """Load taxonomy from YAML file""" file_path = Path(__file__).parent.parent / "data" / "taxonomy.yaml" @@ -50,13 +71,19 @@ def _load_from_file(self): with open(file_path, 'r') as file: data = yaml.safe_load(file) self.taxonomy = Taxonomy(**data) + self._build_lookup_maps() # Build maps after loading logger.info(f"Loaded taxonomy from file: {self.taxonomy.version}") except Exception as e: - logger.error(f"Failed to load taxonomy: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to load taxonomy") - + logger.error(f"Failed to load taxonomy from file: {str(e)}") + self.taxonomy = None # Ensure taxonomy is None on failure + # Don't raise HTTPException here, allow service to potentially continue without taxonomy if needed + # raise HTTPException(status_code=500, detail="Failed to load taxonomy") + async def _initialize_embeddings(self): """Initialize embedding model for search processing""" + if not self.taxonomy: # Guard against missing taxonomy + logger.warning("Cannot initialize embeddings: Taxonomy not loaded.") + return # Try to get embeddings from Redis cache first cache_key = f"{CACHE_KEYS['TAXONOMY_EMBEDDINGS']}all" cached_embeddings = await get_cache_json(cache_key) @@ -108,6 +135,18 @@ async def _initialize_embeddings(self): logger.error(f"Failed to initialize embeddings: {str(e)}") # Continue without embeddings, we'll use rule-based only + # --- Add get_category_name method --- + def get_category_name(self, category_id: str) -> Optional[str]: + """Get category name from its ID using the lookup map.""" + return self._id_to_name_map.get(category_id) + # --- End Add get_category_name method --- + + # --- Optional: Add get_category_id method --- + def get_category_id(self, category_name: str) -> Optional[str]: + """Get category ID from its name (case-insensitive) using the lookup map.""" + return self._name_to_id_map.get(category_name.lower()) + # --- End Optional: Add get_category_id method --- + def validate_preferences(self, preferences): """Validate preference data against taxonomy""" if not self.taxonomy: @@ -148,7 +187,19 @@ async def match_category(self, query_text): return cached_result if not self.embedding_model or not self.category_embeddings: - raise ValueError("Embedding model not initialized") + # Check if taxonomy exists but embeddings failed + if not self.taxonomy: + logger.error("Cannot match category: Taxonomy not loaded.") + raise ValueError("Taxonomy not available for matching.") + else: + logger.warning(f"Cannot match category '{query_text}': Embeddings not initialized. Returning None.") + # Return a structure indicating failure or inability to match + return { + "category": None, + "score": 0.0, + "threshold_met": False, + "error": "Embeddings not initialized" + } # Generate embedding for query query_embedding = self.embedding_model.encode(query_text) @@ -183,7 +234,9 @@ async def match_category(self, query_text): async def get_taxonomy_service(db=None): """Get or create the taxonomy service singleton""" global _taxonomy_service - if _taxonomy_service is None: + if (_taxonomy_service is None): _taxonomy_service = TaxonomyService(db) await _taxonomy_service.initialize() + # Ensure the service is returned even if initialization had issues (e.g., file not found) + # Downstream code should handle potential lack of taxonomy data within the service object. return _taxonomy_service \ No newline at end of file From c7bd37381cbda0d68140eb31571853b96c345721 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 03:14:28 +0530 Subject: [PATCH 29/34] fix: Update API documentation to remove versioning from endpoint paths --- web/src/pages/static/ApiDocsPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/pages/static/ApiDocsPage.tsx b/web/src/pages/static/ApiDocsPage.tsx index dd6b4ea..20b08a0 100644 --- a/web/src/pages/static/ApiDocsPage.tsx +++ b/web/src/pages/static/ApiDocsPage.tsx @@ -41,7 +41,7 @@ export default function ApiDocsPage() {

{/* Add dark mode text color */}
- POST /v1/interactions + POST /interactions
{/* Add dark mode text color */}

@@ -51,7 +51,7 @@ export default function ApiDocsPage() { {/* Adjusted pre/code dark mode styles */}

           
-            {`POST /v1/interactions
+            {`POST /interactions
 Authorization: Bearer YOUR_API_KEY
 Content-Type: application/json
 X-API-Key: YOUR_STORE_API_KEY // Corrected Header
@@ -77,7 +77,7 @@ X-API-Key: YOUR_STORE_API_KEY // Corrected Header
         
{/* Add dark mode text color */}
- POST /v1/users/data + POST /users/data
{/* Add dark mode text color */}

@@ -86,7 +86,7 @@ X-API-Key: YOUR_STORE_API_KEY // Corrected Header {/* Adjusted pre/code dark mode styles */}

           
-            {`POST /v1/users/data HTTP/1.1
+            {`POST /users/data HTTP/1.1
 Host: api.tapiro.com
 Content-Type: application/json
 X-API-Key: YOUR_API_KEY  // Changed from Authorization: Bearer

From 4ce63b4e3d07210cbbd60f021d541cba84200a42 Mon Sep 17 00:00:00 2001
From: CDevmina 
Date: Wed, 23 Apr 2025 03:52:50 +0530
Subject: [PATCH 30/34] fix: Update Datepicker onChange handlers for start and
 end date selection

---
 web/src/pages/UserDashboard.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx
index 8f0a4b0..666ea85 100644
--- a/web/src/pages/UserDashboard.tsx
+++ b/web/src/pages/UserDashboard.tsx
@@ -328,7 +328,7 @@ export default function UserDashboard() {
                    setStartDate(date)}
+                    onChange={(date: Date | null) => setStartDate(date)}
                     maxDate={endDate || undefined}
                     className="w-full"
                     placeholder="Start Date"
@@ -336,7 +336,7 @@ export default function UserDashboard() {
                    setEndDate(date)}
+                    onChange={(date: Date | null) => setEndDate(date)}
                     minDate={startDate || undefined}
                     className="w-full"
                     placeholder="End Date"

From b1053ccd23ee12a4f7a9ea1a0438b023d65a0683 Mon Sep 17 00:00:00 2001
From: CDevmina 
Date: Wed, 23 Apr 2025 05:52:58 +0530
Subject: [PATCH 31/34] feat: Update InterestFormModal to handle attribute
 value selection and improve taxonomy integration

---
 ml-service/app/data/taxonomy.yaml             |   2 +-
 web/src/components/auth/InterestFormModal.tsx | 287 ++++++++++++------
 2 files changed, 187 insertions(+), 102 deletions(-)

diff --git a/ml-service/app/data/taxonomy.yaml b/ml-service/app/data/taxonomy.yaml
index 95502d4..c87ba70 100644
--- a/ml-service/app/data/taxonomy.yaml
+++ b/ml-service/app/data/taxonomy.yaml
@@ -1,4 +1,4 @@
-version: "1.0.2"
+version: "1.0.5"
 categories:
   # Electronics Category Tree
   - id: "100"
diff --git a/web/src/components/auth/InterestFormModal.tsx b/web/src/components/auth/InterestFormModal.tsx
index 3ef7a32..3999a66 100644
--- a/web/src/components/auth/InterestFormModal.tsx
+++ b/web/src/components/auth/InterestFormModal.tsx
@@ -1,59 +1,55 @@
-import { useState, useEffect, useMemo } from "react";
+import { useState, useEffect, useMemo } from "react"; // Removed ReactElement
 import {
-  Modal,
   Button,
   Card,
-  Spinner,
+  Modal,
   ModalBody,
   ModalFooter,
   ModalHeader,
-  Badge,
+  Spinner,
 } from "flowbite-react";
-import {
-  PreferenceItem,
-  TaxonomyCategory,
-} from "../../api/types/data-contracts";
 import {
   HiChevronDown,
   HiChevronUp,
-  // Import icons for categories
-  HiOutlineDesktopComputer, // Electronics
-  HiOutlineShoppingBag, // Fashion
-  HiOutlineHome, // Home & Garden (covers Home)
-  HiOutlineSparkles, // Beauty & Personal Care (covers Beauty)
-  HiOutlineBookOpen, // Media (Books, Movies, etc.)
-  HiOutlineHeart, // Health & Wellness
-  HiOutlinePuzzle, // Toys & Games
-  HiOutlineBriefcase, // Office Supplies
-  HiOutlineKey, // Gaming (Changed from HiOutlineKey)
-  HiOutlineGlobeAlt, // Travel
-  HiOutlineShoppingCart, // Grocery
-  HiOutlineGift, // Jewelry & Watches, Gifts
-  HiOutlineCode, // Software
-  HiQuestionMarkCircle, // Default
+  HiOutlineDesktopComputer,
+  HiOutlineShoppingBag,
+  HiOutlineHome,
+  HiOutlineSparkles,
+  HiOutlineBookOpen,
+  HiOutlineHeart,
+  HiOutlinePuzzle,
+  HiOutlineBriefcase,
+  HiOutlineKey,
+  HiOutlineGlobeAlt,
+  HiOutlineShoppingCart,
+  HiOutlineGift,
+  HiOutlineCode,
+  HiQuestionMarkCircle,
 } from "react-icons/hi";
 import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks";
 import { useUpdateUserPreferences } from "../../api/hooks/useUserHooks";
-import ErrorDisplay from "../common/ErrorDisplay";
+import {
+  PreferenceItem,
+  // Removed TaxonomyCategory
+} from "../../api/types/data-contracts";
+import ErrorDisplay from "../common/ErrorDisplay"; // Assuming ErrorDisplay exists
 
-// --- Icon Mapping ---
+// --- Icon Mapping (Keep as is) ---
 const categoryIcons: { [key: string]: React.ElementType } = {
   Electronics: HiOutlineDesktopComputer,
   Fashion: HiOutlineShoppingBag,
-  Home: HiOutlineHome, // Covers Home & Garden, Tools, Furniture etc.
+  Home: HiOutlineHome,
   Beauty: HiOutlineSparkles,
   Media: HiOutlineBookOpen,
   "Health & Wellness": HiOutlineHeart,
   "Toys & Games": HiOutlinePuzzle,
   "Office Supplies": HiOutlineBriefcase,
-  Gaming: HiOutlineKey, // Corrected Icon
+  Gaming: HiOutlineKey,
   Travel: HiOutlineGlobeAlt,
   Grocery: HiOutlineShoppingCart,
-  "Jewelry & Watches": HiOutlineGift, // Covers Jewelry
+  "Jewelry & Watches": HiOutlineGift,
   Gifts: HiOutlineGift,
   Software: HiOutlineCode,
-  // Add mappings for other new top-level categories if needed
-  // If a category doesn't have a specific icon, it will use DefaultIcon
 };
 const DefaultIcon = HiQuestionMarkCircle;
 // --- End Icon Mapping ---
@@ -63,6 +59,13 @@ interface InterestFormModalProps {
   onClose: () => void;
 }
 
+// --- State for selected attribute values ---
+interface SelectedAttributeValue {
+  categoryId: string;
+  attributeName: string;
+  value: string;
+}
+
 export function InterestFormModal({ show, onClose }: InterestFormModalProps) {
   const {
     data: taxonomyData,
@@ -71,11 +74,14 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {
   } = useTaxonomy();
   const updateUserPreferences = useUpdateUserPreferences();
 
-  const [selectedSubCategoryIds, setSelectedSubCategoryIds] = useState<
-    string[]
+  // --- Updated State ---
+  const [selectedAttributeValues, setSelectedAttributeValues] = useState<
+    SelectedAttributeValue[]
   >([]);
   const [expandedCategoryIds, setExpandedCategoryIds] = useState([]);
+  // --- End Updated State ---
 
+  // --- Top Level Categories (Keep as is) ---
   const topLevelCategories = useMemo(() => {
     return (
       taxonomyData?.categories
@@ -84,23 +90,9 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {
     );
   }, [taxonomyData]);
 
-  const subCategoriesMap = useMemo(() => {
-    const map = new Map();
-    if (taxonomyData?.categories) {
-      for (const category of taxonomyData.categories) {
-        if (category.parent_id) {
-          const children = map.get(category.parent_id) || [];
-          children.push(category);
-          map.set(
-            category.parent_id,
-            children.sort((a, b) => a.name.localeCompare(b.name)),
-          );
-        }
-      }
-    }
-    return map;
-  }, [taxonomyData]);
+  // --- Category Map Removed (was unused) ---
 
+  // --- Handlers ---
   const handleExpandCategory = (categoryId: string) => {
     setExpandedCategoryIds((prev) =>
       prev.includes(categoryId)
@@ -109,28 +101,94 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {
     );
   };
 
-  const handleSelectSubCategory = (subCategoryId: string) => {
-    setSelectedSubCategoryIds((prev) =>
-      prev.includes(subCategoryId)
-        ? prev.filter((id) => id !== subCategoryId)
-        : [...prev, subCategoryId],
-    );
+  // --- New Handler for Attribute Value Selection ---
+  const handleSelectAttributeValue = (
+    categoryId: string,
+    attributeName: string,
+    value: string,
+  ) => {
+    setSelectedAttributeValues((prev) => {
+      const existingIndex = prev.findIndex(
+        (item) =>
+          item.categoryId === categoryId &&
+          item.attributeName === attributeName &&
+          item.value === value,
+      );
+      if (existingIndex > -1) {
+        // Remove if already selected
+        return prev.filter((_, index) => index !== existingIndex);
+      } else {
+        // Add if not selected
+        return [...prev, { categoryId, attributeName, value }];
+      }
+    });
   };
+  // --- End New Handler ---
 
+  // --- Updated handleSubmit ---
   const handleSubmit = async () => {
-    const preferences: PreferenceItem[] = selectedSubCategoryIds.map((id) => ({
-      category: id,
-      score: 1.0,
-    }));
+    const groupedPreferences = new Map();
+
+    selectedAttributeValues.forEach(({ categoryId, attributeName, value }) => {
+      // Initialize preference for the category if not present
+      if (!groupedPreferences.has(categoryId)) {
+        groupedPreferences.set(categoryId, {
+          category: categoryId,
+          score: 1.0, // Assign a base score for selecting the category
+          attributes: {}, // Initialize attributes object
+        });
+      }
+
+      const pref = groupedPreferences.get(categoryId)!;
+
+      // Ensure attributes object exists (it should from the initialization above)
+      if (!pref.attributes) {
+        pref.attributes = {};
+      }
+
+      // --- Type Assertion for Dynamic Attribute Access ---
+      // Cast attributes to allow indexing by any string key
+      const attributesMap = pref.attributes as Record<
+        string,
+        Record
+      >;
+      // --- End Type Assertion ---
+
+      // Ensure the specific attribute object (value map) exists
+      let attributeValueMap = attributesMap[attributeName]; // Use the casted map
+      if (!attributeValueMap) {
+        attributeValueMap = {};
+        attributesMap[attributeName] = attributeValueMap; // Use the casted map
+      }
+
+      // Assign score to the specific attribute value
+      attributeValueMap[value] = 1.0; // Assign score to the inner map
+    });
+
+    const preferencesPayload: PreferenceItem[] = Array.from(
+      groupedPreferences.values(),
+    );
+
+    if (preferencesPayload.length === 0) {
+      console.warn("No preferences selected.");
+      // Optionally show a message to the user or simply close
+      onClose(); // Close if nothing selected, or handle differently
+      return;
+    }
 
     try {
-      await updateUserPreferences.mutateAsync({ preferences });
-      onClose();
+      await updateUserPreferences.mutateAsync({
+        preferences: preferencesPayload,
+      });
+      onClose(); // Close modal on success
     } catch (err) {
       console.error("Failed to save preferences:", err);
+      // Optionally display an error message to the user
     }
   };
+  // --- End Updated handleSubmit ---
 
+  // --- useEffect for Error Handling (Keep as is) ---
   useEffect(() => {
     if (taxonomyError) {
       console.error("Taxonomy failed to load, closing interest modal.");
@@ -138,6 +196,21 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {
     }
   }, [taxonomyError, onClose]);
 
+  // --- Check if a specific attribute value is selected ---
+  const isAttributeValueSelected = (
+    categoryId: string,
+    attributeName: string,
+    value: string,
+  ): boolean => {
+    return selectedAttributeValues.some(
+      (item) =>
+        item.categoryId === categoryId &&
+        item.attributeName === attributeName &&
+        item.value === value,
+    );
+  };
+  // --- End Check ---
+
   return (
     
       
@@ -162,14 +235,16 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {

Select topics to personalize your experience. Click a main topic - to see more options. Choose at least one specific interest. + to refine your interests by selecting specific features. Choose + at least one feature.

+ {/* --- Render Top Level Categories --- */} {topLevelCategories.map((category) => { const isExpanded = expandedCategoryIds.includes(category.id); - const subCategories = subCategoriesMap.get(category.id) || []; - const hasSubCategories = subCategories.length > 0; - // --- Ensure icon mapping uses the correct category name --- + // Get attributes directly from the category object + const attributes = category.attributes || []; + const hasAttributes = attributes.length > 0; const IconComponent = categoryIcons[category.name] || DefaultIcon; @@ -177,19 +252,15 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {
handleExpandCategory(category.id) - : undefined + : undefined // No action if no attributes } - className={`h-full cursor-pointer transition-all duration-150 ${isExpanded ? "ring-2 ring-blue-500 dark:ring-blue-400" : "hover:bg-gray-50 dark:hover:bg-gray-600"}`} + className={`h-full transition-all duration-150 ${hasAttributes ? "cursor-pointer" : "cursor-default"} ${isExpanded ? "ring-2 ring-blue-500 dark:ring-blue-400" : hasAttributes ? "hover:bg-gray-50 dark:hover:bg-gray-600" : ""}`} > - {/* --- Centering Content --- */} - {/* The flex container with items-center should center the content horizontally */} + {/* Card Content (Icon, Title, Description) - Keep as is */}
- {/* Content Block (Icon, Title, Description) */}
- {" "} - {/* Ensure this inner div also centers its items */}
{category.name} @@ -200,8 +271,8 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) {

)}
- {/* Chevron Block */} - {hasSubCategories && ( + {/* Chevron or Placeholder */} + {hasAttributes && (
{isExpanded ? ( @@ -210,38 +281,52 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { )}
)} - {!hasSubCategories && ( -
// Placeholder for alignment + {!hasAttributes && ( +
// Placeholder )}
- {/* --- End Centering Content --- */}
- {isExpanded && hasSubCategories && ( -
-
- Refine '{category.name}' -
-
- {subCategories.map((subCat) => { - const isSelected = - selectedSubCategoryIds.includes(subCat.id); - return ( - - handleSelectSubCategory(subCat.id) - } - className="cursor-pointer px-2 py-1 text-sm" - > - {subCat.name} - - ); - })} -
+ {/* --- Render Attributes and Values when Expanded --- */} + {isExpanded && hasAttributes && ( +
+ {attributes.map((attribute) => ( +
+
+ {attribute.description || attribute.name}{" "} + {/* Use description or name */} +
+
+ {(attribute.values || []).map((value) => { + const isSelected = isAttributeValueSelected( + category.id, + attribute.name, + value, + ); + return ( + + ); + })} +
+
+ ))}
)} + {/* --- End Attribute Rendering --- */}
); })} @@ -254,7 +339,7 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { onClick={handleSubmit} disabled={ isLoadingTaxonomy || - selectedSubCategoryIds.length === 0 || + selectedAttributeValues.length === 0 || // Disable if nothing selected updateUserPreferences.isPending } > From 37cd9ff13fe35514b55849a79b97fa3ef31e762b Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 07:31:26 +0530 Subject: [PATCH 32/34] refactor: Remove dataAccess property from user-related schemas and services for simplification --- api-service/api/openapi.yaml | 16 ---- api-service/service/AuthenticationService.js | 3 - api-service/service/UserProfileService.js | 8 -- api-service/utils/dbSchemas.js | 8 +- web/src/api/types/data-contracts.ts | 94 ++++++++++++-------- 5 files changed, 57 insertions(+), 72 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 9814089..eb0eaf2 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -793,14 +793,6 @@ components: items: type: string description: List of store IDs user has opted out from - dataAccess: - type: object - properties: - allowedDomains: - type: array - items: - type: string - description: List of allowed domains for data access createdAt: type: string format: date-time @@ -943,14 +935,6 @@ components: items: type: string description: List of store IDs the user has opted out of sharing data with - dataAccess: - type: object - properties: - allowedDomains: - type: array - items: - type: string - description: List of domains allowed to access user data via API keys gender: type: string nullable: true diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index 7ab7ba9..e3daed0 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -111,9 +111,6 @@ exports.registerUser = async function (req, body) { optInStores: [], optOutStores: [], }, - dataAccess: { - allowedDomains: [], - }, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index cc56439..60189d9 100644 --- a/api-service/service/UserProfileService.js +++ b/api-service/service/UserProfileService.js @@ -153,14 +153,6 @@ exports.updateUserProfile = async function (req, body) { // DO NOT update optInStores or optOutStores here } - // Update dataAccess using dot notation if necessary, or as a whole object - if (body.dataAccess !== undefined && body.dataAccess.allowedDomains !== undefined) { - updateData['dataAccess.allowedDomains'] = body.dataAccess.allowedDomains; - } else if (body.dataAccess !== undefined) { - // If updating the whole object (less common for partial updates) - // updateData.dataAccess = body.dataAccess; - } - // Check if there's anything to update (excluding updatedAt) const updateKeys = Object.keys(updateData).filter(key => key !== 'updatedAt'); diff --git a/api-service/utils/dbSchemas.js b/api-service/utils/dbSchemas.js index 4a61954..108d7a2 100644 --- a/api-service/utils/dbSchemas.js +++ b/api-service/utils/dbSchemas.js @@ -3,7 +3,7 @@ */ // Schema version tracking -const SCHEMA_VERSION = '2.0.7'; // Incremented version +const SCHEMA_VERSION = '2.0.8'; // Incremented version const userSchema = { validator: { @@ -123,12 +123,6 @@ const userSchema = { optOutStores: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type }, }, - dataAccess: { - bsonType: 'object', - properties: { - allowedDomains: { bsonType: 'array', items: { bsonType: 'string' } }, // Specify item type - }, - }, createdAt: { bsonType: 'date' }, updatedAt: { bsonType: 'date' }, }, diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index a765bfb..4e1d9af 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -59,10 +59,6 @@ export interface User { /** List of store IDs user has opted out from */ optOutStores?: string[]; }; - dataAccess?: { - /** List of allowed domains for data access */ - allowedDomains?: string[]; - }; /** @format date-time */ createdAt?: string; /** @format date-time */ @@ -128,9 +124,6 @@ export interface UserUpdate { optInStores?: string[]; optOutStores?: string[]; }; - dataAccess?: { - allowedDomains?: string[]; - }; /** User gender identity */ gender?: string | null; /** User income bracket category */ @@ -155,67 +148,92 @@ export interface ApiKey { status?: "active" | "revoked"; } +/** @example {"email":"user@example.com","dataType":"purchase","entries":[{"$ref":"#/components/schemas/PurchaseEntry/example"}],"metadata":{"source":"web","deviceType":"desktop","sessionId":"abc-123-xyz-789"}} */ export interface UserData { - /** User's email address */ + /** + * User's email address (used as identifier for API key auth). Must match a registered Tapiro user. + * @format email + */ email: string; - /** Type of data being submitted */ + /** Specifies the type of data contained in the 'entries' array. */ dataType: "purchase" | "search"; - /** Array of data entries */ + /** + * List of data entries. Each entry must conform to either the PurchaseEntry or SearchEntry schema, matching the top-level 'dataType'. + * @minItems 1 + */ entries: (PurchaseEntry | SearchEntry)[]; - /** Additional information about the collection event */ + /** + * Additional metadata about the collection event (e.g., source, device). + * @example {"source":"web","deviceType":"desktop","sessionId":"abc-123-xyz-789"} + */ metadata?: { - /** Optional user ID if known */ - userId?: string; - /** Source of the data (web, mobile, pos, etc) */ + /** Source of the data (e.g., 'web', 'mobile_app', 'pos'). */ source?: string; - /** Type of device used */ + /** Type of device used (e.g., 'desktop', 'mobile', 'tablet'). */ deviceType?: string; - /** Unique identifier for the user session */ + /** Identifier for the user's session. */ sessionId?: string; }; } +/** @example {"timestamp":"2024-05-15T14:30:00Z","items":[{"$ref":"#/components/schemas/PurchaseItem/example"},{"sku":"ABC-789","name":"Running Shorts","category":"201","price":39.95,"quantity":1,"attributes":{"color":"black","size":"M","material":"polyester"}}],"totalValue":91.93} */ export interface PurchaseEntry { - /** @format date-time */ + /** + * ISO 8601 timestamp of when the purchase occurred. + * @format date-time + */ timestamp: string; + /** List of items included in the purchase. */ items: PurchaseItem[]; - /** @format float */ - totalAmount?: number; + /** + * Optional total value of the purchase event. + * @format float + */ + totalValue?: number; } +/** @example {"sku":"XYZ-123","name":"Men's Cotton T-Shirt","category":"201","price":25.99,"quantity":2,"attributes":{"color":"navy","size":"M","material":"cotton"}} */ export interface PurchaseItem { + /** Stock Keeping Unit or unique product identifier. */ sku?: string; + /** Name of the purchased item. */ name: string; - /** Category ID (e.g., "101") or name (e.g., "smartphones") */ + /** Category ID or name matching the Tapiro taxonomy (e.g., "101" or "Smartphones"). Providing the most specific category ID is recommended. */ category: string; - /** @default 1 */ - quantity?: number; - /** @format float */ + /** + * Price of a single unit of the item. + * @format float + */ price?: number; - /** Category-specific attributes */ + /** + * Number of units purchased. + * @default 1 + */ + quantity?: number; + /** Key-value pairs representing product attributes based on the taxonomy. Keys should be attribute names (e.g., "color", "size", "brand") and values should be the specific attribute value (e.g., "blue", "large", "Acme"). */ attributes?: ItemAttributes; } -/** Category-specific attributes */ -export interface ItemAttributes { - price_range?: "budget" | "mid_range" | "premium" | "luxury"; - brand?: string; - color?: string; - material?: string; - style?: string; - room?: string; - size?: string; - feature?: string; - season?: string; - gender?: string; -} +/** + * Key-value pairs representing product attributes based on the taxonomy. Keys should be attribute names (e.g., "color", "size", "brand") and values should be the specific attribute value (e.g., "blue", "large", "Acme"). + * @example {"color":"blue","size":"L","material":"cotton"} + */ +export type ItemAttributes = Record; +/** @example {"timestamp":"2024-05-15T10:15:00Z","query":"noise cancelling headphones","category":"105","results":25,"clicked":["Bose-QC45","Sony-WH1000XM5"]} */ export interface SearchEntry { - /** @format date-time */ + /** + * ISO 8601 timestamp of when the search occurred. + * @format date-time + */ timestamp: string; + /** The search query string entered by the user. */ query: string; + /** Optional category context provided during the search (e.g., user was browsing 'Electronics'). Should match a Tapiro taxonomy ID or name. */ category?: string; + /** Optional number of results returned for the search query. */ results?: number; + /** Optional list of product IDs or SKUs clicked from the search results. */ clicked?: string[]; } From 806c606379469a1e94caac7fad8110118912fbbc Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 23 Apr 2025 08:26:37 +0530 Subject: [PATCH 33/34] feat: Enhance data submission guide with attribute examples and improve user dashboard activity limit --- data_submission.md | 10 +- .../app/services/preferenceProcessor.py | 360 +++++++++++------- ml-service/app/services/taxonomyService.py | 15 + web/src/pages/UserDashboard.tsx | 54 +-- 4 files changed, 268 insertions(+), 171 deletions(-) diff --git a/data_submission.md b/data_submission.md index ed2c7a3..1e37c69 100644 --- a/data_submission.md +++ b/data_submission.md @@ -34,13 +34,15 @@ The request body must be a JSON object conforming to the `UserData` schema: { "sku": "XYZ-123", "name": "Men's Cotton T-Shirt", - "category": "201", + "category": "201", // Must match a category ID or name from the Taxonomy "price": 25.99, "quantity": 2, "attributes": { - "color": "navy", - "size": "M", - "material": "cotton" + // <-- Optional: Key-value pairs based on Taxonomy for the category + "color": "navy", // Example: Value for the 'color' attribute + "size": "M", // Example: Value for the 'size' attribute + "material": "cotton" // Example: Value for the 'material' attribute + // Add other relevant attributes defined in the taxonomy for category "201" } }, { diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index f383e84..2300526 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -5,12 +5,17 @@ from bson import ObjectId from app.utils.redis_util import invalidate_cache, CACHE_KEYS from typing import List, Dict, Any, Optional -from app.services.taxonomyService import get_taxonomy_service +from app.services.taxonomyService import TaxonomyService, get_taxonomy_service # Updated import from collections import defaultdict from app.services.demographicInference import run_inference_for_user # Import the inference runner +from sentence_transformers import util # Import sentence-transformers utility for similarity +import numpy as np # Import numpy logger = logging.getLogger(__name__) +# --- Configuration --- +ATTRIBUTE_SIMILARITY_THRESHOLD = 0.55 # Configurable threshold for matching attribute values + async def process_user_data(data: UserDataEntry, db) -> UserPreferences: """Process user data and update their preferences""" @@ -219,16 +224,15 @@ async def mark_processing_failed(db, email: str): except Exception as e: logger.error(f"Failed to mark userData as failed for {email}: {str(e)}") -async def process_purchase_data(entries, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): +async def process_purchase_data(entries, preference_dict, taxonomy: TaxonomyService, demographics: Optional[Dict[str, Any]] = None): """Process purchase data using rule-based system, considering demographics and buying patterns""" category_counts = defaultdict(int) - # Store attribute counts AND total price/item count per category for override logic attribute_counts = defaultdict(lambda: defaultdict(lambda: defaultdict(int))) category_price_totals = defaultdict(float) category_item_counts = defaultdict(int) - # --- Refined Demographic Usage --- - demographics = demographics or {} # Ensure demographics is a dict + # --- Demographic Usage (remains the same) --- + demographics = demographics or {} gender = demographics.get("gender") age = demographics.get("age") income = demographics.get("incomeBracket") @@ -245,34 +249,108 @@ async def process_purchase_data(entries, preference_dict, taxonomy, demographics # Count purchases, attributes, and track prices for entry in entries: for item in entry.get("items", []): - category = item.get("category") - if not category: - continue - + category_input = item.get("category") # Can be ID or Name + item_name = item.get("name") quantity = item.get("quantity", 1) - price = item.get("price") # Get item price + price = item.get("price") + provided_attributes = item.get("attributes") # Attributes from the store - # Increment category count - category_counts[category] += quantity + if not category_input or not item_name: + logger.warning(f"Skipping item due to missing category or name: {item}") + continue - # Track price for override logic - if price is not None and price > 0: # Only consider valid prices - category_price_totals[category] += price * quantity - category_item_counts[category] += quantity + # --- Resolve Category ID --- + category_id = None + if taxonomy.taxonomy: # Check if taxonomy is loaded + # Try direct ID lookup first + if category_input in taxonomy._id_to_name_map: + category_id = category_input + else: + # Try name lookup (case-insensitive) + category_id = taxonomy.get_category_id(category_input) + + if not category_id: + logger.warning(f"Could not resolve category '{category_input}' for item '{item_name}'. Skipping attribute processing for this item.") + # Decide if you still want to count the category score even if attributes can't be processed + # For now, we skip attribute processing but might still count category later if needed + continue # Skip attribute part if category is unresolved + # --- End Resolve Category ID --- + + + # --- Hybrid Attribute Logic --- + final_attributes = None + is_valid_provided = False + + # 1. Check if store provided valid attributes + if provided_attributes and isinstance(provided_attributes, dict): + try: + # Basic validation: Check if keys exist in taxonomy for this category + category_details = taxonomy.get_category_details(category_id) + if category_details and category_details.attributes: + valid_attr_names = {attr.name for attr in category_details.attributes} + # Check if all provided keys are valid attribute names for the category + is_valid_provided = all(key in valid_attr_names for key in provided_attributes.keys()) + if is_valid_provided: + final_attributes = provided_attributes + logger.debug(f"Using valid store-provided attributes for item: {item_name}") + else: + invalid_keys = [key for key in provided_attributes.keys() if key not in valid_attr_names] + logger.warning(f"Invalid attribute keys provided by store for item '{item_name}' in category '{category_id}': {invalid_keys}. Falling back to AI.") + else: + logger.warning(f"No attributes defined in taxonomy for category '{category_id}', cannot validate provided attributes for '{item_name}'. Falling back to AI.") + is_valid_provided = False # Cannot validate + + except Exception as val_err: + logger.warning(f"Error validating provided attributes for {item_name}: {val_err}. Falling back to AI.") + is_valid_provided = False + + # 2. Fallback to AI extraction if needed + if not final_attributes: + logger.debug(f"Attempting AI attribute extraction for item: {item_name}") + try: + # Call the AI extraction function using the embedding model + extracted_attributes = await extract_attributes_with_similarity( + item_name, category_id, taxonomy # Pass taxonomy service + ) + if extracted_attributes: + final_attributes = extracted_attributes + logger.info(f"Successfully extracted attributes via AI for '{item_name}': {final_attributes}") # Log success + else: + logger.debug(f"AI could not extract attributes for {item_name}") + except Exception as ai_err: + logger.error(f"AI attribute extraction failed for {item_name}: {ai_err}", exc_info=True) + final_attributes = None # Ensure it's None on failure + + # --- End Hybrid Attribute Logic --- + + # --- Update Category Counts (Moved here to ensure category_id is valid) --- + category_counts[category_id] += quantity + if price is not None: + category_price_totals[category_id] += price * quantity + category_item_counts[category_id] += quantity + # --- End Update Category Counts --- + + + # --- Attribute Scoring (Using final_attributes) --- + if final_attributes: + for attr_name, attr_value in final_attributes.items(): + # Ensure attr_value is a string (as expected from store or AI extraction) + if isinstance(attr_value, str): + value_str = attr_value.lower() # Normalize to lower case + attribute_counts[category_id][attr_name][value_str] += quantity + else: + logger.warning(f"Skipping attribute scoring for non-string value: {attr_name}={attr_value} in item '{item_name}'") + # --- End Attribute Scoring --- - # Process attributes - if "attributes" in item: - for attr_name, attr_value in item["attributes"].items(): - attribute_counts[category][attr_name][attr_value] += quantity - # Update preference scores (Category level) + # --- Update preference scores (Category level - remains the same) --- total_items_overall = sum(category_counts.values()) if total_items_overall > 0: - for category, count in category_counts.items(): + for category_id, count in category_counts.items(): # --- Category Score Calculation (remains largely the same) --- base_score = min(count / (total_items_overall * 0.5), 1.0) boost_factor = 1.0 - category_name = taxonomy.get_category_name(category) + category_name = taxonomy.get_category_name(category_id) # Apply demographic boosts (gender, age) if gender == "female" and category_name in ["Fashion", "Beauty", "Skincare", "Makeup"]: @@ -323,132 +401,49 @@ async def process_purchase_data(entries, preference_dict, taxonomy, demographics final_score = min(base_score * boost_factor, 1.0) # Update category score in preference_dict (using EMA) - if category not in preference_dict: - preference_dict[category] = { - "category": category, + if category_id not in preference_dict: + preference_dict[category_id] = { + "category": category_id, "score": final_score, "attributes": {} } else: alpha = 0.3 - old_score = preference_dict[category]["score"] - preference_dict[category]["score"] = alpha * final_score + (1 - alpha) * old_score + old_score = preference_dict[category_id]["score"] + preference_dict[category_id]["score"] = alpha * final_score + (1 - alpha) * old_score # --- End Category Score Calculation --- - # --- Process Attributes for this Category --- - if category in attribute_counts: - # Calculate average price for this category in this batch (for override) - avg_price_in_batch = (category_price_totals[category] / category_item_counts[category]) \ - if category_item_counts[category] > 0 else 0 - - # Define a 'low price threshold' (EXAMPLE - needs tuning per category) - # This is highly dependent on your product mix and taxonomy. - # You might fetch these thresholds from config or taxonomy definition. - low_price_thresholds = { - "Laptops": 700, - "Smartphones": 400, - "Clothing": 30, - "Shoes": 40, - # ... add more categories - } - is_buying_cheap = avg_price_in_batch > 0 and \ - avg_price_in_batch < low_price_thresholds.get(category_name, float('inf')) - - if is_buying_cheap: - logger.info(f"User buying pattern override triggered for category '{category_name}' (Avg Price: {avg_price_in_batch:.2f})") - - - for attr_name, attr_values in attribute_counts[category].items(): - attr_total = sum(attr_values.values()) - if "attributes" not in preference_dict[category]: - preference_dict[category]["attributes"] = {} - if attr_name not in preference_dict[category]["attributes"]: - preference_dict[category]["attributes"][attr_name] = {} - - for value, value_count in attr_values.items(): - normalized_score = value_count / attr_total - attribute_boost = 1.0 - - # --- Apply Demographic Influence (with potential override) --- - - # 1. Income influence on price_range - if attr_name == "price_range" and income: - if income in ['100k-200k', '>200k']: - if value in ['premium', 'luxury']: - attribute_boost *= 1.2 # Stronger boost - # OVERRIDE: If buying cheap, negate the high-income boost - if is_buying_cheap: - attribute_boost /= 1.3 # Reduce significantly - elif value in ['budget', 'mid_range']: - # If high income but buying cheap, slightly boost lower ranges - if is_buying_cheap: - attribute_boost *= 1.1 - - elif income in ['<25k', '25k-50k']: - if value in ['budget', 'mid_range']: - attribute_boost *= 1.15 - # If low income but buying expensive (less likely override needed, but possible) - # elif value in ['premium', 'luxury'] and not is_buying_cheap: - # attribute_boost *= 0.9 # Slightly penalize? - - # 2. Gender influence on color (example) - if category_name == "Fashion" and attr_name == "color" and gender: - if gender == "female" and value in ["pink", "purple", "rose_gold"]: attribute_boost *= 1.1 - elif gender == "male" and value in ["navy", "gray", "black"]: attribute_boost *= 1.05 - - # 3. Age influence on brand (example) - if category_name == "Electronics" and attr_name == "brand" and age: - if age <= 25 and value in ["Apple", "Beats", "Razer"]: attribute_boost *= 1.05 - elif age >= 45 and value in ["Sony", "Bose", "Dell"]: attribute_boost *= 1.05 - - # 4. Income influence on brand (example - add luxury brands) - # luxury_brands = ["Gucci", "Prada", "Rolex", "Le Creuset", "All-Clad"] # Example - # if attr_name == "brand" and income in ['100k-200k', '>200k'] and value in luxury_brands: - # attribute_boost *= 1.1 - # # OVERRIDE: If buying cheap, negate boost for luxury brands - # if is_buying_cheap: - # attribute_boost /= 1.2 - - # --- Apply inferred attribute boosts --- - if has_kids is True and attr_name == "size" and category_name == "Clothing" and value in ["kids", "toddler", "infant"]: - attribute_boost *= 1.2 # Boost kids sizes if kids inferred - logger.debug(f"Applying 'has_kids' boost to attribute {attr_name}={value}") - - # Example: Boost 'gift' attribute if relationship status is known? - # if relationship_status in ["relationship", "married"] and attr_name == "purpose" and value == "gift": - # attribute_boost *= 1.1 - # --- End inferred attribute boosts --- - - # --- Apply NEW inferred attribute boosts (Examples) --- - if employment_status == "student" and attr_name == "price_range" and value == "budget": - attribute_boost *= 1.15 # Boost budget items for students - logger.debug(f"Applying 'student' boost to attribute {attr_name}={value}") - if employment_status == "student" and attr_name == "usage_type" and category_name == "Laptops" and value == "student": - attribute_boost *= 1.1 - logger.debug(f"Applying 'student' boost to attribute {attr_name}={value}") - - if education_level in ["masters", "doctorate"] and attr_name == "genre" and category_name == "Books" and value in ["non_fiction", "history", "science"]: - attribute_boost *= 1.1 # Boost non-fiction for higher education - logger.debug(f"Applying 'higher_education' boost to attribute {attr_name}={value}") - - # Example using age bracket for attribute - if age is None and age_bracket == "18-24" and attr_name == "brand" and category_name == "Fashion" and value in ["H&M", "Zara", "ASOS"]: # Example fast fashion brands - attribute_boost *= 1.1 - logger.debug(f"Applying '18-24' boost to attribute {attr_name}={value}") - # --- End NEW inferred attribute boosts --- - - final_attribute_score = min(normalized_score * attribute_boost, 1.0) - # --- End Attribute Influence --- - - # Update attribute score using EMA - alpha_attr = 0.3 # Use same alpha or different one for attributes - if value in preference_dict[category]["attributes"][attr_name]: - old_value = preference_dict[category]["attributes"][attr_name][value] - preference_dict[category]["attributes"][attr_name][value] = \ - alpha_attr * final_attribute_score + (1 - alpha_attr) * old_value - else: - preference_dict[category]["attributes"][attr_name][value] = final_attribute_score + # --- Update preference scores (Attribute level - Adjusted) --- + for category_id, attrs in attribute_counts.items(): + if category_id in preference_dict: # Ensure category exists + if "attributes" not in preference_dict[category_id] or preference_dict[category_id]["attributes"] is None: + preference_dict[category_id]["attributes"] = {} # Initialize if missing + + for attr_name, values in attrs.items(): + if attr_name not in preference_dict[category_id]["attributes"]: + preference_dict[category_id]["attributes"][attr_name] = {} # Initialize specific attribute dict + + total_attr_count = sum(values.values()) + if total_attr_count > 0: + current_attr_prefs = preference_dict[category_id]["attributes"][attr_name] + # Decay existing scores slightly + for val, score in current_attr_prefs.items(): + current_attr_prefs[val] = max(0.0, score * 0.9) # Decay factor + + # Add new scores based on counts + for value_str, count in values.items(): + new_score_contribution = (count / total_attr_count) * 0.5 # Contribution weight + current_score = current_attr_prefs.get(value_str, 0.0) + current_attr_prefs[value_str] = min(1.0, current_score + new_score_contribution) + + # Normalize scores within the attribute so they sum roughly to 1 (optional but good practice) + total_score = sum(current_attr_prefs.values()) + if total_score > 0: + for val in current_attr_prefs: + current_attr_prefs[val] /= total_score + # --- End Update preference scores (Attribute level) --- + async def process_search_data(entries, preference_dict, taxonomy, demographics: Optional[Dict[str, Any]] = None): """Process search data using embedding model, considering demographics""" @@ -656,4 +651,85 @@ async def normalize_categories(preferences, taxonomy): normalized.append(pref) - return normalized \ No newline at end of file + return normalized + +async def extract_attributes_with_similarity(item_name: str, category_id: str, taxonomy_service: TaxonomyService) -> Optional[Dict[str, str]]: + """ + Uses the semantic embedding model to extract attributes for an item by comparing + the item name to potential attribute values defined in the taxonomy. + Returns a dictionary like {"color": "blue", "size": "M"} or None. + """ + if not taxonomy_service.embedding_model: + logger.warning("AI Extraction: Embedding model not available in TaxonomyService.") + return None + + logger.debug(f"AI Extraction: Processing '{item_name}' in category '{category_id}'") + extracted = {} + + # 1. Get category details and expected attributes/values + category_details = taxonomy_service.get_category_details(category_id) + if not category_details or not category_details.attributes: + logger.debug(f"AI Extraction: No attributes defined in taxonomy for category {category_id}") + return None + + # Prepare list of attributes and their potential values for this category + attributes_to_check = [] + for attr in category_details.attributes: + if attr.values: # Only consider attributes with defined values + attributes_to_check.append({"name": attr.name, "values": attr.values}) + + if not attributes_to_check: + logger.debug(f"AI Extraction: No attributes with values defined for category {category_id}") + return None + + logger.debug(f"AI Extraction: Expected attributes for {category_id}: {[a['name'] for a in attributes_to_check]}") + + try: + # 2. Generate embedding for the item name + item_embedding = taxonomy_service.embedding_model.encode(item_name.lower(), convert_to_tensor=True) + + # 3. Iterate through attributes and their values + for attribute_info in attributes_to_check: + attr_name = attribute_info["name"] + possible_values = attribute_info["values"] + + if not possible_values: + continue + + # Generate embeddings for all possible values of this attribute + value_embeddings = taxonomy_service.embedding_model.encode([v.lower() for v in possible_values], convert_to_tensor=True) + + # Calculate cosine similarities between item name and all values + # Use pytorch_cos_sim for efficiency + similarities = util.pytorch_cos_sim(item_embedding, value_embeddings)[0] # Get the first row (item vs all values) + + # Find the value with the highest similarity + best_match_idx = similarities.argmax().item() # Get index of max value + highest_similarity = similarities[best_match_idx].item() # Get the max similarity score + + logger.debug(f"AI Extraction: Attribute '{attr_name}', Best match: '{possible_values[best_match_idx]}', Score: {highest_similarity:.4f}") + + # 4. Check against threshold and store if match is strong enough + if highest_similarity >= ATTRIBUTE_SIMILARITY_THRESHOLD: + best_match_value = possible_values[best_match_idx] + # Simple conflict resolution: If we already extracted a value for this attribute, + # only overwrite if the new score is significantly higher (e.g., > 0.1 difference). + # A more complex approach could consider multiple high-scoring values. + if attr_name in extracted: + # We need the previous score to compare - this simple approach just takes the first good match. + # For improvement, store scores alongside values during iteration. + logger.debug(f"AI Extraction: Attribute '{attr_name}' already extracted ('{extracted[attr_name]}'). Keeping first match above threshold.") + else: + extracted[attr_name] = best_match_value + logger.debug(f"AI Extraction: Extracted '{attr_name}' = '{best_match_value}' (Score: {highest_similarity:.4f})") + + + except Exception as e: + logger.error(f"AI Extraction: Error during embedding/similarity calculation for '{item_name}': {e}", exc_info=True) + return None + + if not extracted: + logger.debug(f"AI Extraction: No attributes met threshold for '{item_name}'") + return None + + return extracted \ No newline at end of file diff --git a/ml-service/app/services/taxonomyService.py b/ml-service/app/services/taxonomyService.py index 6987a22..3615e71 100644 --- a/ml-service/app/services/taxonomyService.py +++ b/ml-service/app/services/taxonomyService.py @@ -147,6 +147,21 @@ def get_category_id(self, category_name: str) -> Optional[str]: return self._name_to_id_map.get(category_name.lower()) # --- End Optional: Add get_category_id method --- + # +++ Add get_category_details method +++ + def get_category_details(self, category_id: str) -> Optional[TaxonomyCategory]: + """Get the full TaxonomyCategory object by its ID.""" + if not self.taxonomy: + logger.warning("Taxonomy not loaded, cannot get category details.") + return None + # Find the category in the list + for category in self.taxonomy.categories: + if category.id == category_id: + return category + logger.warning(f"Category ID '{category_id}' not found in taxonomy.") + return None + # +++ End Add get_category_details method +++ + + def validate_preferences(self, preferences): """Validate preference data against taxonomy""" if not self.taxonomy: diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 666ea85..87563c8 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -127,7 +127,7 @@ export default function UserDashboard() { data: recentActivity, isLoading: activityLoading, error: activityError, - } = useRecentUserData(5); + } = useRecentUserData(3); // <-- Changed limit from 5 to 3 const { data: spendingData, isLoading: spendingLoading, @@ -282,30 +282,34 @@ export default function UserDashboard() { No recent activity found.

) : ( - - {recentActivity.map((activity: RecentUserDataEntry) => ( - - - - - {formatDate(activity.timestamp)} - - - {activity.dataType === "purchase" - ? "Purchase Data Submitted" - : "Search Data Submitted"} - - - From:{" "} - {activity.storeId - ? storeNameMap.get(activity.storeId) || - `Store ID: ${activity.storeId}` - : "Unknown Store"} - - - - ))} - +
+ + {recentActivity.map( + ( + activity: RecentUserDataEntry, // No change needed here, map will iterate over 3 items max + ) => ( + + + + + {formatDate(activity.timestamp)} + + + {activity.dataType === "purchase" + ? "Purchase" + : "Search"}{" "} + from{" "} + {storeNameMap.get(activity.storeId) || + "Unknown Store"} + + {/* Add more details if needed */} + {/* Details about the activity... */} + + + ), + )} + +
)}
Date: Wed, 23 Apr 2025 17:17:46 +0530 Subject: [PATCH 34/34] refactor: Remove unused TimelineBody import from UserDashboard --- web/src/pages/UserDashboard.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/pages/UserDashboard.tsx b/web/src/pages/UserDashboard.tsx index 87563c8..b5e8415 100644 --- a/web/src/pages/UserDashboard.tsx +++ b/web/src/pages/UserDashboard.tsx @@ -10,7 +10,6 @@ import { TimelineContent, TimelineTime, TimelineTitle, - TimelineBody, ListItem, Datepicker, Button, @@ -299,8 +298,10 @@ export default function UserDashboard() { ? "Purchase" : "Search"}{" "} from{" "} - {storeNameMap.get(activity.storeId) || - "Unknown Store"} + {activity.storeId + ? storeNameMap.get(activity.storeId) || + `Store ID: ${activity.storeId}` + : "Unknown Store"} {/* Add more details if needed */} {/* Details about the activity... */}