Skip to content

Commit 0ef5a00

Browse files
authored
Merge pull request #44 from Sajith15/feature/trie-search-suggestions
feat: implement trie-based search auto-suggestions
2 parents abce467 + a7dde16 commit 0ef5a00

12 files changed

Lines changed: 505 additions & 44 deletions

File tree

server/__tests__/trie.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const Trie = require("../utils/trie");
2+
3+
describe("Trie", () => {
4+
let trie;
5+
6+
beforeEach(() => {
7+
trie = new Trie();
8+
});
9+
10+
it("returns empty array for unknown prefix", () => {
11+
trie.insert("React Basics", { type: "course", id: "1" });
12+
expect(trie.search("vue")).toEqual([]);
13+
});
14+
15+
it("finds suggestions by prefix (case-insensitive)", () => {
16+
trie.insert("React Basics", { type: "course", id: "1", courseId: "1" });
17+
trie.insert("Reading Skills", { type: "course", id: "2", courseId: "2" });
18+
19+
const results = trie.search("rea");
20+
expect(results).toHaveLength(2);
21+
expect(results.map((r) => r.label)).toEqual(
22+
expect.arrayContaining(["React Basics", "Reading Skills"])
23+
);
24+
});
25+
26+
it("deduplicates entries with the same type and id", () => {
27+
trie.insert("React", { type: "course", id: "1", courseId: "1" });
28+
trie.insert("react", { type: "course", id: "1", courseId: "1" });
29+
30+
expect(trie.search("re")).toHaveLength(1);
31+
});
32+
33+
it("respects the result limit", () => {
34+
trie.insert("Alpha", { type: "course", id: "1" });
35+
trie.insert("Apple", { type: "course", id: "2" });
36+
trie.insert("Application", { type: "course", id: "3" });
37+
38+
expect(trie.search("a", 2)).toHaveLength(2);
39+
});
40+
41+
it("clears all entries", () => {
42+
trie.insert("Node.js", { type: "course", id: "1" });
43+
trie.clear();
44+
expect(trie.search("node")).toEqual([]);
45+
});
46+
});

