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
27 changes: 27 additions & 0 deletions config/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Search Configuration
* Issue #634: High-Performance Search Engine
*/

module.exports = {
// Caching settings
cache: {
enabled: true,
ttl: 60 * 5, // 5 minutes in seconds
maxSize: 1000 // Maximum number of items in cache
},

// Search result settings
results: {
defaultLimit: 50,
maxLimit: 200,
facetsEnabled: true
},

// Scoring weights for results
scoring: {
merchantMatch: 2.0,
descriptionMatch: 1.5,
categoryMatch: 1.0
}
};
70 changes: 70 additions & 0 deletions middleware/cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Simple In-Memory LRU Cache Middleware
* Issue #634: Enhances search performance
*/

const config = require('../config/search');

class SimpleCache {
constructor() {
this.cache = new Map();
this.maxSize = config.cache.maxSize;
this.ttl = config.cache.ttl * 1000; // to ms
}

get(key) {
const item = this.cache.get(key);
if (!item) return null;

if (Date.now() > item.expiry) {
this.cache.delete(key);
return null;
}

return item.value;
}

set(key, value) {
if (this.cache.size >= this.maxSize) {
// Very simple eviction: delete first item (FIFO approximation)
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}

this.cache.set(key, {
value,
expiry: Date.now() + this.ttl
});
}

clear() {
this.cache.clear();
}
}

const searchCache = new SimpleCache();

const cacheMiddleware = (req, res, next) => {
if (!config.cache.enabled) return next();

// Create unique key based on URL and user
const key = `${req.user._id}_${req.originalUrl}`;
const cachedData = searchCache.get(key);

if (cachedData) {
return res.json({ ...cachedData, _cached: true });
}

// Override res.json to capture data
const originalJson = res.json;
res.json = function (data) {
if (res.statusCode === 200) {
searchCache.set(key, data);
}
return originalJson.call(this, data);
};

next();
};

module.exports = { cacheMiddleware, searchCache };
4 changes: 2 additions & 2 deletions models/Transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ transactionSchema.methods.logStep = async function (step, status, message, detai
};

// Indexes for performance optimization
transactionSchema.index({ description: 'text', merchant: 'text' }); // Text search
transactionSchema.index({ user: 1, date: -1 });
transactionSchema.index({ workspace: 1, date: -1 });
transactionSchema.index({ user: 1, type: 1, date: -1 });
transactionSchema.index({ workspace: 1, type: 1, date: -1 });
transactionSchema.index({ user: 1, amount: 1 }); // Range queries optimization
transactionSchema.index({ user: 1, category: 1, date: -1 });
transactionSchema.index({ workspace: 1, category: 1, date: -1 });
transactionSchema.index({ receiptId: 1 });
Expand Down
42 changes: 42 additions & 0 deletions routes/search.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const express = require('express');
const router = express.Router();
const auth = require('../middleware/auth');
const searchService = require('../services/searchService');
const { cacheMiddleware } = require('../middleware/cache');

/**
* @route GET /api/search/smart
* @desc Get transactions using smart query parsing and facets
* @access Private
*/
router.get('/smart', auth, cacheMiddleware, async (req, res) => {
try {
const { q, page, limit } = req.query;

if (!q) {
return res.status(400).json({ error: 'Search query (q) is required' });
}

const results = await searchService.search(req.user._id, q, { page, limit });
res.json(results);
} catch (error) {
res.status(500).json({ error: error.message });
}
});

/**
* @route GET /api/search/merchants
* @desc Suggest merchants based on partial name (fuzzy)
* @access Private
*/
router.get('/merchants', auth, async (req, res) => {
try {
const { name } = req.query;
const suggestions = await searchService.findSimilarMerchants(req.user._id, name);
res.json({ success: true, data: suggestions });
} catch (error) {
res.status(500).json({ error: error.message });
}
});

module.exports = router;
2 changes: 1 addition & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ app.use('/api/profile', require('./routes/profile'));
// Serve uploaded avatars
app.use('/uploads', express.static(require('path').join(__dirname, 'uploads')));
app.use('/api/treasury', require('./routes/treasury'));
app.use('/api/assets', require('./routes/assets'));
app.use('/api/search', require('./routes/search'));

// Import error handling middleware
const { errorHandler, notFoundHandler } = require('./middleware/errorMiddleware');
Expand Down
94 changes: 94 additions & 0 deletions services/searchService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
const Transaction = require('../models/Transaction');
const queryParser = require('../utils/queryParser');
const config = require('../config/search');

