Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 77 additions & 2 deletions api-service/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ paths:
get:
tags: [User Management]
summary: Get Recent User Data Submissions
description: Retrieves a list of recent data submissions made about the authenticated user.
description: Retrieves a list of recent data submissions made about the authenticated user, with optional filtering and searching.
operationId: getRecentUserData
parameters:
- name: limit
Expand All @@ -630,9 +630,42 @@ paths:
schema:
type: integer
default: 1
- name: dataType
in: query
description: Filter by data type (purchase or search)
required: false
schema:
type: string
enum: [purchase, search]
- name: storeId
in: query
description: Filter by store ID
required: false
schema:
type: string
- name: startDate
in: query
description: Filter by start date (ISO 8601 format YYYY-MM-DD)
required: false
schema:
type: string
format: date
- name: endDate
in: query
description: Filter by end date (ISO 8601 format YYYY-MM-DD)
required: false
schema:
type: string
format: date
- name: searchTerm
in: query
description: Search term for entries (e.g., item name, search query)
required: false
schema:
type: string
responses:
"200":
description: Recent data submissions retrieved successfully
description: Recent user data retrieved successfully.
content:
application/json:
schema:
Expand All @@ -641,6 +674,8 @@ paths:
$ref: "#/components/schemas/RecentUserDataEntry"
"401":
$ref: "#/components/responses/UnauthorizedError"
"404":
$ref: "#/components/responses/NotFoundError"
"500":
$ref: "#/components/responses/InternalServerError"
security:
Expand Down Expand Up @@ -717,6 +752,46 @@ paths:
- oauth2: [user:read]
x-swagger-router-controller: StoreProfile

/stores/search:
get:
tags: [Store Management]
summary: Search Stores
description: Searches for stores by name. Requires authentication.
operationId: searchStores
parameters:
- name: query
in: query
description: The search term to look for in store names.
required: true
schema:
type: string
minLength: 2
- name: limit
in: query
description: Maximum number of results to return.
required: false
schema:
type: integer
default: 10
responses:
"200":
description: Stores matching the query retrieved successfully.
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/StoreBasicInfo"
"400":
$ref: "#/components/responses/BadRequestError"
"401":
$ref: "#/components/responses/UnauthorizedError"
"500":
$ref: "#/components/responses/InternalServerError"
security:
- oauth2: [user:read]
x-swagger-router-controller: StoreProfile

components:
schemas:
AttributeDistribution:
Expand Down
10 changes: 10 additions & 0 deletions api-service/controllers/StoreProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,14 @@ module.exports.lookupStores = function lookupStores(req, res, next, ids) {
.catch((response) => {
utils.writeJson(res, response);
});
};

module.exports.getStoreUsers = function getStoreUsers(req, res, next) {
StoreProfile.getStoreUsers(req)
.then((response) => {
utils.writeJson(res, response);
})
.catch((response) => {
utils.writeJson(res, response);
});
};
4 changes: 2 additions & 2 deletions api-service/controllers/UserProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ module.exports.deleteUserProfile = function deleteUserProfile(req, res, next) {
};

module.exports.getRecentUserData = function getRecentUserData(req, res, next, limit, page) {
// Pass query parameters to the service function
UserProfile.getRecentUserData(req, limit, page)
const { dataType, storeId, startDate, endDate, searchTerm } = req.query;
UserProfile.getRecentUserData(req, limit, page, dataType, storeId, startDate, endDate, searchTerm)
.then((response) => {
utils.writeJson(res, response);
})
Expand Down
37 changes: 37 additions & 0 deletions api-service/service/StoreProfileService.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,40 @@ exports.lookupStores = async function (req, ids) {
return respondWithCode(500, { code: 500, message: 'Internal server error' });
}
};