server/controllers/Course.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ const User = require("../models/User")
66
const { uploadImageToCloudinary } = require("../utils/imageUploader")
77
const CourseProgress = require("../models/CourseProgress")
88
const { convertSecondsToDuration } = require("../utils/secToDuration")
9+
const searchIndex = require("../services/searchIndex")
10+
11+
const refreshSearchIndex = () => {
12+
searchIndex.rebuild().catch((err) => {
13+
console.error("[SearchIndex] Rebuild failed:", err.message)
14+
})
15+
}
916
// Function to create a new course
1017
exports.createCourse = async (req, res) => {
1118
try {
@@ -115,6 +122,7 @@ exports.createCourse = async (req, res) => {
115122
{ new: true }
116123
)
117124
console.log("HEREEEEEEEE", categoryDetails2)
125+
refreshSearchIndex()
118126
// Return the new course and a success message
119127
res.status(200).json({
120128
success: true,
@@ -185,6 +193,7 @@ exports.editCourse = async (req, res) => {
185193
})
186194
.exec()
187195

196+
refreshSearchIndex()
188197
res.json({
189198
success: true,
190199
message: "Course updated successfully",
@@ -477,6 +486,7 @@ exports.deleteCourse = async (req, res) => {
477486
// Delete the course
478487
await Course.findByIdAndDelete(courseId)
479488

489+
refreshSearchIndex()
480490
return res.status(200).json({
481491
success: true,
482492
message: "Course deleted successfully",
@@ -491,6 +501,36 @@ exports.deleteCourse = async (req, res) => {
491501
}
492502
}
493503

504+
// Trie-backed search auto-suggestions
505+
exports.getSearchSuggestions = async (req, res) => {
506+
try {
507+
const { query, limit } = req.query;
508+
const trimmedQuery = (query || "").trim();
509+
510+
if (!trimmedQuery) {
511+
return res.status(200).json({
512+
success: true,
513+
data: [],
514+
});
515+
}
516+
517+
const maxResults = Math.min(parseInt(limit, 10) || 8, 20);
518+
const suggestions = searchIndex.getSuggestions(trimmedQuery, maxResults);
519+
520+
return res.status(200).json({
521+
success: true,
522+
data: suggestions,
523+
});
524+
} catch (error) {
525+
console.error(error);
526+
return res.status(500).json({
527+
success: false,
528+
message: "Failed to fetch search suggestions",
529+
error: error.message,
530+
});
531+
}
532+
};
533+
494534
// Search Courses by query (including instructor name)
495535
exports.searchCourse = async (req, res) => {
496536
try {

server/index.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const aiRoutes = require("./routes/AI");
5555
// ── Infrastructure ────────────────────────────────────────────────────
5656
const database = require("./config/database");
5757
const { cloudinaryConnect } = require("./config/cloudinary");
58+
const searchIndex = require("./services/searchIndex");
5859

5960
const app = express();
6061
const PORT = process.env.PORT || 4000;
@@ -100,7 +101,15 @@ if (process.env.NODE_ENV !== "test") {
100101
const mongoose = require("mongoose");
101102
mongoose
102103
.connect(process.env.MONGODB_URL, mongooseOptions)
103-
.then(() => console.log("DB Connected Successfully"))
104+
.then(async () => {
105+
console.log("DB Connected Successfully");
106+
try {
107+
const count = await searchIndex.rebuild();
108+
console.log(`[SearchIndex] Indexed ${count} published courses`);
109+
} catch (err) {
110+
console.error("[SearchIndex] Initial build failed:", err.message);
111+
}
112+
})
104113
.catch((err) => {
105114
console.error("DB Connection Failed:", err);
106115
process.exit(1);

server/routes/Course.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ const {
99
editCourse,
1010
deleteCourse,
1111
getInstructorCourses,
12+
searchCourse,
13+
getSearchSuggestions,
14+
instructorDetails,
1215
} = require("../controllers/Course");
1316

1417
const {
@@ -55,6 +58,11 @@ const {
5558
// Works for guests (no token) and logged-in students (personalised)
5659
router.get("/recommendations", optionalAuth, getRecommendations);
5760

61+
// ── Search ────────────────────────────────────────────────────────────────────
62+
router.get("/searchSuggestions", getSearchSuggestions);
63+
router.get("/searchCourse", searchCourse);
64+
router.get("/instructorDetails/:instructorId", instructorDetails);
65+
5866
// Content-similar courses – shown on the course detail page
5967
router.get("/:courseId/similar", getSimilarCourses);
6068

server/services/searchIndex.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const Course = require("../models/Course");
2+
const Trie = require("../utils/trie");
3+
4+
const trie = new Trie();
5+
let isReady = false;
6+
7+
function indexCourse(course) {
8+
const instructor = course.instructor;
9+
const instructorName = instructor
10+
? [instructor.firstName, instructor.lastName].filter(Boolean).join(" ")
11+
: "";
12+
13+
trie.insert(course.courseName, {
14+
type: "course",
15+
id: course._id.toString(),
16+
courseId: course._id.toString(),
17+
thumbnail: course.thumbnail,
18+
instructorName,
19+
});
20+
21+
if (instructor && instructor._id) {
22+
trie.insert(instructorName, {
23+
type: "instructor",
24+
id: instructor._id.toString(),
25+
instructorId: instructor._id.toString(),
26+
image: instructor.image,
27+
});
28+
}
29+
30+
if (Array.isArray(course.tag)) {
31+
for (const tag of course.tag) {
32+
trie.insert(tag, {
33+
type: "tag",
34+
id: tag.toLowerCase(),
35+
courseId: course._id.toString(),
36+
});
37+
}
38+
}
39+
40+
if (course.category?.name) {
41+
trie.insert(course.category.name, {
42+
type: "category",
43+
id: course.category._id.toString(),
44+
courseId: course._id.toString(),
45+
});
46+
}
47+
}
48+
49+
async function rebuild() {
50+
trie.clear();
51+
52+
const courses = await Course.find({ status: "Published" })
53+
.populate("instructor", "firstName lastName image")
54+
.populate("category", "name")
55+
.lean();
56+
57+
for (const course of courses) {
58+
indexCourse(course);
59+
}
60+
61+
isReady = true;
62+
return courses.length;
63+
}
64+
65+
function getSuggestions(prefix, limit = 8) {
66+
return trie.search(prefix, limit);
67+
}
68+
69+
function ready() {
70+
return isReady;
71+
}
72+
73+
module.exports = {
74+
rebuild,
75+
getSuggestions,
76+
ready,
77+
};

server/utils/trie.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
class TrieNode {
2+
constructor() {
3+
this.children = new Map();
4+
this.entries = [];
5+
}
6+
}
7+
8+
class Trie {
9+
constructor() {
10+
this.root = new TrieNode();
11+
}
12+
13+
_normalize(text) {
14+
return text.trim().toLowerCase();
15+
}
16+
17+
insert(text, metadata) {
18+
const normalized = this._normalize(text);
19+
if (!normalized) return;
20+
21+
let node = this.root;
22+
for (const char of normalized) {
23+
if (!node.children.has(char)) {
24+
node.children.set(char, new TrieNode());
25+
}
26+
node = node.children.get(char);
27+
}
28+
29+
const key = `${metadata.type}:${metadata.id}`;
30+
const exists = node.entries.some(
31+
(entry) => `${entry.type}:${entry.id}` === key
32+
);
33+
if (!exists) {
34+
node.entries.push({ ...metadata, label: text.trim() });
35+
}
36+
}
37+
38+
search(prefix, limit = 8) {
39+
const normalized = this._normalize(prefix);
40+
if (!normalized) return [];
41+
42+
let node = this.root;
43+
for (const char of normalized) {
44+
if (!node.children.has(char)) {
45+
return [];
46+
}
47+
node = node.children.get(char);
48+
}
49+
50+
const results = [];
51+
const seen = new Set();
52+
this._collectSuggestions(node, results, seen, limit);
53+
return results;
54+
}
55+
56+
_collectSuggestions(node, results, seen, limit) {
57+
if (results.length >= limit) return;
58+
59+
for (const entry of node.entries) {
60+
const key = `${entry.type}:${entry.id}`;
61+
if (!seen.has(key)) {
62+
seen.add(key);
63+
results.push(entry);
64+
if (results.length >= limit) return;
65+
}
66+
}
67+
68+
for (const child of node.children.values()) {
69+
this._collectSuggestions(child, results, seen, limit);
70+
if (results.length >= limit) return;
71+
}
72+
}
73+
74+
clear() {
75+
this.root = new TrieNode();
76+
}
77+
}
78+
79+
module.exports = Trie;

src/App.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function App() {
4545
<Route path="/" element={<Home />} />
4646
<Route path="about" element={<About />} />
4747
<Route path="contact" element={<Contact />} />
48-
<Route path="search" element={<Search />} />
48+
<Route path="search/:query" element={<Search />} />
4949
<Route path="error" element={<Error />} />
5050
<Route path="/catalog/:catalogName" element={<Catalog />} />
5151
<Route path="/courses/:courseId" element={<CourseDetails />} />

0 commit comments

Comments
 (0)