From e571ce6eca14a4d95092a2ca85186bab767c8f46 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Wed, 7 May 2025 22:30:27 +0530 Subject: [PATCH 01/20] feat: Add username field to user registration and profile management --- api-service/api/openapi.yaml | 7 + api-service/service/AuthenticationService.js | 23 +- api-service/service/UserProfileService.js | 263 ++++++++++-------- web/src/api/types/data-contracts.ts | 11 +- .../components/auth/UserRegistrationForm.tsx | 53 ++++ .../pages/UserDashboard/UserProfilePage.tsx | 30 +- 6 files changed, 253 insertions(+), 134 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index 93ebe0c..fc1611f 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -975,7 +975,14 @@ components: type: object required: - dataSharingConsent + - username # Add username to required fields properties: + username: # Add username property + type: string + minLength: 3 + maxLength: 15 + pattern: "^[a-zA-Z0-9_-]+$" + description: User's unique username, chosen during registration. preferences: type: array items: diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index 9e9ebf8..6eb6c0f 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -1,8 +1,13 @@ const { getDB } = require('../utils/mongoUtil'); -const { setCache} = require('../utils/redisUtil'); +const { setCache } = require('../utils/redisUtil'); const { checkExistingRegistration } = require('../utils/helperUtil'); const { respondWithCode } = require('../utils/writer'); -const { assignUserRole, linkAccounts, updateUserMetadata, getUserMetadata } = require('../utils/auth0Util'); +const { + assignUserRole, + linkAccounts, + updateUserMetadata, + getUserMetadata, +} = require('../utils/auth0Util'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); @@ -13,8 +18,9 @@ const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); exports.registerUser = async function (req, body) { try { const db = getDB(); - // Destructure new demographic fields AND allowInference + // Destructure new demographic fields AND allowInference AND username const { + username, // <-- Add username preferences, dataSharingConsent, allowInference, // <-- Add allowInference @@ -74,7 +80,7 @@ exports.registerUser = async function (req, body) { // Check if username already exists const existingUserByUsername = await db.collection('users').findOne({ - username: userData.nickname, + username: username, }); if (existingUserByUsername) { @@ -98,7 +104,7 @@ exports.registerUser = async function (req, body) { // Create user in database const user = { auth0Id: userData.sub, - username: userData.username || userData.nickname || userData.sub, + username: username || null, // <-- Set username email: userData.email, phone: userData.phone_number || null, demographicData: { @@ -162,7 +168,8 @@ exports.registerUser = async function (req, body) { // Update user metadata await updateUserMetadata(userData.sub, { registrationType: 'user', - registrationComplete: true + registrationComplete: true, + nickname: username, }); return respondWithCode(201, { ...user, userId: result.insertedId }); @@ -263,7 +270,7 @@ exports.registerStore = async function (req, body) { // Update store metadata await updateUserMetadata(userData.sub, { registrationType: 'store', - registrationComplete: true + registrationComplete: true, }); return respondWithCode(201, { ...store, storeId: result.insertedId }); @@ -281,7 +288,7 @@ exports.getUserMetadata = async function (req) { try { // Get user data from middleware or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - + // Get user metadata from Auth0 using Management API const metadata = await getUserMetadata(userData.sub); diff --git a/api-service/service/UserProfileService.js b/api-service/service/UserProfileService.js index 940d3cb..dbd8122 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'); // Ensure ObjectId is imported +const { updateUserPhone, deleteAuth0User, updateUserMetadata } = require('../utils/auth0Util'); +const { ObjectId } = require('mongodb'); /** * Get User Profile @@ -30,7 +30,7 @@ exports.getUserProfile = async function (req) { // Get from database, excluding preferences field const user = await db.collection('users').findOne( { auth0Id: userData.sub }, - { projection: { preferences: 0 } } // Exclude preferences + { projection: { preferences: 0 } }, // Exclude preferences ); if (!user) { return respondWithCode(404, { @@ -66,7 +66,10 @@ exports.updateUserProfile = async function (req, body) { auth0Id: { $ne: auth0UserId }, }); if (existingUser) { - return respondWithCode(409, { code: 409, message: 'Username already taken in application' }); + return respondWithCode(409, { + code: 409, + message: 'Username already taken in application', + }); } } @@ -75,14 +78,12 @@ exports.updateUserProfile = async function (req, body) { // Auth0 will enforce its own uniqueness rules per connection. if (body.username) { try { - await updateAuth0Username(auth0UserId, body.username); await updateUserMetadata(auth0UserId, { nickname: body.username }); } catch (auth0Error) { console.error(`Auth0 username update failed for ${auth0UserId}:`, auth0Error); - return respondWithCode(409, { // Use 409 Conflict or appropriate code + return respondWithCode(409, { code: 409, message: 'Failed to update username with identity provider. It might already be taken.', - // Optionally include details: details: auth0Error.message }); } } @@ -113,50 +114,54 @@ exports.updateUserProfile = async function (req, body) { // User-provided fields if (body.demographicData?.gender !== undefined) { - updateData['demographicData.gender'] = body.demographicData.gender; - updateData['demographicData.inferredGender'] = null; // Clear inferred on user update - demographicsChanged = true; + updateData['demographicData.gender'] = body.demographicData.gender; + updateData['demographicData.inferredGender'] = null; // Clear inferred on user update + demographicsChanged = true; } if (body.demographicData?.incomeBracket !== undefined) { - updateData['demographicData.incomeBracket'] = body.demographicData.incomeBracket; - demographicsChanged = true; + updateData['demographicData.incomeBracket'] = body.demographicData.incomeBracket; + demographicsChanged = true; } if (body.demographicData?.country !== undefined) { - updateData['demographicData.country'] = body.demographicData.country; - demographicsChanged = true; + updateData['demographicData.country'] = body.demographicData.country; + demographicsChanged = true; } if (body.demographicData?.age !== undefined) { - const ageValue = body.demographicData.age === null ? null : parseInt(body.demographicData.age); - if (ageValue === null || (!isNaN(ageValue) && ageValue >= 0)) { // Added age >= 0 check - updateData['demographicData.age'] = ageValue; - // No inferred age bracket to clear anymore - demographicsChanged = true; - } else { - console.warn(`Invalid age value provided for user ${auth0UserId}: ${body.demographicData.age}`); - // Optionally return a 400 error here - // return respondWithCode(400, { code: 400, message: 'Invalid age provided.' }); - } + const ageValue = + body.demographicData.age === null ? null : parseInt(body.demographicData.age); + if (ageValue === null || (!isNaN(ageValue) && ageValue >= 0)) { + // Added age >= 0 check + updateData['demographicData.age'] = ageValue; + // No inferred age bracket to clear anymore + demographicsChanged = true; + } else { + console.warn( + `Invalid age value provided for user ${auth0UserId}: ${body.demographicData.age}`, + ); + // Optionally return a 400 error here + // return respondWithCode(400, { code: 400, message: 'Invalid age provided.' }); + } } // --- NEW User-Provided Fields --- if (body.demographicData?.hasKids !== undefined) { - updateData['demographicData.hasKids'] = body.demographicData.hasKids; - updateData['demographicData.inferredHasKids'] = null; // Clear inferred on user update - demographicsChanged = true; + updateData['demographicData.hasKids'] = body.demographicData.hasKids; + updateData['demographicData.inferredHasKids'] = null; // Clear inferred on user update + demographicsChanged = true; } if (body.demographicData?.relationshipStatus !== undefined) { - updateData['demographicData.relationshipStatus'] = body.demographicData.relationshipStatus; - updateData['demographicData.inferredRelationshipStatus'] = null; // Clear inferred on user update - demographicsChanged = true; + updateData['demographicData.relationshipStatus'] = body.demographicData.relationshipStatus; + updateData['demographicData.inferredRelationshipStatus'] = null; // Clear inferred on user update + demographicsChanged = true; } if (body.demographicData?.employmentStatus !== undefined) { - updateData['demographicData.employmentStatus'] = body.demographicData.employmentStatus; - updateData['demographicData.inferredEmploymentStatus'] = null; // Clear inferred on user update - demographicsChanged = true; + updateData['demographicData.employmentStatus'] = body.demographicData.employmentStatus; + updateData['demographicData.inferredEmploymentStatus'] = null; // Clear inferred on user update + demographicsChanged = true; } if (body.demographicData?.educationLevel !== undefined) { - updateData['demographicData.educationLevel'] = body.demographicData.educationLevel; - updateData['demographicData.inferredEducationLevel'] = null; // Clear inferred on user update - demographicsChanged = true; + updateData['demographicData.educationLevel'] = body.demographicData.educationLevel; + updateData['demographicData.inferredEducationLevel'] = null; // Clear inferred on user update + demographicsChanged = true; } // --- REMOVED Verification Flag Handling --- @@ -164,7 +169,6 @@ exports.updateUserProfile = async function (req, body) { // --- End Update Demographic Data --- - // Only update allowed privacy settings let privacySettingsChanged = false; // Flag for privacy changes if (body.privacySettings !== undefined) { @@ -173,23 +177,22 @@ exports.updateUserProfile = async function (req, body) { updateData['privacySettings.dataSharingConsent'] = body.privacySettings.dataSharingConsent; privacySettingsChanged = true; } - if (body.privacySettings.allowInference !== undefined) { // <-- Add check for allowInference + if (body.privacySettings.allowInference !== undefined) { + // <-- Add check for allowInference updateData['privacySettings.allowInference'] = body.privacySettings.allowInference; privacySettingsChanged = true; } // DO NOT update optInStores or optOutStores here } - // Check if there's anything to update (excluding updatedAt) - const updateKeys = Object.keys(updateData).filter(key => key !== '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 } } - ); - return respondWithCode(200, currentUser || { message: "No changes detected." }); + // Nothing changed + const currentUser = await db + .collection('users') + .findOne({ auth0Id: auth0UserId }, { projection: { preferences: 0 } }); + return respondWithCode(200, currentUser || { message: 'No changes detected.' }); } console.log(`Updating user ${auth0UserId} with data:`, updateData); @@ -212,21 +215,26 @@ exports.updateUserProfile = async function (req, body) { // 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.`); + await invalidateCache(`${CACHE_KEYS.PREFERENCES}${auth0UserId}`); + console.log( + `Invalidated general preferences cache for ${auth0UserId} due to demographic update.`, + ); } // Invalidate store-specific preferences if demographics or relevant privacy settings changed // (Keep existing logic, as privacySettingsChanged flag now includes allowInference) const updatedUserDoc = result; // Use the returned document from findOneAndUpdate - if ((demographicsChanged || privacySettingsChanged) && updatedUserDoc.privacySettings?.optInStores) { - const userObjectId = updatedUserDoc._id; // Use the _id from the updated result - console.log(`Invalidating store preferences for user ${userObjectId} due to update.`); - for (const storeId of updatedUserDoc.privacySettings.optInStores) { - const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`; - await invalidateCache(storePrefCacheKey); - console.log(`Invalidated cache: ${storePrefCacheKey}`); - } + 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 updatedUserDoc.privacySettings.optInStores) { + const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`; + await invalidateCache(storePrefCacheKey); + console.log(`Invalidated cache: ${storePrefCacheKey}`); + } } // Update cache with the new data (without preferences) @@ -236,7 +244,10 @@ exports.updateUserProfile = async function (req, body) { } catch (error) { 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' }); + return respondWithCode(500, { + code: 500, + message: 'Internal server error during profile update', + }); } }; @@ -252,8 +263,10 @@ exports.deleteUserProfile = async function (req) { const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); // Find user to get ID for cache invalidation later - const user = await db.collection('users').findOne({ auth0Id: userData.sub }, { projection: { _id: 1, privacySettings: 1 } }); - if (!user) { + const user = await db + .collection('users') + .findOne({ auth0Id: userData.sub }, { projection: { _id: 1, privacySettings: 1 } }); + if (!user) { return respondWithCode(404, { code: 404, message: 'User not found', @@ -262,11 +275,10 @@ exports.deleteUserProfile = async function (req) { const userObjectId = user._id; const userPrivacySettings = user.privacySettings; - // Delete from database const deleteResult = await db.collection('users').deleteOne({ auth0Id: userData.sub }); if (deleteResult.deletedCount === 0) { - // This case should ideally not happen if findOne succeeded, but good practice + // This case should ideally not happen if findOne succeeded, but good practice return respondWithCode(404, { code: 404, message: 'User not found during deletion', @@ -280,14 +292,13 @@ exports.deleteUserProfile = async function (req) { await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); - // Clear related store preference caches + // Clear related store preference caches if (userPrivacySettings?.optInStores) { for (const storeId of userPrivacySettings.optInStores) { await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`); } } - return respondWithCode(204); } catch (error) { console.error('Delete profile failed:', error); @@ -299,13 +310,25 @@ exports.deleteUserProfile = async function (req) { * Get Recent User Data Submissions * Retrieves a list of recent data submissions made about the authenticated user. */ -exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, storeId, startDate, endDate, searchTerm) { // Add new params +exports.getRecentUserData = async function ( + req, + limit = 10, + page = 1, + dataType, + storeId, + startDate, + endDate, + searchTerm, +) { + // Add new params try { const db = getDB(); const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); // Find user to get their internal _id - const user = await db.collection('users').findOne({ auth0Id: userData.sub }, { projection: { _id: 1 } }); + const user = await db + .collection('users') + .findOne({ auth0Id: userData.sub }, { projection: { _id: 1 } }); if (!user) { return respondWithCode(404, { code: 404, message: 'User not found' }); } @@ -323,9 +346,9 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, try { matchQuery.storeId = new ObjectId(storeId); } catch (e) { - console.warn(`Invalid storeId format provided: ${storeId}`); - // Decide how to handle: return empty, error, or ignore filter - return respondWithCode(400, { code: 400, message: 'Invalid store ID format provided.' }); + console.warn(`Invalid storeId format provided: ${storeId}`); + // Decide how to handle: return empty, error, or ignore filter + return respondWithCode(400, { code: 400, message: 'Invalid store ID format provided.' }); } } @@ -334,14 +357,18 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, if (startDate) { try { dateFilter.$gte = new Date(startDate); - } catch (e) { console.warn('Invalid startDate format:', startDate); } + } catch (e) { + console.warn('Invalid startDate format:', startDate); + } } if (endDate) { try { const end = new Date(endDate); end.setDate(end.getDate() + 1); // Include the whole end day dateFilter.$lt = end; - } catch (e) { console.warn('Invalid endDate format:', endDate); } + } catch (e) { + console.warn('Invalid endDate format:', endDate); + } } if (Object.keys(dateFilter).length > 0) { matchQuery.timestamp = dateFilter; @@ -362,12 +389,14 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, // --- End Query Building --- // Query userData collection with the built query - const recentData = await db.collection('userData') + const recentData = await db + .collection('userData') .find(matchQuery) // Use the dynamic query .sort({ timestamp: -1 }) // Sort by submission time descending .skip(skip) .limit(limit) - .project({ // Expand projection to include details needed for display + .project({ + // Expand projection to include details needed for display _id: 1, storeId: 1, dataType: 1, @@ -384,17 +413,17 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, .toArray(); // Simple transformation (can be enhanced on frontend) - const formattedData = recentData.map(entry => ({ + const formattedData = recentData.map((entry) => ({ _id: entry._id.toString(), // Convert ObjectId to string storeId: entry.storeId.toString(), // Convert ObjectId to string dataType: entry.dataType, timestamp: entry.timestamp, // Process entries for simpler display structure if needed here, // or handle it on the frontend. Example: - details: entry.entries.map(e => ({ - timestamp: e.timestamp, - ...(entry.dataType === 'purchase' && { items: e.items }), - ...(entry.dataType === 'search' && { query: e.query, results: e.results }), + details: entry.entries.map((e) => ({ + timestamp: e.timestamp, + ...(entry.dataType === 'purchase' && { items: e.items }), + ...(entry.dataType === 'search' && { query: e.query, results: e.results }), })), })); @@ -403,7 +432,6 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, // Example: const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}:${dataType || 'all'}:${storeId || 'all'}:${startDate || 'all'}:${endDate || 'all'}:${searchTerm || ''}`; return respondWithCode(200, formattedData); - } catch (error) { console.error('Get recent user data failed:', error); return respondWithCode(500, { code: 500, message: 'Internal server error' }); @@ -442,22 +470,24 @@ exports.getSpendingAnalytics = async function (req) { 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 } }); + 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 (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; // Assuming category ID is used in items - map[cat.name] = cat.name; // Allow matching by name too, just in case - return map; - }, {}) - : {}; + const categoryMap = + taxonomyDoc && taxonomyDoc.data && taxonomyDoc.data.categories + ? taxonomyDoc.data.categories.reduce((map, cat) => { + 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; + }, {}) + : {}; const pipeline = [ // Match user and data type @@ -473,53 +503,62 @@ exports.getSpendingAnalytics = async function (req) { $group: { _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 + 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: [ - { $isNumber: '$entries.items.price' }, - { $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 } } - } - } - } - } + if: { + $and: [ + { $isNumber: '$entries.items.price' }, + { $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' - } - } - } + $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, // Exclude the default _id month: '$_id', // Rename _id to month - spending: { $arrayToObject: '$categories' } // Convert [{k: "Cat1", v: 100}, ...] to { "Cat1": 100, ... } - } + spending: { $arrayToObject: '$categories' }, // Convert [{k: "Cat1", v: 100}, ...] to { "Cat1": 100, ... } + }, }, // Sort by month ascending - { $sort: { month: 1 } } + { $sort: { month: 1 } }, ]; const results = await db.collection('userData').aggregate(pipeline).toArray(); // --- Map category IDs/names to proper names from taxonomy --- - const spendingAnalytics = results.map(monthlyData => { + const spendingAnalytics = results.map((monthlyData) => { const mappedSpending = {}; for (const categoryKey in monthlyData.spending) { const categoryName = categoryMap[categoryKey] || categoryKey; // Use mapped name or original key @@ -527,19 +566,17 @@ exports.getSpendingAnalytics = async function (req) { } return { month: monthlyData.month, - spending: mappedSpending + spending: mappedSpending, }; }); // --- End Mapping --- - // 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) { console.error('Get spending analytics failed:', error); return respondWithCode(500, { code: 500, message: 'Internal server error' }); diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 9c68958..422bd3f 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -45,10 +45,6 @@ export interface PrivacySettings { * @default true */ allowInference?: boolean; - /** List of store IDs the user explicitly allows data sharing with. */ - optInStores?: string[]; - /** List of store IDs the user explicitly blocks data sharing with. */ - optOutStores?: string[]; } export interface Store { @@ -71,6 +67,13 @@ export interface Store { } export interface UserCreate { + /** + * User's unique username, chosen during registration. + * @minLength 3 + * @maxLength 15 + * @pattern ^[a-zA-Z0-9_-]+$ + */ + username: string; preferences?: PreferenceItem[]; /** User's consent for data sharing */ dataSharingConsent: boolean; diff --git a/web/src/components/auth/UserRegistrationForm.tsx b/web/src/components/auth/UserRegistrationForm.tsx index 6975b77..22a137a 100644 --- a/web/src/components/auth/UserRegistrationForm.tsx +++ b/web/src/components/auth/UserRegistrationForm.tsx @@ -10,6 +10,7 @@ import { Popover, Select, TextInput, + HelperText, // <-- Import HelperText } from "flowbite-react"; import { UserCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; @@ -77,6 +78,32 @@ export function UserRegistrationForm({ const [incomeBracket, setIncomeBracket] = useState(null); const [country, setCountry] = useState(null); const [age, setAge] = useState(null); + // --- Add state for username --- + const [username, setUsername] = useState(""); + const [usernameError, setUsernameError] = useState(null); + + const validateUsername = (value: string): boolean => { + if (!value) { + setUsernameError("Username is required."); + return false; + } + if (value.length < 3) { + setUsernameError("Username must be at least 3 characters."); + return false; + } + if (value.length > 15) { + setUsernameError("Username cannot exceed 15 characters."); + return false; + } + if (!/^[a-zA-Z0-9_-]+$/.test(value)) { + setUsernameError( + "Username can only contain letters, numbers, underscores, and hyphens.", + ); + return false; + } + setUsernameError(null); + return true; + }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -88,7 +115,12 @@ export function UserRegistrationForm({ // return; } + if (!validateUsername(username)) { + return; + } + const userData: UserCreate = { + username: username, // <-- Include username dataSharingConsent: dataSharingConsent, allowInference: allowInference, // <-- Include allowInference preferences: [], @@ -140,6 +172,27 @@ export function UserRegistrationForm({ Complete User Registration + {/* Username Input */} +
+
+ +
+ { + setUsername(e.target.value); + validateUsername(e.target.value); + }} + color={usernameError ? "failure" : "gray"} + /> + {usernameError && ( + {usernameError} + )} +
+ {/* Demographic Information Section */}