/**
* Search Stores
* Searches for stores by name.
*/
exports.searchStores = async function (req, query, limit = 10) {
try {
if (!query || query.length < 2) { // Basic validation
return respondWithCode(400, { code: 400, message: 'Search query must be at least 2 characters long.' });
}

const db = getDB();
const regex = new RegExp(query, 'i'); // Case-insensitive search

// Consider adding a text index on the 'name' field in the 'stores' collection for performance
// db.collection('stores').createIndex({ name: "text" });
// Then use: { $text: { $search: query } } instead of regex for better performance

const stores = await db.collection('stores')
.find({ name: regex }) // Using regex for simplicity here
.limit(parseInt(limit)) // Ensure limit is an integer
.project({ _id: 1, name: 1 }) // Project only ID and name
.toArray();

// Format the response to match StoreBasicInfo schema
const formattedStores = stores.map(store => ({
storeId: store._id.toString(), // Convert ObjectId back to string
name: store.name
}));

return respondWithCode(200, formattedStores);

} catch (error) {
console.error('Search stores failed:', error);
return respondWithCode(500, { code: 500, message: 'Internal server error during store search' });
}
};
93 changes: 76 additions & 17 deletions api-service/service/UserProfileService.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ exports.deleteUserProfile = async function (req) {
* Get Recent User Data Submissions
* Retrieves a list of recent data submissions made about the authenticated user.
*/
exports.getRecentUserData = async function (req, limit = 10, page = 1) {
exports.getRecentUserData = async function (req, limit = 10, page = 1, dataType, storeId, startDate, endDate, searchTerm) { // Add new params
try {
const db = getDB();
const userData = req.user || (await getUserData(req.headers.authorization?.split(' ')[1]));
Expand All @@ -285,36 +285,95 @@ exports.getRecentUserData = async function (req, limit = 10, page = 1) {

const skip = (page - 1) * limit;

// Query userData collection
// --- Build the MongoDB query dynamically ---
const matchQuery = { userId: user._id };

if (dataType) {
matchQuery.dataType = dataType;
}
if (storeId) {
// Validate storeId format if necessary before querying
try {
matchQuery.storeId = new ObjectId(storeId);
} catch (e) {
console.warn(`Invalid storeId format provided: ${storeId}`);
// Decide how to handle: return empty, error, or ignore filter
return respondWithCode(400, { code: 400, message: 'Invalid store ID format provided.' });
}
}

// Date range filtering on the main document timestamp
const dateFilter = {};
if (startDate) {
try {
dateFilter.$gte = new Date(startDate);
} catch (e) { console.warn('Invalid startDate format:', startDate); }
}
if (endDate) {
try {
const end = new Date(endDate);
end.setDate(end.getDate() + 1); // Include the whole end day
dateFilter.$lt = end;
} catch (e) { console.warn('Invalid endDate format:', endDate); }
}
if (Object.keys(dateFilter).length > 0) {
matchQuery.timestamp = dateFilter;
}

// Search term filtering within entries (simple regex example)
// NOTE: For better performance on large datasets, consider a text index
// on 'entries.items.name', 'entries.items.category', 'entries.query', etc.
if (searchTerm) {
const regex = new RegExp(searchTerm, 'i'); // Case-insensitive regex
matchQuery.$or = [
{ 'entries.items.name': regex },
{ 'entries.items.category': regex },
{ 'entries.query': regex },
// Add other fields within entries to search if needed
];
}
// --- End Query Building ---

// Query userData collection with the built query
const recentData = await db.collection('userData')
.find({ userId: user._id }) // Filter by the user's ObjectId
.find(matchQuery) // Use the dynamic query
.sort({ timestamp: -1 }) // Sort by submission time descending
.skip(skip)
.limit(limit)
.project({ // Project only necessary fields for RecentUserDataEntry schema
.project({ // Expand projection to include details needed for display
_id: 1,
storeId: 1,
dataType: 1,
timestamp: 1, // Submission timestamp
entryTimestamp: '$entries.timestamp', // Assuming timestamp is within entries array
// Add simplified details if needed, e.g., item count or query string
// details: { $cond: { if: { $eq: ['$dataType', 'purchase'] }, then: { itemCount: { $size: '$entries.items' } }, else: '$entries.query' } }
entries: 1, // Include the full entries array for now
// Alternatively, project specific fields from entries if known:
// 'entries.timestamp': 1,
// 'entries.query': 1,
// 'entries.items.name': 1,
// 'entries.items.price': 1,
// 'entries.items.quantity': 1,
// 'entries.items.category': 1,
})
.toArray();

// Simple transformation if needed (e.g., flatten entryTimestamp if it's an array)
// Simple transformation (can be enhanced on frontend)
const formattedData = recentData.map(entry => ({
...entry,
// If entryTimestamp is an array due to projection, take the first element
entryTimestamp: Array.isArray(entry.entryTimestamp) ? entry.entryTimestamp[0] : entry.entryTimestamp,
// Add placeholder for details
details: {}
_id: entry._id.toString(), // Convert ObjectId to string
storeId: entry.storeId.toString(), // Convert ObjectId to string
dataType: entry.dataType,
timestamp: entry.timestamp,
// Process entries for simpler display structure if needed here,
// or handle it on the frontend. Example:
details: entry.entries.map(e => ({
timestamp: e.timestamp,
...(entry.dataType === 'purchase' && { items: e.items }),
...(entry.dataType === 'search' && { query: e.query, results: e.results }),
})),
}));


// Caching could be added here if this data is frequently accessed
// const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}`;
// await setCache(cacheKey, JSON.stringify(formattedData), { EX: CACHE_TTL.SHORT }); // Example TTL
// Caching: Consider if caching is appropriate with dynamic filters.
// If cached, the cache key MUST include all filter parameters.
// Example: const cacheKey = `${CACHE_KEYS.USER_RECENT_DATA}${user._id}:${page}:${limit}:${dataType || 'all'}:${storeId || 'all'}:${startDate || 'all'}:${endDate || 'all'}:${searchTerm || ''}`;

return respondWithCode(200, formattedData);

Expand Down
52 changes: 49 additions & 3 deletions web/src/api/hooks/useStoreHooks.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useApiClients } from "../apiClient";
import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache"; // <-- Import CACHE_TIMES
import { cacheKeys, cacheSettings, CACHE_TIMES } from "../utils/cache";
import {
ApiKeyCreate,
StoreUpdate,
StoreBasicInfo, // <-- Import StoreBasicInfo
StoreBasicInfo,
Error, // <-- Import Error type
SearchStoresParams, // <-- Import SearchStoresParams if generated
} from "../types/data-contracts";
import { useAuth } from "../../hooks/useAuth"; // Import useAuth
import { useAuth } from "../../hooks/useAuth";
import { useState, useEffect } from "react"; // <-- Import useState and useEffect for debounce

export function useStoreProfile() {
// Get clientsReady state
Expand Down Expand Up @@ -90,6 +93,49 @@ export function useApiKeyUsage(keyId: string) {
});
}

// --- New Hook for Searching Stores ---
export function useSearchStores(searchTerm: string, debounceMs = 300) {
const { apiClients, clientsReady } = useApiClients();
const { isAuthenticated, isLoading: authLoading } = useAuth();
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);

// Debounce effect
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, debounceMs);

// Cleanup function to cancel the timeout if searchTerm changes again quickly
return () => {
clearTimeout(handler);
};
}, [searchTerm, debounceMs]);

// Define query parameters type if not auto-generated
// type SearchStoresParams = { query: string; limit?: number };

return useQuery<StoreBasicInfo[], Error>({
// Query key includes the debounced term
queryKey: [...cacheKeys.stores.all, "search", debouncedSearchTerm],
queryFn: () => {
// Prepare parameters for the API call
const params: SearchStoresParams = {
query: debouncedSearchTerm,
limit: 15,
}; // Adjust limit as needed
return apiClients.stores.searchStores(params).then((res) => res.data);
},
// Only run query if client is ready, user is authenticated,
// and the debounced search term is long enough
enabled:
clientsReady &&
isAuthenticated &&
!authLoading &&
debouncedSearchTerm.length >= 2, // Match backend validation
staleTime: CACHE_TIMES.MEDIUM, // Cache results for a bit
});
}

// --- New Hook ---

// Hook to lookup multiple stores by their IDs
Expand Down
Loading
Loading