class SearchService {
/**
* Perform advanced search with facets and pagination
*/
async search(userId, searchString, options = {}) {
const { page = 1, limit = config.results.defaultLimit } = options;
const skip = (page - 1) * limit;

// 1. Parse query string
const filters = queryParser.parse(searchString);
filters.user = userId; // Ensure user scoping

// 2. Build Aggregation Pipeline
const pipeline = [
{ $match: filters }
];

// If text search is present, sort by relevance score
if (filters.$text) {
pipeline.push({
$addFields: { score: { $meta: "textScore" } }
});
pipeline.push({
$sort: { score: { $meta: "textScore" }, date: -1 }
});
} else {
pipeline.push({ $sort: { date: -1 } });
}

// Facets for category and merchant distribution
const facetStages = {
metadata: [{ $count: "total" }, { $addFields: { page: parseInt(page) } }],
data: [{ $skip: skip }, { $limit: parseInt(limit) }],
};

if (config.results.facetsEnabled) {
facetStages.categories = [
{ $group: { _id: "$category", count: { $sum: 1 }, totalAmount: { $sum: "$amount" } } },
{ $sort: { count: -1 } }
];
facetStages.merchants = [
{ $group: { _id: "$merchant", count: { $sum: 1 } } },
{ $match: { _id: { $ne: "" } } },
{ $sort: { count: -1 } },
{ $limit: 10 }
];
}

pipeline.push({ $facet: facetStages });

const results = await Transaction.aggregate(pipeline);

// Process results
const output = results[0];
const total = output.metadata[0] ? output.metadata[0].total : 0;

return {
success: true,
data: output.data,
facets: {
categories: output.categories,
merchants: output.merchants
},
pagination: {
total,
page: parseInt(page),
limit: parseInt(limit),
pages: Math.ceil(total / limit)
},
query: filters
};
}

/**
* Fuzzy Merchant Search using Regex (Trigram approximation for MongoDB)
*/
async findSimilarMerchants(userId, partialName) {
if (!partialName || partialName.length < 2) return [];

// Simple fuzzy match: matches sub-sequences
const regex = new RegExp(partialName.split('').join('.*'), 'i');

return await Transaction.distinct('merchant', {
user: userId,
merchant: regex
});
}
}

module.exports = new SearchService();
60 changes: 60 additions & 0 deletions tests/search.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Search Engine Test Suite
* Issue #634: High-Performance Search Engine
*/

const assert = require('assert');
const queryParser = require('../utils/queryParser');
const searchService = require('../services/searchService');

describe('High-Performance Search Engine', () => {

describe('Query Parser', () => {
it('should parse category filter correctly', () => {
const result = queryParser.parse('category:food');
assert.strictEqual(result.category, 'food');
});

it('should parse amount greater than filter', () => {
const result = queryParser.parse('>500');
assert.deepStrictEqual(result.amount, { $gt: 500 });
});

it('should parse amount less than or equal filter', () => {
const result = queryParser.parse('<=120.50');
assert.deepStrictEqual(result.amount, { $lte: 120.50 });
});

it('should parse date preset: last-month', () => {
const result = queryParser.parse('date:last-month');
assert(result.date.$gte instanceof Date);
assert(result.date.$lte instanceof Date);
});

it('should parse complex query: "category:transport >20 uber"', () => {
const result = queryParser.parse('category:transport >20 uber');
assert.strictEqual(result.category, 'transport');
assert.deepStrictEqual(result.amount, { $gt: 20 });
assert.deepStrictEqual(result.$text, { $search: 'uber' });
});

it('should parse merchant specific filter: "merchant:Apple Store"', () => {
const result = queryParser.parse('merchant:Apple Store >1000');
assert.ok(result.merchant instanceof RegExp);
assert.deepStrictEqual(result.amount, { $gt: 1000 });
});
});

describe('Search Service Integration (Concepts)', () => {
it('searchService should exist and have search method', () => {
assert.strictEqual(typeof searchService.search, 'function');
});

it('should handle pagination options correctly', () => {
// Mock testing logic for pagination parameters
const options = { page: 2, limit: 10 };
assert.strictEqual(options.page, 2);
assert.strictEqual(options.limit, 10);
});
});
});
Loading