diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index ca1ee5c..ab6d65a 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -92,7 +92,7 @@ jobs: docker-tests: name: Docker Tests runs-on: ubuntu-latest - environment: development + environment: Production if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v4 @@ -108,39 +108,56 @@ jobs: curl -SL https://github.com/docker/compose/releases/download/v2.23.3/docker-compose-linux-x86_64 -o $DOCKER_CONFIG/cli-plugins/docker-compose chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose - - name: Configure environment + - name: Create env file run: | - # Create .env file - cat << EOF > .env - MONGODB_URI=${{ secrets.MONGODB_URI }} - AUTH0_CLIENT_ID=${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_ISSUER_BASE_URL=${{ secrets.AUTH0_ISSUER_BASE_URL }} - AUTH0_CLIENT_SECRET=${{ secrets.AUTH0_CLIENT_SECRET }} - EOF + echo "MONGODB_URI=${{ secrets.MONGODB_URI }}" >> .env + echo "DB_NAME=tapiro" >> .env + echo "AUTH0_SPA_CLIENT_ID=${{ secrets.AUTH0_SPA_CLIENT_ID }}" >> .env + echo "AUTH0_ISSUER_BASE_URL=${{ secrets.AUTH0_ISSUER_BASE_URL }}" >> .env + echo "AUTH0_TOKEN_URL=${{ secrets.AUTH0_TOKEN_URL }}" >> .env + echo "AUTH0_AUTHORIZE_URL=${{ secrets.AUTH0_AUTHORIZE_URL }}" >> .env + echo "AUTH0_AUDIENCE=${{ secrets.AUTH0_AUDIENCE }}" >> .env + echo "AUTH0_MANAGEMENT_API_TOKEN=${{ secrets.AUTH0_MANAGEMENT_API_TOKEN }}" >> .env + echo "AUTH0_USER_ROLE_ID=${{ secrets.AUTH0_USER_ROLE_ID }}" >> .env + echo "AUTH0_STORE_ROLE_ID=${{ secrets.AUTH0_STORE_ROLE_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_ID=${{ secrets.AUTH0_M2M_CLIENT_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_SECRET=${{ secrets.AUTH0_M2M_CLIENT_SECRET }}" >> .env + echo "AI_SERVICE_API_KEY=${{ secrets.AI_SERVICE_API_KEY }}" >> .env + echo "AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN }}" >> .env + echo "ALLOWED_ORIGINS=http://localhost:5174" >> .env + # Variables for compose.yml that are not secrets but good to have in .env for consistency + echo "REDIS_HOST=redis" >> .env + echo "REDIS_PORT=6379" >> .env + echo "BASE_URL=http://localhost:3000" >> .env + echo "FRONTEND_URL=http://localhost:5173" >> .env + echo "AI_SERVICE_URL=http://ml-service:8000/api" >> .env + echo "EXTERNAL_API_URL=http://tapiro-api-external:3001" >> .env + echo "API_BASE_URL=http://tapiro-api-internal:3000" >> .env # For ml-service + echo "VITE_API_URL=http://localhost:3000" >> .env # For web + echo "VITE_STORE_API_URL=http://localhost:3001" >> .env # For demo-store - name: Build containers - env: - MONGODB_URI: ${{ secrets.MONGODB_URI }} - AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_ISSUER_BASE_URL: ${{ secrets.AUTH0_ISSUER_BASE_URL }} - AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} - run: | - docker compose -f compose.yml build - docker compose -f compose.yml config + run: docker compose -f compose.yml build + # No explicit env block needed here if .env file is comprehensive - name: Test container health run: | docker compose -f compose.yml up -d echo "Waiting for containers to start..." - sleep 30 + sleep 30 # Adjust sleep time if services take longer to start - # Simple container status check - RUNNING_CONTAINERS=$(docker compose ps | grep -c "Up") - if [ "${RUNNING_CONTAINERS}" -eq 3 ]; then + # Check status of all services defined in compose.yml + SERVICES_COUNT=$(docker compose -f compose.yml config --services | wc -l) + RUNNING_CONTAINERS=$(docker compose -f compose.yml ps --services --filter "status=running" | wc -l) + + echo "Expected services: $SERVICES_COUNT" + echo "Running containers: $RUNNING_CONTAINERS" + + if [ "${RUNNING_CONTAINERS}" -eq "${SERVICES_COUNT}" ]; then echo "All containers are running" - docker ps + docker compose ps else - echo "Container startup failed" + echo "Not all containers started successfully." docker compose ps docker compose logs exit 1 diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml index 7a13c3c..a256a54 100644 --- a/.github/workflows/ci-prod.yml +++ b/.github/workflows/ci-prod.yml @@ -104,31 +104,53 @@ jobs: - name: Create env file run: | echo "MONGODB_URI=${{ secrets.MONGODB_URI }}" >> .env - echo "AUTH0_CLIENT_ID=${{ secrets.AUTH0_CLIENT_ID }}" >> .env + echo "DB_NAME=tapiro" >> .env + echo "AUTH0_SPA_CLIENT_ID=${{ secrets.AUTH0_SPA_CLIENT_ID }}" >> .env echo "AUTH0_ISSUER_BASE_URL=${{ secrets.AUTH0_ISSUER_BASE_URL }}" >> .env - echo "AUTH0_CLIENT_SECRET=${{ secrets.AUTH0_CLIENT_SECRET }}" >> .env + echo "AUTH0_TOKEN_URL=${{ secrets.AUTH0_TOKEN_URL }}" >> .env + echo "AUTH0_AUTHORIZE_URL=${{ secrets.AUTH0_AUTHORIZE_URL }}" >> .env + echo "AUTH0_AUDIENCE=${{ secrets.AUTH0_AUDIENCE }}" >> .env + echo "AUTH0_MANAGEMENT_API_TOKEN=${{ secrets.AUTH0_MANAGEMENT_API_TOKEN }}" >> .env + echo "AUTH0_USER_ROLE_ID=${{ secrets.AUTH0_USER_ROLE_ID }}" >> .env + echo "AUTH0_STORE_ROLE_ID=${{ secrets.AUTH0_STORE_ROLE_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_ID=${{ secrets.AUTH0_M2M_CLIENT_ID }}" >> .env + echo "AUTH0_M2M_CLIENT_SECRET=${{ secrets.AUTH0_M2M_CLIENT_SECRET }}" >> .env + echo "AI_SERVICE_API_KEY=${{ secrets.AI_SERVICE_API_KEY }}" >> .env + echo "AUTH0_DOMAIN=${{ secrets.AUTH0_DOMAIN }}" >> .env + echo "ALLOWED_ORIGINS=http://localhost:5174" >> .env + # Variables for compose.yml that are not secrets but good to have in .env for consistency + echo "REDIS_HOST=redis" >> .env + echo "REDIS_PORT=6379" >> .env + echo "BASE_URL=http://localhost:3000" >> .env + echo "FRONTEND_URL=http://localhost:5173" >> .env + echo "AI_SERVICE_URL=http://ml-service:8000/api" >> .env + echo "EXTERNAL_API_URL=http://tapiro-api-external:3001" >> .env + echo "API_BASE_URL=http://tapiro-api-internal:3000" >> .env # For ml-service + echo "VITE_API_URL=http://localhost:3000" >> .env # For web + echo "VITE_STORE_API_URL=http://localhost:3001" >> .env # For demo-store - name: Build containers run: docker compose -f compose.yml build - env: - MONGODB_URI: ${{ secrets.MONGODB_URI }} - AUTH0_CLIENT_ID: ${{ secrets.AUTH0_CLIENT_ID }} - AUTH0_ISSUER_BASE_URL: ${{ secrets.AUTH0_ISSUER_BASE_URL }} - AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} + # No explicit env block needed here if .env file is comprehensive - name: Test container health run: | docker compose -f compose.yml up -d echo "Waiting for containers to start..." - sleep 30 + sleep 30 # Adjust sleep time if services take longer to start + + # Check status of all services defined in compose.yml + SERVICES_COUNT=$(docker compose -f compose.yml config --services | wc -l) + RUNNING_CONTAINERS=$(docker compose -f compose.yml ps --services --filter "status=running" | wc -l) - # Simple container status check - RUNNING_CONTAINERS=$(docker compose ps | grep -c "Up") - if [ "${RUNNING_CONTAINERS}" -eq 3 ]; then + echo "Expected services: $SERVICES_COUNT" + echo "Running containers: $RUNNING_CONTAINERS" + + if [ "${RUNNING_CONTAINERS}" -eq "${SERVICES_COUNT}" ]; then echo "All containers are running" - docker ps + docker compose ps else - echo "Container startup failed" + echo "Not all containers started successfully." docker compose ps docker compose logs exit 1 diff --git a/demo/src/App.tsx b/demo/src/App.tsx index b233477..f957d25 100644 --- a/demo/src/App.tsx +++ b/demo/src/App.tsx @@ -19,6 +19,127 @@ interface UserPreferences { updatedAt: string; } +// --- NEW: Define types for simulated purchase data --- +interface SimulatedPurchaseItem { + sku: string; + name: string; + category: string; + price: number; + quantity: number; + attributes: Record; // Or a more specific type if attributes have a consistent structure +} + +interface SimulatedPurchaseEntry { + timestamp: string; // ISO 8601 format + items: SimulatedPurchaseItem[]; + totalValue: number; +} +// --- END NEW: Define types --- + +// Simple map for category IDs to names for the demo +const categoryNameMap: Record = { + "100": "Electronics", + "101": "Mobile Phones", + "102": "Laptops", + "103": "Tablets", + "104": "Wearables", + "105": "Audio Devices", + "200": "Fashion", + "201": "Apparel", + "202": "Footwear", + "300": "Home Goods", + "301": "Furniture", + "302": "Kitchenware", + "303": "Gardening", + "304": "Home Improvement", + "400": "Beauty & Personal Care", + "401": "Skincare", + "402": "Makeup", + "500": "Media", + "501": "Books", + "502": "Movies & Music", + "600": "Health & Wellness", + "700": "Toys & Kids", + "701": "Baby Gear", + "702": "Kids Clothing", + "800": "Office Supplies", + "900": "Gaming", + "1100": "Grocery", + "1101": "Pantry Goods", + "1200": "Jewelry & Watches", + "1300": "Gifts", + "1400": "Software", +}; + +// --- NEW: Products designed to trigger demographic inference --- +const demographicTriggerProducts: Product[] = [ + { + id: "sim-p1", + name: "Organic Baby Food Variety Pack", + price: 25, + imageUrl: "/products/apples.webp", // Placeholder image + categoryId: "701", // Baby Gear + attributes: { type: "food", dietary_preference: "organic" }, + description: "A selection of organic purees for infants.", + }, + { + id: "sim-p2", + name: "Luxury Wedding Anniversary Gift Basket", + price: 75, + imageUrl: "/products/giftbasket.webp", // Placeholder image + categoryId: "1300", // Gifts + attributes: { occasion: "anniversary", recipient: "couple" }, + description: "Perfect for celebrating a wedding anniversary.", + }, + { + id: "sim-p3", + name: "University Student Textbook: Advanced Statistics", + price: 120, + imageUrl: "/products/fictionnoval.webp", // Placeholder image + categoryId: "501", // Books + attributes: { genre: "academic", subject: "statistics" }, + description: "Required textbook for university-level statistics course.", + }, + { + id: "sim-p4", + name: "Men's Classic Leather Wallet", + price: 45, + imageUrl: "/products/tshirt.webp", // Placeholder image, replace with actual if available + categoryId: "201", // Apparel (could be accessories) + attributes: { type: "wallet", gender: "men", material: "leather" }, + description: "A stylish and durable leather wallet for men.", + }, + { + id: "sim-p5", + name: "Women's Floral Print Summer Dress", + price: 60, + imageUrl: "/products/tshirt.webp", // Placeholder image, replace with actual if available + categoryId: "201", // Apparel + attributes: { type: "dress", gender: "women", season: "summer" }, + description: "A light and airy floral dress for women.", + }, + { + id: "sim-p6", + name: "Professional Business Laptop Bag", + price: 80, + imageUrl: "/products/suitecase.webp", // Placeholder image + categoryId: "800", // Office Supplies (or a more specific category if available) + attributes: { type: "bag", use: "business", material: "nylon" }, + description: + "A durable and professional bag for carrying laptops and documents.", + }, + { + id: "sim-p7", + name: "Newborn Baby Essentials Set (Diapers, Wipes, Onesies)", + price: 55, + imageUrl: "/products/genpens.webp", // Placeholder image + categoryId: "701", // Baby Gear + attributes: { type: "newborn_set", contents: "diapers_wipes_onesies" }, + description: "A complete starter set for a newborn baby.", + }, +]; +// --- END NEW: Products --- + function App() { const [userEmail, setUserEmail] = useState(null); const [apiKey, setApiKey] = useState(null); // State for API Key @@ -31,6 +152,7 @@ function App() { useState(sampleProducts); // Products to show (filtered/sorted) const [preferences, setPreferences] = useState(null); const [isLoadingPrefs, setIsLoadingPrefs] = useState(false); + const [isSimulatingData, setIsSimulatingData] = useState(false); // --- NEW: Loading state for simulation --- const [apiError, setApiError] = useState(null); const [apiSuccessMessage, setApiSuccessMessage] = useState( null @@ -90,9 +212,18 @@ function App() { // Sort by preference score if preferences are loaded if (preferences && preferences.length > 0) { const getScore = (product: Product): number => { - const categoryPreference = preferences.find( - (p) => p.category === product.categoryId - ); + const categoryPreference = preferences.find((p) => { + const prefCat = p.category; + const prodCat = product.categoryId; + // Match if exact, or if prefCat is a prefix of prodCat AND + // (lengths are same OR the char in prodCat after prefCat prefix is not '0' - heuristic for demo) + return ( + prodCat.startsWith(prefCat) && + (prodCat.length === prefCat.length || + (prefCat.length < prodCat.length && + prodCat.charAt(prefCat.length) !== "0")) + ); + }); if (!categoryPreference) { return 0; // No preference for this category @@ -175,13 +306,188 @@ function App() { }; const handleProductClick = (product: Product) => { - console.log(`Product clicked: ${product.name}`); // Placeholder - // Submit view data if user and key are set + console.log(`Product clicked (View): ${product.name}`); if (userEmail && apiKey) { - submitInteractionData(userEmail, "view", product); // Using 'view' as dataType + submitInteractionData(userEmail, "view", product); } }; + const handlePurchaseClick = (product: Product) => { + console.log(`Product purchased: ${product.name}`); + if (userEmail && apiKey) { + submitInteractionData(userEmail, "purchase", product); + } + }; + + // --- NEW: Handler for Simulate Bulk Data --- + const handleSimulateBulkData = async () => { + if (!userEmail || !apiKey) { + setApiError("Please set User Email and API Key before simulating data."); + if (!apiKey) setIsApiKeyModalOpen(true); + else if (!userEmail) setIsEmailModalOpen(true); + return; + } + + setIsSimulatingData(true); + setApiSuccessMessage(null); + setApiError(null); + + const allPurchaseEntries: SimulatedPurchaseEntry[] = []; + const numMonths = 6; + const purchasesPerWeekMin = 1; + const purchasesPerWeekMax = 3; + const itemsPerPurchaseMin = 1; + const itemsPerPurchaseMax = 3; + const today = new Date(); + + // --- Define focused product pools --- + const kidsTriggerProducts = demographicTriggerProducts.filter( + (p) => ["sim-p1", "sim-p7"].includes(p.id) // Organic Baby Food, Newborn Essentials + ); + const marriedTriggerProducts = demographicTriggerProducts.filter( + (p) => p.id === "sim-p2" // Luxury Wedding Anniversary Gift Basket + ); + const maleTriggerProducts = demographicTriggerProducts.filter( + (p) => p.id === "sim-p4" // Men's Classic Leather Wallet + ); + const femaleTriggerProducts = demographicTriggerProducts.filter( + (p) => p.id === "sim-p5" // Women's Floral Print Summer Dress + ); + + const highlyTargetedProducts = Array.from( + new Set([ + ...kidsTriggerProducts, + ...marriedTriggerProducts, + ...maleTriggerProducts, + ...femaleTriggerProducts, + ]) + ); + + // Products for general variety, excluding those already in highlyTargetedProducts + const varietyPool = [ + ...sampleProducts, + ...demographicTriggerProducts.filter( + (p) => !highlyTargetedProducts.find((ht) => ht.id === p.id) + ), + ]; + + // Fallback pool, same as original + const combinedProductPool = [ + ...sampleProducts, + ...demographicTriggerProducts, + ]; + // --- End focused product pools --- + + for (let week = 0; week < numMonths * 4; week++) { + const purchasesThisWeek = + Math.floor( + Math.random() * (purchasesPerWeekMax - purchasesPerWeekMin + 1) + ) + purchasesPerWeekMin; + for (let p = 0; p < purchasesThisWeek; p++) { + const simDate = new Date(today); + simDate.setDate( + today.getDate() - week * 7 - Math.floor(Math.random() * 7) + ); // Random day within the target week + simDate.setHours( + Math.floor(Math.random() * 24), + Math.floor(Math.random() * 60), + Math.floor(Math.random() * 60) + ); + + const purchaseItems: SimulatedPurchaseItem[] = []; + let totalValue = 0; + const numItems = + Math.floor( + Math.random() * (itemsPerPurchaseMax - itemsPerPurchaseMin + 1) + ) + itemsPerPurchaseMin; + + for (let i = 0; i < numItems; i++) { + let productToPurchase: Product; + const randomChoice = Math.random(); + + // 70% chance to pick a product focused on kids, marriage, or gender + if (randomChoice < 0.7 && highlyTargetedProducts.length > 0) { + productToPurchase = + highlyTargetedProducts[ + Math.floor(Math.random() * highlyTargetedProducts.length) + ]; + } + // 30% chance for a product from the general variety pool + else { + if (varietyPool.length > 0) { + productToPurchase = + varietyPool[Math.floor(Math.random() * varietyPool.length)]; + } else if (highlyTargetedProducts.length > 0) { + // Fallback if variety pool is empty + productToPurchase = + highlyTargetedProducts[ + Math.floor(Math.random() * highlyTargetedProducts.length) + ]; + } else if (combinedProductPool.length > 0) { + // Absolute fallback + productToPurchase = + combinedProductPool[ + Math.floor(Math.random() * combinedProductPool.length) + ]; + } else { + console.error("CRITICAL: No products available for simulation!"); + // If this happens, the simulation might generate empty purchases or fail. + // Consider adding a default placeholder product or stopping simulation. + // For now, we'll let it proceed, but it indicates a setup issue. + continue; // Skip this item if no product can be selected + } + } + + purchaseItems.push({ + sku: productToPurchase.id, + name: productToPurchase.name, + category: productToPurchase.categoryId, + price: productToPurchase.price, + quantity: 1, + attributes: productToPurchase.attributes || {}, + }); + totalValue += productToPurchase.price; + } + + if (purchaseItems.length > 0) { + allPurchaseEntries.push({ + timestamp: simDate.toISOString(), + items: purchaseItems, + totalValue: totalValue, + }); + } + } + } + + if (allPurchaseEntries.length === 0) { + setApiError("No purchase entries generated for simulation."); + setIsSimulatingData(false); + return; + } + + const payload = { + email: userEmail, + dataType: "purchase", + entries: allPurchaseEntries, + metadata: { source: "demo-bulk-simulation" }, + }; + + console.log(`Simulating ${allPurchaseEntries.length} purchase entries...`); + const result = await makeApiCall("/users/data", "POST", payload, true); + + if (result.success) { + setApiSuccessMessage( + `Successfully submitted ${allPurchaseEntries.length} simulated purchase entries. Analytics and demographics will update shortly.` + ); + // Optionally, trigger a refetch of preferences or analytics data + // await fetchPreferences(userEmail); + } else { + // Error is set by makeApiCall + } + setIsSimulatingData(false); + }; + // --- END NEW: Handler --- + // --- API Call Functions --- const makeApiCall = async ( endpoint: string, @@ -267,6 +573,7 @@ function App() { const prefsData = result.data as UserPreferences; setPreferences(prefsData.preferences || []); } else { + // Error is set by makeApiCall setPreferences(null); // Clear prefs on error } }; @@ -357,9 +664,16 @@ function App() { if (preferences && preferences.length > 0 && displayedProducts.length > 0) { // Get scores for all currently displayed products const productScores = displayedProducts.map((product) => { - const categoryPreference = preferences.find( - (p) => p.category === product.categoryId - ); + const categoryPreference = preferences.find((p) => { + const prefCat = p.category; + const prodCat = product.categoryId; + return ( + prodCat.startsWith(prefCat) && + (prodCat.length === prefCat.length || + (prefCat.length < prodCat.length && + prodCat.charAt(prefCat.length) !== "0")) + ); + }); if (!categoryPreference) return { id: product.id, score: 0 }; let score = categoryPreference.score; @@ -430,88 +744,249 @@ function App() { onSubmit={handleEmailSubmit} /> -
-
-