diff --git a/web/src/pages/UserDashboard/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx index 1eca662..e2cd742 100644 --- a/web/src/pages/UserDashboard/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -102,22 +102,28 @@ export default function UserProfilePage() { // Show toasts based on UPDATE mutation state useEffect(() => { if (isUpdateSuccess) { + setShowErrorToast(false); // Ensure error toast is hidden setShowSuccessToast(true); setToastMessage("Profile updated successfully!"); - resetUpdateMutation(); - const timer = setTimeout(() => setShowSuccessToast(false), 5000); + const timer = setTimeout(() => { + setShowSuccessToast(false); + resetUpdateMutation(); // Reset mutation state after toast is hidden + }, 5000); return () => clearTimeout(timer); } }, [isUpdateSuccess, resetUpdateMutation]); useEffect(() => { if (updateError) { + setShowSuccessToast(false); // Ensure success toast is hidden setShowErrorToast(true); setToastMessage( updateError.message || "Failed to update profile. Please try again.", ); - resetUpdateMutation(); - const timer = setTimeout(() => setShowErrorToast(false), 5000); + const timer = setTimeout(() => { + setShowErrorToast(false); + resetUpdateMutation(); // Reset mutation state after toast is hidden + }, 5000); return () => clearTimeout(timer); } }, [updateError, resetUpdateMutation]); @@ -133,8 +139,12 @@ export default function UserProfilePage() { "showPostLogoutToast", "User account deleted successfully.", ); // <-- Set flag - resetDeleteMutation(); - logout(); // <-- Call logout directly + const timer = setTimeout(() => { + // Added a delay for reset similar to update toasts + resetDeleteMutation(); + logout(); + }, 100); // Short delay to ensure state updates propagate if needed before logout + return () => clearTimeout(timer); } }, [isDeleteSuccess, resetDeleteMutation, logout]); // Keep dependencies @@ -147,8 +157,10 @@ export default function UserProfilePage() { deleteError.message || "Failed to delete user account. Please try again.", ); - resetDeleteMutation(); - const timer = setTimeout(() => setShowErrorToast(false), 5000); + const timer = setTimeout(() => { + setShowErrorToast(false); + resetDeleteMutation(); + }, 5000); return () => clearTimeout(timer); } }, [deleteError, resetDeleteMutation]); @@ -156,7 +168,7 @@ export default function UserProfilePage() { const onSubmit: SubmitHandler = (data) => { const updatePayload: UserUpdate = { username: data.username, - phone: data.phone, + phone: data.phone || undefined, // If phone is empty string, send undefined privacySettings: { dataSharingConsent: data.privacySettings_dataSharingConsent ?? false, allowInference: data.privacySettings_allowInference ?? true, // Default to true if undefined From 70259933e1eab0f1542ced5da48b134a0258ca6a Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 8 May 2025 01:49:27 +0530 Subject: [PATCH 02/20] feat: Add nickname update for store profile and enhance metadata handling --- api-service/service/AuthenticationService.js | 1 + api-service/service/StoreProfileService.js | 72 +++++++++++++++----- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index 6eb6c0f..e363abc 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -271,6 +271,7 @@ exports.registerStore = async function (req, body) { await updateUserMetadata(userData.sub, { registrationType: 'store', registrationComplete: true, + nickname: name, // <-- Add store name as nickname }); return respondWithCode(201, { ...store, storeId: result.insertedId }); diff --git a/api-service/service/StoreProfileService.js b/api-service/service/StoreProfileService.js index 1cbdf3f..50a5df3 100644 --- a/api-service/service/StoreProfileService.js +++ b/api-service/service/StoreProfileService.js @@ -3,7 +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'); -const { deleteAuth0User } = require('../utils/auth0Util'); +const { deleteAuth0User, updateUserMetadata } = require('../utils/auth0Util'); // <-- Import updateUserMetadata const { ObjectId } = require('mongodb'); // Import ObjectId /** @@ -52,20 +52,40 @@ exports.updateStoreProfile = async function (req, 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])); + const auth0UserId = userData.sub; // Get Auth0 user ID for metadata update // Update store const updateData = { updatedAt: new Date(), }; - if (body.name !== undefined) updateData.name = body.name; + let nameChanged = false; // Flag to check if name was updated + + if (body.name !== undefined) { + updateData.name = body.name; + // We need the current store to compare if the name actually changed + // This will be checked after fetching the store for the update operation + } if (body.address !== undefined) updateData.address = body.address; if (body.webhooks !== undefined) updateData.webhooks = body.webhooks; + // Fetch the current store profile to check if name actually changed + const currentStore = await db.collection('stores').findOne({ auth0Id: auth0UserId }); + if (!currentStore) { + return respondWithCode(404, { + code: 404, + message: 'Store not found before update.', + }); + } + + if (body.name !== undefined && body.name !== currentStore.name) { + nameChanged = true; + } + const result = await db .collection('stores') .findOneAndUpdate( - { auth0Id: userData.sub }, + { auth0Id: auth0UserId }, { $set: updateData }, { returnDocument: 'after' }, ); @@ -77,8 +97,19 @@ exports.updateStoreProfile = async function (req, body) { }); } + // If name changed, update Auth0 metadata + if (nameChanged && body.name) { + try { + await updateUserMetadata(auth0UserId, { nickname: body.name }); + console.log(`Auth0 nickname updated for store ${auth0UserId} to ${body.name}`); + } catch (auth0Error) { + // Log error but don't fail the whole operation, as local DB update succeeded + console.error(`Failed to update Auth0 nickname for store ${auth0UserId}:`, auth0Error); + } + } + // Update cache with standardized key and TTL - const cacheKey = `${CACHE_KEYS.STORE_DATA}${userData.sub}`; + const cacheKey = `${CACHE_KEYS.STORE_DATA}${auth0UserId}`; await setCache(cacheKey, JSON.stringify(result), { EX: CACHE_TTL.STORE_DATA }); return respondWithCode(200, result); } catch (error) { @@ -139,29 +170,29 @@ exports.lookupStores = async function (req, ids) { 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 + 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 => ({ + const formattedStores = stores.map((store) => ({ storeId: store._id.toString(), // Convert ObjectId back to string - name: store.name + 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(400, { code: 400, message: 'Invalid store ID format provided.' }); } return respondWithCode(500, { code: 500, message: 'Internal server error' }); } @@ -173,8 +204,12 @@ exports.lookupStores = async function (req, ids) { */ exports.searchStores = async function (req, query, limit = 10) { try { - if (!query || query.length < 2) { // Basic validation - return respondWithCode(400, { code: 400, message: 'Search query must be at least 2 characters long.' }); + if (!query || query.length < 2) { + // Basic validation + return respondWithCode(400, { + code: 400, + message: 'Search query must be at least 2 characters long.', + }); } const db = getDB(); @@ -184,22 +219,25 @@ exports.searchStores = async function (req, query, limit = 10) { // db.collection('stores').createIndex({ name: "text" }); // Then use: { $text: { $search: query } } instead of regex for better performance - const stores = await db.collection('stores') + const stores = await db + .collection('stores') .find({ name: regex }) // Using regex for simplicity here .limit(parseInt(limit)) // Ensure limit is an integer .project({ _id: 1, name: 1 }) // Project only ID and name .toArray(); // Format the response to match StoreBasicInfo schema - const formattedStores = stores.map(store => ({ + const formattedStores = stores.map((store) => ({ storeId: store._id.toString(), // Convert ObjectId back to string - name: store.name + name: store.name, })); return respondWithCode(200, formattedStores); - } catch (error) { console.error('Search stores failed:', error); - return respondWithCode(500, { code: 500, message: 'Internal server error during store search' }); + return respondWithCode(500, { + code: 500, + message: 'Internal server error during store search', + }); } }; From e67e169f11024820add021cc6da999f53271cd30 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 8 May 2025 02:51:06 +0530 Subject: [PATCH 03/20] Username fix --- api-service/api/openapi.yaml | 4 +++- web/src/api/types/data-contracts.ts | 3 ++- web/src/layout/Header.tsx | 27 +++++++++++++++++---------- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/api-service/api/openapi.yaml b/api-service/api/openapi.yaml index fc1611f..35589fc 100644 --- a/api-service/api/openapi.yaml +++ b/api-service/api/openapi.yaml @@ -1529,7 +1529,9 @@ components: registrationComplete: type: boolean description: Whether registration process is complete - description: User metadata from Auth0 + nickname: + type: string + description: User or store display name TaxonomyAttribute: type: object diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index 422bd3f..fdf4e8a 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -393,12 +393,13 @@ export interface PingStatus { export interface UserMetadataResponse { /** Whether metadata was updated successfully */ updated?: boolean; - /** User metadata from Auth0 */ metadata?: { /** The type of registration */ registrationType?: "user" | "store"; /** Whether registration process is complete */ registrationComplete?: boolean; + /** User or store display name */ + nickname?: string; }; } diff --git a/web/src/layout/Header.tsx b/web/src/layout/Header.tsx index 8412362..7022a61 100644 --- a/web/src/layout/Header.tsx +++ b/web/src/layout/Header.tsx @@ -15,11 +15,13 @@ import { import { useAuth } from "../hooks/useAuth"; import { Link, useLocation } from "react-router"; import LoadingSpinner from "../components/common/LoadingSpinner"; +import { useUserMetadata } from "../api/hooks/useAuthHooks"; export function Header() { const { isLoading, isAuthenticated, user, userRoles, login, logout } = - useAuth(); // Use the auth context - const location = useLocation(); // Get current location for active links + useAuth(); + const { data: userMetadata, isLoading: metadataLoading } = useUserMetadata(); + const location = useLocation(); // Determine dashboard link based on role const getDashboardLink = () => { @@ -43,6 +45,15 @@ export function Header() { return "/"; }; + // Get display name from metadata or user object + const getDisplayName = () => { + // First try to get custom nickname from metadata (set during registration) + if (userMetadata?.metadata?.nickname) { + return userMetadata.metadata.nickname; + } + return user?.nickname || user?.name || "User"; + }; + const handleLogin = () => login(); const handleLogout = () => logout(); @@ -50,7 +61,6 @@ export function Header() { {" "} - {/* Use Link for internal navigation */}
- {isLoading ? ( + {isLoading || metadataLoading ? ( } // Use user picture from Auth0 + label={} > - {user?.name || user?.nickname} - {" "} - {/* Use name or nickname */} + {getDisplayName()} {/* Use the new function */} + {user?.email} - {/* Add Profile Link */} Profile @@ -92,7 +100,6 @@ export function Header() { ) : ( <> - {/* Use onClick for Auth0 actions */} From c2e3aba1be434a43c4c334d55329cca16de5f1de Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 8 May 2025 02:55:13 +0530 Subject: [PATCH 04/20] refactor: Remove unused navigation state from RegistrationCompletionModal --- web/src/components/auth/RegistrationCompletionModal.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/components/auth/RegistrationCompletionModal.tsx b/web/src/components/auth/RegistrationCompletionModal.tsx index ecad6aa..e05ed63 100644 --- a/web/src/components/auth/RegistrationCompletionModal.tsx +++ b/web/src/components/auth/RegistrationCompletionModal.tsx @@ -17,7 +17,6 @@ export function RegistrationCompletionModal() { "user" | "store" | null >(null); const [error, setError] = useState(null); - const [isNavigating, setIsNavigating] = useState(false); const registerUserMutation = useRegisterUser(); const registerStoreMutation = useRegisterStore(); @@ -32,13 +31,11 @@ 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 From 0da4ecb4130137deeb48830f3395c8edce3386b1 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 8 May 2025 15:19:06 +0530 Subject: [PATCH 05/20] refactor: Remove linkAccounts function and clean up user registration logic --- api-service/service/AuthenticationService.js | 158 ++++--------------- api-service/utils/auth0Util.js | 105 ++++-------- 2 files changed, 61 insertions(+), 202 deletions(-) diff --git a/api-service/service/AuthenticationService.js b/api-service/service/AuthenticationService.js index e363abc..d05dd23 100644 --- a/api-service/service/AuthenticationService.js +++ b/api-service/service/AuthenticationService.js @@ -2,12 +2,7 @@ const { getDB } = require('../utils/mongoUtil'); const { setCache } = require('../utils/redisUtil'); const { checkExistingRegistration } = require('../utils/helperUtil'); const { respondWithCode } = require('../utils/writer'); -const { - assignUserRole, - linkAccounts, - updateUserMetadata, - getUserMetadata, -} = require('../utils/auth0Util'); +const { assignUserRole, updateUserMetadata, getUserMetadata } = require('../utils/auth0Util'); // Removed linkAccounts const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); @@ -18,22 +13,19 @@ const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); exports.registerUser = async function (req, body) { try { const db = getDB(); - // Destructure new demographic fields AND allowInference AND username const { - username, // <-- Add username + username, preferences, dataSharingConsent, - allowInference, // <-- Add allowInference + allowInference, 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])); - // Check if already registered as a user or store const registration = await checkExistingRegistration(userData.sub); if (registration.exists) { return respondWithCode(409, { @@ -42,43 +34,17 @@ exports.registerUser = async function (req, body) { }); } - // Check if email already exists in our database - const existingUserByEmail = await db.collection('users').findOne({ - email: userData.email, - }); - - // Handle account linking if the email exists but with a different auth0Id - if (existingUserByEmail && existingUserByEmail.auth0Id !== userData.sub) { - try { - // Link the accounts in Auth0 - await linkAccounts(existingUserByEmail.auth0Id, userData.sub); - - // Cache the linked user data - await setCache( - `${CACHE_KEYS.USER_DATA}${userData.sub}`, - JSON.stringify(existingUserByEmail), - { - EX: CACHE_TTL.USER_DATA, - }, - ); - - // Return the existing user - return respondWithCode(200, { - ...existingUserByEmail, - message: 'Account linked successfully', - accountLinked: true, - }); - } catch (linkError) { - console.error('Account linking failed:', linkError); - return respondWithCode(400, { - code: 400, - message: 'Failed to link accounts. Please contact support.', - details: linkError.message, - }); - } + // Check if email already exists in users or stores collection + const existingEmail = + (await db.collection('users').findOne({ email: userData.email })) || + (await db.collection('stores').findOne({ email: userData.email })); + if (existingEmail) { + return respondWithCode(409, { + code: 409, + message: 'Email is already registered with another account.', + }); } - // Check if username already exists const existingUserByUsername = await db.collection('users').findOne({ username: username, }); @@ -90,52 +56,33 @@ exports.registerUser = async function (req, body) { }); } - // Add user role assignment - try { - await assignUserRole(userData.sub, 'user'); - } catch (error) { - console.error('Role assignment failed:', error); - return respondWithCode(500, { - code: 500, - message: 'Failed to assign role', - }); - } + await assignUserRole(userData.sub, 'user'); - // Create user in database const user = { auth0Id: userData.sub, - username: username || null, // <-- Set username + username: username || null, email: userData.email, phone: userData.phone_number || null, demographicData: { - // User provided (initialize as null unless provided in registration body) gender: gender || null, incomeBracket: incomeBracket || null, country: country || null, age: age || null, - hasKids: null, // NEW user-provided, init null - relationshipStatus: null, // NEW user-provided, init null - employmentStatus: null, // NEW user-provided, init null - educationLevel: null, // NEW user-provided, init null - // Inferred (initialize as null) + hasKids: null, + relationshipStatus: null, + employmentStatus: null, + educationLevel: null, inferredHasKids: null, - // REMOVED hasKidsIsVerified inferredRelationshipStatus: null, - // REMOVED relationshipStatusIsVerified inferredEmploymentStatus: null, - // REMOVED employmentStatusIsVerified inferredEducationLevel: null, - // REMOVED educationLevelIsVerified - // REMOVED inferredAgeBracket - // REMOVED ageBracketIsVerified inferredGender: null, - // REMOVED genderIsVerified }, preferences: preferences || [], privacySettings: { dataSharingConsent, anonymizeData: false, - allowInference: allowInference !== undefined ? allowInference : true, // <-- Set allowInference, default true + allowInference: allowInference !== undefined ? allowInference : true, optInStores: [], optOutStores: [], }, @@ -147,17 +94,14 @@ exports.registerUser = async function (req, body) { const result = await db.collection('users').insertOne(user); - // Cache the newly created user data const userWithId = { ...user, _id: result.insertedId }; await setCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`, JSON.stringify(userWithId), { EX: CACHE_TTL.USER_DATA, }); - // 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 + preferences: user.preferences || [], updatedAt: user.updatedAt || new Date(), }; @@ -165,7 +109,6 @@ exports.registerUser = async function (req, body) { EX: CACHE_TTL.USER_DATA, }); - // Update user metadata await updateUserMetadata(userData.sub, { registrationType: 'user', registrationComplete: true, @@ -188,10 +131,8 @@ exports.registerStore = async function (req, body) { const db = getDB(); const { name, address, webhooks } = 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])); - // Check if already registered const registration = await checkExistingRegistration(userData.sub); if (registration.exists) { return respondWithCode(409, { @@ -200,54 +141,19 @@ exports.registerStore = async function (req, body) { }); } - // Check if a store with the same email exists but with different auth0Id - const existingStoreByEmail = await db.collection('stores').findOne({ - email: userData.email, - }); - - // Handle account linking if the email exists but with a different auth0Id - if (existingStoreByEmail && existingStoreByEmail.auth0Id !== userData.sub) { - try { - // Link the accounts in Auth0 - await linkAccounts(existingStoreByEmail.auth0Id, userData.sub); - - // Cache the linked store data - await setCache( - `${CACHE_KEYS.STORE_DATA}${userData.sub}`, - JSON.stringify(existingStoreByEmail), - { - EX: CACHE_TTL.STORE_DATA, - }, - ); - - // Return the existing store - return respondWithCode(200, { - ...existingStoreByEmail, - message: 'Account linked successfully', - accountLinked: true, - }); - } catch (linkError) { - console.error('Account linking failed:', linkError); - return respondWithCode(400, { - code: 400, - message: 'Failed to link accounts. Please contact support.', - details: linkError.message, - }); - } - } - - // Assign store role - try { - await assignUserRole(userData.sub, 'store'); - } catch (error) { - console.error('Role assignment failed:', error); - return respondWithCode(500, { - code: 500, - message: 'Failed to assign role', + // Check if email already exists in users or stores collection + const existingEmail = + (await db.collection('users').findOne({ email: userData.email })) || + (await db.collection('stores').findOne({ email: userData.email })); + if (existingEmail) { + return respondWithCode(409, { + code: 409, + message: 'Email is already registered with another account.', }); } - // Create store in database + await assignUserRole(userData.sub, 'store'); + const store = { auth0Id: userData.sub, name, @@ -261,17 +167,15 @@ exports.registerStore = async function (req, body) { const result = await db.collection('stores').insertOne(store); - // Cache the newly created store data const storeWithId = { ...store, _id: result.insertedId }; await setCache(`${CACHE_KEYS.STORE_DATA}${userData.sub}`, JSON.stringify(storeWithId), { EX: CACHE_TTL.STORE_DATA, }); - // Update store metadata await updateUserMetadata(userData.sub, { registrationType: 'store', registrationComplete: true, - nickname: name, // <-- Add store name as nickname + nickname: name, }); return respondWithCode(201, { ...store, storeId: result.insertedId }); diff --git a/api-service/utils/auth0Util.js b/api-service/utils/auth0Util.js index 593b90b..53b6e1a 100644 --- a/api-service/utils/auth0Util.js +++ b/api-service/utils/auth0Util.js @@ -62,56 +62,6 @@ async function assignUserRole(userId, role) { } } -/** - * Links two user accounts in Auth0 - * @param {string} primaryUserId - The main user ID (to keep) - * @param {string} secondaryUserId - The user ID to link to primary - * @returns {Promise} - The linked user data - */ -async function linkAccounts(primaryUserId, secondaryUserId) { - try { - const token = await getManagementToken(); - - // Get the secondary user's identity provider data - const secondaryUserResponse = await axios.get( - `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${secondaryUserId}`, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }, - ); - - const secondaryUser = secondaryUserResponse.data; - if (!secondaryUser.identities || !secondaryUser.identities.length) { - throw new Error('No identities found on secondary account'); - } - - // Get the provider connection info - const identity = secondaryUser.identities[0]; - const provider = identity.provider; - const userId = identity.user_id; - - // Link the accounts - const response = await axios.post( - `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${primaryUserId}/identities`, - { provider, user_id: userId }, - { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - }, - ); - - return response.data; - } catch (error) { - console.error('Account linking failed:', error?.response?.data || error); - throw error; - } -} - /** * Update Auth0 user metadata * @param {string} userId - Auth0 user ID @@ -122,9 +72,9 @@ async function linkAccounts(primaryUserId, secondaryUserId) { async function updateUserMetadata(userId, metadata, invalidateUserCache = false) { try { const token = await getManagementToken(); - + const metadataUpdate = { - user_metadata: metadata + user_metadata: metadata, }; // Update user metadata in Auth0 @@ -136,16 +86,16 @@ async function updateUserMetadata(userId, metadata, invalidateUserCache = false) Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - } + }, ); - + // Invalidate cache if requested if (invalidateUserCache) { const { invalidateCache } = require('../utils/redisUtil'); const { CACHE_KEYS } = require('../utils/cacheConfig'); await invalidateCache(`${CACHE_KEYS.USER_DATA}${userId}`); } - + return response.data.user_metadata || {}; } catch (error) { console.error('Failed to update user metadata:', error?.response?.data || error); @@ -178,12 +128,15 @@ async function updateUserPhone(userId, phone) { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - } + }, ); return response.data; } catch (error) { - console.error(`Failed to update Auth0 phone number for ${userId}:`, error?.response?.data || error.message); + console.error( + `Failed to update Auth0 phone number for ${userId}:`, + error?.response?.data || error.message, + ); // Re-throw the error so the calling service can decide how to handle it throw error; } @@ -197,7 +150,7 @@ async function updateUserPhone(userId, phone) { async function getUserMetadata(userId) { try { const token = await getManagementToken(); - + // Get user with metadata from Auth0 Management API const response = await axios.get( `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userId}`, @@ -206,9 +159,9 @@ async function getUserMetadata(userId) { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - } + }, ); - + return response.data.user_metadata || {}; } catch (error) { console.error('Failed to get user metadata:', error?.response?.data || error); @@ -241,14 +194,17 @@ async function updateAuth0Username(userId, newUsername) { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', }, - } + }, ); console.log(`Successfully updated Auth0 username for ${userId}.`); return response.data; } catch (error) { // Log the specific error (e.g., username already exists) - console.error(`Failed to update Auth0 username for ${userId}:`, error?.response?.data || error.message); + console.error( + `Failed to update Auth0 username for ${userId}:`, + error?.response?.data || error.message, + ); // Re-throw the error so the calling service knows the update failed throw error; } @@ -264,27 +220,26 @@ async function deleteAuth0User(userId) { const token = await getManagementToken(); // Delete user from Auth0 - await axios.delete( - `${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userId}`, - { - headers: { - Authorization: `Bearer ${token}`, - }, - } - ); + await axios.delete(`${process.env.AUTH0_ISSUER_BASE_URL}/api/v2/users/${userId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); console.log(`Successfully deleted user ${userId} from Auth0.`); } catch (error) { // Log error but don't throw, allowing the calling service to continue if needed - console.error(`Auth0 deletion failed for user ${userId}:`, error?.response?.data || error.message); + console.error( + `Auth0 deletion failed for user ${userId}:`, + error?.response?.data || error.message, + ); // If you want the deletion failure to stop the process in the service, re-throw the error: // throw error; } } -module.exports = { - getManagementToken, - assignUserRole, - linkAccounts, +module.exports = { + getManagementToken, + assignUserRole, updateUserMetadata, updateUserPhone, getUserMetadata, From fb6c8828a4a847a35185a6ef4e40632ac8d53529 Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 8 May 2025 16:42:51 +0530 Subject: [PATCH 06/20] feat: Add OpenAPI specification for Tapiro Store Operations API --- api-service/api/tapiro-openapi.yaml | 262 ++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 api-service/api/tapiro-openapi.yaml diff --git a/api-service/api/tapiro-openapi.yaml b/api-service/api/tapiro-openapi.yaml new file mode 100644 index 0000000..ac786a5 --- /dev/null +++ b/api-service/api/tapiro-openapi.yaml @@ -0,0 +1,262 @@ +openapi: 3.0.0 +info: + title: Tapiro Store Operations API + description: API for external services to interact with Tapiro's Store Operations. + version: 1.0.0 + contact: + name: Tapiro Support + email: tapirosupport@gmail.com +servers: + - description: SwaggerHub API Auto Mocking + url: https://virtserver.swaggerhub.com/CHAMATHDEWMINA25/Tapiro/1.0.0 + - url: https://api.tapiro.com/v1 + description: Production API + - url: http://localhost:3000 + description: Development API +paths: + /users/data: + post: + tags: [Data Collection] + summary: Submit user data + description: Submit purchase/search history for a user (authenticated via API key) + operationId: submitUserData + security: + - apiKey: [] + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/UserData" + required: true + responses: + "202": + description: Data accepted for processing + "401": + $ref: "#/components/responses/UnauthorizedError" + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalServerError" + + /users/{userId}/preferences: + get: + tags: [Data Retrieval] + summary: Get user preferences + description: Retrieve preferences for targeted advertising + operationId: getUserPreferences + parameters: + - name: userId + in: path + required: true + schema: + type: string + security: + - apiKey: [] + responses: + "200": + description: UserPreferences retrieved + content: + application/json: + schema: + $ref: "#/components/schemas/UserPreferences" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "500": + $ref: "#/components/responses/InternalServerError" + +components: + schemas: + UserData: + type: object + required: + - email + - dataType + - entries + properties: + email: + type: string + format: email + description: User's email address + dataType: + type: string + enum: [purchase, search] + description: Type of data being submitted + entries: + type: array + items: + oneOf: + - $ref: "#/components/schemas/PurchaseEntry" + - $ref: "#/components/schemas/SearchEntry" + UserPreferences: + type: object + properties: + preferences: + type: array + items: + $ref: "#/components/schemas/PreferenceItem" + PurchaseEntry: + type: object + required: + - timestamp + - items + properties: + 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" + totalValue: + type: number + format: float + description: Optional total value of the purchase event. + + PurchaseItem: + type: object + required: + - name + - category + 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 or name matching the taxonomy. + 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: + type: object + description: Key-value pairs representing product attributes. + additionalProperties: + type: string + + SearchEntry: + type: object + required: + - timestamp + - query + properties: + 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. + 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 + + PreferenceItem: + type: object + required: + - category + - score + properties: + category: + type: string + description: Category ID or name. + score: + type: number + format: float + minimum: 0.0 + maximum: 1.0 + description: Preference score (0.0-1.0). + attributes: + type: object + description: Category-specific attribute preferences. + additionalProperties: + type: string + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + description: HTTP status code of the error + example: 400 + message: + type: string + description: Description of the error + example: "Bad request - invalid input" + details: + type: object + description: Additional details about the error (optional) + additionalProperties: true + example: { field: "username", issue: "already taken" } + + responses: + BadRequestError: + description: Bad request - invalid input + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + UnauthorizedError: + description: Unauthorized - authentication required + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + ForbiddenError: + description: Forbidden - insufficient permissions + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + NotFoundError: + description: Not found - resource doesn't exist + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + ConflictError: + description: Conflict - resource already exists + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + InternalServerError: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-API-Key From 9f7c52878b6c081a85144318207cd7cb45b6cd8d Mon Sep 17 00:00:00 2001 From: CDevmina Date: Thu, 8 May 2025 18:17:02 +0530 Subject: [PATCH 07/20] feat: Add new product images and remove obsolete video streaming subscription entry --- demo/public/products/apples.webp | Bin 0 -> 23968 bytes demo/public/products/babystroller.webp | Bin 0 -> 7988 bytes demo/public/products/boots.webp | Bin 0 -> 14368 bytes demo/public/products/buildingblocks.webp | Bin 0 -> 12078 bytes demo/public/products/cleanser.webp | Bin 0 -> 8988 bytes demo/public/products/coffee.webp | Bin 0 -> 39630 bytes demo/public/products/dell-laptop.webp | Bin 0 -> 7462 bytes demo/public/products/dress.webp | Bin 0 -> 9824 bytes demo/public/products/drill.webp | Bin 0 -> 2478 bytes demo/public/products/fictionnoval.webp | Bin 0 -> 25688 bytes demo/public/products/gamgingmouse.webp | Bin 0 -> 3802 bytes demo/public/products/gaming-laptop.webp | Bin 0 -> 12778 bytes demo/public/products/garden-tools.webp | Bin 0 -> 20058 bytes demo/public/products/gardening.jpg | Bin 0 -> 11563 bytes demo/public/products/genpens.webp | Bin 0 -> 19340 bytes demo/public/products/giftbasket.webp | Bin 0 -> 14500 bytes demo/public/products/headphones.webp | Bin 0 -> 5434 bytes demo/public/products/indiegame.webp | Bin 0 -> 26930 bytes demo/public/products/ipad.webp | Bin 0 -> 5566 bytes demo/public/products/iphonex.webp | Bin 0 -> 9828 bytes demo/public/products/jeans.webp | Bin 0 -> 4550 bytes demo/public/products/kidstshirt.webp | Bin 0 -> 13918 bytes demo/public/products/lipstick.webp | Bin 0 -> 6960 bytes demo/public/products/mattfoundation.webp | Bin 0 -> 2078 bytes demo/public/products/necklace.webp | Bin 0 -> 13778 bytes demo/public/products/productivitysuite.webp | Bin 0 -> 9322 bytes demo/public/products/samsung.webp | Bin 0 -> 2806 bytes demo/public/products/sciencefiction.webp | Bin 0 -> 16918 bytes demo/public/products/smartwatch.webp | Bin 0 -> 5312 bytes demo/public/products/sneakers.jpg.webp | Bin 0 -> 5210 bytes demo/public/products/sofa.webp | Bin 0 -> 10804 bytes demo/public/products/steelpan.webp | Bin 0 -> 6700 bytes demo/public/products/suit.webp | Bin 0 -> 9328 bytes demo/public/products/suitecase.webp | Bin 0 -> 12672 bytes demo/public/products/tabel.webp | Bin 0 -> 4764 bytes demo/public/products/toothbrush.webp | Bin 0 -> 1822 bytes demo/public/products/tshirt.webp | Bin 0 -> 2676 bytes demo/public/products/tuna.webp | Bin 0 -> 14420 bytes demo/public/products/vitamin.webp | Bin 0 -> 50174 bytes demo/public/products/vitaminc.webp | Bin 0 -> 12448 bytes demo/src/data/products.ts | 9 --------- 41 files changed, 9 deletions(-) create mode 100644 demo/public/products/apples.webp create mode 100644 demo/public/products/babystroller.webp create mode 100644 demo/public/products/boots.webp create mode 100644 demo/public/products/buildingblocks.webp create mode 100644 demo/public/products/cleanser.webp create mode 100644 demo/public/products/coffee.webp create mode 100644 demo/public/products/dell-laptop.webp create mode 100644 demo/public/products/dress.webp create mode 100644 demo/public/products/drill.webp create mode 100644 demo/public/products/fictionnoval.webp create mode 100644 demo/public/products/gamgingmouse.webp create mode 100644 demo/public/products/gaming-laptop.webp create mode 100644 demo/public/products/garden-tools.webp create mode 100644 demo/public/products/gardening.jpg create mode 100644 demo/public/products/genpens.webp create mode 100644 demo/public/products/giftbasket.webp create mode 100644 demo/public/products/headphones.webp create mode 100644 demo/public/products/indiegame.webp create mode 100644 demo/public/products/ipad.webp create mode 100644 demo/public/products/iphonex.webp create mode 100644 demo/public/products/jeans.webp create mode 100644 demo/public/products/kidstshirt.webp create mode 100644 demo/public/products/lipstick.webp create mode 100644 demo/public/products/mattfoundation.webp create mode 100644 demo/public/products/necklace.webp create mode 100644 demo/public/products/productivitysuite.webp create mode 100644 demo/public/products/samsung.webp create mode 100644 demo/public/products/sciencefiction.webp create mode 100644 demo/public/products/smartwatch.webp create mode 100644 demo/public/products/sneakers.jpg.webp create mode 100644 demo/public/products/sofa.webp create mode 100644 demo/public/products/steelpan.webp create mode 100644 demo/public/products/suit.webp create mode 100644 demo/public/products/suitecase.webp create mode 100644 demo/public/products/tabel.webp create mode 100644 demo/public/products/toothbrush.webp create mode 100644 demo/public/products/tshirt.webp create mode 100644 demo/public/products/tuna.webp create mode 100644 demo/public/products/vitamin.webp create mode 100644 demo/public/products/vitaminc.webp diff --git a/demo/public/products/apples.webp b/demo/public/products/apples.webp new file mode 100644 index 0000000000000000000000000000000000000000..162c984ed4621115248863bef5b6e9ce48c2a272 GIT binary patch literal 23968 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 literal 0 HcmV?d00001 diff --git a/demo/public/products/babystroller.webp b/demo/public/products/babystroller.webp new file mode 100644 index 0000000000000000000000000000000000000000..3223549fb76578bf3ce9ae4bb15629946247c89d GIT binary patch literal 7988 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 literal 0 HcmV?d00001 diff --git a/demo/public/products/boots.webp b/demo/public/products/boots.webp new file mode 100644 index 0000000000000000000000000000000000000000..8e5f7fba6b28b9b6f67bf02b36e8deeeb9490a58 GIT binary patch literal 14368 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 AUH||9 literal 0 HcmV?d00001 diff --git a/demo/public/products/cleanser.webp b/demo/public/products/cleanser.webp new file mode 100644 index 0000000000000000000000000000000000000000..5bcdae5c273645d21a92866cc2a7d0fc239dd0c8 GIT binary patch literal 8988 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 Tt}W5isaHRAQYJ*6sDOU~8BzsS literal 0 HcmV?d00001 diff --git a/demo/public/products/coffee.webp b/demo/public/products/coffee.webp new file mode 100644 index 0000000000000000000000000000000000000000..33d84a0bdce8570f0e91d89dd977c4bf74695e5b GIT binary patch literal 39630 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