+ {/* Header Area */} +
+
+

Tapiro Demo Store

- {/* User and API Key Info */} -
-
- API Key: {displayApiKey} - -
-
- {userEmail ? `Simulating as: ${userEmail}` : "User Email Not Set"} - {userEmail && ( +
+ + User: {userEmail || "Not Set"} + + + API Key: {displayApiKey} + + + +
+
+
+ + {/* Main Content Area */} +
+ {/* API Messages */} + {apiError && ( +
+ Error: {apiError} +
+ )} + {apiSuccessMessage && ( +
+ Success: {apiSuccessMessage} +
+ )} + + {/* --- NEW: Simulate Data Button --- */} +
+ +
+ {/* --- END NEW: Simulate Data Button --- */} + + + + {isLoadingPrefs && !preferences && ( +
+ +
+ )} + + {/* User Info & Preferences Display Area */} + {(userEmail || apiKey) && ( +
+
+
+

+ Demo Store +

+ {userEmail && ( +

+ User: {userEmail} +

+ )} +

+ API Key:{" "} + {displayApiKey} +

+
+
+ - )} - {!userEmail && - apiKey && ( // Show button to set email if key is set but email isn't - - )} +
-
-
- {" "} - {/* Ensure search bar wraps correctly */} - -
-

- {/* Display API Status Messages */} - {apiError && ( -
- {apiError} + {apiError && ( +
+ Error: {apiError} +
+ )} + {apiSuccessMessage && ( +
+ Success: {apiSuccessMessage} +
+ )}
)} - {apiSuccessMessage && ( -
- {apiSuccessMessage} + + {/* Preferences Display - only if preferences exist */} + {preferences && preferences.length > 0 && ( +
+

+ Your Inferred Preferences +

+
    + {preferences + .filter((p) => p.score > 0.1) // Only show relevant preferences + .sort((a, b) => b.score - a.score) // Sort by score desc + .slice(0, 10) // Show top 10 + .map((pref) => { + const categoryName = + categoryNameMap[pref.category] || pref.category; + return ( +
  • +
    + + {categoryName} + + 0.65 + ? "bg-green-100 text-green-700 dark:bg-green-700 dark:text-green-100" + : pref.score > 0.35 + ? "bg-yellow-100 text-yellow-700 dark:bg-yellow-600 dark:text-yellow-100" + : "bg-red-100 text-red-700 dark:bg-red-700 dark:text-red-100" + }`} + > + Score: {(pref.score * 100).toFixed(0)}% + +
    + {pref.attributes && + Object.keys(pref.attributes).length > 0 && ( +
    +

    + Attribute Preferences: +

    +
      + {Object.entries(pref.attributes).map( + ([attrKey, attrValueObj]) => ( +
    • + {attrKey}:{" "} + {Object.entries(attrValueObj) + .map( + ([val, score]) => + `${val} (${(score * 100).toFixed( + 0 + )}%)` + ) + .join(", ")} +
    • + ) + )} +
    +
    + )} +
  • + ); + })} +
)} -
-
{/* Product List Area */}

{searchQuery ? `Search Results for "${searchQuery}"` : "Products"} {isLoadingPrefs && apiKey && - userEmail && ( // Only show loading if key and email are set - - (Loading Preferences...) + userEmail && ( // Only show loading if API key and email are set + + (Loading preferences...) )} - {(!apiKey || !userEmail) && ( // Show message if key or email is missing - - (Set API Key and User Email to see personalized results) - - )}

+
diff --git a/demo/src/components/ProductCard.tsx b/demo/src/components/ProductCard.tsx index cddb345..cbb3ceb 100644 --- a/demo/src/components/ProductCard.tsx +++ b/demo/src/components/ProductCard.tsx @@ -3,13 +3,17 @@ import { Product } from "../data/products"; // Make sure path is correct interface ProductCardProps { product: Product; onProductClick?: (product: Product) => void; // Handler for clicks + onPurchaseClick?: (product: Product) => void; // Handler for purchase clicks isRecommended?: boolean; // Optional flag for highlighting + categoryNameMap: Record; // Add categoryNameMap prop } export function ProductCard({ product, onProductClick, + onPurchaseClick, isRecommended, + categoryNameMap, // Destructure categoryNameMap }: ProductCardProps) { const handleCardClick = () => { if (onProductClick) { @@ -17,6 +21,13 @@ export function ProductCard({ } }; + const handlePurchase = (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click event from firing + if (onPurchaseClick) { + onPurchaseClick(product); + } + }; + return (

- Category: {product.categoryId} + Category:{" "} + {categoryNameMap[product.categoryId] || product.categoryId}

{product.description && (

@@ -54,6 +66,14 @@ export function ProductCard({

${product.price.toFixed(2)}

+ {onPurchaseClick && ( + + )}
); diff --git a/demo/src/components/ProductList.tsx b/demo/src/components/ProductList.tsx index 08fce40..3e2d826 100644 --- a/demo/src/components/ProductList.tsx +++ b/demo/src/components/ProductList.tsx @@ -4,13 +4,17 @@ import { ProductCard } from "./ProductCard"; // Import ProductCard interface ProductListProps { products: Product[]; onProductClick?: (product: Product) => void; // Pass click handler down + onPurchaseClick?: (product: Product) => void; // Pass purchase click handler down recommendedProductIds?: Set; // Set of IDs to highlight + categoryNameMap: Record; // Add categoryNameMap prop } export function ProductList({ products, onProductClick, + onPurchaseClick, recommendedProductIds = new Set(), + categoryNameMap, // Destructure categoryNameMap }: ProductListProps) { if (!products || products.length === 0) { return ( @@ -27,7 +31,9 @@ export function ProductList({ key={product.id} product={product} onProductClick={onProductClick} + onPurchaseClick={onPurchaseClick} isRecommended={recommendedProductIds.has(product.id)} + categoryNameMap={categoryNameMap} // Pass categoryNameMap to ProductCard /> ))} diff --git a/ml-service/app/services/demographicInference.py b/ml-service/app/services/demographicInference.py index b20914f..c0c4515 100644 --- a/ml-service/app/services/demographicInference.py +++ b/ml-service/app/services/demographicInference.py @@ -397,15 +397,30 @@ def check_and_set(field_name: str, inferred_value: Any): logger.info(f"Inference: Successfully updated inferred demographic data for user {user_id}") # --- Invalidate Caches --- auth0_id = user.get("auth0Id") + email = user.get("email") # Ensure email is available + if auth0_id: await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") # Invalidate prefs as demographics changed + # Invalidate store-specific caches if opt-in stores exist - if user.get("privacySettings", {}).get("optInStores"): + if email and user.get("privacySettings", {}).get("optInStores"): # Check if email is available + for store_id in user["privacySettings"]["optInStores"]: + # Use email for STORE_PREFERENCES cache key consistency + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Inference: Invalidated STORE_PREFERENCES for user {email} (Auth0 ID: {auth0_id}) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Inference: Cannot invalidate STORE_PREFERENCES for user {auth0_id} as email is missing from user object.") + logger.info(f"Inference: Invalidated USER_DATA and PREFERENCES caches for user {auth0_id}") + else: + logger.warning(f"Inference: Cannot invalidate USER_DATA/PREFERENCES caches for user {email} as auth0Id is missing.") + # Attempt to invalidate STORE_PREFERENCES with email if available + if email and user.get("privacySettings", {}).get("optInStores"): for store_id in user["privacySettings"]["optInStores"]: - # Use user_id (ObjectId string) for store cache key consistency - await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_id}:{store_id}") - logger.info(f"Inference: Invalidated relevant caches for user {auth0_id}") + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Inference: Invalidated STORE_PREFERENCES for user {email} (auth0Id missing) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Inference: Cannot invalidate STORE_PREFERENCES for user as email is missing and auth0Id is missing.") # --- End Cache Invalidation --- else: logger.warning(f"Inference: Update attempted for {user_id} but no documents were modified.") diff --git a/ml-service/app/services/preferenceProcessor.py b/ml-service/app/services/preferenceProcessor.py index ea1a3fa..3616a95 100644 --- a/ml-service/app/services/preferenceProcessor.py +++ b/ml-service/app/services/preferenceProcessor.py @@ -492,17 +492,31 @@ async def process_user_data(data: UserDataEntry, db) -> UserPreferences: logger.info(f"Skipping demographic inference for user {email} ({user_id}) as allowInference is False.") auth0_id = user.get("auth0Id") + email = user.get("email") # Ensure email is fetched + if auth0_id: logger.info(f"Running post-processing cache invalidation for user {auth0_id}.") await invalidate_cache(f"{CACHE_KEYS['USER_DATA']}{auth0_id}") await invalidate_cache(f"{CACHE_KEYS['PREFERENCES']}{auth0_id}") logger.info(f"Invalidated USER_DATA and PREFERENCES caches for user {auth0_id} (post-processing)") - if user.get("privacySettings", {}).get("optInStores"): + + if email and user.get("privacySettings", {}).get("optInStores"): # Check if email is available for store_id in user["privacySettings"]["optInStores"]: - await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{user_id}:{store_id}") - logger.info(f"Invalidated STORE_PREFERENCES for user {auth0_id} for {len(user['privacySettings']['optInStores'])} stores.") + # Use email for STORE_PREFERENCES cache key + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Invalidated STORE_PREFERENCES for user {email} (Auth0 ID: {auth0_id}) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Cannot invalidate STORE_PREFERENCES for user {auth0_id} as email is missing from user object.") + else: - logger.warning(f"Cannot invalidate caches for user {email} as auth0Id is missing.") + logger.warning(f"Cannot invalidate USER_DATA/PREFERENCES caches for user {email} as auth0Id is missing.") + # Attempt to invalidate STORE_PREFERENCES with email if available, even if auth0Id is missing for other caches + if email and user.get("privacySettings", {}).get("optInStores"): + for store_id in user["privacySettings"]["optInStores"]: + await invalidate_cache(f"{CACHE_KEYS['STORE_PREFERENCES']}{email}:{store_id}") + logger.info(f"Invalidated STORE_PREFERENCES for user {email} (auth0Id missing) for {len(user['privacySettings']['optInStores'])} stores.") + elif not email and user.get("privacySettings", {}).get("optInStores"): + logger.warning(f"Cannot invalidate STORE_PREFERENCES for user as email is missing and auth0Id is missing.") return UserPreferences( user_id=user_id, diff --git a/ml-service/app/utils/redis_util.py b/ml-service/app/utils/redis_util.py index 6db4443..1e1a8b2 100644 --- a/ml-service/app/utils/redis_util.py +++ b/ml-service/app/utils/redis_util.py @@ -2,6 +2,7 @@ import os import json import logging +from typing import Optional # Add this import from app.core.config import settings # Configure logging @@ -84,7 +85,7 @@ async def get_cache(key: str): logger.error(f"Error getting cache {prefixed_key}: {e}") return None -async def set_cache(key: str, value: str, options: dict = None): +async def set_cache(key: str, value: str, options: Optional[dict] = None): """ Set value in cache with options """ @@ -93,11 +94,10 @@ async def set_cache(key: str, value: str, options: dict = None): if not await ensure_connection(): return False - if options is None: - options = {} + current_options = options if options is not None else {} # Handle expiration time - ex = options.get("EX", None) + ex = current_options.get("EX", None) if ex: redis_client.set(prefixed_key, value, ex=ex) @@ -150,7 +150,7 @@ async def get_cache_json(key: str): logger.error(f"Error decoding JSON from cache {key}: {e}") return None -async def set_cache_json(key: str, value, options: dict = None): +async def set_cache_json(key: str, value, options: Optional[dict] = None): """Set JSON value in cache with options""" try: # Convert numpy values to Python native types diff --git a/tapiro-api-external/api/openapi.yaml b/tapiro-api-external/api/openapi.yaml index c0384bb..705da14 100644 --- a/tapiro-api-external/api/openapi.yaml +++ b/tapiro-api-external/api/openapi.yaml @@ -320,13 +320,6 @@ components: 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: diff --git a/tapiro-api-external/service/StoreOperationsService.js b/tapiro-api-external/service/StoreOperationsService.js index ebe1dc6..0edf0a7 100644 --- a/tapiro-api-external/service/StoreOperationsService.js +++ b/tapiro-api-external/service/StoreOperationsService.js @@ -66,6 +66,12 @@ exports.getUserPreferences = async function (req, userId) { $set: { updatedAt: new Date() }, }, ); + // ---- ADD INVALIDATION ---- + if (user.auth0Id) { + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${user.auth0Id}`); + console.log(`Invalidated USER_STORE_CONSENT cache for user ${user.auth0Id} (Auth0 ID) due to auto opt-in by store ${req.storeId} in getUserPreferences.`); + } + // ---- END INVALIDATION ---- } // Prepare user preferences @@ -155,6 +161,12 @@ exports.submitUserData = async function (req, body) { $set: { updatedAt: new Date() }, }, ); + // ---- ADD INVALIDATION ---- + if (user.auth0Id) { + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${user.auth0Id}`); + console.log(`Invalidated USER_STORE_CONSENT cache for user ${user.auth0Id} (Auth0 ID) due to auto opt-in by store ${req.storeId} in submitUserData.`); + } + // ---- END INVALIDATION ---- } // Process entries to ensure proper data types @@ -202,7 +214,8 @@ exports.submitUserData = async function (req, body) { const insertedId = result.insertedId; // Get the ID of the inserted document // Invalidate the preferences cache - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${req.storeId}`); + // Use 'email' for STORE_PREFERENCES key to match how it's set in getUserPreferences + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${email}:${req.storeId}`); await invalidateCache(`${CACHE_KEYS.PREFERENCES}${user.auth0Id}`); // Call AI service but DO NOT await it. diff --git a/tapiro-api-external/utils/cacheConfig.js b/tapiro-api-external/utils/cacheConfig.js index 8d19004..c25c0d3 100644 --- a/tapiro-api-external/utils/cacheConfig.js +++ b/tapiro-api-external/utils/cacheConfig.js @@ -25,6 +25,7 @@ const CACHE_KEYS = { STORE_PREFERENCES: 'prefs:', // Store preferences TAXONOMY: 'taxonomy:current', // <-- Add this line AI_REQUEST: 'ai_request:', // AI service request cache + USER_STORE_CONSENT: 'userconsent:', // <-- ADD THIS LINE }; module.exports = { CACHE_TTL, CACHE_KEYS }; diff --git a/tapiro-api-internal/api/openapi.yaml b/tapiro-api-internal/api/openapi.yaml index 3ceb8b7..95f5db7 100644 --- a/tapiro-api-internal/api/openapi.yaml +++ b/tapiro-api-internal/api/openapi.yaml @@ -801,6 +801,35 @@ paths: - oauth2: [user:read] x-swagger-router-controller: StoreProfile + /users/data/history: + delete: + tags: [User Management] + summary: Delete User Data History + description: Allows authenticated users to delete their submitted data history based on a scope or specific entry IDs. + operationId: deleteUserDataHistory + security: + - oauth2: [user:write] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserDataHistoryDeletionRequest" + responses: + "204": + description: User data history deleted successfully. + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalServerError" + x-swagger-router-controller: UserProfile + components: schemas: AttributeDistribution: @@ -1052,10 +1081,15 @@ components: prefer_not_to_say, null, ] - # REMOVED verification flags from update payload ApiKey: type: object + required: + - keyId + - prefix + - name + - createdAt + - status properties: keyId: type: string @@ -1075,177 +1109,6 @@ components: type: string enum: [active, revoked] - UserData: - type: object - required: - - email - - dataType # Make dataType required - - entries - properties: - email: - type: string - 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: Specifies the type of data contained in the 'entries' array. - entries: - type: array - description: > - List of data entries. Each entry must conform to either the PurchaseEntry - or SearchEntry schema, matching the top-level 'dataType'. - items: - oneOf: # Use oneOf to specify possible entry types - - $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 metadata about the collection event (e.g., source, device). - properties: - source: - type: string - description: Source of the data (e.g., 'web', 'mobile_app', 'pos'). - deviceType: - type: string - description: Type of device used (e.g., 'desktop', 'mobile', 'tablet'). - sessionId: - type: string - 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 - 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. - 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 # 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 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: > - 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 - 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 (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 properties: @@ -1343,12 +1206,31 @@ components: type: string enum: [purchase, opt-out] + UserDataHistoryDeletionRequest: + type: object + required: + - scope + properties: + scope: + type: string + enum: [today, last7days, all, individual] + description: "Scope of data to delete. If 'individual', entryIds must be provided." + entryIds: + type: array + items: + type: string + description: "Array of specific userData entry IDs to delete. Required if scope is 'individual'." + nullable: true + ApiKeyCreate: type: object + required: + - name properties: name: type: string description: Name for the API key + minLength: 1 ApiKeyList: type: array @@ -1630,6 +1512,83 @@ components: description: "Inferred: User gender identity (null if unknown or user provided)" enum: [male, female, non-binary, null] + PurchaseItem: # Added + 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: {} # Allows values of any type, aligning with potential data structures + + PurchaseEntry: # Added + 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 + nullable: true + description: Optional total value of the purchase event. + + SearchEntry: # Added + 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 + nullable: true + description: Optional category context provided during the search. + results: + type: integer + nullable: true + description: Optional number of results returned for the search query. + clicked: + type: array + nullable: true + description: Optional list of product IDs or SKUs clicked from the search results. + items: + type: string + RecentUserDataEntry: type: object properties: @@ -1652,23 +1611,12 @@ components: 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 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: object # Example: { "YYYY-MM": { "Category1": 100, "Category2": 50 } } - additionalProperties: - type: number - format: float - example: - "2025-01": { "Electronics": 1299.99, "Clothing": 150.5 } - "2025-02": { "Clothing": 100, "Home": 85 } + type: array + description: Array of individual purchase or search events within this batch. + items: + oneOf: + - $ref: "#/components/schemas/PurchaseEntry" + - $ref: "#/components/schemas/SearchEntry" StoreBasicInfo: type: object @@ -1688,7 +1636,6 @@ components: properties: month: type: string - format: date description: The month of the spending data (e.g., "2024-01"). spending: type: object diff --git a/tapiro-api-internal/controllers/StoreProfile.js b/tapiro-api-internal/controllers/StoreProfile.js index a45b727..290d8ab 100644 --- a/tapiro-api-internal/controllers/StoreProfile.js +++ b/tapiro-api-internal/controllers/StoreProfile.js @@ -31,8 +31,20 @@ module.exports.deleteStoreProfile = function deleteStoreProfile(req, res, next) }); }; -module.exports.searchStores = function searchStores(req, res, next, ids) { - StoreProfile.searchStores(req, ids) +module.exports.searchStores = function searchStores(req, res, next) { + const { query, limit } = req.query; + StoreProfile.searchStores(req, query, limit) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); +}; + +module.exports.lookupStores = function lookupStores(req, res, next) { + const { ids } = req.query; + StoreProfile.lookupStores(req, ids) .then((response) => { utils.writeJson(res, response); }) diff --git a/tapiro-api-internal/controllers/UserProfile.js b/tapiro-api-internal/controllers/UserProfile.js index fd4dc52..9350eca 100644 --- a/tapiro-api-internal/controllers/UserProfile.js +++ b/tapiro-api-internal/controllers/UserProfile.js @@ -50,4 +50,14 @@ module.exports.getSpendingAnalytics = function getSpendingAnalytics(req, res, ne .catch((response) => { utils.writeJson(res, response); }); +}; + +module.exports.deleteUserDataHistory = function deleteUserDataHistory(req, res, next, body) { + UserProfile.deleteUserDataHistory(req, body) + .then((response) => { + utils.writeJson(res, response); + }) + .catch((response) => { + utils.writeJson(res, response); + }); }; \ No newline at end of file diff --git a/tapiro-api-internal/service/HealthService.js b/tapiro-api-internal/service/HealthService.js index 702211d..89861db 100644 --- a/tapiro-api-internal/service/HealthService.js +++ b/tapiro-api-internal/service/HealthService.js @@ -14,7 +14,7 @@ exports.healthCheck = async function (req) { const response = { status: 'healthy', timestamp: new Date().toISOString(), - service: 'tapiro-api', + service: 'tapiro-api-internal', dependencies: { database: 'disconnected', cache: 'disconnected', diff --git a/tapiro-api-internal/service/PreferenceManagementService.js b/tapiro-api-internal/service/PreferenceManagementService.js index c58ba32..1359a2c 100644 --- a/tapiro-api-internal/service/PreferenceManagementService.js +++ b/tapiro-api-internal/service/PreferenceManagementService.js @@ -103,9 +103,16 @@ exports.optOutFromStore = async function (req, storeId) { ); // Clear relevant caches - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + // Ensure user.email is available from the 'user' object fetched earlier. + if (user.email) { + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + console.log(`Invalidated store preference cache (email key): ${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + } else { + console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) opted out from store ${storeId} but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); + } await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); // User profile cache might contain privacy settings + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`); // Add this line return respondWithCode(204); } catch (error) { @@ -194,17 +201,32 @@ exports.updateUserPreferences = async function (req, body) { // 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 } } + { projection: { preferences: 1, updatedAt: 1, email: 1, privacySettings: 1 } } ); // Clear related caches - const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; + const userCacheKey = `${CACHE_KEYS.PREFERENCES}${userData.sub}`; // userData.sub is the Auth0 user ID await invalidateCache(userCacheKey); + console.log(`Invalidated general preferences cache: ${userCacheKey}`); + + // Invalidate USER_DATA cache as the user document (updatedAt) has changed + const userDataCacheKey = `${CACHE_KEYS.USER_DATA}${userData.sub}`; + await invalidateCache(userDataCacheKey); + console.log(`Invalidated user data cache: ${userDataCacheKey}`); // Clear store-specific preference caches as preferences changed - if (user.privacySettings?.optInStores) { - for (const storeId of user.privacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + if (updatedUser.privacySettings?.optInStores && updatedUser.privacySettings.optInStores.length > 0) { + const userEmail = updatedUser.email; + if (userEmail) { + console.log(`Invalidating store-specific preferences for user ${updatedUser._id} (Email: ${userEmail}) across ${updatedUser.privacySettings.optInStores.length} stores.`); + for (const storeId of updatedUser.privacySettings.optInStores) { + // Standardize to use email for the cache key + const storePrefCacheKeyByEmail = `${CACHE_KEYS.STORE_PREFERENCES}${userEmail}:${storeId}`; + await invalidateCache(storePrefCacheKeyByEmail); + console.log(`Invalidated store preference cache (email key): ${storePrefCacheKeyByEmail}`); + } + } else { + console.warn(`User ${updatedUser._id} (Auth0 ID: ${userData.sub}) has opt-in stores but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); } } @@ -215,8 +237,9 @@ exports.updateUserPreferences = async function (req, body) { updatedAt: updatedUser.updatedAt, // Use the actual updated timestamp }; - // Update the cache with the new minimal response + // Update the general preferences cache with the new minimal response await setCache(userCacheKey, JSON.stringify(preferencesResponse), { EX: CACHE_TTL.USER_DATA }); + console.log(`Re-cached general preferences: ${userCacheKey}`); return respondWithCode(200, preferencesResponse); @@ -281,9 +304,16 @@ exports.optInToStore = async function (req, storeId) { ); // Clear relevant caches - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user._id}:${storeId}`); + // Ensure user.email is available from the 'user' object fetched earlier. + if (user.email) { + await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + console.log(`Invalidated store preference cache (email key): ${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`); + } else { + console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) opted into store ${storeId} but email is missing. Cannot invalidate STORE_PREFERENCES by email.`); + } await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); - await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); // User profile cache might contain privacy settings + await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`); return respondWithCode(204); } catch (error) { @@ -297,15 +327,20 @@ exports.optInToStore = async function (req, storeId) { */ 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])); + // Try cache first + const consentCacheKey = `${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`; + const cachedConsentLists = await getCache(consentCacheKey); + if (cachedConsentLists) { + return respondWithCode(200, JSON.parse(cachedConsentLists)); + } + 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 + { projection: { 'privacySettings.optInStores': 1, 'privacySettings.optOutStores': 1, _id: 0 } } ); if (!user) { @@ -315,14 +350,13 @@ 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 || [], }; - // 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. + // Cache the result + await setCache(consentCacheKey, JSON.stringify(consentLists), { EX: CACHE_TTL.USER_DATA }); // Using USER_DATA TTL, adjust if needed return respondWithCode(200, consentLists); } catch (error) { diff --git a/tapiro-api-internal/service/StoreManagementService.js b/tapiro-api-internal/service/StoreManagementService.js index a31b702..690b46f 100644 --- a/tapiro-api-internal/service/StoreManagementService.js +++ b/tapiro-api-internal/service/StoreManagementService.js @@ -17,6 +17,14 @@ exports.createApiKey = 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]); + // Validate API key name + if (!body.name || body.name.trim() === "") { + return respondWithCode(400, { + code: 400, + message: 'API key name is required.', + }); + } + // Check if store exists const store = await db.collection('stores').findOne({ auth0Id: userData.sub }); if (!store) { @@ -26,6 +34,14 @@ exports.createApiKey = async function (req, body) { }); } + // Check if an API key with the same name already exists for this store (and is active) + if (store.apiKeys && store.apiKeys.some(key => key.name === body.name.trim())) { + return respondWithCode(409, { + code: 409, + message: `An active API key with the name "${body.name.trim()}" already exists.`, + }); + } + // Generate a new API key const apiKeyRaw = crypto.randomBytes(32).toString('hex'); const prefix = apiKeyRaw.substring(0, 8); @@ -35,7 +51,7 @@ exports.createApiKey = async function (req, body) { keyId: new ObjectId().toString(), prefix, hashedKey, - name: body.name || 'API Key', + name: body.name.trim(), // Use the provided and trimmed name status: 'active', createdAt: new Date(), }; diff --git a/tapiro-api-internal/service/UserProfileService.js b/tapiro-api-internal/service/UserProfileService.js index dbd8122..cfeee2c 100644 --- a/tapiro-api-internal/service/UserProfileService.js +++ b/tapiro-api-internal/service/UserProfileService.js @@ -4,7 +4,7 @@ const { respondWithCode } = require('../utils/writer'); const { getUserData } = require('../utils/authUtil'); const { CACHE_TTL, CACHE_KEYS } = require('../utils/cacheConfig'); const { updateUserPhone, deleteAuth0User, updateUserMetadata } = require('../utils/auth0Util'); -const { ObjectId } = require('mongodb'); +const { ObjectId } = require('mongodb'); // Ensure ObjectId is imported /** * Get User Profile @@ -55,21 +55,21 @@ exports.getUserProfile = async function (req) { exports.updateUserProfile = async function (req, body) { try { const db = getDB(); - const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - const auth0UserId = userData.sub; + const tokenUserData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = tokenUserData.sub; + + // Fetch the current user state from DB to compare demographic data + const currentUser = await db.collection('users').findOne({ auth0Id: auth0UserId }); + if (!currentUser) { + return respondWithCode(404, { code: 404, message: 'User not found for update' }); + } // --- Local DB Username Uniqueness Check --- // Keep this check for your application's internal username uniqueness if (body.username) { - const existingUser = await db.collection('users').findOne({ - username: body.username, - auth0Id: { $ne: auth0UserId }, - }); - if (existingUser) { - return respondWithCode(409, { - code: 409, - message: 'Username already taken in application', - }); + const existingUserByUsername = await db.collection('users').findOne({ username: body.username }); + if (existingUserByUsername && existingUserByUsername.auth0Id !== auth0UserId) { + return respondWithCode(409, { code: 409, message: 'Username already taken' }); } } @@ -78,24 +78,20 @@ exports.updateUserProfile = async function (req, body) { // Auth0 will enforce its own uniqueness rules per connection. if (body.username) { try { - await updateUserMetadata(auth0UserId, { nickname: body.username }); - } catch (auth0Error) { - console.error(`Auth0 username update failed for ${auth0UserId}:`, auth0Error); - return respondWithCode(409, { - code: 409, - message: 'Failed to update username with identity provider. It might already be taken.', - }); + await updateUserMetadata(auth0UserId, { username: body.username }); + } catch (authError) { + console.warn(`Auth0 username update failed for ${auth0UserId}:`, authError.message); + // Potentially return error or just log and continue with local DB update } } // --- Phone Number Update in Auth0 --- - if (body.phone && body.phone !== userData.phone_number) { + if (body.phone && body.phone !== tokenUserData.phone_number) { // Use tokenUserData for initial phone try { await updateUserPhone(auth0UserId, body.phone); - } catch (auth0Error) { - // Log and continue, or return error as needed - console.error(`Auth0 phone update failed for ${auth0UserId}:`, auth0Error); - // return respondWithCode(500, { code: 500, message: 'Failed to update phone number with identity provider.' }); + } catch (authError) { + console.warn(`Auth0 phone update failed for ${auth0UserId}:`, authError.message); + // Potentially return error or just log and continue } } @@ -103,70 +99,65 @@ exports.updateUserProfile = async function (req, body) { const updateData = { updatedAt: new Date(), }; - let demographicsChanged = false; // Flag to track if demographics were updated - + let demographicsChanged = false; // Update local DB username and phone if (body.username !== undefined) updateData.username = body.username; if (body.phone !== undefined) updateData.phone = body.phone; // --- Update Demographic Data --- - // Use dot notation to set fields within the demographicData object - - // User-provided fields - if (body.demographicData?.gender !== undefined) { - updateData['demographicData.gender'] = body.demographicData.gender; - updateData['demographicData.inferredGender'] = null; // Clear inferred on user update - demographicsChanged = true; - } - if (body.demographicData?.incomeBracket !== undefined) { - updateData['demographicData.incomeBracket'] = body.demographicData.incomeBracket; - demographicsChanged = true; - } - if (body.demographicData?.country !== undefined) { - 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.' }); + if (body.demographicData) { + const newDemoData = body.demographicData; + const currentDemoData = currentUser.demographicData || {}; + + // Helper function to process demographic fields + const processDemographicField = (fieldName, inferredFieldName) => { + if (newDemoData.hasOwnProperty(fieldName)) { + const newValue = newDemoData[fieldName]; + const currentValue = currentDemoData[fieldName]; + + // Always update the user-provided field if it's in the payload + updateData[`demographicData.${fieldName}`] = newValue; + + if (newValue !== currentValue) { + demographicsChanged = true; + } + + // If the new value is null (clearing/unverifying) OR if the user-provided value changed, + // and there's an inferred field, clear the inferred field. + if (inferredFieldName && (newValue === null || newValue !== currentValue)) { + // Check if the inferred field actually changes to trigger demographicsChanged + if (currentDemoData[inferredFieldName] !== null) { + demographicsChanged = true; + } + updateData[`demographicData.${inferredFieldName}`] = null; + } + } + }; + + processDemographicField('gender', 'inferredGender'); + processDemographicField('incomeBracket'); // No inferred counterpart + processDemographicField('country'); // No inferred counterpart + + // Age - special handling for parsing and validation + if (newDemoData.hasOwnProperty('age')) { + const newAgeValue = newDemoData.age === null || newDemoData.age === undefined ? null : parseInt(String(newDemoData.age)); + if (newAgeValue === null || (typeof newAgeValue === 'number' && newAgeValue >= 0 && Number.isInteger(newAgeValue))) { + if (newAgeValue !== currentDemoData.age) { + updateData['demographicData.age'] = newAgeValue; + demographicsChanged = true; + } else { + updateData['demographicData.age'] = newAgeValue; + } + } else { + console.warn(`Invalid age value provided for user ${auth0UserId}: ${newDemoData.age}`); + } } + + processDemographicField('hasKids', 'inferredHasKids'); + processDemographicField('relationshipStatus', 'inferredRelationshipStatus'); + processDemographicField('employmentStatus', 'inferredEmploymentStatus'); + processDemographicField('educationLevel', 'inferredEducationLevel'); } - // --- NEW User-Provided Fields --- - if (body.demographicData?.hasKids !== undefined) { - updateData['demographicData.hasKids'] = body.demographicData.hasKids; - updateData['demographicData.inferredHasKids'] = null; // Clear inferred on user update - demographicsChanged = true; - } - if (body.demographicData?.relationshipStatus !== undefined) { - 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; - } - if (body.demographicData?.educationLevel !== undefined) { - updateData['demographicData.educationLevel'] = body.demographicData.educationLevel; - updateData['demographicData.inferredEducationLevel'] = null; // Clear inferred on user update - demographicsChanged = true; - } - - // --- REMOVED Verification Flag Handling --- - // The logic for hasKidsIsVerified, relationshipStatusIsVerified, etc. is removed. - // --- End Update Demographic Data --- // Only update allowed privacy settings @@ -189,10 +180,10 @@ exports.updateUserProfile = async function (req, body) { const updateKeys = Object.keys(updateData).filter((key) => key !== 'updatedAt'); if (updateKeys.length === 0) { // Nothing changed - const currentUser = await db + const latestUser = await db .collection('users') .findOne({ auth0Id: auth0UserId }, { projection: { preferences: 0 } }); - return respondWithCode(200, currentUser || { message: 'No changes detected.' }); + return respondWithCode(200, latestUser || { message: 'No changes detected.' }); } console.log(`Updating user ${auth0UserId} with data:`, updateData); @@ -202,7 +193,7 @@ exports.updateUserProfile = async function (req, body) { .findOneAndUpdate( { auth0Id: auth0UserId }, { $set: updateData }, - { returnDocument: 'after', projection: { preferences: 0 } }, + { returnDocument: 'after', projection: { preferences: 0 } }, // Ensure email is projected ); if (!result) { @@ -222,18 +213,20 @@ exports.updateUserProfile = async function (req, body) { } // 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) { + const userObjectId = updatedUserDoc._id; // This should be correct from the result + const userEmail = updatedUserDoc.email; // Make sure email is available in updatedUserDoc + + if (userEmail && updatedUserDoc.privacySettings?.optInStores?.length > 0) { + console.log(`Invalidating store preferences for user ${userObjectId} (email: ${userEmail}) due to privacy/demographic update.`); + for (const storeId of updatedUserDoc.privacySettings.optInStores) { + // Invalidate cache key used by external API (email based) + const externalApiCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${userEmail}:${storeId}`; + await invalidateCache(externalApiCacheKey); + console.log(`Invalidated external store preference cache: ${externalApiCacheKey}`); + } } } @@ -262,10 +255,10 @@ exports.deleteUserProfile = async function (req) { // Get user data - use req.user if available (from middleware) or fetch it const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); - // Find user to get ID for cache invalidation later + // Find user to get ID and email for cache invalidation later const user = await db .collection('users') - .findOne({ auth0Id: userData.sub }, { projection: { _id: 1, privacySettings: 1 } }); + .findOne({ auth0Id: userData.sub }, { projection: { _id: 1, email: 1, privacySettings: 1 } }); // Ensure email is projected if (!user) { return respondWithCode(404, { code: 404, @@ -291,12 +284,19 @@ exports.deleteUserProfile = async function (req) { // Clear user-specific caches await invalidateCache(`${CACHE_KEYS.USER_DATA}${userData.sub}`); await invalidateCache(`${CACHE_KEYS.PREFERENCES}${userData.sub}`); + await invalidateCache(`${CACHE_KEYS.USER_STORE_CONSENT}${userData.sub}`); // Clear related store preference caches - if (userPrivacySettings?.optInStores) { + if (user.email && userPrivacySettings?.optInStores && userPrivacySettings.optInStores.length > 0) { // Check if user.email is available and stores exist + console.log(`Invalidating store-specific preferences for deleted user ${user.email} across ${userPrivacySettings.optInStores.length} stores.`); for (const storeId of userPrivacySettings.optInStores) { - await invalidateCache(`${CACHE_KEYS.STORE_PREFERENCES}${userObjectId}:${storeId}`); + // Use email for the cache key + const storePrefCacheKey = `${CACHE_KEYS.STORE_PREFERENCES}${user.email}:${storeId}`; + await invalidateCache(storePrefCacheKey); + console.log(`Invalidated store preference cache (email key): ${storePrefCacheKey}`); } + } else if (!user.email && userPrivacySettings?.optInStores && userPrivacySettings.optInStores.length > 0) { + console.warn(`User ${user._id} (Auth0 ID: ${userData.sub}) was deleted and had opt-in stores, but email was missing. Cannot invalidate STORE_PREFERENCES by email.`); } return respondWithCode(204); @@ -582,3 +582,68 @@ exports.getSpendingAnalytics = async function (req) { return respondWithCode(500, { code: 500, message: 'Internal server error' }); } }; + +/** + * Delete User Data History + * Deletes user's submitted data entries based on scope or specific IDs. + */ +exports.deleteUserDataHistory = async function (req, body) { + try { + const db = getDB(); + const tokenUserData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1])); + const auth0UserId = tokenUserData.sub; + + const user = await db.collection('users').findOne({ auth0Id: auth0UserId }, { projection: { _id: 1 } }); + if (!user) { + return respondWithCode(404, { code: 404, message: 'User not found.' }); + } + const userId = user._id; // This is the MongoDB ObjectId of the user + + const { scope, entryIds } = body; + + if (!scope) { + return respondWithCode(400, { code: 400, message: 'Scope is required.' }); + } + + const deleteQuery = { userId: userId }; + + const now = new Date(); + switch (scope) { + case 'today': + const startOfToday = new Date(now.setHours(0, 0, 0, 0)); + const endOfToday = new Date(now.setHours(23, 59, 59, 999)); + deleteQuery.timestamp = { $gte: startOfToday, $lte: endOfToday }; + break; + case 'last7days': + const sevenDaysAgo = new Date(now); + sevenDaysAgo.setDate(now.getDate() - 7); + sevenDaysAgo.setHours(0, 0, 0, 0); // Start of 7 days ago + deleteQuery.timestamp = { $gte: sevenDaysAgo, $lte: new Date() /* up to now */ }; + break; + case 'all': + // No additional time filter, will delete all for the user + break; + case 'individual': + if (!entryIds || !Array.isArray(entryIds) || entryIds.length === 0) { + return respondWithCode(400, { code: 400, message: 'entryIds are required for individual scope.' }); + } + try { + deleteQuery._id = { $in: entryIds.map(id => new ObjectId(id)) }; + } catch (e) { + return respondWithCode(400, { code: 400, message: 'Invalid entryId format.' }); + } + break; + default: + return respondWithCode(400, { code: 400, message: 'Invalid scope provided.' }); + } + + const result = await db.collection('userData').deleteMany(deleteQuery); + + console.log(`Deleted ${result.deletedCount} data entries for user ${userId} with scope '${scope}'.`); + + return respondWithCode(204); + } catch (error) { + console.error('Delete user data history failed:', error); + return respondWithCode(500, { code: 500, message: 'Internal server error while deleting data history.' }); + } +}; diff --git a/tapiro-api-internal/utils/cacheConfig.js b/tapiro-api-internal/utils/cacheConfig.js index a07e3ff..f3cba85 100644 --- a/tapiro-api-internal/utils/cacheConfig.js +++ b/tapiro-api-internal/utils/cacheConfig.js @@ -21,9 +21,10 @@ const CACHE_KEYS = { SCOPES: 'scopes:', // Token to scopes mapping 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 + STORE_PREFERENCES: 'prefs:', + TAXONOMY: 'taxonomy:current', + AI_REQUEST: 'ai_request:', + USER_STORE_CONSENT: 'userconsent:', // New key for store consent lists }; module.exports = { CACHE_TTL, CACHE_KEYS }; diff --git a/tapiro-api-internal/utils/dbSchemas.js b/tapiro-api-internal/utils/dbSchemas.js index b000021..afb11a7 100644 --- a/tapiro-api-internal/utils/dbSchemas.js +++ b/tapiro-api-internal/utils/dbSchemas.js @@ -202,12 +202,12 @@ const storeSchema = { bsonType: 'array', items: { bsonType: 'object', - required: ['keyId', 'prefix', 'hashedKey', 'status', 'createdAt'], + required: ['keyId', 'prefix', 'hashedKey', 'status', 'createdAt', 'name'], properties: { keyId: { bsonType: 'string' }, prefix: { bsonType: 'string' }, hashedKey: { bsonType: 'string' }, - name: { bsonType: 'string' }, + name: { bsonType: 'string', description: "User-defined name for the API key" }, status: { bsonType: 'string' }, createdAt: { bsonType: 'date' }, }, diff --git a/tapiro-api-internal/utils/mongoUtil.js b/tapiro-api-internal/utils/mongoUtil.js index 4754526..c723605 100644 --- a/tapiro-api-internal/utils/mongoUtil.js +++ b/tapiro-api-internal/utils/mongoUtil.js @@ -119,6 +119,7 @@ async function setupIndexes(db) { await db.collection('stores').createIndex({ auth0Id: 1 }, { unique: true }); await db.collection('stores').createIndex({ email: 1 }); await db.collection('stores').createIndex({ "apiKeys.prefix": 1 }); + await db.collection('stores').createIndex({ name: "text" }); // API usage indexes await db.collection('apiUsage').createIndex({ apiKeyId: 1, timestamp: -1 }); @@ -128,6 +129,23 @@ async function setupIndexes(db) { await db.collection('userData').createIndex({ userId: 1, timestamp: -1 }); await db.collection('userData').createIndex({ email: 1 }); await db.collection('userData').createIndex({ storeId: 1, timestamp: -1 }); + + // New indexes for filtering by dataType and storeId, user-centric + await db.collection('userData').createIndex({ userId: 1, dataType: 1, timestamp: -1 }); + await db.collection('userData').createIndex({ userId: 1, storeId: 1, timestamp: -1 }); + await db.collection('userData').createIndex({ userId: 1, dataType: 1, storeId: 1, timestamp: -1 }); + + // New text index for searchTerm + await db.collection('userData').createIndex( + { + "entries.items.name": "text", + "entries.items.category": "text", + "entries.query": "text" + }, + { + name: "userData_text_search_idx" + } + ); console.log('MongoDB indexes successfully configured'); } catch (error) { diff --git a/web/public/icons/logo/tapiro.png b/web/public/icons/logo/tapiro.png new file mode 100644 index 0000000..c07f072 Binary files /dev/null and b/web/public/icons/logo/tapiro.png differ diff --git a/web/public/icons/tech/auth0.svg b/web/public/icons/tech/auth0.svg new file mode 100644 index 0000000..50cefff --- /dev/null +++ b/web/public/icons/tech/auth0.svg @@ -0,0 +1,7 @@ + + \ No newline at end of file diff --git a/web/public/icons/tech/fastapi.svg b/web/public/icons/tech/fastapi.svg new file mode 100644 index 0000000..85f2d13 --- /dev/null +++ b/web/public/icons/tech/fastapi.svg @@ -0,0 +1 @@ + diff --git a/web/public/icons/tech/huggingface.svg b/web/public/icons/tech/huggingface.svg new file mode 100644 index 0000000..fc0c80d --- /dev/null +++ b/web/public/icons/tech/huggingface.svg @@ -0,0 +1 @@ +HuggingFace \ No newline at end of file diff --git a/web/public/icons/tech/mongodb.svg b/web/public/icons/tech/mongodb.svg new file mode 100644 index 0000000..65c4a12 --- /dev/null +++ b/web/public/icons/tech/mongodb.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/nodejs.svg b/web/public/icons/tech/nodejs.svg new file mode 100644 index 0000000..ca220b4 --- /dev/null +++ b/web/public/icons/tech/nodejs.svg @@ -0,0 +1,59 @@ + + + + + build-tools/nodejs + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/react.svg b/web/public/icons/tech/react.svg new file mode 100644 index 0000000..345db3c --- /dev/null +++ b/web/public/icons/tech/react.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/redis.svg b/web/public/icons/tech/redis.svg new file mode 100644 index 0000000..fba4f31 --- /dev/null +++ b/web/public/icons/tech/redis.svg @@ -0,0 +1,39 @@ + + + + + databases-and-servers/databases/redis + Created with Sketch. + + + + + + + + + + \ No newline at end of file diff --git a/web/public/icons/tech/swagger.svg b/web/public/icons/tech/swagger.svg new file mode 100644 index 0000000..458529f --- /dev/null +++ b/web/public/icons/tech/swagger.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/web/src/api/hooks/useUserHooks.ts b/web/src/api/hooks/useUserHooks.ts index 17e81ae..93fe98e 100644 --- a/web/src/api/hooks/useUserHooks.ts +++ b/web/src/api/hooks/useUserHooks.ts @@ -10,6 +10,7 @@ import { MonthlySpendingAnalytics, GetSpendingAnalyticsParams, GetRecentUserDataParams, // <-- Import params type for recent data + UserDataDeletionRequest, // Import the request type if you defined it in openapi.yaml components } from "../types/data-contracts"; import { useAuth } from "../../hooks/useAuth"; // Import useAuth @@ -218,3 +219,41 @@ export function useStoreConsentLists() { // ...cacheSettings.consent, // Example }); } + +export function useDeleteUserDataHistory() { + const { apiClients, clientsReady } = useApiClients(); + const queryClient = useQueryClient(); + const { isAuthenticated, isLoading: authLoading } = useAuth(); + + return useMutation< + void, // Assuming 204 No Content response + Error, + UserDataDeletionRequest // Type for the request body + >({ + mutationFn: (deletionRequest: UserDataDeletionRequest) => { + if (!clientsReady || !isAuthenticated || authLoading) { + return Promise.reject( + new Error("API client not ready or user not authenticated."), + ); + } + // Assuming your generated client has a method like 'deleteUserDataHistory' + // Adjust the method name if it's different based on your openapi-generator config + return apiClients.users + .deleteUserDataHistory(deletionRequest) + .then((res) => res.data); + }, + onSuccess: () => { + // Invalidate queries that display this data + // This will cause components using these queries to refetch + queryClient.invalidateQueries({ queryKey: cacheKeys.users.recentData() }); + queryClient.invalidateQueries({ + queryKey: cacheKeys.users.spendingAnalytics(), + }); + // Potentially show a success toast + }, + onError: (error) => { + // Potentially show an error toast + console.error("Failed to delete user data history:", error); + }, + }); +} diff --git a/web/src/api/types/Users.ts b/web/src/api/types/Users.ts index 223ba34..0925f71 100644 --- a/web/src/api/types/Users.ts +++ b/web/src/api/types/Users.ts @@ -19,6 +19,7 @@ import { StoreConsentList, User, UserCreate, + UserDataHistoryDeletionRequest, UserMetadataResponse, UserPreferences, UserPreferencesUpdate, @@ -295,4 +296,31 @@ export class Users< format: "json", ...params, }); + /** + * @description Allows authenticated users to delete their submitted data history based on a scope or specific entry IDs. + * + * @tags User Management + * @name DeleteUserDataHistory + * @summary Delete User Data History + * @request DELETE:/users/data/history + * @secure + * @response `204` `void` User data history deleted successfully. + * @response `400` `Error` + * @response `401` `Error` + * @response `403` `Error` + * @response `404` `Error` + * @response `500` `Error` + */ + deleteUserDataHistory = ( + data: UserDataHistoryDeletionRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/users/data/history`, + method: "DELETE", + body: data, + secure: true, + type: ContentType.Json, + ...params, + }); } diff --git a/web/src/api/types/data-contracts.ts b/web/src/api/types/data-contracts.ts index fdf4e8a..572fb6a 100644 --- a/web/src/api/types/data-contracts.ts +++ b/web/src/api/types/data-contracts.ts @@ -163,102 +163,13 @@ export interface UserUpdate { export interface ApiKey { /** @format uuid */ - keyId?: string; - prefix?: string; + keyId: string; + prefix: string; /** Name for the API key */ - name?: string; - /** @format date-time */ - createdAt?: string; - 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 (used as identifier for API key auth). Must match a registered Tapiro user. - * @format email - */ - email: string; - /** Specifies the type of data contained in the 'entries' array. */ - dataType: "purchase" | "search"; - /** - * 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 metadata about the collection event (e.g., source, device). - * @example {"source":"web","deviceType":"desktop","sessionId":"abc-123-xyz-789"} - */ - metadata?: { - /** Source of the data (e.g., 'web', 'mobile_app', 'pos'). */ - source?: string; - /** Type of device used (e.g., 'desktop', 'mobile', 'tablet'). */ - deviceType?: string; - /** 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 { - /** - * ISO 8601 timestamp of when the purchase occurred. - * @format date-time - */ - timestamp: string; - /** List of items included in the purchase. */ - items: PurchaseItem[]; - /** - * 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 or name matching the Tapiro taxonomy (e.g., "101" or "Smartphones"). Providing the most specific category ID is recommended. */ - category: string; - /** - * Price of a single unit of the item. - * @format float - */ - price?: number; - /** - * 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; -} - -/** - * 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 { - /** - * 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[]; + /** @format date-time */ + createdAt: string; + status: "active" | "revoked"; } export interface UserPreferences { @@ -327,9 +238,19 @@ export interface StoreUpdate { }[]; } +export interface UserDataHistoryDeletionRequest { + /** Scope of data to delete. If 'individual', entryIds must be provided. */ + scope: "today" | "last7days" | "all" | "individual"; + /** Array of specific userData entry IDs to delete. Required if scope is 'individual'. */ + entryIds?: string[] | null; +} + export interface ApiKeyCreate { - /** Name for the API key */ - name?: string; + /** + * Name for the API key + * @minLength 1 + */ + name: string; } export type ApiKeyList = ApiKey[]; @@ -511,6 +432,58 @@ export interface DemographicData { inferredGender?: "male" | "female" | "non-binary" | null; } +export interface PurchaseItem { + /** Stock Keeping Unit or unique product identifier. */ + sku?: string; + /** Name of the purchased item. */ + name: string; + /** Category ID or name matching the taxonomy. */ + category: string; + /** + * Price of a single unit of the item. + * @format float + */ + price?: number; + /** + * Number of units purchased. + * @default 1 + */ + quantity?: number; + /** Key-value pairs representing product attributes. */ + attributes?: Record; +} + +export interface PurchaseEntry { + /** + * ISO 8601 timestamp of when the purchase occurred. + * @format date-time + */ + timestamp: string; + /** List of items included in the purchase. */ + items: PurchaseItem[]; + /** + * Optional total value of the purchase event. + * @format float + */ + totalValue?: number | null; +} + +export interface SearchEntry { + /** + * 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. */ + category?: string | null; + /** Optional number of results returned for the search query. */ + results?: number | null; + /** Optional list of product IDs or SKUs clicked from the search results. */ + clicked?: string[] | null; +} + export interface RecentUserDataEntry { /** The unique ID of the userData entry. */ _id?: string; @@ -528,16 +501,10 @@ export interface RecentUserDataEntry { * @format date-time */ entryTimestamp?: string; - /** Simplified details (e.g., item count for purchase, query string for search) */ - details?: object; + /** Array of individual purchase or search events within this batch. */ + details?: (PurchaseEntry | SearchEntry)[]; } -/** - * 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 interface StoreBasicInfo { /** The unique ID of the store. */ storeId: string; @@ -547,10 +514,7 @@ export interface StoreBasicInfo { /** @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 - */ + /** The month of the spending data (e.g., "2024-01"). */ month: string; /** An object mapping category names to the total amount spent in that category for the month. */ spending: Record; diff --git a/web/src/components/auth/InterestFormModal.tsx b/web/src/components/auth/InterestFormModal.tsx index 3999a66..0b14f70 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"; // Removed ReactElement +import { useState, useEffect, useMemo } from "react"; import { Button, Card, @@ -7,6 +7,7 @@ import { ModalFooter, ModalHeader, Spinner, + Alert, // Added Alert for Step 2 guidance } from "flowbite-react"; import { HiChevronDown, @@ -25,14 +26,13 @@ import { HiOutlineGift, HiOutlineCode, HiQuestionMarkCircle, + HiArrowRight, // For Next button + HiArrowLeft, // For Back button } from "react-icons/hi"; import { useTaxonomy } from "../../api/hooks/useTaxonomyHooks"; import { useUpdateUserPreferences } from "../../api/hooks/useUserHooks"; -import { - PreferenceItem, - // Removed TaxonomyCategory -} from "../../api/types/data-contracts"; -import ErrorDisplay from "../common/ErrorDisplay"; // Assuming ErrorDisplay exists +import { PreferenceItem } from "../../api/types/data-contracts"; +import ErrorDisplay from "../common/ErrorDisplay"; // --- Icon Mapping (Keep as is) --- const categoryIcons: { [key: string]: React.ElementType } = { @@ -72,14 +72,23 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { isLoading: isLoadingTaxonomy, error: taxonomyError, } = useTaxonomy(); - const updateUserPreferences = useUpdateUserPreferences(); + const { + mutateAsync: performUpdateUserPreferences, + isPending: isUpdatingUserPreferences, + reset: resetUpdateUserPreferencesMutation, + } = useUpdateUserPreferences(); + + // --- New State for Steps --- + const [currentStep, setCurrentStep] = useState<1 | 2>(1); + const [selectedCategoryIdsStep1, setSelectedCategoryIdsStep1] = useState< + string[] + >([]); + // --- End New State --- - // --- Updated State --- const [selectedAttributeValues, setSelectedAttributeValues] = useState< SelectedAttributeValue[] >([]); const [expandedCategoryIds, setExpandedCategoryIds] = useState([]); - // --- End Updated State --- // --- Top Level Categories (Keep as is) --- const topLevelCategories = useMemo(() => { @@ -90,7 +99,26 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { ); }, [taxonomyData]); - // --- Category Map Removed (was unused) --- + // --- Categories selected in Step 1, for display in Step 2 --- + const categoriesForStep2 = useMemo(() => { + if (!taxonomyData?.categories) return []; + return taxonomyData.categories + .filter((cat) => selectedCategoryIdsStep1.includes(cat.id)) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [taxonomyData, selectedCategoryIdsStep1]); + + // --- Reset state when modal is shown/hidden --- + useEffect(() => { + if (show) { + setCurrentStep(1); + setSelectedCategoryIdsStep1([]); + setSelectedAttributeValues([]); + setExpandedCategoryIds([]); + } else { + // Reset mutation state if modal is closed + resetUpdateUserPreferencesMutation(); + } + }, [show, resetUpdateUserPreferencesMutation]); // --- Handlers --- const handleExpandCategory = (categoryId: string) => { @@ -101,7 +129,14 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { ); }; - // --- New Handler for Attribute Value Selection --- + const handleSelectCategoryStep1 = (categoryId: string) => { + setSelectedCategoryIdsStep1((prev) => + prev.includes(categoryId) + ? prev.filter((id) => id !== categoryId) + : [...prev, categoryId], + ); + }; + const handleSelectAttributeValue = ( categoryId: string, attributeName: string, @@ -115,80 +150,77 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { 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 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 --- + // 1. Initialize preferences for all categories selected in Step 1 + selectedCategoryIdsStep1.forEach((categoryId) => { + groupedPreferences.set(categoryId, { + category: categoryId, + score: 1.0, // Default score + attributes: {}, + }); + }); - // 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 + // 2. Populate attributes from selectedAttributeValues (populated in Step 2) + selectedAttributeValues.forEach(({ categoryId, attributeName, value }) => { + if (groupedPreferences.has(categoryId)) { + const pref = groupedPreferences.get(categoryId)!; + if (!pref.attributes) { + pref.attributes = {}; + } + const attributesMap = pref.attributes as Record< + string, + Record + >; + let attributeValueMap = attributesMap[attributeName]; + if (!attributeValueMap) { + attributeValueMap = {}; + attributesMap[attributeName] = attributeValueMap; + } + attributeValueMap[value] = 1.0; } - - // Assign score to the specific attribute value - attributeValueMap[value] = 1.0; // Assign score to the inner map }); const preferencesPayload: PreferenceItem[] = Array.from( groupedPreferences.values(), - ); + ).filter((pref) => selectedCategoryIdsStep1.includes(pref.category)); - 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 + if (preferencesPayload.length === 0 && currentStep === 1) { + // If submitting from step 1 and nothing selected, just close. + // Or, if "Save & Close" is clicked, this path is taken if selectedCategoryIdsStep1 is empty. + // The button disable logic should prevent this, but as a safeguard: + onClose(); return; } + if (preferencesPayload.length === 0 && currentStep === 2) { + // If user went to step 2 but selected no attributes, and somehow submitted (e.g. if we allowed it) + // we still want to save the categories from step 1. The filter above ensures this. + // If selectedCategoryIdsStep1 is also empty (should not happen if UI logic is correct), then close. + if (selectedCategoryIdsStep1.length === 0) { + onClose(); + return; + } + } try { - await updateUserPreferences.mutateAsync({ + await performUpdateUserPreferences({ preferences: preferencesPayload, }); - onClose(); // Close modal on success + onClose(); } catch (err) { console.error("Failed to save preferences:", err); - // Optionally display an error message to the user + // Error will be handled by the hook's error state, can show in modal if needed } }; - // --- End Updated handleSubmit --- - // --- useEffect for Error Handling (Keep as is) --- useEffect(() => { if (taxonomyError) { console.error("Taxonomy failed to load, closing interest modal."); @@ -196,7 +228,6 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { } }, [taxonomyError, onClose]); - // --- Check if a specific attribute value is selected --- const isAttributeValueSelected = ( categoryId: string, attributeName: string, @@ -209,12 +240,164 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { item.value === value, ); }; - // --- End Check --- + + const renderStep1 = () => ( +
+

+ Select your general areas of interest. You can refine these in the next + step. +

+
+ {topLevelCategories.map((category) => { + const isSelected = selectedCategoryIdsStep1.includes(category.id); + const IconComponent = categoryIcons[category.name] || DefaultIcon; + return ( + handleSelectCategoryStep1(category.id)} + className={`h-full cursor-pointer transition-all duration-150 hover:bg-gray-50 dark:hover:bg-gray-600 ${ + isSelected + ? "ring-2 ring-blue-500 dark:ring-blue-400" + : "ring-1 ring-gray-200 dark:ring-gray-700" + }`} + > +
+ +
+ {category.name} +
+ {category.description && ( +

+ {category.description} +

+ )} +
+
+ ); + })} +
+
+ ); + + const renderStep2 = () => ( +
+ + Optionally refine your selected interests by choosing specific features. + Click on an interest to expand and see available attributes. + + {categoriesForStep2.length === 0 && ( +

+ No interests selected in the previous step. Go back to select some. +

+ )} +
+ {categoriesForStep2.map((category) => { + const isExpanded = expandedCategoryIds.includes(category.id); + const attributes = category.attributes || []; + const hasAttributes = attributes.length > 0; + const IconComponent = categoryIcons[category.name] || DefaultIcon; + + return ( +
+ handleExpandCategory(category.id) + : undefined + } + className={`transition-all duration-150 ${ + hasAttributes ? "cursor-pointer" : "cursor-default" + } ${ + isExpanded && hasAttributes + ? "ring-2 ring-blue-500 dark:ring-blue-400" + : hasAttributes + ? "hover:bg-gray-50 dark:hover:bg-gray-600" + : "" + } ${!hasAttributes ? "bg-gray-50 opacity-70 dark:bg-gray-700" : ""}`} + > +
+
+ +
+
+ {category.name} +
+ {category.description && ( +

+ {category.description} +

+ )} + {!hasAttributes && ( +

+ (No specific attributes to refine for this interest) +

+ )} +
+
+ {hasAttributes && ( +
+ {isExpanded ? ( + + ) : ( + + )} +
+ )} +
+
+ + {isExpanded && hasAttributes && ( +
+ {attributes.map((attribute) => ( +
+
+ {attribute.description || attribute.name} +
+
+ {(attribute.values || []).map((value) => { + const isSelected = isAttributeValueSelected( + category.id, + attribute.name, + value, + ); + return ( + + ); + })} +
+
+ ))} +
+ )} +
+ ); + })} +
+
+ ); return (
- Tell us what you're interested in + + {currentStep === 1 + ? "Step 1: Select Your Interests" + : "Step 2: Refine Your Interests"} +
{isLoadingTaxonomy && ( @@ -228,137 +411,63 @@ export function InterestFormModal({ show, onClose }: InterestFormModalProps) { message={taxonomyError.message} /> )} - {!isLoadingTaxonomy && - !taxonomyError && - taxonomyData && - taxonomyData.categories && ( -
-

- Select topics to personalize your experience. Click a main topic - 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); - // Get attributes directly from the category object - const attributes = category.attributes || []; - const hasAttributes = attributes.length > 0; - const IconComponent = - categoryIcons[category.name] || DefaultIcon; - - return ( -
- handleExpandCategory(category.id) - : undefined // No action if no attributes - } - 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" : ""}`} - > - {/* Card Content (Icon, Title, Description) - Keep as is */} -
-
- -
- {category.name} -
- {category.description && ( -

- {category.description} -

- )} -
- {/* Chevron or Placeholder */} - {hasAttributes && ( -
- {isExpanded ? ( - - ) : ( - - )} -
- )} - {!hasAttributes && ( -
// Placeholder - )} -
-
- - {/* --- 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 --- */} -
- ); - })} -
-
- )} + {!isLoadingTaxonomy && !taxonomyError && taxonomyData && ( + <> + {currentStep === 1 && renderStep1()} + {currentStep === 2 && renderStep2()} + + )}
- - + {currentStep === 1 && ( + <> + + + + )} + {currentStep === 2 && ( + <> + + + + )}
); diff --git a/web/src/components/auth/StoreRegistrationForm.tsx b/web/src/components/auth/StoreRegistrationForm.tsx index 0ce97db..c8f5065 100644 --- a/web/src/components/auth/StoreRegistrationForm.tsx +++ b/web/src/components/auth/StoreRegistrationForm.tsx @@ -1,5 +1,5 @@ -import { useForm, SubmitHandler } from "react-hook-form"; // Import useForm and SubmitHandler -import { Button, FloatingLabel, HelperText } from "flowbite-react"; +import { useForm, SubmitHandler } from "react-hook-form"; +import { Button, HelperText, Label, TextInput } from "flowbite-react"; // Import Label and TextInput import { StoreCreate } from "../../api/types/data-contracts"; import LoadingSpinner from "../common/LoadingSpinner"; @@ -42,14 +42,21 @@ export function StoreRegistrationForm({ Complete Store Registration - {/* Store Name with FloatingLabel and Icon */} -
- +
+ +
+ - {/* Display validation error */} {errors.name && ( {errors.name.message} @@ -71,14 +76,20 @@ export function StoreRegistrationForm({ )}
- {/* Store Address with FloatingLabel and Icon */} -
- +
+ +
+ - {/* Display validation error */} {errors.address && ( {errors.address.message} )} - {!errors.address && ( // Show helper text only if no error + {!errors.address && ( Optional: Provide a physical or primary business address. @@ -103,7 +113,6 @@ export function StoreRegistrationForm({ {isLoading ? ( ) : ( - // No need to manually disable based on name state anymore diff --git a/web/src/components/layout/Breadcrumbs.tsx b/web/src/components/layout/Breadcrumbs.tsx new file mode 100644 index 0000000..4c1a6b6 --- /dev/null +++ b/web/src/components/layout/Breadcrumbs.tsx @@ -0,0 +1,83 @@ +import { Breadcrumb, BreadcrumbItem } from "flowbite-react"; +import { HiHome } from "react-icons/hi"; +import { Link, useLocation } from "react-router"; + +const formatBreadcrumbSegment = (segment: string): string => { + if (!segment) return ""; + // Add space before uppercase letters (for camelCase) then convert to lower case + const spacedSegment = segment.replace(/([A-Z0-9])/g, " $1").toLowerCase(); + // Replace hyphens and underscores with spaces + const withSpaces = spacedSegment.replace(/[-_]/g, " "); + + return withSpaces + .split(" ") + .filter((word) => word.length > 0) // Remove empty strings from multiple spaces + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + .trim(); +}; + +export function Breadcrumbs() { + const location = useLocation(); + const { pathname } = location; + + const pathSegments = pathname.split("/").filter((x) => x); + + // If there are no segments (e.g., we are on a path Layout decided should have breadcrumbs, + // but it's effectively a root-like page for a sub-section not yet deep enough for segments), + // it might only show "Home". This is generally fine. + // The main decision to show breadcrumbs at all is in Layout.tsx. + + return ( + + + + Home + + + + {pathSegments.map((segment, index) => { + const routeTo = `/${pathSegments.slice(0, index + 1).join("/")}`; + const isLast = index === pathSegments.length - 1; + let displayName = formatBreadcrumbSegment(segment); + + // Basic heuristic to avoid showing raw IDs as intermediate breadcrumb links + // If it's the last segment and an ID, the page title should ideally reflect the item. + const isObjectIdLike = (s: string) => + s.length === 24 && /^[a-f0-9]+$/i.test(s); + const isUuidLike = (s: string) => + s.length === 36 && + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test( + s, + ); + + if (!isLast && (isObjectIdLike(segment) || isUuidLike(segment))) { + displayName = "Detail"; // Placeholder for ID-based routes + } + + return ( + + {isLast ? ( + + {displayName} + + ) : ( + + {displayName} + + )} + + ); + })} + + ); +} diff --git a/web/src/layout/Header.tsx b/web/src/layout/Header.tsx index 42a6daa..a28a590 100644 --- a/web/src/layout/Header.tsx +++ b/web/src/layout/Header.tsx @@ -62,7 +62,7 @@ export function Header() { {" "} Tapiro Logo diff --git a/web/src/layout/Layout.tsx b/web/src/layout/Layout.tsx index 50d98a8..ab8f00c 100644 --- a/web/src/layout/Layout.tsx +++ b/web/src/layout/Layout.tsx @@ -1,16 +1,34 @@ -import { Outlet } from "react-router"; -import { Header } from "./Header"; // Assuming Header is in the same layout folder -import { Footer } from "./Footer"; // Assuming Footer is in the same layout folder -import { RegistrationGuard } from "../components/auth/RegistrationGuard"; // Import the guard +import { Outlet, useLocation, matchPath } from "react-router"; +import { Footer } from "./Footer"; +import { Header } from "./Header"; +import { Breadcrumbs } from "../components/layout/Breadcrumbs"; +import { RegistrationGuard } from "../components/auth/RegistrationGuard"; export function Layout() { + const location = useLocation(); + + const noBreadcrumbPatterns: (string | { path: string; end?: boolean })[] = [ + { path: "/", end: true }, // HomePage + { path: "/about", end: true }, // AboutPage + { path: "/api-docs", end: true }, // ApiDocsPage + ]; + + const shouldShowBreadcrumbs = !noBreadcrumbPatterns.some((pattern) => + matchPath( + typeof pattern === "string" ? { path: pattern, end: true } : pattern, + location.pathname, + ), + ); + return ( -
- {" "} - {/* Added dark background */} +
- {/* Wrap Outlet with RegistrationGuard */}
+ {shouldShowBreadcrumbs && ( +
+ +
+ )} diff --git a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx index 7700535..9607f1e 100644 --- a/web/src/pages/StoreDashboard/ApiKeyManagement.tsx +++ b/web/src/pages/StoreDashboard/ApiKeyManagement.tsx @@ -39,6 +39,7 @@ import { import { ApiKey } from "../../api/types/data-contracts"; // Removed unused ApiKeyCreate import LoadingSpinner from "../../components/common/LoadingSpinner"; // Import LoadingSpinner import ErrorDisplay from "../../components/common/ErrorDisplay"; // Import ErrorDisplay +import { handleApiError } from "../../api/utils/errorHandler"; // Import handleApiError // Define a type for the response when creating a key, which includes the raw key interface GeneratedApiKeyResponse extends ApiKey { @@ -87,6 +88,9 @@ export function ApiKeyManagement() { const [generatedApiKey, setGeneratedApiKey] = useState(null); const [copied, setCopied] = useState(false); + const [keyNameValidationError, setKeyNameValidationError] = useState< + string | null + >(null); // Added for client-side validation const [showRevokeModal, setShowRevokeModal] = useState(false); const [keyToRevoke, setKeyToRevoke] = useState(null); @@ -111,8 +115,17 @@ export function ApiKeyManagement() { // --- Handlers --- const handleGenerateSubmit = () => { + resetCreateKeyMutation(); + const trimmedName = newKeyName.trim(); + + if (!trimmedName) { + setKeyNameValidationError("API key name is required."); + return; + } + setKeyNameValidationError(null); // Clear validation error if present + createApiKey( - { name: newKeyName || undefined }, + { name: trimmedName }, // Use trimmedName { onSuccess: (data) => { // Cast the received data to the expected response type @@ -155,6 +168,7 @@ export function ApiKeyManagement() { setCopied(false); // Reset copied state setNewKeyName(""); // Clear name input resetCreateKeyMutation(); // Reset mutation state including error + setKeyNameValidationError(null); // Clear validation error }; const copyToClipboard = () => { @@ -212,7 +226,7 @@ export function ApiKeyManagement() { No API keys generated yet.

) : ( -
+
@@ -223,22 +237,19 @@ export function ApiKeyManagement() { Actions - + {apiKeysData.map((key) => ( - + {/* Key Name */} - + {key.name || Unnamed Key} {/* Key Prefix */} - + {key.prefix}... {/* Key Status */} - + {/* Created At */} - {formatDate(key.createdAt)} + + {formatDate(key.createdAt)} + {/* Actions */} - + {key.status === "active" ? ( ) : ( - + Revoked )} @@ -281,10 +295,10 @@ export function ApiKeyManagement() { )} {/* Generate Key Modal */} - + {generatedApiKey ? "API Key Generated" : "Generate New API Key"} - + {generatedApiKey ? ( // Display generated key info
@@ -314,13 +328,13 @@ export function ApiKeyManagement() { type="text" value={generatedApiKey.apiKey} // Display the full key here readOnly - className="pr-10" // Add padding for the button + className="pr-12" // Increased padding for more space for the button /> @@ -401,33 +428,43 @@ export function ApiKeyManagement() { onClose={() => !isRevokingKey && setShowRevokeModal(false)} // Prevent closing while revoking popup > - - + +
- +

Are you sure you want to revoke this API key?

- {/* Display key details */} + {/* Display key details - Improved UI */} {keyToRevoke && ( -

- Name:{" "} - - {keyToRevoke.name || ( - Unnamed Key - )} - -
- Prefix:{" "} - {keyToRevoke.prefix}... -

+
+
+ + Name:{" "} + + + {keyToRevoke.name || ( + Unnamed Key + )} + +
+
+ + Prefix:{" "} + + + {keyToRevoke.prefix}... + +
+
)}

This key will immediately stop working and cannot be reactivated.

diff --git a/web/src/pages/StoreDashboard/StoreDashboard.tsx b/web/src/pages/StoreDashboard/StoreDashboard.tsx index c524a61..1af00f9 100644 --- a/web/src/pages/StoreDashboard/StoreDashboard.tsx +++ b/web/src/pages/StoreDashboard/StoreDashboard.tsx @@ -66,7 +66,7 @@ export default function StoreDashboard() { return ( // Removed relative positioning, toasts are now inside child components -
+

Store Dashboard

@@ -101,7 +101,7 @@ export default function StoreDashboard() {

)} - diff --git a/web/src/pages/StoreDashboard/StoreProfilePage.tsx b/web/src/pages/StoreDashboard/StoreProfilePage.tsx index 0ca0142..6c29fca 100644 --- a/web/src/pages/StoreDashboard/StoreProfilePage.tsx +++ b/web/src/pages/StoreDashboard/StoreProfilePage.tsx @@ -3,7 +3,6 @@ import { useForm, SubmitHandler } from "react-hook-form"; import { Button, Card, - FloatingLabel, HelperText, Spinner, Tabs, @@ -13,6 +12,8 @@ import { Modal, // Import Modal ModalBody, ModalHeader, + Label, // <-- Add Label + TextInput, // <-- Add TextInput } from "flowbite-react"; // Import necessary icons import { @@ -215,12 +216,18 @@ export default function StoreProfilePage() { Store Information {/* Store Name */} -
- +
+ +
+ {errors.name?.message && ( @@ -229,13 +236,19 @@ export default function StoreProfilePage() {
{/* Address */} -
- +
+ +
+ {errors.address?.message && ( @@ -347,7 +360,7 @@ export default function StoreProfilePage() {
- +

Are you sure you want to permanently delete your store account?

@@ -370,7 +383,7 @@ export default function StoreProfilePage() { )} )} @@ -325,9 +423,39 @@ const UserAnalyticsPage: React.FC = () => { {/* --- Recent Activity Section --- */} -

- Recent Activity Log -

+
+

+ Recent Activity Log +

+ + handleDeleteRequest("today")} + icon={HiTrash} + > + Delete Today's Activity + + handleDeleteRequest("last7days")} + icon={HiTrash} + > + Delete Last 7 Days + + + handleDeleteRequest("all")} + icon={HiTrash} + className="text-red-700 hover:bg-red-50 dark:text-red-500 dark:hover:bg-red-600" + > + Delete All Activity + + +
+ {/* Activity Filters (Keep existing) */}
{ />
-
{/* Activity Table */} - {activityError || storesError ? ( // <-- Check both activityError and storesError + {activityError || storesError ? ( + // ... error display ... - ) : activityLoading && isPlaceholderData ? ( // Show spinner only if loading AND data is placeholder + ) : activityLoading && isPlaceholderData ? (
@@ -417,7 +551,7 @@ const UserAnalyticsPage: React.FC = () => {

) : ( <> -
+
@@ -425,17 +559,15 @@ const UserAnalyticsPage: React.FC = () => { Type Store Details + Actions {activityData.map((entry: RecentUserDataEntry) => { - // Safely access details[0] const firstDetail = Array.isArray(entry.details) && entry.details.length > 0 ? entry.details[0] : undefined; - - // Cast details based on dataType for better type safety (optional but recommended) const purchaseDetail = entry.dataType === "purchase" ? (firstDetail as PurchaseEntry | undefined) @@ -457,27 +589,38 @@ const UserAnalyticsPage: React.FC = () => { {entry.dataType} - {/* Check storeId before using map */} {entry.storeId ? (storeNameMap.get(entry.storeId) ?? entry.storeId) : "N/A"} - {/* Display relevant details based on type */} {entry.dataType === "purchase" && - purchaseDetail?.items && // Use casted detail and optional chaining + purchaseDetail?.items && purchaseDetail.items.length > 0 && ( {purchaseDetail.items - .map((item: PurchaseItem) => item.name) // Add type to item + .map((item: PurchaseItem) => item.name) .join(", ")} )} {entry.dataType === "search" && - searchDetail?.query && ( // Use casted detail and optional chaining + searchDetail?.query && ( Query: "{searchDetail.query}" )} - {/* Add more detail rendering as needed */} + + + ); @@ -490,19 +633,21 @@ const UserAnalyticsPage: React.FC = () => {
- + Page {activityPage} + +
+ + + ); }; diff --git a/web/src/pages/UserDashboard/UserDashboard.tsx b/web/src/pages/UserDashboard/UserDashboard.tsx index fe83073..d1bfa4b 100644 --- a/web/src/pages/UserDashboard/UserDashboard.tsx +++ b/web/src/pages/UserDashboard/UserDashboard.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from "react"; // <-- Import useState, useEffect +import React, { useState, useMemo, useEffect, useRef } from "react"; // <-- Added useRef import { Card, Alert, @@ -15,22 +15,24 @@ import { Spinner, Tabs, TabItem, + type TabsRef, // <-- Added TabsRef type import } from "flowbite-react"; import { - HiArrowRight, - HiClock, - HiInformationCircle, HiOutlineNewspaper, HiOutlineCurrencyDollar, HiOutlineShare, + HiArrowRight, + HiInformationCircle, + HiOutlineSparkles, + HiOutlineChartPie, + HiOutlineOfficeBuilding, // Added store icon HiCalendar, - HiOutlineGlobeAlt, + HiClock, + HiOutlineViewGrid, + HiOutlineUserCircle, HiOutlineCake, + HiOutlineGlobeAlt, HiOutlineCash, - HiOutlineUserCircle, - HiOutlineViewGrid, - HiOutlineSparkles, - HiOutlineChartPie, } from "react-icons/hi"; import { ResponsiveContainer, @@ -59,6 +61,8 @@ import ErrorDisplay from "../../components/common/ErrorDisplay"; import { StoreBasicInfo, MonthlySpendingItem, + RecentUserDataEntry, // Keep this import + TaxonomyCategory, // Keep this import } from "../../api/types/data-contracts"; import { InterestFormModal } from "../../components/auth/InterestFormModal"; @@ -135,6 +139,7 @@ interface CustomizedLabelProps { innerRadius: number; outerRadius: number; percent: number; + name: string; } const renderCustomizedLabel = ({ @@ -159,6 +164,7 @@ const renderCustomizedLabel = ({ textAnchor={x > cx ? "start" : "end"} dominantBaseline="central" fontSize={12} + fontWeight="bold" > {`${(percent * 100).toFixed(0)}%`} @@ -171,6 +177,7 @@ interface DemoInfoCardProps { label: string; value: string | number | null | undefined; isLoading?: boolean; + isInferred?: boolean; // Added isInferred } const DemoInfoCard: React.FC = ({ @@ -178,9 +185,11 @@ const DemoInfoCard: React.FC = ({ label, value, isLoading, + isInferred, // Added isInferred }) => (
- + {" "} + {/* Changed icon color */}

{label} @@ -190,6 +199,12 @@ const DemoInfoCard: React.FC = ({ ) : (

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

)}
@@ -200,6 +215,7 @@ const DemoInfoCard: React.FC = ({ export default function UserDashboard() { // --- State for Active Tab --- const [activeTab, setActiveTab] = useState(0); // 0 = Overview, 1 = Profile, etc. + const tabsRef = useRef(null); // <-- Added ref for Tabs component // --- State for Date Range --- const [startDate, setStartDate] = useState(null); @@ -221,7 +237,7 @@ export default function UserDashboard() { data: recentActivity, isLoading: activityLoading, error: activityError, - } = useRecentUserData({ limit: 3 }); + } = useRecentUserData({ limit: 3 }); // Fetch 3 for overview const { data: spendingData, isLoading: spendingLoading, @@ -312,7 +328,7 @@ export default function UserDashboard() { // Build helper maps from taxonomy const categoryNameMap = new Map(); const parentMap = new Map(); - taxonomyData.categories.forEach((cat) => { + taxonomyData.categories.forEach((cat: TaxonomyCategory) => { categoryNameMap.set(cat.id, cat.name); parentMap.set(cat.id, cat.parent_id || null); }); @@ -348,9 +364,9 @@ export default function UserDashboard() { if (topLevelCat) { const current = aggregatedScores.get(topLevelCat.id) || { name: topLevelCat.name, - value: 0, // Use 'value' + value: 0, }; - current.value += pref.score; // Add score to value + current.value += pref.score; // Sum scores (assuming score is 0-1) aggregatedScores.set(topLevelCat.id, current); } } @@ -359,9 +375,6 @@ export default function UserDashboard() { // Convert map to array suitable for PieChart const chartData = Array.from(aggregatedScores.values()); - // Optional: Normalize scores to percentages if needed, or just use raw scores - // For PieChart, raw values usually work fine as it calculates percentages internally. - // Filter out items with zero or negative score if necessary return chartData.filter((item) => item.value > 0); }, [preferencesData, taxonomyData]); @@ -417,8 +430,9 @@ export default function UserDashboard() { // --- Render Dashboard --- return ( <> -
+
setActiveTab(tab)} @@ -429,7 +443,7 @@ export default function UserDashboard() { title="Overview" icon={HiOutlineViewGrid} > - {activeTab === 0 && ( // Conditionally render Overview tab content + {activeTab === 0 && ( <> {profileLoading || activityLoading || @@ -487,31 +501,34 @@ export default function UserDashboard() { ) : (
- {recentActivity.map((entry) => ( - - - - - {formatDate(entry.timestamp)} - - - {entry.dataType} - {entry.storeId && - ` at ${storeNameMap.get(entry.storeId) || "Unknown Store"}`} - - {/* Further details can be added here if needed */} - - - ))} + {recentActivity.map( + (entry: RecentUserDataEntry) => ( + + + + + {formatDate(entry.timestamp)} + + + {entry.dataType} + {entry.storeId && + ` at ${storeNameMap.get(entry.storeId) || "Unknown Store"}`} + + {/* Further details can be added here if needed */} + + + ), + )}
)}
- {/* --- Top Interests Card --- */} - -
-

- Top Interests -

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

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

- ) : ( -
- - - - {preferencesPieChartData.map( - (_entry, index) => ( - - ), - )} - - - `${Math.round(value * 100)}% Interest` - } - /> - - - -
- )} -
- -
- {/* --- Data Sharing Card --- */} - +

@@ -712,17 +661,25 @@ export default function UserDashboard() { You are not currently sharing data with any stores.

) : ( - + + {" "} + {/* Increased spacing */} {(consentLists?.optInStores ?? []).map( (storeId) => ( - - Sharing with{" "} - - {storeNameMap.get(storeId) || storeId} - + + {" "} + {/* Ensure ListItem takes full width */} +
+ +
+

+ Sharing data with +

+

+ {storeNameMap.get(storeId) || storeId} +

+
+
), )} @@ -730,59 +687,146 @@ export default function UserDashboard() { )}

- {/* Demographics Section */} - -
-

- About You -

- {profileError ? ( - - Could not load profile information. - - ) : profileLoading ? ( -
- Loading profile... + {/* --- Top Interests Card --- */} + +
+ {/* New flex container for horizontal layout on md screens and up, vertical on sm */} +
+ {/* Section 1: Top Interests */} +
+

+ Top Interests +

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

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

+ ) : ( +
+ + + + {preferencesPieChartData.map( + (_entry, index) => ( + + ), + )} + + + `${Math.round(value * 100)}% Interest` + } + /> + + + +
+ )}
- ) : ( -
- - - - + + {/* Section 2: About You */} + {/* Removed original mt-6, border-t, pt-6 wrapper. Added mt-6 for small screens, md:mt-0 for larger */} +
+

+ About You +

+ {profileError ? ( + + Could not load profile information. + + ) : profileLoading ? ( +
+ Loading profile... +
+ ) : ( +
+ + + + +
+ )}
- )} +
{" "} + {/* End of new flex container */}
+
)} diff --git a/web/src/pages/UserDashboard/UserDataSharingPage.tsx b/web/src/pages/UserDashboard/UserDataSharingPage.tsx index 3d26345..2ddaa4d 100644 --- a/web/src/pages/UserDashboard/UserDataSharingPage.tsx +++ b/web/src/pages/UserDashboard/UserDataSharingPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { Card, List, @@ -7,8 +7,15 @@ import { Spinner, Alert, TextInput, // For search + Toast, // Added Toast + ToastToggle, // Added ToastToggle } from "flowbite-react"; -import { HiInformationCircle, HiOutlineSearch } from "react-icons/hi"; +import { + HiInformationCircle, + HiOutlineSearch, + HiCheck, // Added HiCheck + HiX, // Added HiX +} from "react-icons/hi"; import { useStoreConsentLists, useOptInToStore, @@ -28,18 +35,35 @@ const UserDataSharingPage: React.FC = () => { data: consentLists, isLoading: consentLoading, error: consentError, + refetch: refetchConsentLists, } = useStoreConsentLists(); const { mutate: optIn, isPending: isOptingIn, variables: optInVariables, // Get variables for optIn + reset: resetOptInMutation, // Added reset } = useOptInToStore(); const { mutate: optOut, isPending: isOptingOut, variables: optOutVariables, // Get variables for optOut + reset: resetOptOutMutation, // Added reset } = useOptOutFromStore(); + const [toastInfo, setToastInfo] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + + useEffect(() => { + if (toastInfo) { + const timer = setTimeout(() => { + setToastInfo(null); + }, 5000); + return () => clearTimeout(timer); + } + }, [toastInfo]); + // Combine IDs from both lists for lookup const storeIdsToLookup = useMemo(() => { const ids = new Set(); @@ -83,11 +107,47 @@ const UserDataSharingPage: React.FC = () => { }, [searchResults, consentLists]); // Dependencies remain the same const handleOptIn = (storeId: string) => { - optIn(storeId); + optIn(storeId, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `Successfully opted in to ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptInMutation(); + refetchConsentLists(); // Refetch lists to update UI + }, + onError: (error: Error) => { + setToastInfo({ + type: "error", + message: + error.message || + `Failed to opt in to ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptInMutation(); + }, + }); }; const handleOptOut = (storeId: string) => { - optOut(storeId); + optOut(storeId, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `Successfully opted out of ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptOutMutation(); + refetchConsentLists(); // Refetch lists to update UI + }, + onError: (error: Error) => { + setToastInfo({ + type: "error", + message: + error.message || + `Failed to opt out of ${storeNameMap.get(storeId) || "store"}.`, + }); + resetOptOutMutation(); + }, + }); }; // --- Loading and Error Checks (Now after the useMemo) --- @@ -112,6 +172,21 @@ const UserDataSharingPage: React.FC = () => { return (
+ {toastInfo && ( + +
+ {toastInfo.type === "success" ? ( + + ) : ( + + )} +
+
{toastInfo.message}
+ setToastInfo(null)} /> +
+ )}

Control Data Sharing

@@ -120,7 +195,8 @@ const UserDataSharingPage: React.FC = () => { {/* Opt-In List (Existing) */}

- Stores You Share Data With (Opt-In) + Stores You Share Data With{" "} + (Opt-In)

These stores can access your anonymized preference data based on @@ -166,7 +242,8 @@ const UserDataSharingPage: React.FC = () => { {/* Opt-Out List (Existing) */}

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

These stores cannot access your preference data. diff --git a/web/src/pages/UserDashboard/UserPreferencesPage.tsx b/web/src/pages/UserDashboard/UserPreferencesPage.tsx index dc68d41..3b04a02 100644 --- a/web/src/pages/UserDashboard/UserPreferencesPage.tsx +++ b/web/src/pages/UserDashboard/UserPreferencesPage.tsx @@ -1,8 +1,6 @@ import { Card, Button, - Spinner, - Alert, Modal, ModalHeader, ModalBody, @@ -10,9 +8,13 @@ import { Label, TextInput, Select, - RangeSlider, List, ListItem, + Spinner, + Alert, + RangeSlider, + Toast, // Added Toast + ToastToggle, // Added ToastToggle } from "flowbite-react"; import { HiUser, @@ -46,6 +48,7 @@ import { PreferenceItem, TaxonomyCategory, DemographicData, // <-- Import DemographicData type + TaxonomyAttribute, // <-- Import TaxonomyAttribute if not already (assuming it exists based on usage) } from "../../api/types/data-contracts"; import LoadingSpinner from "../../components/common/LoadingSpinner"; import ErrorDisplay from "../../components/common/ErrorDisplay"; @@ -147,13 +150,13 @@ interface DemoInfoCardProps { label: string; value: string | number | null | undefined; isLoading?: boolean; - isInferred?: boolean; // Added: Flag for inferred data - fieldName?: keyof DemographicsFormData; // Added: Field name for verification + isInferred?: boolean; + fieldName?: keyof DemographicsFormData; onVerify?: ( fieldName: keyof DemographicsFormData, - // --- CHANGE HERE --- valueToVerify: string | number | boolean | null | undefined, - ) => void; // Added: Handler for verify button + ) => void; + onUnverify?: (fieldName: keyof DemographicsFormData) => void; // Added onUnverify } const DemoInfoCard: React.FC = ({ @@ -161,48 +164,74 @@ const DemoInfoCard: React.FC = ({ label, value, isLoading, - isInferred, // Destructure - fieldName, // Destructure - onVerify, // Destructure -}) => ( -

- -
-

- {label} -

- {isLoading ? ( - - ) : ( -
- {" "} - {/* Wrap value and button */} -

- {value || "Not set"} - {isInferred && - value && ( // Show "(inferred)" text - + isInferred, + fieldName, + onVerify, + onUnverify, +}) => { + const showVerifyButton = !!( + isInferred && + value && + value !== "Not set" && + onVerify + ); + const showUnverifyButton = !!(value && value !== "Not set" && onUnverify); + const showAnyButton = showVerifyButton || showUnverifyButton; + + return ( +

+
+ +
+

+ {label} +

+ {isLoading ? ( + + ) : ( +

+ {value || "Not set"} + {isInferred && value && value !== "Not set" && ( + (inferred) )} -

- {/* Show Verify button if inferred, has value, not loading, and handler provided */} - {isInferred && value && !isLoading && fieldName && onVerify && ( +

+ )} +
+
+ {/* Buttons section - below the main content */} + {!isLoading && fieldName && isInferred && showAnyButton && ( +
+ {showVerifyButton && ( )} + {showUnverifyButton && ( + + )}
)}
-
-); -// --- End Mini Demographic Card Component --- + ); +}; const UserPreferencesPage: React.FC = () => { // --- Data Fetching (Keep existing) --- @@ -226,12 +255,14 @@ const UserPreferencesPage: React.FC = () => { const { mutate: updateProfile, isPending: isUpdatingProfile, - error: updateProfileError, + error: updateProfileError, // Keep for Alert if needed, toast will supplement + reset: resetUpdateProfileMutation, // Added reset } = useUpdateUserProfile(); const { mutate: updatePreferences, isPending: isUpdatingPreferences, - error: updatePreferencesError, + error: updatePreferencesError, // Keep for Alert if needed, toast will supplement + reset: resetUpdatePreferencesMutation, // Added reset } = useUpdateUserPreferences(); // --- State (Keep existing) --- @@ -241,12 +272,25 @@ const UserPreferencesPage: React.FC = () => { number | null >(null); + const [toastInfo, setToastInfo] = useState<{ + type: "success" | "error"; + message: string; + } | null>(null); + + useEffect(() => { + if (toastInfo) { + const timer = setTimeout(() => { + setToastInfo(null); + }, 5000); + return () => clearTimeout(timer); + } + }, [toastInfo]); + // --- Forms --- const { register: registerDemo, handleSubmit: handleDemoSubmit, reset: resetDemoForm, - setValue: setValueDemo, // <-- Get setValue for verification formState: { isDirty: isDemoDirty, errors: demoErrors }, // <-- Add errors } = useForm(); @@ -323,15 +367,15 @@ const UserPreferencesPage: React.FC = () => { ]); // --- Memos --- - const { categoryMap, attributeMap } = useMemo(() => { + const { categoryMap, fullAttributeMap } = useMemo(() => { const catMap = new Map(); - const attrMap = new Map>(); // categoryId -> Map + const attrFullMap = new Map>(); // Changed type if (taxonomyData?.categories) { taxonomyData.categories.forEach((cat) => { catMap.set(cat.id, cat); - const catAttrs = new Map(); + const catAttrs = new Map(); // Changed type cat.attributes?.forEach((attr) => { - catAttrs.set(attr.name, attr.description || attr.name); + catAttrs.set(attr.name, attr); // Store the whole attribute object }); // Include parent attributes (simple one-level for now) if (cat.parent_id) { @@ -341,20 +385,23 @@ const UserPreferencesPage: React.FC = () => { parentCat?.attributes?.forEach((attr) => { if (!catAttrs.has(attr.name)) { // Avoid overwriting child attributes - catAttrs.set(attr.name, attr.description || attr.name); + catAttrs.set(attr.name, attr); // Store the whole attribute object } }); } - attrMap.set(cat.id, catAttrs); + attrFullMap.set(cat.id, catAttrs); }); } - return { categoryMap: catMap, attributeMap: attrMap }; + return { categoryMap: catMap, fullAttributeMap: attrFullMap }; }, [taxonomyData]); const selectedCategoryId = watchPref("category"); - const availableAttributes = useMemo(() => { - return attributeMap.get(selectedCategoryId) || new Map(); - }, [selectedCategoryId, attributeMap]); + const attributesForForm = useMemo(() => { + return ( + fullAttributeMap.get(selectedCategoryId) || + new Map() + ); + }, [selectedCategoryId, fullAttributeMap]); // --- Handlers --- // Update onDemoSubmit to handle all fields and nest payload @@ -388,14 +435,26 @@ const UserPreferencesPage: React.FC = () => { demographicData: demoPayload, }; - console.log("Submitting demographic update:", finalPayload); // Debug log + // console.log("Submitting demographic update:", finalPayload); // Debug log // Only submit if the form is dirty (React Hook Form tracks this) if (isDemoDirty) { updateProfile(finalPayload, { - onSuccess: () => setIsEditingDemographics(false), - onError: (err) => { - console.error("Profile update failed:", err); // Log error + onSuccess: () => { + setIsEditingDemographics(false); + setToastInfo({ + type: "success", + message: "Demographic information updated successfully.", + }); + resetUpdateProfileMutation(); + }, + onError: (err: Error) => { + // console.error("Profile update failed:", err); // Log error + setToastInfo({ + type: "error", + message: err.message || "Failed to update demographic information.", + }); + resetUpdateProfileMutation(); }, }); } else { @@ -441,6 +500,25 @@ const UserPreferencesPage: React.FC = () => { onSuccess: () => { setShowPreferenceModal(false); setEditingPreferenceIndex(null); + setToastInfo({ + type: "success", + message: + editingPreferenceIndex !== null + ? "Preference updated successfully." + : "Preference added successfully.", + }); + resetUpdatePreferencesMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: + err.message || + (editingPreferenceIndex !== null + ? "Failed to update preference." + : "Failed to add preference."), + }); + resetUpdatePreferencesMutation(); }, }, ); @@ -467,7 +545,25 @@ const UserPreferencesPage: React.FC = () => { }; }); - updatePreferences({ preferences: sanitizedPreferences }); + updatePreferences( + { preferences: sanitizedPreferences }, + { + onSuccess: () => { + setToastInfo({ + type: "success", + message: "Preference removed successfully.", + }); + resetUpdatePreferencesMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: err.message || "Failed to remove preference.", + }); + resetUpdatePreferencesMutation(); + }, + }, + ); }; const openAddModal = () => { @@ -479,65 +575,128 @@ const UserPreferencesPage: React.FC = () => { setEditingPreferenceIndex(index); setShowPreferenceModal(true); }; + // --- REVISED handleVerify --- const handleVerify = ( fieldName: keyof DemographicsFormData, - // --- CHANGE HERE --- valueToVerify: string | number | boolean | null | undefined, ) => { - setIsEditingDemographics(true); - // Use timeout to ensure state update completes before setting value - setTimeout(() => { - // Convert boolean "Yes"/"No" back to boolean for ToggleSwitch - // --- FIX: Handle potential undefined from valueToVerify --- - let formValue: string | number | boolean | null = valueToVerify ?? null; + let apiValue: string | number | boolean | null | undefined = valueToVerify; - // Find the corresponding value for enum fields OR hasKids - if (fieldName === "hasKids") { - // Find the option matching the display label - const option = hasKidsOptions.find((o) => o.label === valueToVerify); - // Convert the option's string value back to boolean/null - formValue = - option?.value === "true" - ? true - : option?.value === "false" - ? false - : null; - } else if (fieldName === "gender") - formValue = - genderOptions.find((o) => o.label === valueToVerify)?.value ?? null; - else if (fieldName === "country") - formValue = - countryOptions.find((o) => o.label === valueToVerify)?.value ?? null; - else if (fieldName === "incomeBracket") - formValue = - incomeOptions.find((o) => o.label === valueToVerify)?.value ?? null; - else if (fieldName === "relationshipStatus") - formValue = - relationshipOptions.find((o) => o.label === valueToVerify)?.value ?? - null; - else if (fieldName === "employmentStatus") - formValue = - employmentOptions.find((o) => o.label === valueToVerify)?.value ?? - null; - else if (fieldName === "educationLevel") - formValue = - educationOptions.find((o) => o.label === valueToVerify)?.value ?? - null; - // Ensure age is treated as a number or null for the form - else if (fieldName === "age") { - // Use valueToVerify here as formValue might already be null - formValue = typeof valueToVerify === "number" ? valueToVerify : null; - } + // Transform displayed value back to API value if necessary + if (fieldName === "gender") { + apiValue = + genderOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "country") { + apiValue = + countryOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "incomeBracket") { + apiValue = + incomeOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "relationshipStatus") { + apiValue = + relationshipOptions.find((o) => o.label === valueToVerify)?.value ?? + null; + } else if (fieldName === "employmentStatus") { + apiValue = + employmentOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "educationLevel") { + apiValue = + educationOptions.find((o) => o.label === valueToVerify)?.value ?? null; + } else if (fieldName === "age") { + apiValue = + typeof valueToVerify === "number" + ? valueToVerify + : valueToVerify === "Not set" + ? null + : parseInt(String(valueToVerify), 10); + if (apiValue !== null && isNaN(Number(apiValue))) apiValue = null; + } else if (fieldName === "hasKids") { + if (valueToVerify === "Yes") apiValue = true; + else if (valueToVerify === "No") apiValue = false; + else apiValue = null; + } + + if (apiValue === "Not set") apiValue = null; - // Use setValueDemo with the potentially transformed formValue - setValueDemo(fieldName, formValue, { shouldDirty: true }); + const demoPayload: Partial = { + [fieldName]: apiValue, + }; + + const finalPayload: UserUpdate = { + demographicData: demoPayload, + }; - // Optional: Focus the element after setting value - const element = document.getElementById(fieldName); - element?.focus(); - element?.scrollIntoView({ behavior: "smooth", block: "center" }); - }, 0); - console.log(`Verifying ${fieldName} with value:`, valueToVerify); + updateProfile(finalPayload, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `${labelForField(fieldName)} verified successfully.`, + }); + resetUpdateProfileMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: + err.message || `Failed to verify ${labelForField(fieldName)}.`, + }); + resetUpdateProfileMutation(); + }, + }); + }; + + // --- NEW handleUnverify --- + const handleUnverify = (fieldName: keyof DemographicsFormData) => { + const demoPayload: Partial = { + [fieldName]: null, // Setting to null clears the user-provided value + }; + + const finalPayload: UserUpdate = { + demographicData: demoPayload, + }; + + updateProfile(finalPayload, { + onSuccess: () => { + setToastInfo({ + type: "success", + message: `User value for ${labelForField(fieldName)} cleared successfully.`, + }); + resetUpdateProfileMutation(); + }, + onError: (err: Error) => { + setToastInfo({ + type: "error", + message: + err.message || + `Failed to clear user value for ${labelForField(fieldName)}.`, + }); + resetUpdateProfileMutation(); + }, + }); + }; + + // Helper to get a display-friendly label for toast messages + const labelForField = (fieldName: keyof DemographicsFormData): string => { + switch (fieldName) { + case "gender": + return "Gender"; + case "age": + return "Age"; + case "country": + return "Country"; + case "incomeBracket": + return "Income Bracket"; + case "hasKids": + return "Has Children"; + case "relationshipStatus": + return "Relationship Status"; + case "employmentStatus": + return "Employment Status"; + case "educationLevel": + return "Education Level"; + default: + return fieldName; + } }; // --- Render Logic --- const isLoading = profileLoading || preferencesLoading || taxonomyLoading; @@ -576,6 +735,21 @@ const UserPreferencesPage: React.FC = () => { return (
+ {toastInfo && ( + +
+ {toastInfo.type === "success" ? ( + + ) : ( + + )} +
+
{toastInfo.message}
+ setToastInfo(null)} /> +
+ )}

Manage Your Profile & Interests

@@ -591,7 +765,7 @@ const UserPreferencesPage: React.FC = () => { {!isEditingDemographics && ( +
) : ( // --- DISPLAY VIEW (Combined User-Provided and Inferred) --- -
- {/* User Provided or Verified */} +
+ {/* Gender */} + {/* Age */} + {/* Country */} + {/* Income Bracket */} + {/* Has Kids */} + {/* Relationship Status */} + {/* Employment Status */} + {/* Education Level */} - - {/* Display inferred values ONLY if user hasn't provided one */} - {!userProfile?.demographicData?.gender && - userProfile?.demographicData?.inferredGender && ( - - )} - {!userProfile?.demographicData?.hasKids && - userProfile?.demographicData?.inferredHasKids !== null && ( - - )} - {!userProfile?.demographicData?.relationshipStatus && - userProfile?.demographicData?.inferredRelationshipStatus && ( - - )} - {!userProfile?.demographicData?.employmentStatus && - userProfile?.demographicData?.inferredEmploymentStatus && ( - - )} - {!userProfile?.demographicData?.educationLevel && - userProfile?.demographicData?.inferredEducationLevel && ( - - )}
)} @@ -1046,8 +1201,9 @@ const UserPreferencesPage: React.FC = () => {
{/* Attributes */} - {selectedCategoryId && availableAttributes.size > 0 && ( + {selectedCategoryId && attributesForForm.size > 0 && (
Refine Interest (Optional)
- {Array.from(availableAttributes.entries()).map( - ([attrName, attrDesc]) => ( -
- - -
- ), + {Array.from(attributesForForm.entries()).map( + ([attrName, attributeObject]) => { + if ( + attributeObject.values && + attributeObject.values.length > 0 + ) { + return ( +
+ + +
+ ); + } + // If attribute has no predefined values, it won't be rendered as a dropdown. + // You could add a TextInput here as a fallback if needed. + return null; + }, )}
@@ -1162,6 +1331,18 @@ const UserPreferencesPage: React.FC = () => { value={value ?? 50} onChange={(e) => onChange(parseInt(e.target.value, 10))} className="flex-grow" + theme={{ + field: { + input: { + base: "h-2 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-600", // Adjusted dark mode background for better contrast + sizes: { + sm: "h-1", + md: "h-2", + lg: "h-3", + }, + }, + }, + }} /> {value ?? 50} @@ -1176,13 +1357,6 @@ const UserPreferencesPage: React.FC = () => {
- + diff --git a/web/src/pages/UserDashboard/UserProfilePage.tsx b/web/src/pages/UserDashboard/UserProfilePage.tsx index e2cd742..dc4a08c 100644 --- a/web/src/pages/UserDashboard/UserProfilePage.tsx +++ b/web/src/pages/UserDashboard/UserProfilePage.tsx @@ -4,7 +4,6 @@ import { Button, Card, ToggleSwitch, - FloatingLabel, HelperText, Spinner, Tabs, @@ -15,6 +14,8 @@ import { ModalBody, ModalHeader, Tooltip, // <-- Import Tooltip + Label, // <-- Add Label + TextInput, // <-- Add TextInput } from "flowbite-react"; // Import necessary icons import { @@ -239,12 +240,18 @@ export default function UserProfilePage() { Basic Information {/* Username */} -
- +
+ +
+ {/* Phone Number */} -
- +
+ +
+
- +

Are you sure you want to permanently delete your user account?

@@ -482,7 +495,7 @@ export default function UserProfilePage() { )} +
+
+ + +
+ +
+

+ Support & FAQ +

+

+ Find answers to common questions in our comprehensive FAQ or + join our community forum. +

+ + {" "} + {/* Assuming you might have an FAQ page */} + + +
+
+
+
+ + + {/* Call to Action Section */} +
+

+ Ready to Experience the Future of Data? +

+

+ Join Tapiro today and take control of your digital identity or + empower your business with ethical data. +

+
+ + + + + + +
+
+
); } diff --git a/web/src/pages/static/ApiDocsPage.tsx b/web/src/pages/static/ApiDocsPage.tsx index 20b08a0..cb2836f 100644 --- a/web/src/pages/static/ApiDocsPage.tsx +++ b/web/src/pages/static/ApiDocsPage.tsx @@ -1,104 +1,1044 @@ -import { Card } from "flowbite-react"; +import React, { useState, useEffect, useRef } from "react"; +import { + Card, + Table, + TableBody, + TableCell, + TableHead, + TableHeadCell, + TableRow, +} from "flowbite-react"; +import { + HiOutlineClipboardList, + HiOutlineCode, + HiOutlineKey, + HiOutlineCloudUpload, + HiOutlineCloudDownload, + HiOutlineBookOpen, + HiOutlineExclamationCircle, + HiOutlineLink, + HiOutlineHeart, +} from "react-icons/hi"; + +// Helper for section titles +const SectionTitle = ({ + title, + icon: Icon, +}: { + title: string; + icon?: React.ElementType; +}) => ( +
+ {" "} + {/* Increased mb and added pt for spacing before title */} + {Icon && } +

+ {title} +

+
+); + +// Define sidebarLinks outside the component as it's static +const sidebarLinks = [ + { title: "Introduction", href: "#introduction" }, + { title: "Authentication", href: "#authentication" }, + { title: "Base URL", href: "#base-url" }, + { title: "Core Concepts", href: "#core-concepts" }, + { + title: "Endpoints", + href: "#endpoints", + sublinks: [ + { title: "Submit User Data", href: "#submit-user-data" }, + { + title: "Retrieve User Preferences", + href: "#retrieve-user-preferences", + }, + { title: "Health Check", href: "#health-check" }, + ], + }, + { title: "Taxonomy", href: "#taxonomy" }, + { + title: "Data Schemas", + href: "#data-schemas", + sublinks: [ + { title: "UserData", href: "#schema-userdata" }, + { title: "PurchaseEntry", href: "#schema-purchaseentry" }, + { title: "PurchaseItem", href: "#schema-purchaseitem" }, + { title: "SearchEntry", href: "#schema-searcheentry" }, + { title: "UserPreferences", href: "#schema-userpreferences" }, + { title: "PreferenceItem", href: "#schema-preferenceitem" }, + { title: "Error", href: "#schema-error" }, + ], + }, + { title: "Error Handling", href: "#error-handling" }, + { title: "Code Examples", href: "#code-examples" }, +]; + +interface LocalCodeBlockProps { + code: string; + language?: string; + className?: string; +} + +const LocalCodeBlock: React.FC = ({ + code, + language, + className, +}) => { + // Refactored to use Tailwind classes for both light and dark mode + return ( +
+      
+        {code.trim()}
+      
+    
+ ); +}; export default function ApiDocsPage() { + const [activeId, setActiveId] = useState(sidebarLinks[0]?.href || ""); + const observer = useRef(null); + const sectionRefs = useRef>(new Map()); + + useEffect(() => { + const currentSectionRefs = sectionRefs.current; // Capture for cleanup + + // Ensure all refs are populated + sidebarLinks.forEach((link) => { + if (link.href) { + const el = document.getElementById(link.href.substring(1)); + currentSectionRefs.set(link.href, el); + } + link.sublinks?.forEach((sublink) => { + if (sublink.href) { + const el = document.getElementById(sublink.href.substring(1)); + currentSectionRefs.set(sublink.href, el); + } + }); + }); + + const handleIntersect = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting && entry.intersectionRatio >= 0.5) { + // Check if the element is sufficiently visible + setActiveId(`#${entry.target.id}`); + } + }); + }; + + observer.current = new IntersectionObserver(handleIntersect, { + rootMargin: "-20% 0px -50% 0px", // Adjust rootMargin to trigger when section is in middle/upper part of viewport + threshold: 0.5, // Trigger when 50% of the element is visible + }); + + const currentObserver = observer.current; + + currentSectionRefs.forEach((el) => { + if (el) currentObserver.observe(el); + }); + + return () => { + currentSectionRefs.forEach((el) => { + if (el) currentObserver.unobserve(el); + }); + }; + }, []); // sidebarLinks is now stable and defined outside, so it's not needed as a dependency + + // Function to determine if a link or its sublink is active + const isLinkActive = ( + linkHref: string, + sublinks?: Array<{ href: string }>, + ) => { + if (activeId === linkHref) return true; + return sublinks?.some((sublink) => activeId === sublink.href); + }; + return (
- {/* Add dark mode text color */} -

- API Documentation -

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

- Welcome to the Tapiro API documentation. This guide provides details - on how stores can integrate with our platform to send user interaction - data and retrieve preference profiles for personalization. -

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

- Authentication -

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

- All API requests must be authenticated using an API key provided via - the Store Dashboard. Include your key in the `Authorization` header as - a Bearer token. -

- {/* Adjusted pre/code dark mode styles */} -
-          
-            Authorization: Bearer YOUR_API_KEY
-          
-        
- {/* Add dark mode text color */} -

- Endpoints -

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

- Below is an example of submitting user interaction data. More - endpoints (e.g., for retrieving preferences) will be documented soon. +

+

+ Tapiro API Documentation +

+

+ Integrate with Tapiro to power personalized experiences ethically and + effectively.

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

- Sends user interaction data (e.g., purchases, views) to Tapiro for - analysis. -

- {/* Adjusted pre/code dark mode styles */} -
-          
-            {`POST /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",
-  "sessionId": "session-abc-456",
-  "eventType": "purchase",
-  "timestamp": "2025-04-14T10:30:00Z",
-  "details": {
-    "items": [
-      { "productId": "prod-a", "name": "Running Shoes", "category": "Footwear", "price": 89.99, "quantity": 1 },
-      { "productId": "prod-b", "name": "Sports Socks", "category": "Apparel", "price": 9.99, "quantity": 2 }
-    ],
-    "totalValue": 109.97
-  },
-  "metadata": {
-    "source": "web",
-    "deviceType": "desktop"
-  }
-}`}
-          
-        
- {/* Add dark mode text color */} -
- POST /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 /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": [ ... ]
-}`}
-          
-        
- +
+ +
+ {/* Sidebar */} + + + {/* Main Content Area */} +
+ {/* Card already has dark:bg-gray-800 */} + + {/* Introduction Section (Example) */} +
{ + sectionRefs.current.set("#introduction", el); + }} + className="mb-12 scroll-mt-20" + > + + {/* Paragraphs already use dark:text-gray-300 */} +

+ Welcome to the Tapiro Store Operations API. This API allows your + services (stores, e-commerce platforms) to submit user + interaction data (like purchases and searches) for analysis and + to retrieve processed user preferences. This enables you to + build personalized experiences while respecting user consent and + privacy. +

+

+ Our platform leverages AI and a structured taxonomy to infer + user interests, helping you overcome data fragmentation and + deliver more accurate recommendations. +

+
+ + {/* Authentication Section (Example with inline code) */} +
{ + sectionRefs.current.set("#authentication", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ Include your API key in the{" "} + {/* Inline code styling for dark mode */} + + X-API-Key + {" "} + header for every request. +

+ +
+ + {/* Base URL */} +
{ + sectionRefs.current.set("#base-url", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ The primary base URL for the Tapiro Store Operations API is: +

+ +

+ Please use the appropriate URLs provided for development or + staging environments if applicable. +

+
+ {/* Core Concepts */} +
{ + sectionRefs.current.set("#core-concepts", el); + }} + className="mb-12 scroll-mt-20" + > + +
    +
  • + + User Identification: + {" "} + Users are primarily identified by their email address when + submitting data or retrieving preferences. This email must + correspond to a user registered within the Tapiro ecosystem. +
  • +
  • + + Consent: + {" "} + Tapiro is built on user consent. Data submissions are + processed only if the user has granted{" "} + + dataSharingConsent + {" "} + in Tapiro and has not explicitly opted-out of sharing data + with your specific store. Preference retrieval will result in + a{" "} + + 403 Forbidden + {" "} + error 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 Tapiro Taxonomy + is crucial for the AI models to generate accurate preferences. + (See{" "} + + Taxonomy Section + + ). +
  • +
  • + + Rate Limiting: + {" "} + (Information about rate limits, if any, would go here. E.g., + "API requests are rate-limited to X requests per minute per + API key.") +
  • +
+
+ {/* Endpoints */} +
{ + sectionRefs.current.set("#endpoints", el); + }} + className="mb-12 scroll-mt-20" + > + + +
{ + sectionRefs.current.set("#submit-user-data", el); + }} + className="mb-10 scroll-mt-20" + > + {" "} + {/* Increased mb */} +

+ {" "} + POST /users/data +

+

+ Submits user interaction data (purchases or searches) for + analysis and preference building. +

+

+ Request Body: +

+

+ A JSON object conforming to the{" "} + + UserData schema + + . +

+ +

+ Responses: +

+
    +
  • + + 202 Accepted + + : Data accepted for processing. +
  • +
  • + + 400 Bad Request + + : Invalid input or schema violation. +
  • +
  • + + 401 Unauthorized + + : Invalid or missing API key. +
  • +
+
+ +
{ + sectionRefs.current.set("#retrieve-user-preferences", el); + }} + className="mb-10 scroll-mt-20" + > +

+ {" "} + GET /users/{`{userId}`}/preferences +

+

+ Retrieves processed interest preferences for a specific user. +

+

+ Path Parameter: +

+
    +
  • + {`{userId}`}{" "} + (string, required): The email address of the user. +
  • +
+

+ Example URL: +

+ +

+ Responses: +

+
    +
  • + + 200 OK + + : Successfully retrieved preferences. Body contains{" "} + + UserPreferences + {" "} + object. +
  • +
  • + + 401 Unauthorized + + : Invalid or missing API key. +
  • +
  • + + 403 Forbidden + + : User has not consented or has opted out of sharing with + your store. +
  • +
  • + + 404 Not Found + + : User not found in Tapiro. +
  • +
+
+ +
{ + sectionRefs.current.set("#health-check", el); + }} + className="mb-8 scroll-mt-20" + > +

+ GET + /health & GET /ping +

+

+ + GET /health + + : Provides a comprehensive health check of the API and its + dependencies. +

+

+ + GET /ping + + : A simple uptime check endpoint. +

+

+ Responses (for /ping): +

+
    +
  • + + 200 OK + + : Body contains{" "} + {`{"status": "ok", "timestamp": "..."}`} + . +
  • +
+
+
+ + {/* Taxonomy Section */} +
{ + sectionRefs.current.set("#taxonomy", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ The Tapiro Taxonomy is a hierarchical classification system for + products, services, and user interests. Accurate use of this + taxonomy is **critical** for the effectiveness of Tapiro's AI + models in generating meaningful user preferences. +

+

+ **Structure:** The taxonomy consists of categories, each with a + unique ID and a human-readable name. Categories can have + parent-child relationships to form a hierarchy. +

+

+ **Usage:** When submitting data (e.g.,{" "} + + PurchaseItem.category + {" "} + or{" "} + + SearchEntry.category + + ), you must use either the category ID or the exact category + name as defined in the Tapiro Taxonomy. +

+

+ **Obtaining the Taxonomy:** (Details on how stores can + access/view the taxonomy. E.g., "The full taxonomy can be + retrieved via the `/taxonomy/categories` endpoint in the Tapiro + Internal API if exposed, or it is provided during + onboarding/available in your Store Dashboard.") +

+

+ Example Snippet: +

+ +

+ It is recommended to use the most specific category ID possible + for accurate preference inference. +

+
+ + {/* Data Schemas */} +
{ + sectionRefs.current.set("#data-schemas", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ The following tables describe the main data objects used in API + requests and responses. +

+ + {/* UserData Schema */} +
{ + sectionRefs.current.set("#schema-userdata", el); + }} + className="mb-10 scroll-mt-20" + > +
+ UserData Object +
+
+
+ + Field + Type + Required + Description + + + + email + string (email format) + Yes + User's email address. + + + dataType + + string (enum: "purchase", "search") + + Yes + Type of data being submitted. + + + entries + + array of (PurchaseEntry or SearchEntry) + + Yes + Array of interaction entries. + + +
+
+ + + {/* PurchaseEntry Schema */} +
{ + sectionRefs.current.set("#schema-purchaseentry", el); + }} + className="mb-10 scroll-mt-20" + > +
+ PurchaseEntry Object +
+
+ + + Field + Type + Required + Description + + + + timestamp + string (date-time) + Yes + ISO 8601 timestamp of purchase. + + + items + array of PurchaseItem + Yes + List of items in the purchase. + + + totalValue + number (float) + No + + Optional total value of the purchase. + + + +
+
+
+ + {/* PurchaseItem Schema */} +
{ + sectionRefs.current.set("#schema-purchaseitem", el); + }} + className="mb-10 scroll-mt-20" + > +
+ PurchaseItem Object +
+
+ + + Field + Type + Required + Description + + + + sku + string + No + Stock Keeping Unit or product ID. + + + name + string + Yes + Name of the item. + + + category + string + Yes + + Category ID or name from Taxonomy. + + + + price + number (float) + No + Price of a single unit. + + + quantity + integer + No (default: 1) + Number of units purchased. + + + attributes + object + No + + Key-value pairs of product attributes (e.g.,{" "} + {'{ "color": "blue", "size": "L" }'}) + + + +
+
+
+ + {/* SearchEntry Schema */} +
{ + sectionRefs.current.set("#schema-searcheentry", el); + }} + className="mb-10 scroll-mt-20" + > + {" "} + {/* Corrected ID */} +
+ SearchEntry Object +
+
+ + + Field + Type + Required + Description + + + + timestamp + string (date-time) + Yes + ISO 8601 timestamp of search. + + + query + string + Yes + The search query string. + + + category + string + No + + Optional category context for the search (from + Taxonomy). + + + + results + integer + No + + Optional number of results returned. + + + + clicked + array of string + No + + Optional list of product IDs/SKUs clicked from + results. + + + +
+
+
+ + {/* UserPreferences Schema */} +
{ + sectionRefs.current.set("#schema-userpreferences", el); + }} + className="mb-10 scroll-mt-20" + > +
+ UserPreferences Object (Response for GET /users/{`{userId}`} + /preferences) +
+
+ + + Field + Type + Description + + + + userId + string + Tapiro's internal User ID. + + + preferences + array of PreferenceItem + + List of user's interest preferences. + + + + updatedAt + string (date-time) + + Timestamp of when preferences were last updated. + + + +
+
+
+ + {/* PreferenceItem Schema */} +
{ + sectionRefs.current.set("#schema-preferenceitem", el); + }} + className="mb-10 scroll-mt-20" + > +
+ PreferenceItem Object +
+
+ + + Field + Type + Required + Description + + + + category + string + Yes + + Category ID or name from Taxonomy. + + + + score + number (float, 0.0-1.0) + Yes + Interest score for this category. + + + attributes + object + No + + Category-specific attribute preferences (e.g.,{" "} + {'{ "brand": "Nike", "color_preference_score": 0.8 }'} + ) + + + +
+
+
+ + {/* Error Schema */} +
{ + sectionRefs.current.set("#schema-error", el); + }} + className="mb-10 scroll-mt-20" + > +
+ Error Object (Standard Error Response) +
+
+ + + Field + Type + Required + Description + + + + code + integer + Yes + HTTP status code. + + + message + string + Yes + Description of the error. + + + details + object + No + + Additional error details (e.g., field validation + issues). + + + +
+
+
+ + + {/* Error Handling */} +
{ + sectionRefs.current.set("#error-handling", el); + }} + className="mb-12 scroll-mt-20" + > + +

+ Tapiro API uses standard HTTP status codes to indicate the + success or failure of an API request. +

+
    +
  • + + 2xx + {" "} + codes indicate success. +
  • +
  • + + 4xx + {" "} + codes indicate client-side errors (e.g., bad input, + authentication failure, insufficient permissions). Check the + response body for an{" "} + + Error object + {" "} + with details. +
  • +
  • + + 5xx + {" "} + codes indicate server-side errors. Please retry after a short + delay or contact support if the issue persists. +
  • +
+
+ + {/* Code Examples */} +
{ + sectionRefs.current.set("#code-examples", el); + }} + className="scroll-mt-20" + > + {" "} + {/* Removed mb-12 for last section */} + +

+ Below are basic examples of how to interact with the API. +

+
+ Submitting Purchase Data (Conceptual JavaScript Example) +
+ +
+ Retrieving User Preferences (Conceptual JavaScript Example) +
+ +
+ +
+
); } diff --git a/web/src/pages/static/HomePage.tsx b/web/src/pages/static/HomePage.tsx index 9e58dc2..c6b22e1 100644 --- a/web/src/pages/static/HomePage.tsx +++ b/web/src/pages/static/HomePage.tsx @@ -1,168 +1,317 @@ -import { useState, useEffect } from "react"; // <-- Import hooks -import { Toast, ToastToggle } from "flowbite-react"; // <-- Import Toast components -import { HiCheck } from "react-icons/hi"; // <-- Import icon +import { useState, useEffect } from "react"; +import { Toast, ToastToggle, Button, Card } from "flowbite-react"; import { - DocsIcon, - BlocksIcon, - IconsIcon, - IllustrationsIcon, -} from "../../components/icons/ResourceIcons"; + HiCheck, + HiOutlineArrowRight, + HiOutlineUserGroup, + HiOutlineLockClosed, + HiOutlineSparkles, + HiOutlinePuzzlePiece, + HiOutlineChartBar, +} from "react-icons/hi2"; // Using Hi2 for potentially newer icons +import { Link } from "react-router"; // Assuming react-router is used + +// Placeholder icons for features - replace with actual or more suitable icons +const FeatureIconUserControl = HiOutlineUserGroup; +const FeatureIconPersonalization = HiOutlineSparkles; +const FeatureIconTransparency = HiOutlineLockClosed; +const FeatureIconStoreIntegration = HiOutlinePuzzlePiece; +const FeatureIconStoreInsights = HiOutlineChartBar; export default function HomePage() { const [showToast, setShowToast] = useState(false); const [toastMessage, setToastMessage] = useState(""); - // Check for post-logout toast message on mount useEffect(() => { const message = sessionStorage.getItem("showPostLogoutToast"); if (message) { setToastMessage(message); setShowToast(true); - sessionStorage.removeItem("showPostLogoutToast"); // Clear the flag - - // Optional: Auto-hide toast after a delay + sessionStorage.removeItem("showPostLogoutToast"); const timer = setTimeout(() => setShowToast(false), 5000); - return () => clearTimeout(timer); // Cleanup timer on unmount + return () => clearTimeout(timer); } - }, []); // Empty dependency array ensures this runs only once on mount - - const CARDS = [ - { - title: "Flowbite React Docs", - description: - "Learn more on how to get started and use the Flowbite React components", - url: "https://flowbite-react.com/", - icon: , - }, - { - title: "Flowbite Blocks", - description: - "Get started with over 450 blocks to build websites even faster", - url: "https://flowbite.com/blocks/", - icon: , - }, - { - title: "Flowbite Icons", - description: - "Get started with over 650+ SVG free and open-source icons for your apps", - url: "https://flowbite.com/icons/", - icon: , - }, - { - title: "Flowbite Illustrations", - description: - "Start using over 50+ SVG illustrations in 3D style to add character to your apps", - url: "https://flowbite.com/illustrations/", - icon: , - }, - { - title: "Flowbite Pro", - description: - "Upgrade your development stack with more components and templates from Flowbite", - url: "https://flowbite.com/pro/", - icon: Flowbite Pro logo, - }, - { - title: "Flowbite Figma", - description: - "Use our Figma Design System to design and collaborate better within your team", - url: "https://flowbite.com/figma/", - icon: Figma logo, - }, - ]; + }, []); return ( - // Add relative positioning if needed for absolute toast -
- {/* Success Toast */} +
+ {/* Toast Notification */} {showToast && ( - +
-
{toastMessage}
+
+ {toastMessage} +
setShowToast(false)} />
)} - {/* Background pattern - kept for visual style */} -
-
+ {/* Hero Section */} +
+
Pattern Light Pattern Dark
-
- -
- {" "} - {/* Centered content */} -
-

- Welcome to Tapiro {/* Updated Title */} +
+

+ Tapiro: Centralized Data Management Platform.

- - - Manage your preferences and data sharing easily.{" "} - {/* Updated Subtitle */} - - {/* You can add more introductory text here */} - +

+ Tired of data fragmentation and lack of control? Tapiro empowers + users with transparency and provides businesses with ethical, + high-quality data for truly personalized recommendations. +

+
+ + + + + {" "} + {/* Or a relevant page for store sign-ups */} + + +
+
+ + + {/* For Users Section */} +
+
+
+

+ Empowering{" "} + Users +

+

+ Take control of your digital footprint and enjoy experiences + tailored to you. +

+
+
+ + +

+ Full Data Control +

+

+ View, update, and manage your data sharing consents on a + per-service or global basis. You decide who sees what. +

+
+ + +

+ Accurate Personalization +

+

+ Benefit from more relevant recommendations and offers, thanks to + centralized and accurately managed preference data. +

+
+ + +

+ Enhanced Transparency +

+

+ Understand how your data is used with a clear view of your + interactions and preferences through our modern UI. +

+
+
+
+
+ + {/* For Stores Section */} +
+
+
+

+ Powering{" "} + + Businesses + +

+

+ Access high-quality, consented data to drive engagement and growth + ethically. +

+
+
+ + +

+ Richer User Insights +

+

+ Overcome data fragmentation. Leverage sophisticated AI and + taxonomy systems for deeper understanding of user interests. +

+
+ + +

+ Seamless API Integration +

+

+ Easily connect your services with our well-documented + OpenAPI-based RESTful APIs (Node.js, FastAPI). +

+
+ + +

+ Ethical Data Practices +

+

+ Build trust by using a platform that prioritizes user consent + and data security (Auth0, 2FA, Passkeys). +

+
+
- {/* Placeholder/Example Section - Kept from original App.tsx */} -
-

- Explore Resources +

+ + {/* Technology Highlights Section (Optional) */} +
+
+
+

+ Built with Cutting-Edge Technology +

+

+ Leveraging robust and scalable solutions for optimal performance + and security. +

+
+
+
+ React{" "} + {/* Replace with actual icon paths */} +

+ React & Vite +

+
+
+ Node.js +

+ Node.js +

+
+
+ FastAPI +

+ FastAPI +

+
+
+ Auth0 +

+ Auth0 Security +

+
+
+ Hugging Face +

+ AI/ML Models +

+
+
+ OpenAPI +

+ OpenAPI Specs +

+
+
+ MongoDB +

+ MongoDB +

+
+
+ Redis +

+ Caching +

+
+
+
+
+ + {/* Call to Action Section */} +
+ +

); }